feat: BAT-025 add portfolio ledger manual metadata migration

This commit is contained in:
2026-07-15 17:58:18 +09:00
committed by Hermes Agent
parent 600375beca
commit 1909caa22c
4 changed files with 407 additions and 0 deletions
@@ -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