- 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)
136 lines
4.8 KiB
Markdown
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 → 새 코드 배포.
|