From 1909caa22ce3f7d271dfb619b9095f998d0566cc Mon Sep 17 00:00:00 2001 From: Justin Ko Date: Wed, 15 Jul 2026 12:15:56 +0900 Subject: [PATCH] feat: BAT-025 add portfolio ledger manual metadata migration --- .../implementation.md | 48 +++++++ ...ortfolio-ledger-manual-metadata-runbook.md | 130 +++++++++++++++++ ...5_0028_portfolio_ledger_manual_metadata.py | 96 +++++++++++++ ...tfolio_ledger_manual_metadata_migration.py | 133 ++++++++++++++++++ 4 files changed, 407 insertions(+) create mode 100644 docs/batches/bat-025_20260715_portfolio-ledger-alembic-migration/implementation.md create mode 100644 docs/runbooks/bat-025-req057-portfolio-ledger-manual-metadata-runbook.md create mode 100644 migrations/versions/20260715_0028_portfolio_ledger_manual_metadata.py create mode 100644 tests/test_bat025_portfolio_ledger_manual_metadata_migration.py diff --git a/docs/batches/bat-025_20260715_portfolio-ledger-alembic-migration/implementation.md b/docs/batches/bat-025_20260715_portfolio-ledger-alembic-migration/implementation.md new file mode 100644 index 0000000..27db566 --- /dev/null +++ b/docs/batches/bat-025_20260715_portfolio-ledger-alembic-migration/implementation.md @@ -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` diff --git a/docs/runbooks/bat-025-req057-portfolio-ledger-manual-metadata-runbook.md b/docs/runbooks/bat-025-req057-portfolio-ledger-manual-metadata-runbook.md new file mode 100644 index 0000000..66f37ba --- /dev/null +++ b/docs/runbooks/bat-025-req057-portfolio-ledger-manual-metadata-runbook.md @@ -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= +export TIMESCALEDB_PORT= +export TIMESCALEDB_NAME= +export TIMESCALEDB_USER= +export TIMESCALEDB_PASSWORD= +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 활성화 수행 diff --git a/migrations/versions/20260715_0028_portfolio_ledger_manual_metadata.py b/migrations/versions/20260715_0028_portfolio_ledger_manual_metadata.py new file mode 100644 index 0000000..8caf1e2 --- /dev/null +++ b/migrations/versions/20260715_0028_portfolio_ledger_manual_metadata.py @@ -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 diff --git a/tests/test_bat025_portfolio_ledger_manual_metadata_migration.py b/tests/test_bat025_portfolio_ledger_manual_metadata_migration.py new file mode 100644 index 0000000..82f3e6c --- /dev/null +++ b/tests/test_bat025_portfolio_ledger_manual_metadata_migration.py @@ -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