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