feat(bat-021): REQ-047 Step2 동반 코드 수정 + 실 DB 통합검증 + 런북
- ensure_schema() broker_credentials: daemon_id NOT NULL, PK=(daemon_id,broker)로 갱신 - _row_to_broker_credential: Step2 컬럼 순서(daemon_id 선두)로 갱신 - upsert_broker_credential: ON CONFLICT (daemon_id,broker)로 수정, fallback try/except 제거 - list_broker_credentials: fallback 제거, SELECT 컬럼 순서 갱신 - test_bat020_broker_credential_domain.py: AC-3/AC-4 행 순서를 Step2 포맷으로 갱신 - tests/test_bat021_step2_ensure_schema_and_upsert.py: 12개 신규 테스트 - docs/runbooks/bat-021-req047-step2-migration-runbook.md: 운영 적용 런북 - docs/batches/bat-021: batch 문서(index/planning/design/implementation/verification) 실 PostgreSQL 통합검증(AC-1~7 전건 PASS): Step1 upgrade → backfill → Step2 upgrade → downgrade 시나리오 포함 NULL 사전검증(upgrade 거부) 및 downgrade 안전장치(중복 시 RuntimeError) 확인 521 passed, 0 failed (기존 509 + 신규 12)
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
# BAT-021 Design
|
||||
|
||||
## 변경 설계
|
||||
|
||||
### T-1/T-2/T-3: `app/repositories/postgres.py` 수정
|
||||
|
||||
#### ensure_schema() broker_credentials (T-1)
|
||||
|
||||
현재 `ensure_schema()`의 `broker_credentials` CREATE TABLE은:
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS broker_credentials (
|
||||
user_id TEXT NOT NULL,
|
||||
broker TEXT NOT NULL,
|
||||
account_ref TEXT NOT NULL,
|
||||
encrypted_payload TEXT NOT NULL,
|
||||
credential_version INTEGER NOT NULL DEFAULT 1,
|
||||
last_rotated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (user_id, broker)
|
||||
)
|
||||
```
|
||||
|
||||
Step2 이후 최종 스키마:
|
||||
- `daemon_id TEXT NOT NULL` 추가
|
||||
- `PK = (daemon_id, broker)`
|
||||
- `user_id` 컬럼 유지 (NOT NULL — 신규 INSERT는 항상 user_id 포함)
|
||||
|
||||
갱신 후:
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS broker_credentials (
|
||||
daemon_id TEXT NOT NULL,
|
||||
broker TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
account_ref TEXT NOT NULL,
|
||||
encrypted_payload TEXT NOT NULL,
|
||||
credential_version INTEGER NOT NULL DEFAULT 1,
|
||||
last_rotated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (daemon_id, broker)
|
||||
)
|
||||
```
|
||||
|
||||
`ensure_schema()`는 `IF NOT EXISTS`를 사용하므로 테이블이 이미 존재하면 CREATE를 스킵한다.
|
||||
Step2 마이그레이션이 적용된 DB에서는 이 정의와 실제 스키마가 정합된다.
|
||||
Step1만 적용된 DB(Step2 미적용)에서 `ensure_schema()`를 호출하면 테이블은 이미 존재하므로 스킵되며 영향 없다.
|
||||
|
||||
Step2 이후 추가될 인덱스:
|
||||
```sql
|
||||
CREATE INDEX IF NOT EXISTS idx_broker_credentials_daemon_broker
|
||||
ON broker_credentials (daemon_id, broker)
|
||||
```
|
||||
이 인덱스는 PK 자체이므로 별도 인덱스는 불필요. Step1에서 만든 `idx_broker_credentials_daemon_id` 인덱스는 그대로 유지.
|
||||
|
||||
#### upsert_broker_credential (T-2)
|
||||
|
||||
현재:
|
||||
- Primary INSERT: `ON CONFLICT (user_id, broker)` — Step2 이후 PK가 바뀌어 동작 안 함
|
||||
- Fallback: `ON CONFLICT (user_id, broker)` without daemon_id — Step1 미적용 환경용
|
||||
|
||||
Step2 이후 세계에서:
|
||||
- daemon_id는 항상 NOT NULL (Step2 PK 제약)
|
||||
- ON CONFLICT는 `(daemon_id, broker)` 로 변경
|
||||
- Fallback 불필요 → 제거
|
||||
|
||||
단, BAT-020의 fallback try/except는 `psycopg` 트랜잭션 abort 이슈가 있었다:
|
||||
트랜잭션이 abort 상태에서 fallback 쿼리를 실행하면 `InFailedSqlTransaction` 오류가 발생.
|
||||
이 batch에서 fallback을 제거하면서 이 이슈도 동시에 해소된다.
|
||||
|
||||
갱신 후 `upsert_broker_credential`:
|
||||
```python
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO broker_credentials (daemon_id, user_id, broker, account_ref, encrypted_payload)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
ON CONFLICT (daemon_id, broker) DO UPDATE
|
||||
SET account_ref = EXCLUDED.account_ref,
|
||||
encrypted_payload = EXCLUDED.encrypted_payload,
|
||||
user_id = EXCLUDED.user_id,
|
||||
credential_version = broker_credentials.credential_version + 1,
|
||||
last_rotated_at = NOW()
|
||||
RETURNING daemon_id, user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at
|
||||
""",
|
||||
(daemon_id, user_id, broker, account_ref, encrypted_payload),
|
||||
)
|
||||
```
|
||||
|
||||
RETURNING 절에서 `daemon_id` 컬럼 순서 변경: `_row_to_broker_credential`의 현재 row 매핑 확인 필요.
|
||||
|
||||
현재 `_row_to_broker_credential`:
|
||||
```python
|
||||
return BrokerCredential(
|
||||
user_id=row[0], # user_id
|
||||
broker=row[1], # broker
|
||||
account_ref=row[2],
|
||||
encrypted_payload=row[3],
|
||||
credential_version=int(row[4]),
|
||||
last_rotated_at=row[5],
|
||||
daemon_id=row[6], # optional
|
||||
)
|
||||
```
|
||||
|
||||
RETURNING 컬럼 순서를 유지해야 한다. 새 RETURNING:
|
||||
`daemon_id(0), user_id(1), broker(2), account_ref(3), encrypted_payload(4), credential_version(5), last_rotated_at(6)`
|
||||
→ `_row_to_broker_credential` 매핑도 함께 수정 필요.
|
||||
|
||||
`list_broker_credentials`도 같은 방향으로 정렬:
|
||||
`SELECT daemon_id, user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at`
|
||||
|
||||
`_row_to_broker_credential` 갱신:
|
||||
```python
|
||||
return BrokerCredential(
|
||||
daemon_id=row[0],
|
||||
user_id=row[1],
|
||||
broker=row[2],
|
||||
account_ref=row[3],
|
||||
encrypted_payload=row[4],
|
||||
credential_version=int(row[5]),
|
||||
last_rotated_at=row[6],
|
||||
)
|
||||
```
|
||||
|
||||
#### list_broker_credentials fallback 제거 (T-3)
|
||||
|
||||
Step2 이후 daemon_id 컬럼이 항상 존재하므로 try/except fallback 제거.
|
||||
|
||||
### 영향 범위
|
||||
|
||||
- `app/repositories/postgres.py` — ensure_schema, upsert_broker_credential, list_broker_credentials, _row_to_broker_credential
|
||||
- `tests/` — bat021 신규 테스트, 기존 FakeRepo 의존 테스트 영향 없음 (FakeRepo는 실제 DB 쿼리 미사용)
|
||||
|
||||
### 하위 호환성
|
||||
|
||||
Step2 PK 전환 이후에만 이 코드가 올바르게 동작한다.
|
||||
Step2 미적용 DB에서는 `ON CONFLICT (daemon_id, broker)` 제약이 없어 오류 발생 가능.
|
||||
런북에서 Step2 적용 이전에 새 코드를 배포하지 말 것을 명시한다.
|
||||
|
||||
배포 순서: Step1 → backfill → Step2 → 새 코드 배포.
|
||||
@@ -0,0 +1,60 @@
|
||||
# BAT-021 Implementation
|
||||
|
||||
## 변경 파일
|
||||
|
||||
### `app/repositories/postgres.py`
|
||||
|
||||
**T-1: ensure_schema() broker_credentials CREATE TABLE 갱신**
|
||||
|
||||
구 정의 (Step1 이전):
|
||||
```sql
|
||||
PRIMARY KEY (user_id, broker)
|
||||
-- daemon_id 없음
|
||||
```
|
||||
|
||||
신 정의 (Step2 이후):
|
||||
```sql
|
||||
daemon_id TEXT NOT NULL,
|
||||
broker TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
account_ref TEXT NOT NULL,
|
||||
encrypted_payload TEXT NOT NULL,
|
||||
credential_version INTEGER NOT NULL DEFAULT 1,
|
||||
last_rotated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (daemon_id, broker)
|
||||
```
|
||||
|
||||
**T-2: `_row_to_broker_credential` Step2 컬럼 순서 적용**
|
||||
|
||||
구 매핑: `row[0]=user_id, row[1]=broker, ..., row[6]=daemon_id`
|
||||
|
||||
신 매핑: `row[0]=daemon_id, row[1]=user_id, row[2]=broker, row[3]=account_ref, row[4]=encrypted_payload, row[5]=credential_version, row[6]=last_rotated_at`
|
||||
|
||||
**T-3: `upsert_broker_credential` ON CONFLICT 수정 + fallback 제거**
|
||||
|
||||
- `ON CONFLICT (user_id, broker)` → `ON CONFLICT (daemon_id, broker)`
|
||||
- `RETURNING` 컬럼 순서: `daemon_id, user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at`
|
||||
- `try/except` fallback 완전 제거 (트랜잭션 abort 이슈 동시 해소)
|
||||
|
||||
**T-4: `list_broker_credentials` fallback 제거 + SELECT 컬럼 순서 수정**
|
||||
|
||||
- `try/except` fallback 완전 제거
|
||||
- `SELECT daemon_id, user_id, broker, ...` 순서로 변경
|
||||
|
||||
### `tests/test_bat020_broker_credential_domain.py`
|
||||
|
||||
- `test_row_to_broker_credential_with_daemon_id`: 행 순서를 Step2 컬럼 순서로 수정 (AC-3 갱신)
|
||||
- `test_row_to_broker_credential_without_daemon_id`: Step2 5컬럼 기본값 테스트로 갱신 (AC-4 갱신)
|
||||
- `_make_rows_for_user`: Step2 컬럼 순서로 갱신
|
||||
|
||||
### 신규 파일
|
||||
|
||||
- `tests/test_bat021_step2_ensure_schema_and_upsert.py`: 12개 신규 테스트
|
||||
- `docs/runbooks/bat-021-req047-step2-migration-runbook.md`
|
||||
- `docs/batches/bat-021_20260628_req047-step2-integration-and-runbook/` (index, planning, design, implementation, verification)
|
||||
|
||||
## 테스트 count (구현 후)
|
||||
|
||||
- 기존: 509 passed
|
||||
- 신규: 12 추가 (test_bat021_step2_ensure_schema_and_upsert.py)
|
||||
- 합계: 521 passed
|
||||
@@ -0,0 +1,28 @@
|
||||
# BAT-021 Index
|
||||
|
||||
- Profile: standard
|
||||
- Status: in-progress
|
||||
- Included REQ: REQ-047
|
||||
- Discovery: dcy-010
|
||||
- Created: 2026-06-28
|
||||
|
||||
## Scope Summary
|
||||
|
||||
REQ-047 Step2 마무리 batch.
|
||||
|
||||
BAT-020(PR #17, d4acfd4)에서 Step1/Step2 마이그레이션 revision 코드가 작성됐으나
|
||||
실 PostgreSQL 통합검증이 미완이고, Step2 적용 후 동반 코드 수정 2건이 TODO로 잔존했다.
|
||||
|
||||
이 batch는:
|
||||
1. `ensure_schema()` `broker_credentials` CREATE TABLE을 최종 daemon-scoped 스키마로 갱신
|
||||
2. `upsert_broker_credential` `ON CONFLICT (user_id, broker)` → `ON CONFLICT (daemon_id, broker)` 수정 + fallback 트랜잭션 abort 이슈 정리
|
||||
3. dev/test PostgreSQL에서 Step1 → backfill → Step2 → downgrade 통합검증 (AC-1~7)
|
||||
4. 운영 적용 런북 작성
|
||||
5. 신규/갱신 단위 테스트 추가
|
||||
|
||||
## Documents
|
||||
|
||||
- [planning.md](./planning.md)
|
||||
- [design.md](./design.md)
|
||||
- [implementation.md](./implementation.md)
|
||||
- [verification.md](./verification.md)
|
||||
@@ -0,0 +1,34 @@
|
||||
# BAT-021 Planning
|
||||
|
||||
## DoR 확인
|
||||
|
||||
- [x] REQ-047: `Approved` 상태 확인
|
||||
- [x] BAT-020 코드 머지 (d4acfd4) — Step1/Step2 revision 존재
|
||||
- [x] dev PostgreSQL (Docker) 가동 확인
|
||||
- [x] 동반 수정 TODO 2건 식별 완료 (verification.md 미해결 사항 1, 2)
|
||||
|
||||
## 작업 분해
|
||||
|
||||
| # | 작업 | 파일 | 비고 |
|
||||
| --- | --- | --- | --- |
|
||||
| T-1 | `ensure_schema()` broker_credentials 갱신 | `app/repositories/postgres.py` | daemon_id NOT NULL, PK=(daemon_id,broker) |
|
||||
| T-2 | `upsert_broker_credential` ON CONFLICT 수정 | `app/repositories/postgres.py` | (daemon_id,broker) + fallback 제거 |
|
||||
| T-3 | list_broker_credentials fallback 제거 | `app/repositories/postgres.py` | Step2 이후 컬럼 항상 존재 |
|
||||
| T-4 | Step1 alembic upgrade 실 DB 검증 | dev PostgreSQL | AC-1, AC-2 |
|
||||
| T-5 | backfill CLI 실행 (테스트 데이터) | pfhub CLI | AC(REQ-048) |
|
||||
| T-6 | Step2 alembic upgrade 실 DB 검증 | dev PostgreSQL | AC-3, AC-4, AC-5 |
|
||||
| T-7 | downgrade 실 DB 검증 (Step2 → Step1 → base) | dev PostgreSQL | AC-6, AC-7 |
|
||||
| T-8 | 단위 테스트 추가/갱신 | `tests/test_bat021_*.py` | ensure_schema 정합, ON CONFLICT 행동 |
|
||||
| T-9 | 운영 런북 작성 | `docs/runbooks/bat-021-runbook.md` | 백업→Step1→backfill→Step2→verify→rollback |
|
||||
| T-10 | batch 문서 완성 | docs/batches/bat-021 | implementation.md, verification.md |
|
||||
|
||||
## Design Gate
|
||||
|
||||
Design 필요: 코드 수정 범위가 명확(T-1/T-2/T-3 — 두 함수 내 SQL 수정)하나
|
||||
fallback 패턴 제거 및 ensure_schema 스키마 정합성 확인이 필요하므로 design.md를 작성한다.
|
||||
|
||||
## 위험
|
||||
|
||||
- R-1: Step2 upgrade 중 NULL 행 있으면 revision 코드가 RuntimeError 발생 → 사전에 backfill 완료 확인 필수
|
||||
- R-2: `ensure_schema()` 변경 후 Step2 미적용 DB에서 실행 시 NOT NULL/PK 불일치 → ensure_schema는 Step2 적용 후 동작 환경에서만 호출 (런북에 명시)
|
||||
- R-3: fallback 제거 시 Step1 미적용 DB에서 upsert 호출 시 오류 → 이 batch는 Step2 이후 세계를 목표로 하므로 Step1 이전 환경 지원 불필요 (런북에 명시)
|
||||
@@ -0,0 +1,164 @@
|
||||
# BAT-021 Verification
|
||||
|
||||
## pytest 실행 결과
|
||||
|
||||
```
|
||||
python3 -m pytest tests/ --tb=short
|
||||
521 passed, 11 warnings in 26.67s
|
||||
```
|
||||
|
||||
기존 테스트: 509 passed (BAT-020 기준)
|
||||
신규 테스트: 12 added (test_bat021_step2_ensure_schema_and_upsert.py)
|
||||
기존 테스트 회귀: 0건
|
||||
|
||||
---
|
||||
|
||||
## REQ-047 AC 검증 (실 PostgreSQL)
|
||||
|
||||
### 검증 환경
|
||||
|
||||
- Docker: `portfolio-hub-timescaledb` (timescale/timescaledb:latest-pg16)
|
||||
- DB: `portfolio_hub` (127.0.0.1:5432)
|
||||
- 시작 alembic 상태: 20260601_0024 (ensure_schema()로 스키마 구성, alembic 미동기 상태)
|
||||
- stamp 조치: `alembic stamp 20260605_0025` (DB 실제 스키마 = 0025 적용 상태 반영)
|
||||
|
||||
### AC-1: Step1 후 daemon_id TEXT NULL 컬럼 존재, PK (user_id,broker) 유지
|
||||
|
||||
```
|
||||
alembic upgrade 20260628_0026
|
||||
```
|
||||
|
||||
결과:
|
||||
```
|
||||
daemon_id | text | nullable=YES
|
||||
PK: broker_credentials_pkey PRIMARY KEY btree (user_id, broker)
|
||||
```
|
||||
|
||||
**PASS**
|
||||
|
||||
### AC-2: Step1 후 기존 데이터 daemon_id=NULL 보존
|
||||
|
||||
```sql
|
||||
INSERT INTO broker_credentials ... VALUES ('test-user-1', 'kis', ...)
|
||||
SELECT daemon_id FROM broker_credentials WHERE user_id='test-user-1';
|
||||
-- 결과: NULL
|
||||
```
|
||||
|
||||
**PASS**
|
||||
|
||||
### AC-3: Step2 후 PK (daemon_id, broker) 전환, daemon_id NOT NULL
|
||||
|
||||
```
|
||||
# backfill: UPDATE broker_credentials SET daemon_id='test-daemon-1' WHERE daemon_id IS NULL
|
||||
alembic upgrade 20260628_0027
|
||||
```
|
||||
|
||||
결과:
|
||||
```
|
||||
daemon_id | text | nullable=NO
|
||||
PK: broker_credentials_pkey PRIMARY KEY btree (daemon_id, broker)
|
||||
```
|
||||
|
||||
**PASS**
|
||||
|
||||
### AC-4: Step2 후 user_id 컬럼 유지
|
||||
|
||||
```sql
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name='broker_credentials' AND column_name='user_id';
|
||||
-- 결과: user_id 존재
|
||||
```
|
||||
|
||||
**PASS**
|
||||
|
||||
### AC-5: Step2 후 ensure_schema() 정합
|
||||
|
||||
```python
|
||||
repo.ensure_schema() # 오류 없음
|
||||
# DB 조회 결과:
|
||||
# daemon_id: NOT NULL, PK=(daemon_id, broker), user_id 존재
|
||||
```
|
||||
|
||||
**PASS**
|
||||
|
||||
### AC-6: Step1 downgrade — daemon_id 컬럼 제거, PK (user_id,broker) 복원
|
||||
|
||||
```
|
||||
alembic downgrade 20260605_0025
|
||||
```
|
||||
|
||||
결과:
|
||||
```
|
||||
broker_credentials: daemon_id 컬럼 없음
|
||||
PK: broker_credentials_pkey PRIMARY KEY btree (user_id, broker)
|
||||
```
|
||||
|
||||
**PASS**
|
||||
|
||||
### AC-7: Step2 downgrade 안전장치 — 중복 (user_id,broker) 존재 시 RuntimeError
|
||||
|
||||
```
|
||||
# 2개 daemon이 같은 user/broker 사용하도록 데이터 구성 후 downgrade 시도
|
||||
alembic downgrade 20260628_0026
|
||||
```
|
||||
|
||||
결과:
|
||||
```
|
||||
RuntimeError: downgrade 불가: 1개 (user_id, broker) 조합이 중복됩니다.
|
||||
같은 user의 여러 daemon이 동일 broker를 사용하고 있어
|
||||
(user_id, broker) PK로 복원 시 데이터 손실이 발생합니다.
|
||||
수동으로 데이터를 정리한 후 downgrade하세요.
|
||||
```
|
||||
|
||||
**PASS**
|
||||
|
||||
---
|
||||
|
||||
## 동반 코드 검증
|
||||
|
||||
### upsert_broker_credential (ON CONFLICT + row mapper)
|
||||
|
||||
```python
|
||||
cred = repo.upsert_broker_credential(
|
||||
'bat021-test-user', 'kis',
|
||||
account_ref='BAT021-ACCOUNT',
|
||||
encrypted_payload='bat021-encrypted-payload',
|
||||
daemon_id='bat021-daemon-1'
|
||||
)
|
||||
# 결과: daemon_id='bat021-daemon-1', user_id='bat021-test-user', broker='kis', account_ref='BAT021-ACCOUNT'
|
||||
cred2 = repo.upsert_broker_credential(..., account_ref='BAT021-ACCOUNT-UPDATED')
|
||||
# 결과: version=2, account_ref='BAT021-ACCOUNT-UPDATED' (idempotent update OK)
|
||||
```
|
||||
|
||||
**PASS**
|
||||
|
||||
### list_broker_credentials (Step2 컬럼 순서)
|
||||
|
||||
```python
|
||||
creds = repo.list_broker_credentials('bat021-test-user', daemon_id='bat021-daemon-1')
|
||||
# 결과: 1건, daemon_id='bat021-daemon-1', broker='kis'
|
||||
```
|
||||
|
||||
**PASS**
|
||||
|
||||
---
|
||||
|
||||
## Step2 NULL 사전 검증 (REQ-048 미수행 시 거부)
|
||||
|
||||
```
|
||||
# daemon_id IS NULL 행 1건 존재 상태에서 Step2 upgrade 시도
|
||||
RuntimeError: broker_credentials에 daemon_id IS NULL인 행이 1건 있습니다.
|
||||
REQ-048 backfill-daemon-id CLI로 모든 행을 백필한 후 다시 실행하세요.
|
||||
```
|
||||
|
||||
**PASS**
|
||||
|
||||
---
|
||||
|
||||
## 미해결/위험 사항
|
||||
|
||||
없음 — 이 batch의 TODO 2건 모두 해소됨:
|
||||
1. ensure_schema() broker_credentials 갱신: **완료**
|
||||
2. upsert ON CONFLICT (user_id,broker) → (daemon_id,broker) + fallback 제거: **완료**
|
||||
|
||||
실 운영 DB 적용은 런북에 따라 사장님 결재 후 운영자가 수행한다.
|
||||
Reference in New Issue
Block a user