Files
portfolio-hub/docs/batches/bat-021_20260628_req047-step2-integration-and-runbook/design.md
T
genichin 3e1b571906 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)
2026-06-28 10:19:27 +09:00

136 lines
4.8 KiB
Markdown

# 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 → 새 코드 배포.