fix(ledger): dcy-015 outbox 영속 오류 분류 + 백오프/dead-letter (BAT-019)

portfolio-hub 404(미등록 포트폴리오)가 retryable 로 분류되어 백오프·상한 없이
초당 영구 재시도되던 버그(issue #13, 실측 930회) 정정.

거버넌스: 전부 change-req (신규 REQ 없음, dcy-015 OQ-3; 백오프 env 는 REQ-019 흡수).
- REQ-017: 404/400/422 → LedgerPermanentError 분류 (409 conflict / 408·429·5xx·
  network retryable 불변). LedgerPort 에 permanent 타입 추가
- REQ-019: 영속 → 터미널 delivery_failed 즉시(재시도 0). retryable 지수 백오프
  +jitter+next_retry_at, max_attempts 초과 시 dead-letter delivery_failed.
  list_pending 이 백오프 행 스킵(head-of-line 보호). 마이그레이션 007(status enum
  +next_retry_at). 백오프 env(base/cap/max-attempts)
- REQ-018: 터미널/dead-letter WARN + WriterStats.delivery_failed 카운터 가시화
- REQ-020: runtime-flows/data-model/interface-contract 정합

단일 터미널 delivery_failed(permanent/dead-letter, last_outcome 로 사유 구분) —
conflict_blocked 패턴과 동형. dead-letter 는 운영자 재처리 가능 상태.

검증: 단위 311 passed/34 skipped, 인프라 게이트(PG+RabbitMQ) 345 passed, ruff clean.
신규 test_ledger_outbox_backoff + adapter/PG 테스트, 기존 retryable 백오프 정합.
BAT-019 release-candidate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 16:12:17 +09:00
co-authored by Claude Opus 4.8
parent 0b7db66463
commit b851b927ee
29 changed files with 1069 additions and 40 deletions
@@ -0,0 +1,52 @@
# BAT-019 Design
- Status: confirmed
- Batch: BAT-019
## 설계 목표
- portfolio-hub 404(미등록 포트폴리오)의 초당 무한 재시도(실측 930회)를 제거하고, 일시 오류는 절제된 백오프로 재시도하되 상한 초과 시 dead-letter 로 종료한다. 터미널 실패는 가시화한다(issue #13).
## 1. 오류 분류 (REQ-017)
- `LedgerPort` 에 `LedgerPermanentError(LedgerError)` 추가.
- `portfolio_hub.py`: 응답 분류 — 2xx 성공, 409→Conflict(불변), **404/400/422→Permanent**(`_PERMANENT_STATUS` frozenset), 그 외 4xx/5xx/network→Retryable. 408/429 는 permanent 집합에 없으므로 retryable 유지(OQ-1).
- `InMemoryLedger` 에 `permanent_executions` 주입 hook(테스트용).
## 2. outbox 상태머신 (REQ-019)
- OutboxOutcome 추가: `PERMANENT_FAILURE`, `DEAD_LETTERED`. 둘 다 WorkItemStatus 신규 `DELIVERY_FAILED`(단일 터미널, 사유는 last_outcome). `_DELIVERY_FAILED_OUTCOMES` frozenset.
- `LedgerWorkItem.next_retry_at: Optional[datetime]` 추가.
- `LedgerWriter._process`:
- `LedgerPermanentError` → `mark_outcome(PERMANENT_FAILURE)` + WARN → 터미널(재시도 0).
- `LedgerRetryableError` → `new_attempts = item.attempts+1`; `new_attempts >= max_attempts` 면 `DEAD_LETTERED`+WARN(터미널); 아니면 `next_retry_at = clock() + backoff(new_attempts)` 로 `RETRYABLE_FAILURE`(pending).
- conflict/완료/중복 경로 불변.
- `_backoff_delay(n) = min(base*2^(n-1), cap) * (1 + 0.25*rng())`. clock/rng 주입(결정론).
- `mark_outcome(..., next_retry_at=None)` 시그니처 확장 — retryable 일 때만 저장, 그 외 None.
## 3. list_pending 백오프 스킵 (REQ-019, NFR-3)
- InMemory: `status==PENDING and (next_retry_at is None or next_retry_at <= now)`. now 는 outbox 의 주입 clock(writer 와 공유해 결정론).
- PG: `WHERE status='pending' AND (next_retry_at IS NULL OR next_retry_at <= now())`. 부분 인덱스 `idx_ledger_outbox_pending_retry`.
## 4. 마이그레이션 007 (REQ-019)
- `ADD COLUMN IF NOT EXISTS next_retry_at TIMESTAMPTZ`.
- status CHECK: DROP/ADD constraint 로 `delivery_failed` 추가(idempotent). last_outcome CHECK: permanent_failure/dead_lettered 추가.
- 글롭 재적용 모델 → DROP CONSTRAINT IF EXISTS + ADD 로 재실행 안전.
## 5. 가시화 (REQ-018, NFR-2)
- `WriterStats.delivery_failed` 카운터. WARN 로그(permanent: reason / dead-letter: attempts+reason). status `delivery_failed` 로 pending 과 구분.
- dcy-011 `ledger_skipped` 패턴 정합; dead-letter 누락은 REQ-055 `find_ledger_gap_slots` 와 연계.
## 6. 설정 (REQ-019)
- `DaemonConfig`: `ledger_retry_base_sec`(1.0)/`ledger_retry_cap_sec`(300.0)/`ledger_max_attempts`(8). env `TREX_LEDGER_RETRY_BASE_SEC`/`_CAP_SEC`/`TREX_LEDGER_MAX_ATTEMPTS`. daemon 이 LedgerWriter 에 주입.
## 7. baseline 문서 (REQ-020)
- runtime-flows: ledger writer permanent/backoff/dead-letter + list_pending 스킵 + env.
- data-model: ledger_outbox status `delivery_failed`·`next_retry_at`·last_outcome 확장.
- interface-contract: LedgerPermanentError 오류 계약.
## 정합/멱등 (NFR-4)
- 단일 터미널 패턴(A-3): 409 conflict_blocked 와 동형 → outbox 처리 구조 최소 변경. correlation_id dedup(REQ-019) 불변.
## 테스트 설계
- adapter: 404/400/422→permanent(param), 429→retryable, 409→conflict, 5xx/network→retryable.
- outbox(programmable LedgerPort + 주입 clock/rng): permanent 즉시 터미널·재시도 0, retryable 백오프 스킵→due 후 재시도, max_attempts dead-letter, WARN 가시화, 백오프 행이 신규 행 비잠식.
- PG 게이트: delivery_failed permanent 영속, 백오프 행 list_pending 스킵, 기존 retryable(base=0 즉시 due) 정합.
- 회귀: 기존 retryable 테스트(test_ledger_outbox/test_execution_ledger)에 공유 clock 주입으로 백오프 정합.
@@ -0,0 +1,42 @@
# BAT-019 Implementation
- Status: completed
- Batch: BAT-019
## 구현 요약
- dcy-015 ledger outbox 영속 오류 분류 + 백오프/dead-letter + 가시화(issue #13)를 전부 change-req 로 구현(신규 REQ 없음; 백오프 env 는 REQ-019 흡수).
## 변경 내역
### REQ-017 — 오류 분류
- `trex_core/application/ledger_port.py`: `LedgerPermanentError(LedgerError)` 추가.
- `trex_core/adapters/ledger/portfolio_hub.py`: `_PERMANENT_STATUS={400,404,422}` → `LedgerPermanentError`; 409 conflict·408/429/5xx/network retryable 불변. 헤더 계약 갱신.
- `trex_core/adapters/ledger/in_memory.py`: `permanent_executions` 주입 hook.
### REQ-019 — outbox 백오프/dead-letter
- `trex_core/application/ledger_outbox.py`:
- OutboxOutcome `PERMANENT_FAILURE`/`DEAD_LETTERED`; WorkItemStatus `DELIVERY_FAILED`(단일 터미널); `_DELIVERY_FAILED_OUTCOMES`.
- `LedgerWorkItem.next_retry_at`.
- `LedgerWriter(retry_base_sec/retry_cap_sec/max_attempts/clock/rng)` + `_backoff_delay` + `_process`(permanent→터미널, retryable→백오프/dead-letter).
- `mark_outcome(..., next_retry_at=...)`; `WriterStats.delivery_failed`.
- InMemory outbox: `clock` 주입, `list_pending` 백오프 스킵, mark_outcome 신규 outcome/next_retry_at.
- `trex_core/adapters/persistence/postgresql.py`: `_OUTBOX_COLUMNS` next_retry_at; `list_pending` `next_retry_at IS NULL OR <= now()` 스킵; `mark_outcome` permanent/dead-letter→delivery_failed + next_retry_at; `_row_to_item` next_retry_at.
- `trex_core/adapters/persistence/sql/007_ledger_outbox_delivery_failed_and_backoff.sql`(신규): next_retry_at 컬럼 + status/last_outcome CHECK 확장(idempotent) + 부분 인덱스.
- `trex_core/runtime/config.py`: `ledger_retry_base_sec`/`_cap_sec`/`ledger_max_attempts` + env. `trex_core/runtime/daemon.py`: LedgerWriter 에 주입.
### REQ-018 — 가시화
- `ledger_outbox.py` writer: permanent/dead-letter WARN 로그 + `WriterStats.delivery_failed` 카운터, status `delivery_failed` 구분.
### REQ-020 — baseline 문서
- `docs/runtime-flows.md`(ledger writer permanent/backoff/dead-letter + list_pending 스킵 + env), `docs/data-model.md`(ledger_outbox status/next_retry_at/last_outcome), `docs/interface-contract.md`(LedgerPermanentError). Last Updated → BAT-019.
## 테스트
- 신규 `tests/adapters/ledger/test_portfolio_hub_ledger.py` 확장: 404/400/422 permanent(param) + 429 retryable.
- 신규 `tests/application/test_ledger_outbox_backoff.py`: permanent 즉시 터미널·재시도 0, retryable 백오프 스킵→due 재시도, max_attempts dead-letter, WARN 가시화, 백오프 행 비잠식(5건).
- 신규 PG 게이트 `tests/adapters/persistence/test_postgresql_ledger_outbox.py` 확장: delivery_failed permanent 영속, 백오프 list_pending 스킵, 기존 retry(base=0) 정합.
- 회귀 정합: `test_ledger_outbox.py`/`test_execution_ledger.py` 의 retryable 테스트에 공유 clock 주입(백오프 advance).
## 검증 실행
- 단위: `TREX_BROKER=paper .venv/bin/python -m pytest -q` → `311 passed / 34 skipped`(`::test_sandbox_token_and_submit` deselect — LIVE `.env` 환경 의존, 본 batch 무관).
- 인프라 게이트: `TREX_POSTGRESQL_TEST_DSN=...(trex_core_test) TREX_RABBITMQ_TEST_URL=... pytest -q` → `345 passed`(마이그레이션 007 + delivery_failed/backoff PG round-trip 포함).
- `ruff check trex_core tests` → All checks passed.
@@ -0,0 +1,34 @@
# BAT-019: ledger outbox 영속 오류 분류 + 백오프/dead-letter + 가시화 (issue #13)
- Status: release-candidate
- Profile: standard
- Source Discovery: dcy-015
- Included REQ: REQ-017 (CHG-20260608-01), REQ-019 (CHG-20260608-01), REQ-018 (CHG-20260608-03), REQ-020 (CHG-20260608-01)
## Summary
- dcy-015(portfolio-hub 404 가 retryable 로 분류되어 백오프·상한 없이 영구 재시도, issue #13)을 단일 standard batch 로 delivery 한다. 거버넌스는 dcy-015 OQ-3 결정대로 **전부 change-req, 신규 REQ 없음**(백오프 env 는 REQ-019 에 흡수).
- 핵심: portfolio-hub 어댑터가 **404/400/422 → `LedgerPermanentError`** 로 분류하고, outbox 가 이를 터미널 `delivery_failed` 로 즉시 처리(재시도 0). 일시 오류는 **지수 백오프+jitter+max_attempts** 로 재시도하되 상한 초과 시 `delivery_failed`(dead-letter). `list_pending` 이 `next_retry_at` 미도래 행을 건너뛰어 FIFO head-of-line 잠식을 막고, 터미널 실패는 WARN+카운터로 가시화한다.
## Scope
- In Scope:
- 영속 오류 분류 분리(404/400/422 → `LedgerPermanentError`) (REQ-017)
- retryable 지수 백오프+jitter + `next_retry_at` + max_attempts + 단일 터미널 `delivery_failed`(permanent/dead-letter, 사유 구분) + `list_pending` 백오프 스킵 + status enum/컬럼 마이그레이션(007) + 백오프 env (REQ-019)
- 터미널/dead-letter WARN+메트릭 가시화 (REQ-018)
- runtime-flows/data-model/interface-contract 정합 (REQ-020)
- Out of Scope:
- safety-sell→KIS 영속 거부 backoff(dcy-010/REQ-052, 경로 다름)
- portfolio_id 누락 ledger skip(dcy-011/#11)
- dead-letter 행 재처리 트리거(운영 명령/자동 재처리) — 재처리 가능한 상태 제공까지, 트리거는 후속 분리
## Documents
- Planning: [planning.md](./planning.md)
- Design: [design.md](./design.md)
- Implementation: [implementation.md](./implementation.md)
- Verification: [verification.md](./verification.md)
## Notes
- Source discovery 단일(dcy-015). OQ-1(404/400/422 영속)·OQ-2(지수 백오프+next_retry_at·max-attempts env·단일 `delivery_failed`)·OQ-3(전부 change-req) Answered.
- 단일 터미널 패턴(A-3): 409 `conflict_blocked` 와 동일하게 `delivery_failed` 단일 status + last_outcome(permanent_failure/dead_lettered)로 사유 구분 → 스키마 최소 변경.
- 가시화(A-1): dcy-011 `ledger_skipped` 카운터·`find_ledger_gap_slots`(REQ-055) 패턴 정합.
- dead-letter 는 폐기 아님 — 운영자 재처리 가능 상태(R-3 완화).
- 마이그레이션 007 은 글롭 재적용 모델 → ADD COLUMN IF NOT EXISTS + DROP/ADD CONSTRAINT 로 idempotent.
@@ -0,0 +1,47 @@
# BAT-019 Planning
- Status: confirmed
- Batch: BAT-019
- Profile: standard
## Included REQ
- REQ-017 (Integration, change-req): 404/400/422 → `LedgerPermanentError` 분류 + LedgerPort 타입.
- REQ-019 (Exception, change-req): 백오프+jitter+`next_retry_at`+max_attempts+`delivery_failed`+마이그레이션 007+`list_pending` 스킵+env.
- REQ-018 (Integration, change-req): 터미널/dead-letter WARN+카운터 가시화.
- REQ-020 (Documentation, change-req): runtime-flows/data-model/interface-contract 정합.
## Delivery Plan
1. REQ-017: `ledger_port.py` `LedgerPermanentError`; `portfolio_hub.py` 404/400/422 매핑 + 헤더; `in_memory.py` permanent 주입 hook.
2. REQ-019: `ledger_outbox.py` OutboxOutcome(permanent_failure/dead_lettered)+WorkItemStatus(delivery_failed)+`next_retry_at`+LedgerWriter 백오프/dead-letter+`list_pending` 스킵+InMemory clock; `postgresql.py` next_retry_at/status/list_pending skip/mark_outcome; `sql/007_*.sql`; `config.py` env + daemon 배선.
3. REQ-018: writer WARN + `WriterStats.delivery_failed`.
4. REQ-020: 문서 3종 정합.
5. 테스트(adapter classify, outbox backoff/dead-letter/permanent/visibility, PG delivery_failed/skip, 기존 retryable 백오프 정합 migration) + 전체 회귀 + ruff + 인프라 게이트.
## Dependencies
- Internal:
- dcy-004/BAT-006 LedgerPort/outbox/writer(REQ-017/018/019) — 변경 대상.
- dcy-011/BAT-015 ledger skip 가시화·REQ-055 — 가시화 패턴 정합(A-1).
- dcy-010/BAT-014 백오프 env 관례(A-2, 경로 다름).
- External:
- PostgreSQL(status enum/next_retry_at 마이그레이션). portfolio-hub 4xx/5xx 규약(R-1, 운영 확인).
## Design Gate
- Design Required Before Implementation: yes
- Reason: (a) 오류 분류 분리 + outbox 상태머신 확장(영속/백오프/dead-letter)이 LedgerPort·어댑터·outbox·PG·마이그레이션에 교차 — 단일 터미널 `delivery_failed`(사유 구분) 경계와 `next_retry_at` 스킵 규칙을 설계에서 못박지 않으면 상태 정합(R-2)·과대 분류(R-1) 위험. (b) 백오프 공식·max_attempts 전이·clock 주입(결정론 테스트) 경계 확정. (c) status enum CHECK 마이그레이션 idempotency(글롭 재적용) 명시. → design.md 동일 턴 scaffold.
## Milestones
1. M1 — REQ-017 분류 + LedgerPermanentError, adapter 테스트 green
2. M2 — REQ-019 outbox 백오프/dead-letter/next_retry_at + InMemory, backoff 테스트 green
3. M3 — REQ-019 PG next_retry_at/status/skip + 마이그레이션 007, PG 게이트 green
4. M4 — REQ-018 가시화 + REQ-020 문서
5. M5 — 전체 회귀 + ruff + 인프라 게이트
## Risks
- R-1 (Medium): 영속 분류 과대 → 정상 거래 ledger 영구 실패. 대응: 404/400/422 한정(OQ-1) + dcy-011 admission portfolio_id 검증으로 등록-지연 404 감소 + dead-letter 가 운영자 재처리 가능 상태.
- R-2 (Medium): status enum 마이그레이션이 기존 conflict_blocked/pending/completed 와 부정합. 대응: 기존 터미널 패턴 그대로 따르는 단일 새 터미널 + 마이그레이션/전이 테스트.
- R-3 (Medium): dead-letter 단순 포기 시 ledger 영구 누락 고착. 대응: 운영자 재처리 가능 상태 + REQ-055 find_ledger_gap_slots·skip 가시화 연계.
## Notes
- 1차 게이트: 전체 회귀 green(백오프 도입이 기존 retryable 테스트에 미치는 영향 정합).
- 결정론: LedgerWriter/InMemory outbox 에 clock 주입 + rng 주입(jitter)로 백오프 테스트 결정화.
- PG list_pending 백오프 스킵은 인프라 게이트로 검증.
@@ -0,0 +1,57 @@
# BAT-019 Verification
- Status: verified
- Batch: BAT-019
## Acceptance Mapping
### REQ-017 (오류 분류, change-req)
| AC | Evidence |
|----|----------|
| 404/400/422 → LedgerPermanentError | tests/adapters/ledger/test_portfolio_hub_ledger.py::test_client_errors_are_permanent |
| 429 → retryable (영속 아님) | ::test_429_stays_retryable |
| 409 → conflict / 5xx·network → retryable 회귀 | ::test_409_surfaces_as_ledger_conflict_error / ::test_5xx_is_retryable / ::test_network_error_is_retryable |
### REQ-019 (백오프/dead-letter, change-req)
| AC | Evidence |
|----|----------|
| 영속 → delivery_failed 즉시(재시도 0) | tests/application/test_ledger_outbox_backoff.py::test_permanent_error_terminates_immediately_no_retry |
| retryable 백오프 + next_retry_at 스킵 | ::test_retryable_backoff_skips_until_next_retry_at |
| max_attempts → dead-letter delivery_failed | ::test_retryable_dead_letters_after_max_attempts |
| 백오프 행 FIFO 비잠식 | ::test_backoff_row_does_not_block_new_rows |
| PG delivery_failed 영속 + 마이그레이션 007 | tests/adapters/persistence/test_postgresql_ledger_outbox.py::test_writer_marks_delivery_failed_permanent_on_pg |
| PG 백오프 list_pending 스킵 | ::test_backoff_row_skipped_by_pending_scan_on_pg |
| 기존 retryable 정합(백오프) | test_ledger_outbox.py::test_writer_retries_until_success / test_execution_ledger.py::test_retryable_failure_then_eventually_completes / PG::test_writer_retries_on_pg |
### REQ-018 (가시화, change-req)
| AC | Evidence |
|----|----------|
| 터미널 delivery_failed WARN + 카운터 | test_ledger_outbox_backoff.py::test_permanent_failure_emits_warning (+ WriterStats.delivery_failed 단언 전반) |
### REQ-020 (baseline 문서, change-req)
| AC | Evidence |
|----|----------|
| runtime-flows/data-model/interface-contract 정합 | 문서 diff(BAT-019) |
## Evidence
- Test:
- 단위 전체 회귀: `311 passed / 34 skipped`(신규 backoff 5 + adapter 4 포함; `::test_sandbox_token_and_submit` deselect).
- 인프라 게이트(PG trex_core_test + RabbitMQ): `345 passed`(마이그레이션 007 적용 + delivery_failed/backoff PG round-trip 포함).
- `ruff check trex_core tests` → All checks passed.
- Logs: `311 passed, 34 skipped, 1 deselected` / `345 passed, 1 deselected ~17.4s`.
## 성공 기준(dcy-015) 대응
- S-1(404 즉시 터미널, attempts 폭증 없음): test_permanent_error_terminates_immediately_no_retry(ledger.calls==1) + PG::test_writer_marks_delivery_failed_permanent_on_pg.
- S-2(일시 오류 백오프 + max_attempts dead-letter, 무한 1초 재시도 0): test_retryable_backoff_skips_until_next_retry_at + test_retryable_dead_letters_after_max_attempts.
- S-3(터미널/dead-letter WARN·구분 status 가시화): test_permanent_failure_emits_warning + delivery_failed status 단언.
- S-4(백오프/영속 행 FIFO 비잠식): test_backoff_row_does_not_block_new_rows + PG::test_backoff_row_skipped_by_pending_scan_on_pg.
## Carried-Over / 비차단
- R-1(영속 분류 과대): 404/400/422 한정 + dcy-011 admission portfolio_id 검증으로 등록-지연 404 감소 + dead-letter 운영자 재처리 가능. portfolio-hub 실 4xx/5xx 규약(DATA)은 운영 검증 비차단.
- dead-letter 재처리 트리거(운영 명령/자동화)는 Out of Scope(후속 분리) — 본 주기는 재처리 가능한 `delivery_failed` 상태 제공까지.
- 실 portfolio-hub full-stack(미등록 portfolio 404) e2e 는 운영 검증 비차단(InMemory permanent 주입 + adapter status 매핑으로 단위 검증).
## Result
- Ready for Release: true
- Blocking Issues:
- 없음. 영속 분류·백오프·dead-letter·가시화 모두 단위 green + PG 마이그레이션 round-trip(345 passed). LIVE sandbox 토큰 deselect 는 `.env` 환경 의존(본 batch 무관).
+1
View File
@@ -26,3 +26,4 @@
| BAT-016 | standard | release-candidate | REQ-002 (CHG-20260608-01), REQ-003 (CHG-20260608-01), REQ-006 (CHG-20260608-01), REQ-007 (CHG-20260608-02), REQ-013 (CHG-20260608-01), REQ-015 (CHG-20260608-01), REQ-036 (CHG-20260608-01) | dcy-012 | [bat-016](./bat-016_20260608_sell-pricing-limit-price-rename/) |
| BAT-017 | standard | release-candidate | REQ-012 (CHG-20260608-01), REQ-013 (CHG-20260608-02), REQ-018 (CHG-20260608-02), REQ-031 (CHG-20260608-01), REQ-014 (CHG-20260608-01), REQ-015 (CHG-20260608-02) | dcy-013 | [bat-017](./bat-017_20260608_external-cancel-detection-stale-watchdog/) |
| BAT-018 | standard | release-candidate | REQ-005 (CHG-20260608-01), REQ-048 (CHG-20260608-01), REQ-009 (CHG-20260608-01) | dcy-014 | [bat-018](./bat-018_20260608_trading-date-timezone-daily-preallocation/) |
| BAT-019 | standard | release-candidate | REQ-017 (CHG-20260608-01), REQ-019 (CHG-20260608-01), REQ-018 (CHG-20260608-03), REQ-020 (CHG-20260608-01) | dcy-015 | [bat-019](./bat-019_20260608_ledger-outbox-permanent-error-backoff/) |