feat: BAT-025 add portfolio ledger manual metadata migration
This commit is contained in:
@@ -0,0 +1,48 @@
|
|||||||
|
# BAT-025 Implementation Log
|
||||||
|
|
||||||
|
- Date: 2026-07-15
|
||||||
|
- Assignee: dev-impl
|
||||||
|
- Scope: REQ-057 portfolio_ledger 수동 장부 메타데이터 명시 Alembic migration
|
||||||
|
|
||||||
|
## Implemented Changes
|
||||||
|
|
||||||
|
1. Created new Alembic revision `20260715_0028` (`migrations/versions/20260715_0028_portfolio_ledger_manual_metadata.py`)
|
||||||
|
- `down_revision`: `20260628_0027`
|
||||||
|
- Added additive columns:
|
||||||
|
- `source TEXT NOT NULL DEFAULT 'broker'`
|
||||||
|
- `target_ledger_entry_id BIGINT NULL`
|
||||||
|
- `confirmation_evidence TEXT NULL`
|
||||||
|
- `actor_user_id TEXT NULL`
|
||||||
|
- Added idempotent runtime compatibility logic:
|
||||||
|
- If `portfolio_ledger` table missing: no-op
|
||||||
|
- If column already exists: update `source IS NULL` → `'broker'`, enforce `NOT NULL` + `DEFAULT`
|
||||||
|
- Add missing columns only when absent
|
||||||
|
- `downgrade` is forward-only no-op
|
||||||
|
|
||||||
|
2. Added migration-focused tests `tests/test_bat025_portfolio_ledger_manual_metadata_migration.py`
|
||||||
|
- Covers:
|
||||||
|
- fresh schema add-all-columns behavior
|
||||||
|
- precreated schema backfill + source constraint enforcement
|
||||||
|
- missing table no-op
|
||||||
|
- forward-only downgrade no-op
|
||||||
|
|
||||||
|
3. Added operational runbook
|
||||||
|
- `docs/runbooks/bat-025-req057-portfolio-ledger-manual-metadata-runbook.md`
|
||||||
|
|
||||||
|
## Preflight Evidence
|
||||||
|
|
||||||
|
- `uv run alembic heads`
|
||||||
|
- output: `20260715_0028 (head)`
|
||||||
|
- `uv run alembic history --verbose`
|
||||||
|
- confirms `20260715_0028` parent is `20260628_0027` and it is the current tail of the migration chain.
|
||||||
|
- `PYTHONPATH=. uv run pytest -q -o addopts='' tests/test_bat025_portfolio_ledger_manual_metadata_migration.py`
|
||||||
|
- output: `4 passed in 0.24s`
|
||||||
|
|
||||||
|
## Evidence notes
|
||||||
|
|
||||||
|
- Migration is idempotent by design: no-op if `portfolio_ledger` is missing, and for pre-created runtimes it backfills `source` and enforces `NOT NULL DEFAULT 'broker'`.
|
||||||
|
- No DB upgrade was executed in this scope (code-only + evidence-only in repo).
|
||||||
|
|
||||||
|
## Commit
|
||||||
|
|
||||||
|
- Commit SHA: `850e970`
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# 운영 적용 런북: REQ-057 — portfolio_ledger 수동 장부 메타데이터 migration
|
||||||
|
|
||||||
|
- Batch: BAT-025
|
||||||
|
- Discovery: dcy-012
|
||||||
|
- Date: 2026-07-15
|
||||||
|
- 결재 게이트: REQ-057 완료 승인 및 release 결재 필수
|
||||||
|
- 작업 예상 시간: 약 20~40분
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 전제 조건
|
||||||
|
|
||||||
|
- DB 백업 완료
|
||||||
|
- `PORTFOLIOHUB_DB_ENABLED=true` 및 DB 접속 환경변수 설정
|
||||||
|
- 대상 DB는 `alembic current`로 `20260628_0027` 상태(또는 그 이전 배포 라인)에서 진행
|
||||||
|
- 새 코드 배포는 `alembic upgrade 20260715_0028` 완료 + 검증 성공 및 `M5 Release` 결재 후에만 수행
|
||||||
|
|
||||||
|
> 주의: 운영 DB는 본 배치에서 실제 upgrade/runbook 실행 대상을 만들지 않는다. 아래는 실운영 적용 체크리스트이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0) 사전 점검
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export TIMESCALEDB_HOST=<DB_HOST>
|
||||||
|
export TIMESCALEDB_PORT=<DB_PORT>
|
||||||
|
export TIMESCALEDB_NAME=<DB_NAME>
|
||||||
|
export TIMESCALEDB_USER=<DB_USER>
|
||||||
|
export TIMESCALEDB_PASSWORD=<DB_PASS>
|
||||||
|
export PORTFOLIOHUB_DB_ENABLED=true
|
||||||
|
|
||||||
|
alembic heads
|
||||||
|
alembic current
|
||||||
|
```
|
||||||
|
|
||||||
|
- `alembic heads`에는 구현 시점 head `20260715_0028`가 확인되어야 한다.
|
||||||
|
- `alembic current` 값이 `20260715_0028`이면 이미 마이그레이션 적용 완료 상태다.
|
||||||
|
|
||||||
|
## 1) 프리체크
|
||||||
|
|
||||||
|
### 1-1. 대기 중인 락/액티브 커넥션
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT pid, usename, application_name, state, wait_event_type, wait_event, query
|
||||||
|
FROM pg_stat_activity
|
||||||
|
WHERE query ILIKE '%portfolio_ledger%'
|
||||||
|
AND state != 'idle';
|
||||||
|
```
|
||||||
|
|
||||||
|
- 결과는 반드시 비어 있거나 승인된 윈도우에서만 허용.
|
||||||
|
|
||||||
|
### 1-2. 대상 스키마 및 데이터 건수
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 현재 컬럼 상태
|
||||||
|
SELECT column_name, data_type, is_nullable, column_default
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'portfolio_ledger'
|
||||||
|
AND column_name IN ('source', 'target_ledger_entry_id', 'confirmation_evidence', 'actor_user_id')
|
||||||
|
ORDER BY column_name;
|
||||||
|
|
||||||
|
-- 기존 행 수(증적)
|
||||||
|
SELECT COUNT(*) AS rows FROM portfolio_ledger;
|
||||||
|
```
|
||||||
|
|
||||||
|
- `source`가 이미 없거나 NULL 허용 상태여도 업그레이드 실행 전/후로 수렴 가능.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2) 마이그레이션 실행
|
||||||
|
|
||||||
|
```bash
|
||||||
|
alembic upgrade 20260715_0028
|
||||||
|
```
|
||||||
|
|
||||||
|
### 수신 메시지 예시
|
||||||
|
|
||||||
|
- 정상: `Running upgrade ...` 형태로 revision 로그 출력
|
||||||
|
- 실패: 즉시 종료되며 partial 변경 여부는 로그로 확인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3) 사후 검증
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT column_name, data_type, is_nullable, column_default
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'portfolio_ledger'
|
||||||
|
AND column_name IN ('source', 'target_ledger_entry_id', 'confirmation_evidence', 'actor_user_id')
|
||||||
|
ORDER BY column_name;
|
||||||
|
|
||||||
|
-- source backfill 결과(0이어야 함)
|
||||||
|
SELECT COUNT(*) AS null_source_rows
|
||||||
|
FROM portfolio_ledger
|
||||||
|
WHERE source IS NULL;
|
||||||
|
|
||||||
|
SELECT COUNT(*) AS rows_after FROM portfolio_ledger;
|
||||||
|
SELECT column_name, is_nullable
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'portfolio_ledger' AND column_name = 'source';
|
||||||
|
SELECT column_default
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'portfolio_ledger'
|
||||||
|
AND column_name = 'source';
|
||||||
|
```
|
||||||
|
|
||||||
|
- 기대치: `source`가 `NOT NULL`, `DEFAULT 'broker'`, `null_source_rows = 0`
|
||||||
|
- `rows_after` 값은 프리체크 시점과 일치해야 한다.
|
||||||
|
- `alembic current`가 `20260715_0028`로 일치해야 한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
alembic current
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4) 실패 대응 (Forward-only)
|
||||||
|
|
||||||
|
- 업그레이드 실패 시 바로 이전 코드로 rollback은 허용하되, 본 migration에서 추가된 4개 컬럼은 운영에서 자동 삭제하지 않는다.
|
||||||
|
- 필요한 경우 운영 담당과 협의하여 수동 복구 계획을 수립한다.
|
||||||
|
- 필수 검토 항목: 대상 DB 백업 상태, 실행 로그, 에러 스택, 락/동시 접속 상태
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5) 운영 활성화 조건
|
||||||
|
|
||||||
|
- 위 사후 검증(`alembic current`/schema/row count/source null 없음) 완료
|
||||||
|
- API smoke(REQ-056 manual ledger) 통과
|
||||||
|
- release 결재 완료
|
||||||
|
- 위 조건 충족 시 신규 코드 재시작 및 API 활성화 수행
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""portfolio_ledger: manual ledger metadata columns backfill-safe upgrade
|
||||||
|
|
||||||
|
Revision ID: 20260715_0028
|
||||||
|
Revises: 20260628_0027
|
||||||
|
Create Date: 2026-07-15 00:00:00
|
||||||
|
|
||||||
|
This migration adds the portfolio_ledger additive metadata columns introduced by
|
||||||
|
REQ-056. It is safe for both fresh DBs and legacy DBs where ensure_schema() already
|
||||||
|
created columns at runtime.
|
||||||
|
|
||||||
|
- source: TEXT NOT NULL DEFAULT 'broker'
|
||||||
|
- target_ledger_entry_id: BIGINT NULL
|
||||||
|
- confirmation_evidence: TEXT NULL
|
||||||
|
- actor_user_id: TEXT NULL
|
||||||
|
|
||||||
|
Runtime behavior:
|
||||||
|
- if source exists, backfill NULL -> 'broker', then enforce NOT NULL/default.
|
||||||
|
- if source/other columns are absent, add them as idempotent ALTERs.
|
||||||
|
- existing rows are preserved; only metadata defaults/backfill are adjusted.
|
||||||
|
- downgrade is forward-only (columns are not removed).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy import inspect, text
|
||||||
|
|
||||||
|
|
||||||
|
revision = "20260715_0028"
|
||||||
|
down_revision = "20260628_0027"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _table_exists(inspector: sa.engine.Inspector, table_name: str) -> bool:
|
||||||
|
return table_name in inspector.get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def _column_exists(inspector: sa.engine.Inspector, table_name: str, column_name: str) -> bool:
|
||||||
|
if not _table_exists(inspector, table_name):
|
||||||
|
return False
|
||||||
|
return any(c["name"] == column_name for c in inspector.get_columns(table_name))
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
|
||||||
|
if not _table_exists(inspector, "portfolio_ledger"):
|
||||||
|
return
|
||||||
|
|
||||||
|
if not _column_exists(inspector, "portfolio_ledger", "source"):
|
||||||
|
op.add_column(
|
||||||
|
"portfolio_ledger",
|
||||||
|
sa.Column(
|
||||||
|
"source",
|
||||||
|
sa.Text(),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("'broker'"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# runtime-precreated 환경: source가 NULL이면 명시적으로 backfill 후 NOT NULL + 기본값 보장
|
||||||
|
bind.execute(text("UPDATE portfolio_ledger SET source = 'broker' WHERE source IS NULL"))
|
||||||
|
op.alter_column(
|
||||||
|
"portfolio_ledger",
|
||||||
|
"source",
|
||||||
|
existing_type=sa.Text(),
|
||||||
|
server_default=sa.text("'broker'"),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not _column_exists(inspector, "portfolio_ledger", "target_ledger_entry_id"):
|
||||||
|
op.add_column(
|
||||||
|
"portfolio_ledger",
|
||||||
|
sa.Column("target_ledger_entry_id", sa.BigInteger(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not _column_exists(inspector, "portfolio_ledger", "confirmation_evidence"):
|
||||||
|
op.add_column(
|
||||||
|
"portfolio_ledger",
|
||||||
|
sa.Column("confirmation_evidence", sa.Text(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not _column_exists(inspector, "portfolio_ledger", "actor_user_id"):
|
||||||
|
op.add_column(
|
||||||
|
"portfolio_ledger",
|
||||||
|
sa.Column("actor_user_id", sa.Text(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Forward-only policy: 운영 rollback은 컬럼 삭제/재적용 경로가 아니다.
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Forward-only migration: 컬럼 삭제 downgrade는 수행하지 않는다."""
|
||||||
|
return
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"""BAT-025: portfolio_ledger manual metadata migration behavior tests.
|
||||||
|
|
||||||
|
Focus areas:
|
||||||
|
- fresh schema: add required columns safely
|
||||||
|
- runtime-precreated schema: source backfill + source constraints 보정
|
||||||
|
- forward-only downgrade policy
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
|
||||||
|
|
||||||
|
_MIGRATION_PATH = Path(__file__).resolve().parents[1] / "migrations" / "versions" / "20260715_0028_portfolio_ledger_manual_metadata.py"
|
||||||
|
_MIGRATION_NAME = "m_20260715_0028_portfolio_ledger_manual_metadata"
|
||||||
|
_spec = importlib.util.spec_from_file_location(_MIGRATION_NAME, _MIGRATION_PATH)
|
||||||
|
assert _spec is not None and _spec.loader is not None
|
||||||
|
_migration = importlib.util.module_from_spec(_spec)
|
||||||
|
_spec.loader.exec_module(_migration)
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeInspector:
|
||||||
|
def __init__(self, columns: list[str]) -> None:
|
||||||
|
self._columns = columns
|
||||||
|
|
||||||
|
def get_table_names(self) -> list[str]:
|
||||||
|
return ["portfolio_ledger"]
|
||||||
|
|
||||||
|
def get_columns(self, table_name: str) -> list[dict[str, Any]]:
|
||||||
|
assert table_name == "portfolio_ledger"
|
||||||
|
return [{"name": name} for name in self._columns]
|
||||||
|
|
||||||
|
|
||||||
|
class _MissingLedgerInspector(_FakeInspector):
|
||||||
|
def get_table_names(self) -> list[str]:
|
||||||
|
return ["other_table"]
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeResult:
|
||||||
|
def __init__(self, scalar_value: Any = None) -> None:
|
||||||
|
self._scalar_value = scalar_value
|
||||||
|
|
||||||
|
def scalar(self) -> Any:
|
||||||
|
return self._scalar_value
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeBind:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.queries: list[str] = []
|
||||||
|
|
||||||
|
def execute(self, statement: object, *args: object, **kwargs: object) -> _FakeResult:
|
||||||
|
self.queries.append(str(statement).strip())
|
||||||
|
return _FakeResult(0)
|
||||||
|
|
||||||
|
|
||||||
|
def _sql_contains(queries: list[str], needle: str) -> bool:
|
||||||
|
return any(needle in q for q in queries)
|
||||||
|
|
||||||
|
|
||||||
|
def test_upgrade_adds_all_columns_on_fresh_portfolio_ledger() -> None:
|
||||||
|
fake_bind = _FakeBind()
|
||||||
|
fake_inspector = _FakeInspector(columns=[])
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(_migration.op, "get_bind", return_value=fake_bind),
|
||||||
|
patch.object(_migration, "inspect", return_value=fake_inspector),
|
||||||
|
patch.object(_migration.op, "add_column") as add_column,
|
||||||
|
patch.object(_migration.op, "alter_column") as alter_column,
|
||||||
|
):
|
||||||
|
_migration.upgrade()
|
||||||
|
|
||||||
|
assert add_column.call_count == 4
|
||||||
|
called = [call.args[1].name for call in add_column.call_args_list]
|
||||||
|
assert called == [
|
||||||
|
"source",
|
||||||
|
"target_ledger_entry_id",
|
||||||
|
"confirmation_evidence",
|
||||||
|
"actor_user_id",
|
||||||
|
]
|
||||||
|
alter_column.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_upgrade_preserves_data_and_enforces_source_when_precreated() -> None:
|
||||||
|
fake_bind = _FakeBind()
|
||||||
|
fake_inspector = _FakeInspector(columns=[
|
||||||
|
"source",
|
||||||
|
"target_ledger_entry_id",
|
||||||
|
"confirmation_evidence",
|
||||||
|
"actor_user_id",
|
||||||
|
])
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(_migration.op, "get_bind", return_value=fake_bind),
|
||||||
|
patch.object(_migration, "inspect", return_value=fake_inspector),
|
||||||
|
patch.object(_migration.op, "add_column") as add_column,
|
||||||
|
patch.object(_migration.op, "alter_column") as alter_column,
|
||||||
|
):
|
||||||
|
_migration.upgrade()
|
||||||
|
|
||||||
|
add_column.assert_not_called()
|
||||||
|
alter_column.assert_called_once()
|
||||||
|
alter_kwargs = alter_column.call_args.kwargs
|
||||||
|
assert str(alter_kwargs["existing_type"]).upper().startswith("TEXT")
|
||||||
|
assert alter_kwargs["server_default"].text == "'broker'"
|
||||||
|
assert alter_kwargs["nullable"] is False
|
||||||
|
assert _sql_contains(fake_bind.queries, "UPDATE portfolio_ledger SET source = 'broker' WHERE source IS NULL")
|
||||||
|
|
||||||
|
|
||||||
|
def test_upgrade_noop_when_portfolio_ledger_missing() -> None:
|
||||||
|
fake_bind = _FakeBind()
|
||||||
|
fake_inspector = _MissingLedgerInspector(columns=[])
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(_migration.op, "get_bind", return_value=fake_bind),
|
||||||
|
patch.object(_migration, "inspect", return_value=fake_inspector),
|
||||||
|
patch.object(_migration.op, "add_column") as add_column,
|
||||||
|
patch.object(_migration.op, "alter_column") as alter_column,
|
||||||
|
):
|
||||||
|
_migration.upgrade()
|
||||||
|
|
||||||
|
add_column.assert_not_called()
|
||||||
|
alter_column.assert_not_called()
|
||||||
|
assert fake_bind.queries == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_downgrade_is_forward_only_noop() -> None:
|
||||||
|
with patch.object(_migration.op, "get_bind", return_value=MagicMock()):
|
||||||
|
result = _migration.downgrade()
|
||||||
|
assert result is None
|
||||||
Reference in New Issue
Block a user