From b851b927ee0da3edbc170640d36f89a2c3abfaf5 Mon Sep 17 00:00:00 2001 From: Justin Ko Date: Mon, 8 Jun 2026 16:12:17 +0900 Subject: [PATCH] =?UTF-8?q?fix(ledger):=20dcy-015=20outbox=20=EC=98=81?= =?UTF-8?q?=EC=86=8D=20=EC=98=A4=EB=A5=98=20=EB=B6=84=EB=A5=98=20+=20?= =?UTF-8?q?=EB=B0=B1=EC=98=A4=ED=94=84/dead-letter=20(BAT-019)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../design.md | 52 ++++ .../implementation.md | 42 ++++ .../index.md | 34 +++ .../planning.md | 47 ++++ .../verification.md | 57 +++++ docs/batches/index.md | 1 + docs/data-model.md | 9 +- ...dger-outbox-permanent-error-backoff-cap.md | 229 ++++++++++++++++++ docs/discovery/index.md | 3 +- docs/interface-contract.md | 5 +- docs/runtime-flows.md | 11 +- .../req-020_ledger-baseline-alignment.md | 38 +++ ...19_ledger-outbox-durability-idempotency.md | 39 +++ ...7_ledger-port-and-portfolio-hub-adapter.md | 39 +++ .../req-018_fill-to-ledger-recording.md | 39 +++ docs/srs/index.md | 2 +- .../ledger/test_portfolio_hub_ledger.py | 22 ++ .../test_postgresql_ledger_outbox.py | 32 ++- tests/application/test_execution_ledger.py | 24 +- tests/application/test_ledger_outbox.py | 11 +- .../application/test_ledger_outbox_backoff.py | 156 ++++++++++++ trex_core/adapters/ledger/in_memory.py | 9 + trex_core/adapters/ledger/portfolio_hub.py | 16 +- trex_core/adapters/persistence/postgresql.py | 18 +- ...ger_outbox_delivery_failed_and_backoff.sql | 25 ++ trex_core/application/ledger_outbox.py | 120 ++++++++- trex_core/application/ledger_port.py | 10 + trex_core/runtime/config.py | 12 + trex_core/runtime/daemon.py | 7 +- 29 files changed, 1069 insertions(+), 40 deletions(-) create mode 100644 docs/batches/bat-019_20260608_ledger-outbox-permanent-error-backoff/design.md create mode 100644 docs/batches/bat-019_20260608_ledger-outbox-permanent-error-backoff/implementation.md create mode 100644 docs/batches/bat-019_20260608_ledger-outbox-permanent-error-backoff/index.md create mode 100644 docs/batches/bat-019_20260608_ledger-outbox-permanent-error-backoff/planning.md create mode 100644 docs/batches/bat-019_20260608_ledger-outbox-permanent-error-backoff/verification.md create mode 100644 docs/discovery/dcy-015_20260608_ledger-outbox-permanent-error-backoff-cap.md create mode 100644 tests/application/test_ledger_outbox_backoff.py create mode 100644 trex_core/adapters/persistence/sql/007_ledger_outbox_delivery_failed_and_backoff.sql diff --git a/docs/batches/bat-019_20260608_ledger-outbox-permanent-error-backoff/design.md b/docs/batches/bat-019_20260608_ledger-outbox-permanent-error-backoff/design.md new file mode 100644 index 0000000..adcda8d --- /dev/null +++ b/docs/batches/bat-019_20260608_ledger-outbox-permanent-error-backoff/design.md @@ -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 주입으로 백오프 정합. diff --git a/docs/batches/bat-019_20260608_ledger-outbox-permanent-error-backoff/implementation.md b/docs/batches/bat-019_20260608_ledger-outbox-permanent-error-backoff/implementation.md new file mode 100644 index 0000000..87b0ac8 --- /dev/null +++ b/docs/batches/bat-019_20260608_ledger-outbox-permanent-error-backoff/implementation.md @@ -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. diff --git a/docs/batches/bat-019_20260608_ledger-outbox-permanent-error-backoff/index.md b/docs/batches/bat-019_20260608_ledger-outbox-permanent-error-backoff/index.md new file mode 100644 index 0000000..952061b --- /dev/null +++ b/docs/batches/bat-019_20260608_ledger-outbox-permanent-error-backoff/index.md @@ -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. diff --git a/docs/batches/bat-019_20260608_ledger-outbox-permanent-error-backoff/planning.md b/docs/batches/bat-019_20260608_ledger-outbox-permanent-error-backoff/planning.md new file mode 100644 index 0000000..aeff489 --- /dev/null +++ b/docs/batches/bat-019_20260608_ledger-outbox-permanent-error-backoff/planning.md @@ -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 백오프 스킵은 인프라 게이트로 검증. diff --git a/docs/batches/bat-019_20260608_ledger-outbox-permanent-error-backoff/verification.md b/docs/batches/bat-019_20260608_ledger-outbox-permanent-error-backoff/verification.md new file mode 100644 index 0000000..ae02931 --- /dev/null +++ b/docs/batches/bat-019_20260608_ledger-outbox-permanent-error-backoff/verification.md @@ -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 무관). diff --git a/docs/batches/index.md b/docs/batches/index.md index b49f73c..5ed6d05 100644 --- a/docs/batches/index.md +++ b/docs/batches/index.md @@ -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/) | diff --git a/docs/data-model.md b/docs/data-model.md index cfea257..2954a60 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -4,7 +4,7 @@ - Baseline Seed: .stage-pilot/bootstrap/baseline.yaml - Status: draft - Owner: project maintainers -- Last Updated (KST): 2026-06-08 (BAT-018) +- Last Updated (KST): 2026-06-08 (BAT-019) - Source Discovery / Batch: bootstrap-baseline (pre-Discovery) ## Project Summary @@ -132,7 +132,7 @@ - Purpose: 체결 → portfolio-hub 원장 기록을 슬롯 전이와 분리해 무유실 + 멱등으로 처리하기 위한 durable work-item store(dcy-004 OQ-2, orbit `LedgerWriter` 패턴). - Source of Truth: `ledger_outbox` (PostgreSQL) / 테스트는 in-memory `InMemoryLedgerOutbox` -- Lifecycle: pending → completed | conflict_blocked ; retryable_failure 는 status `pending` 유지(attempts++) +- Lifecycle: pending → completed | conflict_blocked | **delivery_failed** ; retryable_failure 는 status `pending` 유지(attempts++ + `next_retry_at` 백오프). delivery_failed = permanent(404/400/422 즉시) 또는 dead-letter(max_attempts 소진), 사유는 last_outcome 으로 구분(dcy-015/REQ-019). ### Key Fields @@ -141,10 +141,11 @@ - side :: TEXT CHECK ('buy','sell') - qty / price / fee / tax :: NUMERIC(18,4) - executed_at :: TIMESTAMPTZ? -- status :: TEXT CHECK ('pending','completed','conflict_blocked') +- status :: TEXT CHECK ('pending','completed','conflict_blocked','delivery_failed') — `delivery_failed` 추가(dcy-015, 마이그레이션 007) - attempts :: INTEGER :: 재시도 누적 -- last_outcome :: TEXT? CHECK ('completed','duplicate_completed','retryable_failure','conflict_blocked') +- last_outcome :: TEXT? CHECK ('completed','duplicate_completed','retryable_failure','conflict_blocked','permanent_failure','dead_lettered') - last_error :: TEXT? +- next_retry_at :: TIMESTAMPTZ? :: 백오프 중 재시도 가능 최소 시각(dcy-015/REQ-019). `list_pending` 은 `next_retry_at > now` 행을 건너뜀(head-of-line 보호) - created_at / updated_at :: TIMESTAMPTZ ### State Rules diff --git a/docs/discovery/dcy-015_20260608_ledger-outbox-permanent-error-backoff-cap.md b/docs/discovery/dcy-015_20260608_ledger-outbox-permanent-error-backoff-cap.md new file mode 100644 index 0000000..af998fd --- /dev/null +++ b/docs/discovery/dcy-015_20260608_ledger-outbox-permanent-error-backoff-cap.md @@ -0,0 +1,229 @@ +# 0. 문서 상태 +이 문서의 현재 상태와 식별 정보를 기록한다. 문서 추적과 승인 흐름에 필요한 최소 메타데이터를 적는다. + +- 상태: confirmed +- 문서 ID: dcy-015_20260608_ledger-outbox-permanent-error-backoff-cap +- 이슈명: portfolio-hub 404(미등록 포트폴리오)가 retryable 로 분류되어 백오프·상한 없이 영구 재시도됨 +- 작성 시각(KST): 2026-06-08 14:21 KST +- 마지막 갱신 시각(KST): 2026-06-08 18:10 KST +- 대체됨: 없음 +- 후속 Discovery 참조: 없음 +- 생성된 REQ 참조: REQ-017/019/018/020 (전부 change-req, 신규 REQ 없음 — OQ-3). REQ-018=CHG-20260608-03, REQ-017/019/020=CHG-20260608-01. 백오프 env 는 REQ-019 에 흡수. delivery: BAT-019 + +# 1. 계획 상태 요약 +현재 Discovery가 어떤 배경에서 작성되고 있는지, 연계 문서가 있는지, 지금 시점의 해석이 무엇인지 짧게 요약한다. + +- 연계 문서 현황: + - 관련 점수 파일: 미생성 + - 관련 TODO/메모: GitHub issue #13 (fourblocks2020/trex-core), 관련 issue #11(dcy-011, ledger silent skip) +- 해석: + - portfolio-hub 가 미등록 포트폴리오에 404 를 반환하면 ledger outbox 가 이를 `LedgerRetryableError`(일시적 실패)로 분류해 **백오프·상한·터미널 상태 없이 초당 1회 영구 재시도**하는 버그를 정식 SDLC 주기로 착수한다(실측: buy 행 930회 재시도). + - 이번 Discovery에서는 (1) 영속(permanent) 오류(404 등 명백한 클라이언트 오류) 분류 분리, (2) retryable 에 지수 백오프 + max-attempts + dead-letter, (3) 터미널 실패 가시화의 범위를 판정한다. 이미 Implemented·released 된 ledger 어댑터/outbox 계약 변경이므로, 거버넌스는 OQ-3 결정에 따라 기존 REQ(REQ-017/019/018/020) `change-req` 로 처리하고 신규 REQ 는 원칙적으로 생성하지 않는다. + +# 2. 요구사항 판정 결과 +이번 요청이 기존 요구사항과 비교해 신규인지, 유사한지, 중복인지 판단하고 그 근거를 남긴다. + +- 판정: 신규 +- 근거 + - 기존 Discovery(dcy-001~dcy-014) 중 ledger outbox 의 영속 오류 분류·백오프·상한·dead-letter 를 다룬 문서는 없다. dcy-004(portfolio-hub ledger 연동)는 경로를 도입했고, dcy-010(safety-sell 영속 거부 backoff)은 **다른 경로**(safety-sell→KIS)의 per-slot 쿨다운이며, dcy-011(issue #11)은 portfolio_id 누락 skip 가시화로 본 이슈(전송 단계 404)와 원인이 다르다. + - 변경이 이미 Implemented·released 된 LedgerPort/portfolio-hub 어댑터(REQ-017)와 outbox durability(REQ-019)의 오류 분류·상태 계약을 바꾸므로, 기존 Discovery 미세 조정이 아니라 독립 주기로 추적하는 것이 명확하다. + +# 3. 문제점의 요약 +문제의 현재 상태, 기대 상태, 관련 이해관계자, 제약사항을 정리한다. Discovery의 핵심 맥락을 가장 먼저 이해할 수 있어야 한다. + +- 현재 상태 + - `trex_core/adapters/ledger/portfolio_hub.py`(79~95)는 응답을 분류할 때 2xx→성공, 409→`LedgerConflictError`(터미널), **그 외 4xx/5xx 전부→`LedgerRetryableError`** 로 보낸다(파일 헤더 계약: `4xx (other)/5xx => LedgerRetryableError`). 따라서 404(portfolio 없음)처럼 **재시도로 절대 해소되지 않는 영속 오류**가 일시적 오류와 동일하게 취급된다. + - `trex_core/application/ledger_outbox.py`(137~142)는 `LedgerConflictError`→`conflict_blocked`(터미널), `LedgerRetryableError`→행을 `pending` 유지 + `attempts += 1` 이 전부다. **지수 백오프·max-attempts 상한·dead-letter 상태가 없다.** ledger 루프는 `ledger_interval_sec`(기본 1.0초)마다 `list_pending(limit=32)` 를 FIFO 로 다시 집어 재시도한다. + - `trex_core/adapters/persistence/sql/002_create_ledger_outbox.sql`(20~21)의 status CHECK 는 `pending`/`completed`/`conflict_blocked` 3개뿐이다. 즉 비-409 영속 실패를 담을 **터미널 실패 상태가 스키마에 없다**. + - 결과(실측, instance `trex-admin-daily-trading`, `portfolio_id=PF-test` 미등록): buy 행 `attempts=930`, sell 행 `attempts=9`, 둘 다 `status=pending last_outcome=retryable_failure`. 멈출 때까지 초당 1회 영구 호출. + - baseline 문서(`docs/project-structure.md`, `docs/runtime-flows.md`, `docs/data-model.md`)는 존재하며 최신이다. ledger outbox 처리는 기존 runtime flow 에 포함된다. +- 기대 상태 + - 404 등 명백한 클라이언트(영속) 오류는 retryable 이 아니라 **터미널 실패 상태**로 분류되어 재시도가 즉시 중단된다. + - 진짜 일시적 오류(5xx·네트워크·429 등)는 **지수 백오프 + max-attempts** 로 재시도하되, 상한 초과 시 **dead-letter** 로 전이되어 무한 1초 재시도가 사라진다. + - 터미널/dead-letter 실패가 WARN 로그·메트릭과 구분되는 status 로 가시화되어, 운영자가 "기록이 영영 안 됨" 을 인지하고 head-of-line 잠식이 해소된다. +- 이해관계자 + - 문서 작성자(AI agent) — Discovery/REQ/Batch 산출물 작성 + - 리뷰어/승인자 — ledger 오류 분류·outbox 상태 계약 변경 승인 + - 운영 담당자 — 영속 실패 가시화·미등록 포트폴리오 조치 + - portfolio-hub 운영 — 무한 404 호출 spam 제거로 부하 경감 +- 제약사항 + - 이미 released 된 LedgerPort/portfolio-hub 어댑터·outbox durability 변경이므로 ad-hoc 수정이 아니라 정식 SDLC(change-req 또는 신규 REQ) 경유가 필요하다. + - outbox status enum 확장은 스키마 마이그레이션을 동반하며 기존 `conflict_blocked` 터미널 패턴과 정합해야 한다. + - 영속 오류 분류가 과하면(예: 등록 지연으로 인한 일시적 404) 정상 거래의 ledger 를 영구 실패시킬 수 있어 분류 경계가 중요하다. + +## 주요 사용자 시나리오 +사용자 또는 운영자가 실제로 어떤 상황에서 이 변경을 필요로 하는지 대표 시나리오를 적는다. + +- 운영자가 미등록 portfolio_id 로 거래를 발생시킨다 → ledger 전송이 404 → 즉시 터미널 실패로 표시되고 WARN/메트릭으로 노출되어, 초당 무한 재시도 없이 운영자가 portfolio 등록 등 조치한다. +- portfolio-hub 가 일시적으로 5xx/네트워크 장애를 낸다 → ledger outbox 가 지수 백오프로 재시도하고, max-attempts 초과 시 dead-letter 로 전이해 무한 재시도를 막는다. +- 영속 실패 행이 다수 쌓여도 FIFO 배치(limit=32)를 잠식하지 않아 신규 ledger 행이 지연 없이 처리된다. + +# 4. 이번에 정의할 변경 +이번 Discovery에서 다루는 핵심 변경 항목을 목록으로 적는다. 기능, 구조, 규칙 변경을 함께 포함해도 된다. + +- ledger 어댑터(portfolio-hub) 오류 분류를 분리한다 — 404(및 명백한 클라이언트 오류)를 retryable 이 아니라 **영속 오류(LedgerPermanentError 등)** 로 매핑해 outbox 가 터미널 실패로 처리하게 한다. +- outbox retryable 처리에 **지수 백오프 + jitter + max-attempts** 를 도입하고, 상한 초과 시 단일 터미널 상태 `delivery_failed`(dead-letter, 사유 저장)로 전이한다. `list_pending` 이 `next_retry_at` 미도래 행을 건너뛰어 head-of-line 잠식을 막는다. +- outbox status enum 에 단일 터미널 `delivery_failed` 추가 + `next_retry_at` 컬럼 스키마 마이그레이션. (영속·dead-letter 공용, 사유로 구분) +- 터미널/dead-letter 실패를 WARN 로그·메트릭으로 가시화한다(dcy-011 #11 가시화 패턴 정합). +- ledger baseline 문서에 오류 분류·백오프·터미널 상태 흐름을 반영한다. + +## 영향 범위 +변경이 미칠 코드/문서/운영 범위를 명시해 REQ 정제와 batch planning에서 추정 누락을 줄인다. + +- 코드 영향: + - `trex_core/adapters/ledger/portfolio_hub.py` — 404/400/422 등 영속 오류 분류(신규 `LedgerPermanentError` 매핑), 계약 헤더 갱신 + - `trex_core/application/ledger_outbox.py` — 영속 오류→`delivery_failed` 터미널, retryable 지수 백오프+jitter + max-attempts → `delivery_failed`(dead-letter), `list_pending` 의 `next_retry_at` 스킵 + - `trex_core/adapters/persistence/sql/` — ledger_outbox status enum 에 `delivery_failed` 추가 + `next_retry_at` 컬럼 마이그레이션 + - `trex_core/runtime/config.py` — 백오프 base/cap·max-attempts env 설정(dcy-010 관례) + - LedgerPort 계약(에러 타입) — 영속 오류 타입 추가 +- 문서 영향: + - `docs/runtime-flows.md` — ledger outbox 오류 분류·백오프·터미널/dead-letter 흐름 반영 + - `docs/data-model.md` — ledger_outbox status enum 확장 반영 + - `docs/interface-contract.md` — ledger 어댑터 오류 분류 계약(필요 시) +- 운영 영향: + - 미등록 포트폴리오의 무한 404 spam 제거 → portfolio-hub 부하·로그 오염 경감 + - 터미널/dead-letter 가시화 + 운영자 재처리 경로 필요 + +# 5. 요구사항 목록 +실제로 구현과 검증으로 이어질 요구사항을 기능/비기능으로 나누어 정리하고, 범위 경계를 명확히 적는다. + +## 기능 요구사항 +- FR-1: portfolio-hub 어댑터는 영속(클라이언트) 오류를 retryable 과 분리한다 — **404·400·422**(OQ-1)를 `LedgerPermanentError`(가칭)로 매핑하고, outbox 는 이를 터미널 실패 상태로 처리해 재시도하지 않는다. 408·429·5xx·네트워크·(보수적으로)401/403 은 retryable 로 유지한다. +- FR-2: 진짜 일시적 오류(5xx·네트워크·408/429 등)는 **지수 백오프 + jitter**(`delay = min(base × 2^(attempts-1), cap)`, `next_retry_at` 기준)로 재시도하고, **max-attempts** 초과 시 터미널 `delivery_failed`(dead-letter)로 전이한다. 백오프 base/cap·max-attempts 는 env 설정(dcy-010 관례). 무한 1초 재시도를 제거한다. +- FR-3: ledger_outbox 에 **단일 터미널 실패 상태 `delivery_failed`** 를 추가(영속·dead-letter 공용, 사유 저장)하고 `next_retry_at` 컬럼 + status enum 마이그레이션을 추가한다. `list_pending` 은 `next_retry_at > now`(백오프 중) 행을 건너뛰어 FIFO head-of-line 잠식을 막는다. +- FR-4: 터미널/dead-letter 실패 발생 시 WARN 로그 + 메트릭으로 가시화하고, status 로 정상 `pending` 과 구분되게 한다(issue #11/dcy-011 가시화 패턴 정합). +- FR-5: `docs/runtime-flows.md`·`docs/data-model.md`(및 필요 시 `docs/interface-contract.md`)에 오류 분류·백오프·터미널 상태 흐름을 반영한다. + +## 비기능 요구사항 +성능, 운영성, 일관성, 자동화 안정성 등 기능 외 요구사항을 적는다. + +- NFR-1: 외부 호출 절제 — 영속 오류는 재시도하지 않고, 일시적 오류는 백오프로 호출 빈도를 제한해 portfolio-hub spam 이 발생하지 않아야 한다. +- NFR-2: 가시성 — ledger 가 끝내 기록되지 않는 실패는 메모리 카운터나 무성 pending 이 아니라 status·로그·메트릭으로 영구 관측 가능해야 한다(무성 영속 실패 금지). +- NFR-3: 처리량 보호 — 영속/백오프 대기 행이 FIFO 배치를 잠식해 신규 ledger 행 처리를 지연시키지 않아야 한다. +- NFR-4: 정합성 — 상태 전이(pending↔backoff↔terminal/dead-letter)와 멱등(correlation_id, REQ-019)이 깨지지 않아야 한다. + +## 범위 경계 +이번 반복에서 반드시 포함할 범위와 명시적으로 제외할 범위를 구분한다. + +- In Scope + - 영속 오류 분류 분리(404 등 → 터미널) + - retryable 지수 백오프 + max-attempts + dead-letter + list_pending 백오프 스킵 + - 터미널/dead-letter 가시화(로그/메트릭/status) + - status enum 마이그레이션 + ledger baseline 문서 갱신 +- Out of Scope + - safety-sell→KIS 영속 거부 backoff(dcy-010/REQ-052 에서 별도 처리 — 경로 다름) + - portfolio_id 누락 ledger skip(dcy-011/#11 에서 처리) + - dead-letter 행의 재처리 트리거(운영 명령/자동 재처리) — 이번 주기는 재처리 가능한 dead-letter 상태 제공까지이며, 재처리 트리거/자동화는 후속 분리(issue #13 핵심 해소에 불요) + +# 6. 리스크/가정 목록 +불확실성과 전제 조건을 분리해서 적는다. 리스크는 영향과 완화 방안을, 가정은 현재 판단의 전제를 기록한다. + +## 리스크 +- R-1 (Medium, OQ-1 으로 완화): 영속 오류 분류가 과하면 정상 거래의 ledger 가 영구 실패로 버려질 수 있다. + - 완화 방안: OQ-1 으로 permanent 범위를 404/400/422 로 한정했고, dcy-011 의 admission-time portfolio_id 검증으로 ledger 404 의 등록-지연 false-positive 가능성이 크게 줄었다. 잔여 false-positive 도 dead-letter 가 폐기가 아니라 **운영자 재처리 가능 상태**라 복구 가능하다. +- R-2 (Medium): status enum 확장 마이그레이션이 기존 `conflict_blocked`/`pending`/`completed` 와 정합하지 않으면 outbox 처리가 깨질 수 있다. + - 완화 방안: 기존 터미널(`conflict_blocked`) 패턴을 그대로 따르는 새 터미널 상태를 추가하고, 마이그레이션·상태 전이를 테스트로 검증한다. +- R-3 (Medium): dead-letter/터미널 행이 단순 포기되면 체결은 됐는데 ledger 영구 누락(P&L 왜곡)이 고착된다. + - 완화 방안: dead-letter 를 운영자 재처리 가능 상태로 두고, dcy-011 의 `find_ledger_gap_slots`(REQ-055)·skip 가시화와 연계해 누락을 추적·복구한다. + +## 가정 +아직 확정되지 않았지만 현재 문서 작성의 기반이 되는 전제를 적는다. + +- A-1: dcy-011(#11) 이 ledger skip 가시화(WARN+카운터, REQ-018)와 `find_ledger_gap_slots`(REQ-055) 를 이미 구현했으므로, 본 이슈의 터미널 실패 가시화도 동일 패턴으로 정합하게 추가할 수 있다. +- A-2: dcy-010(REQ-052/053) 의 백오프/쿨다운 + env 설정 관례가 있어, ledger 백오프 정책의 파라미터·설정 방식을 유사하게 설계할 수 있다(경로는 다름). +- A-3: 409 가 이미 `conflict_blocked` 터미널 상태로 처리되므로, 영속/ dead-letter 도 같은 터미널 상태 패턴으로 추가하면 outbox 처리 구조 변경을 최소화할 수 있다. + +## 오픈 질문 +답을 아직 모르는 항목을 적는다. 추정으로 덮지 말고 REQ 초안 작성 이전에 확인할 질문으로 남긴다. + +- OQ-1: 영속(permanent) 분류 범위 — 404 만 터미널로 보는가, 400/422 등 명백한 클라이언트 오류도 포함하는가? 408/429 같은 일시성 4xx 는 retryable 유지가 맞는가? + - 상태: Answered (2026-06-08, 사용자 결정 — 404/400/422 영속) + - 결정: **404·400·422 = 영속(터미널, 재시도 0)**, **408·429·5xx·네트워크 = retryable + 백오프**, 401/403 은 보수적으로 retryable+백오프(토큰 일시 갱신 가능성). 근거: dcy-011 이 admission 에서 portfolio_id 를 universe 검증(`unknown_portfolio` 거부)하므로 ledger 단계 404 는 타이밍 lag 이 아니라 portfolio-hub 미등록 = 진짜 config 갭 → 재시도로 해소 불가. 400/422 도 동일 payload 재전송으로 풀리지 않음. + - 반영 위치: FR-1, R-1, DATA +- OQ-2: 백오프·상한 정책 — 지수 백오프 파라미터(base/max), max-attempts 값, dead-letter 상태명·전이 조건은? + - 상태: Answered (2026-06-08, 사용자 결정 — 추천안 채택) + - 결정: (1) **지수 백오프 + jitter** — `delay = min(base × 2^(attempts-1), cap)`, `next_retry_at` 컬럼 추가, `list_pending` 은 `next_retry_at > now` 행을 건너뜀(head-of-line 해소). (2) **max-attempts** 초과 시 터미널 전이. 백오프 base/cap·max-attempts 는 **env 설정**(dcy-010 REQ-052/053 관례; 예: `TREX_LEDGER_RETRY_BASE_SEC`/`_CAP_SEC`/`TREX_LEDGER_MAX_ATTEMPTS`, 구체 default 는 REQ 정제에서 확정). (3) **단일 터미널 상태 `delivery_failed`** — 영속(즉시)과 dead-letter(백오프 소진) 둘 다 이 상태로 두고 **사유(reason/last_error)를 저장해 구분**(`conflict_blocked` 가 단일 상태인 것과 동일 패턴, 스키마 최소 변경). (4) **dead-letter 는 폐기 아님 — 운영자 재처리 가능 상태**. + - 반영 위치: FR-2, FR-3, 4. 변경 항목/영향 범위 +- OQ-3: 거버넌스 경로 — 기존 REQ(REQ-017 어댑터, REQ-019 outbox, REQ-018/055 가시화, REQ-020 문서)의 change-req 로 처리할지, 신규 REQ 로 추적할지? + - 상태: Answered (2026-06-08, 사용자 결정 — change-req 우선) + - 결정: 기존 Implemented REQ 본문 수정(change-req)으로 처리한다. change-req 대상 — REQ-017(LedgerPort + portfolio-hub 어댑터: 404/400/422→`LedgerPermanentError` 분류, 계약 헤더 갱신), REQ-019(ledger outbox durability: 지수 백오프+jitter+`next_retry_at`+max-attempts+단일 터미널 `delivery_failed`+status enum/컬럼 마이그레이션+`list_pending` 백오프 스킵+env 설정), REQ-018(체결→원장 fill hook: 터미널/dead-letter WARN+메트릭 가시화 — dcy-011 의 `ledger_skipped` 카운터 패턴 정합, `find_ledger_gap_slots` REQ-055 연계), REQ-020(ledger 레이어 baseline 문서 정합: runtime-flows/data-model 갱신). 백오프 env 설정은 REQ-019 에 흡수. 신규 REQ 는 원칙적으로 생성하지 않으며, dead-letter 운영자 재처리 트리거를 이번 주기에 포함하기로 하면 REQ 정제에서 소형 신규 REQ 가능(조건부). + - 반영 위치: 1. 거버넌스 해석, R-1~R-3 완화 방안, 8. 확인 체크 + +### 오픈 질문 처리 기준 +- `Open`: 아직 답이 없고 다음 단계 진행에 영향이 있는 상태다. 문서에 유지한다. +- `Answered`: 답이 확정된 상태다. 관련 본문/요구사항에 반영한 뒤 오픈 질문 목록에서는 제거하거나 resolved 기록으로 이동한다. +- `Deferred`: 지금 단계에서 결정하지 않고 다음 단계 또는 후속 이슈로 넘기는 상태다. 넘길 대상과 이유를 반드시 적는다. +- `Dropped`: 더 이상 유효하지 않은 질문이다. 삭제 사유를 짧게 남기고 목록에서 제거한다. + +# 7. 초기 성공 기준 +이 변경이 성공했다고 판단할 기준을 측정 가능하게 정의한다. 가능하면 데이터 출처까지 연결한다. + +- S-1: 404 응답이 즉시 터미널 실패 상태로 전이되고 재시도가 0회 추가된다(미등록 포트폴리오 시나리오에서 attempts 폭증 없음). +- S-2: 일시적 오류(5xx 등)가 지수 백오프로 재시도되고 max-attempts 초과 시 dead-letter 로 전이된다(무한 1초 재시도 0건). +- S-3: 터미널/dead-letter 실패가 WARN/메트릭·구분 status 로 가시화되어 정상 pending 과 구별된다. +- S-4: 영속/백오프 대기 행이 `list_pending` FIFO 배치를 잠식하지 않아 신규 행이 지연 없이 처리된다. + +## 측정 방식 및 데이터 출처 +각 성공 기준을 어떻게 측정할지와 어떤 데이터나 문서를 근거로 쓸지 적는다. + +- S-1 측정 방식: 404 응답 fixture 단위 테스트에서 터미널 상태 전이·재시도 0 확인 / 데이터 출처: portfolio_hub 어댑터·outbox 테스트, ledger_outbox status/attempts +- S-2 측정 방식: 5xx 반복 fixture 에서 백오프 간격 증가·max-attempts 후 dead-letter 확인 / 데이터 출처: outbox 처리 테스트, next_retry_at/attempts/status +- S-3 측정 방식: 터미널/dead-letter 발생 시 WARN·메트릭·status 확인 / 데이터 출처: 데몬 로그, 메트릭, ledger_outbox status +- S-4 측정 방식: 영속/백오프 대기 행 다수 + 신규 행 주입 시 신규 행 처리 확인 / 데이터 출처: list_pending 결과, 처리 순서/지연 + +# 8. REQ로 넘기기 전 확인 체크 +REQ 초안 작성 단계로 넘기기 전에 무엇이 확정되어야 하는지 확인한다. 미완료 항목은 후속 결정이 필요함을 뜻한다. + +- [x] 범위(In Scope/Out of Scope) 확정 + - 기준: permanent 분류(OQ-1)·백오프 정책(OQ-2)·거버넌스(OQ-3)·dead-letter 재처리 범위 확정으로 In/Out of Scope 채워짐 +- [x] 성공 지표의 측정 방식/데이터 출처 확정 + - 기준: S-1~S-4 의 테스트/검증 출처가 실제 실행 가능한 형태로 지정됨 +- [x] High 영향 리스크 우선순위 및 해소 책임자 지정 + - 기준: R-1(영속 분류 과대)·R-2(스키마 마이그레이션)·R-3(누락 고착)의 change-req 대상(REQ-017/019/018/020)이 OQ-3 으로 확정됨. 해소 실무 책임자 배정은 REQ/batch planning 단계로 넘김(비차단). +- [x] 모든 오픈 질문(OQ) `Open` 상태 없음 확인 + - 기준: OQ-1~OQ-3 모두 `Answered`, `Open` 0개 +- [x] Freeze 확정 담당자(CONFIRMED_BY) 지정 + - 기준: Confirmed By = Justin Ko (사람 승인자 지정 완료) + +# 9. 파일 처리 결과 +이 Discovery 문서 생성/갱신 결과와 참조 문서를 기록한다. 이후 추적과 리뷰 시 근거로 사용한다. + +- 처리 결과: 생성 +- 생성 경로: docs/discovery/dcy-015_20260608_ledger-outbox-permanent-error-backoff-cap.md +- 참조 문서: + - GitHub issue #13 (fourblocks2020/trex-core) — portfolio-hub 404 영구 재시도 버그 + - docs/discovery/dcy-004_20260604_portfolio-hub-ledger-integration.md — portfolio-hub ledger 경로 도입 근거 + - docs/discovery/dcy-011_20260608_buy-intent-portfolio-id-required-symbol-resolve.md — ledger skip 가시화·find_ledger_gap_slots 패턴(A-1) + - docs/discovery/dcy-010_20260607_safety-sell-persistent-rejection-backoff.md — 백오프/쿨다운 설계 관례(A-2, 경로 다름) + - trex_core/adapters/ledger/portfolio_hub.py:79-95, trex_core/application/ledger_outbox.py — 404 분류·백오프 부재 지점 + +# 10. 사용자 결정 필요 항목 요약 +사용자나 승인자가 결정해야 하는 항목만 모아 정리한다. 결정, 확인, 데이터 요청을 구분해서 적는다. + +## DECIDE +- (없음 — OQ-1/OQ-2/OQ-3 및 dead-letter 재처리 범위 확정으로 해소) + +> 결정 완료(2026-06-08): OQ-1 영속 분류 = 404/400/422 터미널(408/429/5xx/네트워크 retryable, 401/403 보수적 retryable) / OQ-2 백오프 = 지수+jitter+`next_retry_at`·max-attempts(env)·단일 `delivery_failed` 터미널(사유 저장)·dead-letter 운영자 재처리 가능 / dead-letter **재처리 트리거(운영 명령/자동화)는 본 주기 Out of Scope, 후속 분리**(이번 주기는 재처리 가능한 상태 제공까지). + +## CONFIRM +- 없음 — 거버넌스 경로는 OQ-3 으로 확정됨(REQ-017/019/018/020 change-req, REQ-055 연계, 신규 REQ 원칙적 없음). + +## DATA +- (비차단, REQ/delivery 단계) portfolio-hub 의 실제 4xx/5xx 응답 의미(404=영속, 429=rate-limit 등) 규약. 구현/검증 단계에서 실 응답·portfolio-hub 명세로 확정하며, OQ-1 의 404/400/422 영속 분류가 본 Discovery 의 FR/성공 기준을 충분히 정의하므로 confirm 을 차단하지 않는다. + +# 11. Discovery Freeze +REQ 초안 작성 인계 시점의 확정 플래그와 섹션 참조만 기록한다. 내용 재작성 없이 원본 섹션을 링크로 대신한다. + +## 섹션 참조 +- 범위: [# 5. 요구사항 목록 > 범위 경계](#5-요구사항-목록) +- 리스크/오픈 질문: [# 6. 리스크/가정 목록](#6-리스크가정-목록) +- 성공 기준: [# 7. 초기 성공 기준](#7-초기-성공-기준) +- 인계 전 확인: [# 8. REQ로 넘기기 전 확인 체크](#8-req로-넘기기-전-확인-체크) +- 사용자 결정 필요: [# 10. 사용자 결정 필요 항목 요약](#10-사용자-결정-필요-항목-요약) + +## Freeze 플래그 +- Handoff Decision: 승인 +- Handoff Rationale: confirm-discovery 완료. 오픈 질문 OQ-1~OQ-3 모두 Answered (영속 분류=404/400/422 / 백오프=지수+jitter+next_retry_at·max-attempts env·단일 `delivery_failed` 터미널 / 거버넌스=REQ-017/019/018/020 change-req, 신규 REQ 원칙적 없음). C 유형 플레이스홀더 0개, 저장소 사실관계 일치. dead-letter 재처리 트리거는 Out of Scope(후속). DATA(portfolio-hub 오류 규약)는 REQ/delivery 단계 비차단. REQ 초안 작성 인계 가능. +- Ready for REQ Drafting: true +- Confirmed By: Justin Ko +- Confirmed At (KST): 2026-06-08 15:00 KST diff --git a/docs/discovery/index.md b/docs/discovery/index.md index 5502189..d9262e3 100644 --- a/docs/discovery/index.md +++ b/docs/discovery/index.md @@ -19,4 +19,5 @@ | dcy-011_20260608_buy-intent-portfolio-id-required-symbol-resolve | 20260608 | buy intent — portfolio_id를 필수 키로, symbol은 universe(SnapshotStore)에서 resolve (+ issue #11 portfolio_id 누락 체결 ledger silent skip 가시화 흡수) | [Discovery](./dcy-011_20260608_buy-intent-portfolio-id-required-symbol-resolve.md) | confirmed | | dcy-012_20260608_sell-order-pricing-entry-price-bug | 20260608 | 매도 주문이 현재가가 아닌 entry_price(매수가) 지정가로 나가는 버그 — sell dispatch 가격 정책 부재 (issue #10) | [Discovery](./dcy-012_20260608_sell-order-pricing-entry-price-bug.md) | confirmed | | dcy-013_20260608_kis-external-cancel-pending-sell-stuck | 20260608 | KIS 외부 주문 취소 시 슬롯이 pending_sell 에 영구히 갇힘 — cncl_yn 미파싱 (issue #9) | [Discovery](./dcy-013_20260608_kis-external-cancel-pending-sell-stuck.md) | confirmed | -| dcy-014_20260608_trading-date-configurable-timezone-kst | 20260608 | 당일 슬롯 생성 기준 시간을 KST(설정 가능)로 분리 — trading_date 타임존 분리 + 일자 경계 preallocation (issue #14) | [Discovery](./dcy-014_20260608_trading-date-configurable-timezone-kst.md) | confirmed | \ No newline at end of file +| dcy-014_20260608_trading-date-configurable-timezone-kst | 20260608 | 당일 슬롯 생성 기준 시간을 KST(설정 가능)로 분리 — trading_date 타임존 분리 + 일자 경계 preallocation (issue #14) | [Discovery](./dcy-014_20260608_trading-date-configurable-timezone-kst.md) | confirmed | +| dcy-015_20260608_ledger-outbox-permanent-error-backoff-cap | 20260608 | portfolio-hub 404(미등록 포트폴리오)가 retryable 로 분류되어 백오프·상한 없이 영구 재시도됨 (issue #13) | [Discovery](./dcy-015_20260608_ledger-outbox-permanent-error-backoff-cap.md) | confirmed | \ No newline at end of file diff --git a/docs/interface-contract.md b/docs/interface-contract.md index bde1f98..b630c46 100644 --- a/docs/interface-contract.md +++ b/docs/interface-contract.md @@ -4,7 +4,7 @@ - Baseline Seed: .stage-pilot/bootstrap/baseline.yaml - Status: draft - Owner: project maintainers -- Last Updated (KST): 2026-06-08 (BAT-017) +- Last Updated (KST): 2026-06-08 (BAT-019) - Source Discovery / Batch: bootstrap-baseline (pre-Discovery); HTTP API = dcy-002 / BAT-002 - Primary Runtime: daemon (HTTP API) + library (slot 도메인) @@ -356,7 +356,8 @@ resume_kill_switch 요청·응답 (REQ-046): ### Error Contract - `LedgerConflictError` :: portfolio-hub 409 — 운영자 해소 대상(자동 재시도 중단) -- `LedgerRetryableError` :: 5xx / timeout / network — outbox 가 재시도 +- `LedgerPermanentError` :: portfolio-hub **404/400/422**(dcy-015/REQ-017, issue #13) — 재시도로 해소 불가(미등록 포트폴리오·malformed). outbox 가 즉시 터미널 `delivery_failed` 처리(재시도 0) +- `LedgerRetryableError` :: 5xx / timeout / network / 408 / 429 — outbox 가 **지수 백오프 + jitter** 로 재시도, `max_attempts` 초과 시 `delivery_failed`(dead-letter) ### portfolio-hub Endpoint (어댑터 구현) diff --git a/docs/runtime-flows.md b/docs/runtime-flows.md index 2c0093c..1304ab6 100644 --- a/docs/runtime-flows.md +++ b/docs/runtime-flows.md @@ -4,7 +4,7 @@ - Baseline Seed: .stage-pilot/bootstrap/baseline.yaml - Status: draft - Owner: project maintainers -- Last Updated (KST): 2026-06-08 (BAT-018) +- Last Updated (KST): 2026-06-08 (BAT-019) - Source Discovery / Batch: bootstrap-baseline (pre-Discovery); 데몬/HTTP 흐름 = dcy-002 / BAT-002 - Primary Runtime: daemon (HTTP API) + library (slot 도메인) @@ -222,14 +222,17 @@ 데몬 lifespan 에서 별도 백그라운드 워커가 ledger outbox 를 drain 한다. 슬롯 전이와 분리되어 eventually-consistent. -1. `LedgerOutbox.list_pending(limit)` 으로 pending work item 을 FIFO 로 가져온다. +1. `LedgerOutbox.list_pending(limit)` 으로 pending work item 을 FIFO 로 가져온다. **백오프 중(`next_retry_at > now`)인 행은 건너뛴다**(dcy-015/REQ-019 head-of-line 보호, NFR-3). 2. row 의 현재 상태가 이미 `completed` 면 (e.g. 동시/재처리) `duplicate_completed` 로 마킹하고 어댑터를 호출하지 않는다. 3. `LedgerPort.record_trade(trade)` 호출. - `LedgerWriteResult.NEW` → outcome `completed`, row status `completed`. - `LedgerWriteResult.DUPLICATE_OK`(어댑터 server-side dedup) → outcome `duplicate_completed`, row status `completed`, ledger 중복 0. - `LedgerConflictError`(portfolio-hub 409) → outcome `conflict_blocked`, row status `conflict_blocked` (자동 재시도 중단, 운영자 해소 대상). - - `LedgerRetryableError`(timeout/5xx/network) → outcome `retryable_failure`, row status `pending` 유지(다음 tick 재시도). -4. portfolio-hub 장기 장애에도 outbox 가 적재되어 무유실. 재시작 시 PostgreSQL `ledger_outbox.status='pending'` 행을 그대로 이어 처리한다. + - `LedgerPermanentError`(portfolio-hub **404/400/422**, dcy-015/REQ-017, issue #13) → outcome `permanent_failure`, row status **`delivery_failed`**(터미널, 재시도 0). 재시도로 해소 불가한 클라이언트 오류(미등록 포트폴리오 등) → 즉시 중단 + WARN. + - `LedgerRetryableError`(timeout/5xx/network/408/429) → **지수 백오프 + jitter**(`delay = min(base × 2^(attempts-1), cap)`, `next_retry_at` 설정) 후 다음 due tick 에 재시도. **`max_attempts` 초과 시** outcome `dead_lettered`, row status **`delivery_failed`**(dead-letter, 터미널) + WARN. 무한 1초 재시도 제거. +4. 터미널 실패 가시화(dcy-015/REQ-018): `delivery_failed`(permanent/dead-letter)는 `WriterStats.delivery_failed` 카운터 + WARN 로그 + 구분 status 로 노출되어 정상 `pending` 과 구별된다(무성 영속 실패 금지). dead-letter 는 폐기가 아니라 운영자 재처리 가능 상태이며 `find_ledger_gap_slots`(REQ-055)·skip 가시화와 연계해 누락을 추적한다. +5. 백오프 파라미터는 env 설정: `TREX_LEDGER_RETRY_BASE_SEC`(기본 1.0)/`TREX_LEDGER_RETRY_CAP_SEC`(기본 300)/`TREX_LEDGER_MAX_ATTEMPTS`(기본 8). +6. portfolio-hub 장기 장애에도 outbox 가 적재되어 무유실. 재시작 시 PostgreSQL `ledger_outbox.status='pending'` 행을 그대로 이어 처리한다(터미널 `delivery_failed`/`conflict_blocked` 는 제외). ## Shared Components diff --git a/docs/srs/Documentation/req-020_ledger-baseline-alignment.md b/docs/srs/Documentation/req-020_ledger-baseline-alignment.md index 67c574f..ada8f3a 100644 --- a/docs/srs/Documentation/req-020_ledger-baseline-alignment.md +++ b/docs/srs/Documentation/req-020_ledger-baseline-alignment.md @@ -145,3 +145,41 @@ - AC-1: 4 baseline 문서(runtime-flows/interface-contract/project-structure/data-model) 에 ledger 레이어 섹션 반영 완료. - AC-2: baseline 문서 ↔ 코드 정합 manual review (verification.md Manual 섹션). NFR-1 import 스윕 + grep 결과로 보강. - AC-3: 4 baseline 문서 Change Log 모두 2026-06-04 BAT-006 항목 추가. + +### CHG-20260608-01 + +- Date: 2026-06-08 +- Author: Justin Ko + +#### Change Summary + +- (change-req, dcy-015) baseline 문서를 ledger 오류 분류·백오프·터미널 상태에 맞춰 정합: `docs/runtime-flows.md`(ledger writer permanent/backoff/dead-letter + list_pending 백오프 스킵 + env), `docs/data-model.md`(ledger_outbox status `delivery_failed`·`next_retry_at`·last_outcome 확장), `docs/interface-contract.md`(LedgerPermanentError 오류 계약). BAT-019 정합 완료 → Implemented 유지. + +#### Intent + +- 코드(ledger_port/portfolio_hub/ledger_outbox/postgresql/config) 변경과 baseline 문서를 일치(S 기준)시켜 운영자 참조 정합 유지. + +#### Acceptance Criteria Delta + +- Updated: + - runtime-flows/data-model/interface-contract 의 ledger 오류 분류·백오프·터미널 상태 기술 정합. + +#### Impacted Area Delta + +- Docs: docs/runtime-flows.md, docs/data-model.md, docs/interface-contract.md + +#### Delivery Trace Delta + +- Batches: BAT-019 +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: no (문서 정합) +- Existing Implementation Invalidated: no +- Status Recommendation: Implemented (change-req → BAT-019 정합) +- Follow-up Action: confirm-req-implemented(BAT-019). + +#### Validation Plan + +- BAT-019: 코드-문서 diff 대조. diff --git a/docs/srs/Exception/req-019_ledger-outbox-durability-idempotency.md b/docs/srs/Exception/req-019_ledger-outbox-durability-idempotency.md index e8725c7..5a8aaa4 100644 --- a/docs/srs/Exception/req-019_ledger-outbox-durability-idempotency.md +++ b/docs/srs/Exception/req-019_ledger-outbox-durability-idempotency.md @@ -149,3 +149,42 @@ - AC-3: tests/application/test_execution_ledger.py::test_conflict_blocks_without_auto_retry + tests/application/test_ledger_outbox.py::test_writer_marks_conflict_blocked_and_does_not_retry. - AC-4: tests/application/test_execution_ledger.py::test_duplicate_completed_when_reprocessing_terminal_item + test_duplicate_execution_id_results_in_zero_duplicate_in_ledger. - AC-5: tests/application/test_execution_ledger.py::test_slot_transition_proceeds_when_ledger_is_down. + +### CHG-20260608-01 + +- Date: 2026-06-08 +- Author: Justin Ko + +#### Change Summary + +- (change-req, dcy-015/issue #13) ledger outbox 에 영속/백오프/dead-letter 도입: 영속 오류(`LedgerPermanentError`)→터미널 `delivery_failed` 즉시; retryable→**지수 백오프+jitter**(`next_retry_at`)·**max_attempts** 초과 시 `delivery_failed`(dead-letter). `list_pending` 이 `next_retry_at > now` 행을 건너뜀(head-of-line 보호). status enum 에 `delivery_failed`·`next_retry_at` 컬럼 마이그레이션(007). 백오프 env 설정(base/cap/max-attempts). BAT-019 재구현·재검증 완료 → Implemented 유지. + +#### Intent + +- 미등록 포트폴리오 404 의 초당 무한 재시도(실측 930회) 제거 + 일시 오류의 절제된 백오프 + FIFO 잠식 방지. + +#### Acceptance Criteria Delta + +- Added: + - 영속→`delivery_failed`(재시도 0), retryable→백오프+max_attempts→`delivery_failed`(dead-letter), `list_pending` 백오프 스킵, status enum/`next_retry_at` 마이그레이션, 백오프 env. + +#### Impacted Area Delta + +- Modules: `trex_core/application/ledger_outbox.py`(LedgerWriter 백오프/dead-letter, InMemory outbox), `trex_core/adapters/persistence/postgresql.py`(next_retry_at/status/list_pending skip), `trex_core/adapters/persistence/sql/007_*.sql`(신규), `trex_core/runtime/config.py`(env)+daemon 배선 +- Tests: tests/application/test_ledger_outbox_backoff.py, tests/adapters/persistence/test_postgresql_ledger_outbox.py(delivery_failed/backoff skip), test_ledger_outbox.py/test_execution_ledger.py(백오프 정합) + +#### Delivery Trace Delta + +- Batches: BAT-019 +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: yes (BAT-019 에서 완료) +- Existing Implementation Invalidated: yes (무한 재시도·백오프 부재 경로) +- Status Recommendation: Implemented (change-req → BAT-019 재검증) +- Follow-up Action: confirm-req-implemented(BAT-019). + +#### Validation Plan + +- BAT-019: permanent 즉시 터미널, backoff 스킵, max_attempts dead-letter, PG 마이그레이션 round-trip 단위/인프라 게이트. diff --git a/docs/srs/Integration/req-017_ledger-port-and-portfolio-hub-adapter.md b/docs/srs/Integration/req-017_ledger-port-and-portfolio-hub-adapter.md index d691902..dc125de 100644 --- a/docs/srs/Integration/req-017_ledger-port-and-portfolio-hub-adapter.md +++ b/docs/srs/Integration/req-017_ledger-port-and-portfolio-hub-adapter.md @@ -189,3 +189,42 @@ - AC-2: tests/adapters/ledger/test_portfolio_hub_ledger.py::test_post_contract_includes_execution_id_and_path_uses_portfolio_id (path `/v1/portfolios/PF-77/ledger/trades` + body `quantity`/`fee_amount`/`tax_amount` 단언, `qty`/`fee`/`tax` 부재 단언). - AC-1/AC-3/AC-4: 기존 테스트 회귀(16 passed) 유지. - 실 e2e: 매수 intent → 체결 → ledger POST 201 + outbox completed. + +### CHG-20260608-01 + +- Date: 2026-06-08 +- Author: Justin Ko + +#### Change Summary + +- (change-req, dcy-015/issue #13) portfolio-hub 어댑터 오류 분류 분리: **404/400/422 → `LedgerPermanentError`**(신규, 재시도로 해소 불가), 409→conflict(불변), 408/429/5xx/network→retryable(불변). LedgerPort 에 `LedgerPermanentError` 타입 추가 + 어댑터 계약 헤더 갱신. BAT-019 재구현·재검증 완료 → Implemented 유지. + +#### Intent + +- 미등록 포트폴리오 404 가 retryable 로 오분류되어 영구 재시도되던 근본 원인 제거 — 영속(클라이언트) 오류를 명시 타입으로 분리해 outbox 가 터미널 처리하게 한다. + +#### Acceptance Criteria Delta + +- Added: + - 어댑터가 404/400/422 를 `LedgerPermanentError` 로 매핑. LedgerPort 에 permanent 오류 타입. + +#### Impacted Area Delta + +- Modules: `trex_core/application/ledger_port.py`(LedgerPermanentError), `trex_core/adapters/ledger/portfolio_hub.py`(분류), `trex_core/adapters/ledger/in_memory.py`(permanent_executions 주입) +- Tests: tests/adapters/ledger/test_portfolio_hub_ledger.py(404/400/422 permanent, 429 retryable) + +#### Delivery Trace Delta + +- Batches: BAT-019 +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: yes (BAT-019 에서 완료) +- Existing Implementation Invalidated: yes (404 등을 retryable 로 분류하던 경로) +- Status Recommendation: Implemented (change-req → BAT-019 재검증) +- Follow-up Action: confirm-req-implemented(BAT-019). + +#### Validation Plan + +- BAT-019: 404/400/422→LedgerPermanentError, 429/5xx→retryable, 409→conflict 단위. diff --git a/docs/srs/Integration/req-018_fill-to-ledger-recording.md b/docs/srs/Integration/req-018_fill-to-ledger-recording.md index b00de48..ed6264a 100644 --- a/docs/srs/Integration/req-018_fill-to-ledger-recording.md +++ b/docs/srs/Integration/req-018_fill-to-ledger-recording.md @@ -228,3 +228,42 @@ #### Validation Plan - BAT-017: 부분 취소 시 sell 원장 1건(qty=체결분, price=체결가) 단위. + +### CHG-20260608-03 + +- Date: 2026-06-08 +- Author: Justin Ko + +#### Change Summary + +- (change-req, dcy-015/issue #13) ledger 터미널 실패 가시화 확장: `delivery_failed`(permanent/dead-letter)를 `WriterStats.delivery_failed` 카운터 + WARN 로그 + 구분 status 로 노출(dcy-011 `ledger_skipped` 가시화 패턴 정합). dead-letter 는 운영자 재처리 가능 상태이며 `find_ledger_gap_slots`(REQ-055)·skip 가시화와 연계. BAT-019 재구현·재검증 완료 → Implemented 유지. + +#### Intent + +- ledger 가 끝내 기록되지 않는 실패(404 영속·dead-letter)가 무성 pending 으로 남지 않고 운영자에게 노출되도록(NFR-2 가시성). + +#### Acceptance Criteria Delta + +- Updated: + - 원장 미기록(터미널 delivery_failed) 시 WARN 로그 + 카운터 + 구분 status 가시화. + +#### Impacted Area Delta + +- Modules: `trex_core/application/ledger_outbox.py`(WARN + WriterStats.delivery_failed) +- Tests: tests/application/test_ledger_outbox_backoff.py::test_permanent_failure_emits_warning + +#### Delivery Trace Delta + +- Batches: BAT-019 +- Releases: none + +#### Revalidation Impact + +- Reverification Needed: yes (BAT-019 에서 완료) +- Existing Implementation Invalidated: no (관측만 추가) +- Status Recommendation: Implemented (change-req → BAT-019 재검증) +- Follow-up Action: confirm-req-implemented(BAT-019). + +#### Validation Plan + +- BAT-019: delivery_failed WARN 로그(caplog) + 카운터 단위. diff --git a/docs/srs/index.md b/docs/srs/index.md index 5a3e2d9..e1f9a8a 100644 --- a/docs/srs/index.md +++ b/docs/srs/index.md @@ -139,8 +139,8 @@ ## Recent Change Log Summary +- 2026-06-08 :: REQ-017/019/018/020 change-req (dcy-015, BAT-019): portfolio-hub 404(미등록 포트폴리오)가 retryable 로 분류되어 백오프·상한 없이 영구 재시도되던 버그(issue #13) 정정. 404/400/422→`LedgerPermanentError`(REQ-017), outbox 영속→터미널 `delivery_failed` 즉시 + retryable 지수 백오프+jitter+`next_retry_at`+max_attempts→dead-letter + `list_pending` 백오프 스킵 + 마이그레이션 007(status enum/컬럼) + 백오프 env(REQ-019), 터미널 WARN+카운터 가시화(REQ-018), baseline 3문서 정합(REQ-020). 전부 change-req(신규 REQ 없음, OQ-3). 단위 311 passed, 인프라 게이트 345 passed, ruff clean. (change-req/confirm-req-implemented) - 2026-06-08 :: REQ-005/048/009 change-req (dcy-014, BAT-018): 당일 슬롯 생성 기준을 시스템 UTC 날짜에서 설정 타임존(기본 KST) 기준 `trading_date` 로 분리(timestamp 는 UTC 유지) + 데몬 1회 preallocation 을 `DailyPreallocationScheduler`(TZ 일자당 1회 멱등 fire, 재시작 없이 다음날 자동 생성)로 대체(REQ-005), TOML `trading_timezone`/`daily_preallocation_time` 키(REQ-048), runtime-flows/data-model 정합(REQ-009). 전부 change-req(신규 REQ 없음, OQ-3; 스케줄러는 REQ-030 패턴 준용). 단위 302 passed, 인프라 게이트 334 passed, ruff clean. (change-req/confirm-req-implemented) - 2026-06-08 :: REQ-012/013/018/031/014/015 change-req (dcy-013, BAT-017): KIS 외부 주문 취소 시 슬롯이 pending_sell 에 영구 갇히던 버그(issue #9) 정정. KIS `poll_fill` 이 `cncl_yn` 파싱해 외부 취소 감지 — 체결 0 취소→`cancelled`(롤백 자동 복구), 부분 체결 후 취소→`is_partial_cancel`(체결분 원장 1회+qty 차감+holding 복귀, OQ-1 A안)(REQ-012/013/018), 잔여 포지션 정합 연계(REQ-031), pending stale 워치독 Alert-only(`TREX_PENDING_STALE_THRESHOLD`, WARN+카운터, 상태 미변경)(REQ-014), baseline 2문서 정합(REQ-015). 전부 change-req(신규 REQ 없음, OQ-3). 단위 294 passed, 인프라 게이트 326 passed, ruff clean. (change-req/confirm-req-implemented) - 2026-06-08 :: REQ-013/007/036/002/006/003/015 change-req (dcy-012, BAT-016): 매도 주문이 매수가(entry_price) 지정가로 나가던 버그(issue #10) 정정. 슬롯 `entry_price`→`limit_price`(side 무관 pending 지정가) rename + pending 이탈 clear(REQ-002), 컬럼 rename 마이그레이션 006(REQ-006), `_dispatch` 가 slot.limit_price 단일 필드로 가격/주문유형 결정(REQ-013), SellIntent/sell payload optional limit_price·_admit_sell 운반(REQ-007), SlotService.sell optional 가격→자율 매도 시장가(REQ-036), 컬럼 계약 가드(REQ-003), baseline 3문서 정합(REQ-015). 전부 change-req(신규 REQ 없음, OQ-3). 단위 286 passed, 인프라 게이트 318 passed, ruff clean. (change-req/confirm-req-implemented) - 2026-06-08 :: REQ-007/018/021 change-req + REQ-055 생성·Implemented (dcy-011, BAT-015): buy intent 계약 hard cutover — portfolio_id 필수화 + symbol 서버 resolve(SnapshotStore) + unknown_portfolio/portfolio_id_required 거부(REQ-007), buy payload symbol 제거(REQ-021), ledger skip 가시화(WARN+ledger_skipped 카운터, REQ-018), closed↔outbox anti-join 운영 점검 `find_ledger_gap_slots` 신규(REQ-055). 전체 인프라 게이트 314 passed, ruff clean. (change-req/draft-req/confirm-req-implemented) -- 2026-06-08 :: REQ-052~054 Approved → Implemented (confirm-req-implemented): BAT-014(batch-lite) verify 완료(신규 8 단위, 전체 278 passed, ruff clean). SafetySellService per-slot 쿨다운(skip/만료재시도/경고/prune, market_close 제외), SafetySellConfig env 2종(min_retry_interval_sec 600/max_retry_warn_count 3), runtime-flows 정합. (confirm-req-implemented) diff --git a/tests/adapters/ledger/test_portfolio_hub_ledger.py b/tests/adapters/ledger/test_portfolio_hub_ledger.py index c8543d4..be77c31 100644 --- a/tests/adapters/ledger/test_portfolio_hub_ledger.py +++ b/tests/adapters/ledger/test_portfolio_hub_ledger.py @@ -10,6 +10,7 @@ import pytest from trex_core.adapters.ledger.portfolio_hub import PortfolioHubLedger from trex_core.application.ledger_port import ( LedgerConflictError, + LedgerPermanentError, LedgerRetryableError, LedgerTrade, ) @@ -76,3 +77,24 @@ def test_network_error_is_retryable(): adapter = PortfolioHubLedger("https://portfolio-hub.test", client=_client(handler)) with pytest.raises(LedgerRetryableError): adapter.record_trade(_trade()) + + +@pytest.mark.parametrize("status", [404, 400, 422]) +def test_client_errors_are_permanent(status): + """dcy-015 / REQ-017: 404/400/422 won't be fixed by retrying → permanent.""" + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(status, text='{"error":"portfolio not found"}') + + adapter = PortfolioHubLedger("https://portfolio-hub.test", client=_client(handler)) + with pytest.raises(LedgerPermanentError): + adapter.record_trade(_trade()) + + +def test_429_stays_retryable(): + """dcy-015 / REQ-017: rate-limit is transient → retryable (not permanent).""" + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(429, text="slow down") + + adapter = PortfolioHubLedger("https://portfolio-hub.test", client=_client(handler)) + with pytest.raises(LedgerRetryableError): + adapter.record_trade(_trade()) diff --git a/tests/adapters/persistence/test_postgresql_ledger_outbox.py b/tests/adapters/persistence/test_postgresql_ledger_outbox.py index 00f37c1..5e7a50f 100644 --- a/tests/adapters/persistence/test_postgresql_ledger_outbox.py +++ b/tests/adapters/persistence/test_postgresql_ledger_outbox.py @@ -60,7 +60,8 @@ def test_writer_marks_conflict_blocked_on_pg(pg_clean): def test_writer_retries_on_pg(pg_clean): outbox = PostgreSQLLedgerOutbox(pg_clean) ledger = InMemoryLedger(fail_n_then_succeed=1) - writer = LedgerWriter(outbox, ledger) + # dcy-015: base 0 => next_retry_at == now, so the row is immediately due again. + writer = LedgerWriter(outbox, ledger, retry_base_sec=0.0) outbox.enqueue(_trade(execution_id="pg-r")) writer.run_once() row1 = outbox.get("PF-1:ORD-1:pg-r") @@ -70,3 +71,32 @@ def test_writer_retries_on_pg(pg_clean): row2 = outbox.get("PF-1:ORD-1:pg-r") assert row2.status is WorkItemStatus.COMPLETED assert ledger.count() == 1 + + +def test_writer_marks_delivery_failed_permanent_on_pg(pg_clean): + """dcy-015 / REQ-019: 404/permanent → terminal delivery_failed persisted + (migration 007 status enum + last_outcome). No retry.""" + outbox = PostgreSQLLedgerOutbox(pg_clean) + ledger = InMemoryLedger(permanent_executions={"pg-perm"}) + writer = LedgerWriter(outbox, ledger) + outbox.enqueue(_trade(execution_id="pg-perm")) + s = writer.run_once() + assert s.delivery_failed == 1 + row = outbox.get("PF-1:ORD-1:pg-perm") + assert row.status is WorkItemStatus.DELIVERY_FAILED + assert row.last_outcome is OutboxOutcome.PERMANENT_FAILURE + # terminal — not returned by a subsequent pending scan + assert outbox.list_pending() == [] + + +def test_backoff_row_skipped_by_pending_scan_on_pg(pg_clean): + """dcy-015 / REQ-019: a future next_retry_at excludes the row from list_pending.""" + outbox = PostgreSQLLedgerOutbox(pg_clean) + ledger = InMemoryLedger(fail_n_then_succeed=99) + # large base => next_retry_at well in the future + writer = LedgerWriter(outbox, ledger, retry_base_sec=600.0, max_attempts=99) + outbox.enqueue(_trade(execution_id="pg-bo")) + writer.run_once() # -> retryable, next_retry_at ~ now + 600s + row = outbox.get("PF-1:ORD-1:pg-bo") + assert row.status is WorkItemStatus.PENDING and row.next_retry_at is not None + assert outbox.list_pending() == [] # still backing off -> skipped diff --git a/tests/application/test_execution_ledger.py b/tests/application/test_execution_ledger.py index b389544..a2506b0 100644 --- a/tests/application/test_execution_ledger.py +++ b/tests/application/test_execution_ledger.py @@ -25,13 +25,16 @@ from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository DAY = date(2026, 6, 4) -def _wire(broker, *, ledger=None): +def _wire(broker, *, ledger=None, clock=None): repo = InMemorySlotRepository() svc = SlotService(repo) intents = IntentService(svc, repo, InMemoryIntentStore()) - outbox = InMemoryLedgerOutbox() + outbox = InMemoryLedgerOutbox(clock=clock) if clock else InMemoryLedgerOutbox() execution = ExecutionService(svc, repo, broker, ledger_outbox=outbox) - writer = LedgerWriter(outbox, ledger or InMemoryLedger()) + writer = ( + LedgerWriter(outbox, ledger or InMemoryLedger(), clock=clock) + if clock else LedgerWriter(outbox, ledger or InMemoryLedger()) + ) svc.preallocate_daily_slots(DAY, 5) return repo, svc, intents, execution, outbox, writer @@ -134,17 +137,26 @@ def test_duplicate_execution_id_results_in_zero_duplicate_in_ledger(): def test_retryable_failure_then_eventually_completes(): - """REQ-019 AC-2: retry until completed; slot transition unaffected.""" + """REQ-019 AC-2: retry until completed; slot transition unaffected. + + dcy-015: retryable rows back off, so advance a shared clock past each + next_retry_at between writer passes. + """ + from datetime import datetime, timedelta, timezone + now = {"t": datetime(2026, 6, 4, tzinfo=timezone.utc)} ledger = InMemoryLedger(fail_n_then_succeed=2) - repo, svc, intents, execution, outbox, writer = _wire(PaperBroker(fill_after_polls=0), - ledger=ledger) + repo, svc, intents, execution, outbox, writer = _wire( + PaperBroker(fill_after_polls=0), ledger=ledger, clock=lambda: now["t"], + ) sid = _buy(intents).slot_id execution.run_once() assert repo.fetch_slot(sid).status is SlotState.HOLDING # slot already transitioned stats1 = writer.run_once() assert stats1.retryable == 1 and stats1.completed == 0 and ledger.count() == 0 + now["t"] += timedelta(seconds=600) # past backoff stats2 = writer.run_once() assert stats2.retryable == 1 and ledger.count() == 0 + now["t"] += timedelta(seconds=600) # past backoff again stats3 = writer.run_once() assert stats3.completed == 1 and ledger.count() == 1 # outbox row terminal-completed diff --git a/tests/application/test_ledger_outbox.py b/tests/application/test_ledger_outbox.py index 3386136..a244655 100644 --- a/tests/application/test_ledger_outbox.py +++ b/tests/application/test_ledger_outbox.py @@ -1,7 +1,7 @@ """LedgerOutbox + LedgerWriter direct unit tests (REQ-019).""" from __future__ import annotations -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from trex_core.adapters.ledger.in_memory import InMemoryLedger from trex_core.application.ledger_outbox import ( @@ -55,11 +55,16 @@ def test_writer_marks_conflict_blocked_and_does_not_retry(): def test_writer_retries_until_success(): - outbox = InMemoryLedgerOutbox() + # dcy-015: retryable rows now back off; share an injected clock between the + # outbox (list_pending "now") and the writer (next_retry_at) to advance past it. + now = {"t": datetime(2026, 6, 4, tzinfo=timezone.utc)} + outbox = InMemoryLedgerOutbox(clock=lambda: now["t"]) ledger = InMemoryLedger(fail_n_then_succeed=1) - writer = LedgerWriter(outbox, ledger) + writer = LedgerWriter(outbox, ledger, clock=lambda: now["t"]) outbox.enqueue(_trade(execution_id="e-r")) s1 = writer.run_once() assert s1.retryable == 1 and ledger.count() == 0 + assert writer.run_once().completed == 0 # still in backoff -> skipped + now["t"] += timedelta(seconds=600) # past the backoff window s2 = writer.run_once() assert s2.completed == 1 and ledger.count() == 1 diff --git a/tests/application/test_ledger_outbox_backoff.py b/tests/application/test_ledger_outbox_backoff.py new file mode 100644 index 0000000..6107f54 --- /dev/null +++ b/tests/application/test_ledger_outbox_backoff.py @@ -0,0 +1,156 @@ +"""Ledger outbox permanent-error + backoff + dead-letter tests (dcy-015 / issue #13). + +A programmable LedgerPort raises chosen errors so we can drive: 404→permanent +terminal (no retry), retryable→backoff (next_retry_at skip), max_attempts→ +dead-letter terminal, and head-of-line protection. +""" +from __future__ import annotations + +import logging +from datetime import datetime, timedelta, timezone + +from trex_core.application.ledger_outbox import ( + InMemoryLedgerOutbox, + LedgerWriter, + OutboxOutcome, + WorkItemStatus, +) +from trex_core.application.ledger_port import ( + LedgerPermanentError, + LedgerRetryableError, + LedgerTrade, + LedgerWriteResult, +) + +UTC = timezone.utc + + +def _trade(cid_suffix="1", asset="PF-1") -> LedgerTrade: + return LedgerTrade( + asset_id=asset, order_id=f"O-{cid_suffix}", execution_id=f"e-{cid_suffix}", + side="buy", qty=10, price=1000.0, + executed_at=datetime(2026, 6, 4, tzinfo=UTC), + ) + + +class _ProgrammableLedger: + """LedgerPort double: `behavior(trade)` returns a result or raises.""" + + def __init__(self, behavior): + self.behavior = behavior + self.calls = 0 + + def record_trade(self, trade) -> LedgerWriteResult: + self.calls += 1 + return self.behavior(trade) + + +def _wire(behavior, *, clock, **writer_kw): + outbox = InMemoryLedgerOutbox(clock=clock) + ledger = _ProgrammableLedger(behavior) + writer = LedgerWriter(outbox, ledger, clock=clock, rng=lambda: 0.0, **writer_kw) + return outbox, ledger, writer + + +def test_permanent_error_terminates_immediately_no_retry(): + """S-1: 404/permanent → delivery_failed terminal, adapter called exactly once.""" + now = {"t": datetime(2026, 6, 4, tzinfo=UTC)} + + def behavior(trade): + raise LedgerPermanentError("404 portfolio not registered") + + outbox, ledger, writer = _wire(behavior, clock=lambda: now["t"]) + outbox.enqueue(_trade()) + s = writer.run_once() + assert s.delivery_failed == 1 and s.retryable == 0 + [item] = outbox.all_items() + assert item.status is WorkItemStatus.DELIVERY_FAILED + assert item.last_outcome is OutboxOutcome.PERMANENT_FAILURE + # no further attempts — terminal, not pending + now["t"] += timedelta(seconds=3600) + assert writer.run_once().delivery_failed == 0 + assert ledger.calls == 1 # never retried + + +def test_retryable_backoff_skips_until_next_retry_at(): + """S-2/S-4: a retryable row backs off and is skipped by list_pending until due.""" + now = {"t": datetime(2026, 6, 4, tzinfo=UTC)} + + def behavior(trade): + raise LedgerRetryableError("503 upstream") + + outbox, ledger, writer = _wire( + behavior, clock=lambda: now["t"], retry_base_sec=10.0, max_attempts=99, + ) + outbox.enqueue(_trade()) + assert writer.run_once().retryable == 1 + [item] = outbox.all_items() + assert item.status is WorkItemStatus.PENDING and item.next_retry_at is not None + # within backoff window -> skipped (no adapter call) + calls_before = ledger.calls + assert writer.run_once().retryable == 0 + assert ledger.calls == calls_before + # advance past backoff -> retried + now["t"] += timedelta(seconds=60) + assert writer.run_once().retryable == 1 + assert ledger.calls == calls_before + 1 + + +def test_retryable_dead_letters_after_max_attempts(): + """S-2: retryable exhausts max_attempts → delivery_failed (dead-letter).""" + now = {"t": datetime(2026, 6, 4, tzinfo=UTC)} + + def behavior(trade): + raise LedgerRetryableError("503 upstream") + + outbox, ledger, writer = _wire( + behavior, clock=lambda: now["t"], retry_base_sec=1.0, retry_cap_sec=2.0, + max_attempts=3, + ) + outbox.enqueue(_trade()) + # attempt 1, 2 -> retryable; attempt 3 -> dead-letter + assert writer.run_once().retryable == 1 + now["t"] += timedelta(seconds=10) + assert writer.run_once().retryable == 1 + now["t"] += timedelta(seconds=10) + s = writer.run_once() + assert s.delivery_failed == 1 + [item] = outbox.all_items() + assert item.status is WorkItemStatus.DELIVERY_FAILED + assert item.last_outcome is OutboxOutcome.DEAD_LETTERED + assert item.attempts == 3 + + +def test_permanent_failure_emits_warning(caplog): + """S-3: terminal delivery_failed is visible (WARN), not silent.""" + now = {"t": datetime(2026, 6, 4, tzinfo=UTC)} + + def behavior(trade): + raise LedgerPermanentError("404 not found") + + outbox, ledger, writer = _wire(behavior, clock=lambda: now["t"]) + outbox.enqueue(_trade()) + with caplog.at_level(logging.WARNING): + writer.run_once() + assert any("delivery_failed" in r.message for r in caplog.records) + + +def test_backoff_row_does_not_block_new_rows(): + """S-4: a backing-off row does not occupy the FIFO batch; new rows proceed.""" + now = {"t": datetime(2026, 6, 4, tzinfo=UTC)} + + def behavior(trade): + if trade.asset_id == "PF-bad": + raise LedgerRetryableError("503") + return LedgerWriteResult.NEW + + outbox, ledger, writer = _wire( + behavior, clock=lambda: now["t"], retry_base_sec=100.0, max_attempts=99, + ) + outbox.enqueue(_trade(cid_suffix="bad", asset="PF-bad")) + writer.run_once() # PF-bad -> backoff + outbox.enqueue(_trade(cid_suffix="good", asset="PF-good")) + s = writer.run_once() # PF-bad skipped (backoff), PF-good completes + assert s.completed == 1 and s.retryable == 0 + good = outbox.get("PF-good:O-good:e-good") + assert good.status is WorkItemStatus.COMPLETED diff --git a/trex_core/adapters/ledger/in_memory.py b/trex_core/adapters/ledger/in_memory.py index 6c0296e..24a8332 100644 --- a/trex_core/adapters/ledger/in_memory.py +++ b/trex_core/adapters/ledger/in_memory.py @@ -5,6 +5,7 @@ import threading from trex_core.application.ledger_port import ( LedgerConflictError, + LedgerPermanentError, LedgerRetryableError, LedgerTrade, LedgerWriteResult, @@ -21,6 +22,8 @@ class InMemoryLedger: then accept. conflict_executions -- execution_ids that raise LedgerConflictError (simulates portfolio-hub 409). + permanent_executions -- execution_ids that raise LedgerPermanentError + (simulates portfolio-hub 404/400/422, dcy-015). """ def __init__( @@ -28,12 +31,14 @@ class InMemoryLedger: *, fail_n_then_succeed: int = 0, conflict_executions: set[str] | None = None, + permanent_executions: set[str] | None = None, ) -> None: self._trades: dict[str, LedgerTrade] = {} self._order: list[LedgerTrade] = [] self._lock = threading.Lock() self._fail_remaining = fail_n_then_succeed self._conflicts = set(conflict_executions or set()) + self._permanent = set(permanent_executions or set()) def record_trade(self, trade: LedgerTrade) -> LedgerWriteResult: with self._lock: @@ -41,6 +46,10 @@ class InMemoryLedger: raise LedgerConflictError( f"ledger conflict for execution_id={trade.execution_id}" ) + if trade.execution_id in self._permanent: + raise LedgerPermanentError( + f"ledger permanent error for execution_id={trade.execution_id}" + ) if self._fail_remaining > 0: self._fail_remaining -= 1 raise LedgerRetryableError("simulated transient failure") diff --git a/trex_core/adapters/ledger/portfolio_hub.py b/trex_core/adapters/ledger/portfolio_hub.py index 751c5dd..4fbeaa7 100644 --- a/trex_core/adapters/ledger/portfolio_hub.py +++ b/trex_core/adapters/ledger/portfolio_hub.py @@ -6,7 +6,9 @@ Live portfolio-hub contract (verified against openapi, REQ-017 CHG-04): execution_id, order_id - 2xx (201) => recorded (or duplicate accepted idempotently) - 409 => LedgerConflictError (operator resolution) - - 4xx (other)/5xx => LedgerRetryableError (outbox retries) + - 404/400/422 => LedgerPermanentError (dcy-015/REQ-017 — retry can't fix: + portfolio not registered / malformed trade) + - 4xx (other)/5xx => LedgerRetryableError (outbox backoff retries) - httpx error => LedgerRetryableError `asset_id` on LedgerTrade carries the portfolio_id value (REQ-018: @@ -21,11 +23,15 @@ import httpx from trex_core.application.ledger_port import ( LedgerConflictError, + LedgerPermanentError, LedgerRetryableError, LedgerTrade, LedgerWriteResult, ) +# dcy-015 / REQ-017: client errors that retrying cannot resolve. +_PERMANENT_STATUS = frozenset({400, 404, 422}) + class PortfolioHubLedger: """portfolio-hub ledger adapter (single endpoint, single responsibility).""" @@ -91,6 +97,14 @@ class PortfolioHubLedger: f"portfolio-hub 409 conflict for execution_id={trade.execution_id}: " f"{resp.text[:200]}" ) + if resp.status_code in _PERMANENT_STATUS: + # dcy-015 / REQ-017 (issue #13): 404/400/422 won't be fixed by + # retrying — surface as permanent so the outbox terminates the row. + raise LedgerPermanentError( + f"portfolio-hub {resp.status_code} permanent error for " + f"asset_id={trade.asset_id} execution_id={trade.execution_id}: " + f"{resp.text[:200]}" + ) raise LedgerRetryableError( f"portfolio-hub {resp.status_code} response: {resp.text[:200]}" ) diff --git a/trex_core/adapters/persistence/postgresql.py b/trex_core/adapters/persistence/postgresql.py index 364f03c..bb21922 100644 --- a/trex_core/adapters/persistence/postgresql.py +++ b/trex_core/adapters/persistence/postgresql.py @@ -325,7 +325,7 @@ class _PgIntentRecord: _OUTBOX_COLUMNS = ( "correlation_id, asset_id, order_id, execution_id, side, qty, price, " "fee, tax, executed_at, status, attempts, last_outcome, last_error, " - "created_at, updated_at" + "created_at, updated_at, next_retry_at" ) @@ -362,7 +362,9 @@ class PostgreSQLLedgerOutbox: with conn.cursor() as cur: cur.execute( f"SELECT {_OUTBOX_COLUMNS} FROM ledger_outbox " - "WHERE status = 'pending' ORDER BY created_at LIMIT %s", + "WHERE status = 'pending' " + "AND (next_retry_at IS NULL OR next_retry_at <= now()) " + "ORDER BY created_at LIMIT %s", (limit,), ) return [self._row_to_item(r) for r in cur.fetchall()] @@ -383,6 +385,7 @@ class PostgreSQLLedgerOutbox: outcome: OutboxOutcome, *, error: Optional[str] = None, + next_retry_at: Optional[datetime] = None, ) -> LedgerWorkItem: with self._psycopg.connect(self._dsn, autocommit=False) as conn: with conn.cursor() as cur: @@ -392,18 +395,24 @@ class PostgreSQLLedgerOutbox: elif outcome is OutboxOutcome.CONFLICT_BLOCKED: status_value = WorkItemStatus.CONFLICT_BLOCKED.value attempts_delta = 1 + elif outcome in (OutboxOutcome.PERMANENT_FAILURE, OutboxOutcome.DEAD_LETTERED): + # dcy-015 / REQ-019: terminal delivery_failed (reason in last_outcome). + status_value = WorkItemStatus.DELIVERY_FAILED.value + attempts_delta = 1 elif outcome is OutboxOutcome.DUPLICATE_COMPLETED: status_value = WorkItemStatus.COMPLETED.value attempts_delta = 0 else: # retryable status_value = WorkItemStatus.PENDING.value attempts_delta = 1 + # next_retry_at only persists for a retryable backoff; cleared otherwise. + nra = next_retry_at if outcome is OutboxOutcome.RETRYABLE_FAILURE else None cur.execute( "UPDATE ledger_outbox SET status=%s, attempts=attempts+%s, " - "last_outcome=%s, last_error=%s, updated_at=%s " + "last_outcome=%s, last_error=%s, next_retry_at=%s, updated_at=%s " "WHERE correlation_id=%s", ( - status_value, attempts_delta, outcome.value, error, + status_value, attempts_delta, outcome.value, error, nra, datetime.now(tz=timezone.utc), correlation_id, ), ) @@ -429,4 +438,5 @@ class PostgreSQLLedgerOutbox: last_error=row[13], created_at=row[14], updated_at=row[15], + next_retry_at=row[16], ) diff --git a/trex_core/adapters/persistence/sql/007_ledger_outbox_delivery_failed_and_backoff.sql b/trex_core/adapters/persistence/sql/007_ledger_outbox_delivery_failed_and_backoff.sql new file mode 100644 index 0000000..0eba1ca --- /dev/null +++ b/trex_core/adapters/persistence/sql/007_ledger_outbox_delivery_failed_and_backoff.sql @@ -0,0 +1,25 @@ +-- BAT-019 / dcy-015 / REQ-019 (issue #13): ledger_outbox terminal failure + +-- backoff scheduling. Adds: +-- - next_retry_at: earliest re-attempt time for a retryable row (backoff). +-- - status 'delivery_failed': single terminal for permanent (404/400/422) +-- and dead-lettered (max_attempts exhausted) rows; reason in last_outcome. +-- - last_outcome 'permanent_failure'/'dead_lettered'. +-- Idempotent: ADD COLUMN IF NOT EXISTS + DROP/ADD CONSTRAINT (re-runnable since +-- migrations are re-applied by glob). +ALTER TABLE ledger_outbox ADD COLUMN IF NOT EXISTS next_retry_at TIMESTAMPTZ; + +ALTER TABLE ledger_outbox DROP CONSTRAINT IF EXISTS ledger_outbox_status_check; +ALTER TABLE ledger_outbox ADD CONSTRAINT ledger_outbox_status_check + CHECK (status IN ('pending','completed','conflict_blocked','delivery_failed')); + +ALTER TABLE ledger_outbox DROP CONSTRAINT IF EXISTS ledger_outbox_last_outcome_check; +ALTER TABLE ledger_outbox ADD CONSTRAINT ledger_outbox_last_outcome_check + CHECK (last_outcome IS NULL OR last_outcome IN ( + 'completed','duplicate_completed','retryable_failure','conflict_blocked', + 'permanent_failure','dead_lettered' + )); + +-- Partial index so the writer's pending scan skips rows still in backoff cheaply. +CREATE INDEX IF NOT EXISTS idx_ledger_outbox_pending_retry + ON ledger_outbox (next_retry_at) + WHERE status = 'pending'; diff --git a/trex_core/application/ledger_outbox.py b/trex_core/application/ledger_outbox.py index 914dd4e..eb47add 100644 --- a/trex_core/application/ledger_outbox.py +++ b/trex_core/application/ledger_outbox.py @@ -9,39 +9,64 @@ of: completed -- adapter accepted (new ledger entry). duplicate_completed -- the correlation_id was already done in this outbox (local dedup, no adapter call). - retryable_failure -- transient adapter failure; left pending for next tick. + retryable_failure -- transient adapter failure; backoff + retry (next_retry_at). conflict_blocked -- portfolio-hub 409; operator must resolve, no retry. + permanent_failure -- 404/400/422 client error; terminal immediately (dcy-015). + dead_lettered -- retryable that exhausted max_attempts; terminal (dcy-015). + +`permanent_failure` and `dead_lettered` both land in the single terminal +`delivery_failed` status (distinguished by last_outcome/last_error), mirroring +the `conflict_blocked` single-status pattern (dcy-015 OQ-2). This separation (orbit `LedgerWriter` pattern) keeps slot transitions on the critical path while making ledger writes eventually-consistent and idempotent. """ from __future__ import annotations +import logging +import random from dataclasses import dataclass, replace -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from enum import Enum -from typing import Optional, Protocol +from typing import Callable, Optional, Protocol from trex_core.application.ledger_port import ( LedgerConflictError, + LedgerPermanentError, LedgerPort, LedgerRetryableError, LedgerTrade, LedgerWriteResult, ) +_log = logging.getLogger(__name__) + +# dcy-015 / REQ-019: exponential backoff defaults (overridable via config/env). +_DEFAULT_RETRY_BASE_SEC = 1.0 +_DEFAULT_RETRY_CAP_SEC = 300.0 +_DEFAULT_MAX_ATTEMPTS = 8 + class OutboxOutcome(str, Enum): COMPLETED = "completed" DUPLICATE_COMPLETED = "duplicate_completed" RETRYABLE_FAILURE = "retryable_failure" CONFLICT_BLOCKED = "conflict_blocked" + PERMANENT_FAILURE = "permanent_failure" # dcy-015: 404/400/422 — terminal now + DEAD_LETTERED = "dead_lettered" # dcy-015: retryable exhausted max_attempts class WorkItemStatus(str, Enum): PENDING = "pending" # awaiting next writer tick COMPLETED = "completed" # adapter accepted (terminal success) CONFLICT_BLOCKED = "conflict_blocked" # 409 — operator resolves (terminal failure) + DELIVERY_FAILED = "delivery_failed" # dcy-015 — permanent or dead-lettered (terminal) + + +# Outcomes that move a row to the terminal delivery_failed state. +_DELIVERY_FAILED_OUTCOMES = frozenset( + {OutboxOutcome.PERMANENT_FAILURE, OutboxOutcome.DEAD_LETTERED} +) @dataclass(frozen=True) @@ -58,6 +83,9 @@ class LedgerWorkItem: attempts: int = 0 last_outcome: Optional[OutboxOutcome] = None last_error: Optional[str] = None + # dcy-015 / REQ-019: earliest time a retryable row may be re-attempted. + # list_pending skips rows whose next_retry_at is still in the future. + next_retry_at: Optional[datetime] = None created_at: Optional[datetime] = None updated_at: Optional[datetime] = None @@ -83,8 +111,12 @@ class LedgerOutbox(Protocol): outcome: OutboxOutcome, *, error: Optional[str] = None, + next_retry_at: Optional[datetime] = None, ) -> LedgerWorkItem: - """Record the outcome of a writer attempt and advance status.""" + """Record the outcome of a writer attempt and advance status. + + For a retryable outcome `next_retry_at` schedules the backoff; terminal + outcomes ignore it (dcy-015 / REQ-019).""" ... @@ -94,6 +126,7 @@ class WriterStats: duplicates: int = 0 retryable: int = 0 conflicts: int = 0 + delivery_failed: int = 0 # dcy-015: permanent + dead-lettered terminals class LedgerWriter: @@ -104,9 +137,25 @@ class LedgerWriter: the adapter (REQ-019 AC-4). """ - def __init__(self, outbox: LedgerOutbox, ledger: LedgerPort) -> None: + def __init__( + self, + outbox: LedgerOutbox, + ledger: LedgerPort, + *, + retry_base_sec: float = _DEFAULT_RETRY_BASE_SEC, + retry_cap_sec: float = _DEFAULT_RETRY_CAP_SEC, + max_attempts: int = _DEFAULT_MAX_ATTEMPTS, + clock: Optional[Callable[[], datetime]] = None, + rng: Optional[Callable[[], float]] = None, + ) -> None: self._outbox = outbox self._ledger = ledger + # dcy-015 / REQ-019: exponential backoff + jitter + max-attempts cap. + self._retry_base = retry_base_sec + self._retry_cap = retry_cap_sec + self._max_attempts = max_attempts + self._clock = clock or (lambda: datetime.now(tz=timezone.utc)) + self._rng = rng or random.random # jitter source; inject for determinism def enqueue_fill(self, trade: LedgerTrade) -> LedgerWorkItem: return self._outbox.enqueue(trade) @@ -123,8 +172,15 @@ class LedgerWriter: stats.retryable += 1 elif outcome is OutboxOutcome.CONFLICT_BLOCKED: stats.conflicts += 1 + elif outcome in _DELIVERY_FAILED_OUTCOMES: + stats.delivery_failed += 1 return stats + def _backoff_delay(self, attempts: int) -> float: + """Exponential backoff with jitter: min(base*2^(n-1), cap) + up to 25%.""" + base_delay = min(self._retry_base * (2 ** (attempts - 1)), self._retry_cap) + return base_delay * (1.0 + 0.25 * self._rng()) + def _process(self, item: LedgerWorkItem) -> OutboxOutcome: # Local dedup: another writer pass (or restart) already completed this # outbox row. Skip the adapter call. @@ -139,9 +195,36 @@ class LedgerWriter: item.correlation_id, OutboxOutcome.CONFLICT_BLOCKED, error=str(exc) ) return OutboxOutcome.CONFLICT_BLOCKED - except LedgerRetryableError as exc: + except LedgerPermanentError as exc: + # dcy-015 / REQ-017/018 (issue #13): non-retryable client error → + # terminal delivery_failed immediately (no retry spam). Surface for ops. self._outbox.mark_outcome( - item.correlation_id, OutboxOutcome.RETRYABLE_FAILURE, error=str(exc) + item.correlation_id, OutboxOutcome.PERMANENT_FAILURE, error=str(exc) + ) + _log.warning( + "ledger delivery_failed (permanent): correlation_id=%s reason=%s", + item.correlation_id, exc, + ) + return OutboxOutcome.PERMANENT_FAILURE + except LedgerRetryableError as exc: + # dcy-015 / REQ-019: backoff, or dead-letter once max_attempts hit. + new_attempts = item.attempts + 1 + if new_attempts >= self._max_attempts: + self._outbox.mark_outcome( + item.correlation_id, OutboxOutcome.DEAD_LETTERED, error=str(exc) + ) + _log.warning( + "ledger delivery_failed (dead-letter, attempts=%d): " + "correlation_id=%s reason=%s", + new_attempts, item.correlation_id, exc, + ) + return OutboxOutcome.DEAD_LETTERED + next_retry_at = self._clock() + timedelta( + seconds=self._backoff_delay(new_attempts) + ) + self._outbox.mark_outcome( + item.correlation_id, OutboxOutcome.RETRYABLE_FAILURE, + error=str(exc), next_retry_at=next_retry_at, ) return OutboxOutcome.RETRYABLE_FAILURE # Adapter-level dedup (server says "already recorded"): outcome is @@ -159,16 +242,18 @@ class LedgerWriter: class InMemoryLedgerOutbox: """Process-local outbox. Thread-safe; idempotent on correlation_id.""" - def __init__(self) -> None: + def __init__(self, *, clock: Optional[Callable[[], datetime]] = None) -> None: import threading self._rows: dict[str, LedgerWorkItem] = {} self._order: list[str] = [] self._lock = threading.Lock() + # dcy-015: shared clock lets tests align backoff (next_retry_at) with + # list_pending's "now" deterministically. + self._clock = clock or (lambda: datetime.now(tz=timezone.utc)) - @staticmethod - def _now() -> datetime: - return datetime.now(tz=timezone.utc) + def _now(self) -> datetime: + return self._clock() def enqueue(self, trade: LedgerTrade) -> LedgerWorkItem: with self._lock: @@ -188,11 +273,16 @@ class InMemoryLedgerOutbox: return row def list_pending(self, limit: int = 32) -> list[LedgerWorkItem]: + now = self._now() with self._lock: out: list[LedgerWorkItem] = [] for cid in self._order: row = self._rows[cid] - if row.status is WorkItemStatus.PENDING: + # dcy-015 / REQ-019: skip rows still in backoff so they don't + # occupy the FIFO batch (head-of-line protection, NFR-3). + if row.status is WorkItemStatus.PENDING and ( + row.next_retry_at is None or row.next_retry_at <= now + ): out.append(row) if len(out) >= limit: break @@ -208,6 +298,7 @@ class InMemoryLedgerOutbox: outcome: OutboxOutcome, *, error: Optional[str] = None, + next_retry_at: Optional[datetime] = None, ) -> LedgerWorkItem: with self._lock: row = self._rows[correlation_id] @@ -215,6 +306,8 @@ class InMemoryLedgerOutbox: new_status = WorkItemStatus.COMPLETED elif outcome is OutboxOutcome.CONFLICT_BLOCKED: new_status = WorkItemStatus.CONFLICT_BLOCKED + elif outcome in _DELIVERY_FAILED_OUTCOMES: + new_status = WorkItemStatus.DELIVERY_FAILED # dcy-015 terminal elif outcome is OutboxOutcome.DUPLICATE_COMPLETED: # already terminal; keep COMPLETED status new_status = WorkItemStatus.COMPLETED @@ -226,6 +319,9 @@ class InMemoryLedgerOutbox: attempts=row.attempts + (0 if outcome is OutboxOutcome.DUPLICATE_COMPLETED else 1), last_outcome=outcome, last_error=error, + next_retry_at=( + next_retry_at if outcome is OutboxOutcome.RETRYABLE_FAILURE else None + ), updated_at=self._now(), ) self._rows[correlation_id] = updated diff --git a/trex_core/application/ledger_port.py b/trex_core/application/ledger_port.py index e971207..bb8c992 100644 --- a/trex_core/application/ledger_port.py +++ b/trex_core/application/ledger_port.py @@ -63,6 +63,16 @@ class LedgerRetryableError(LedgerError): loop should re-attempt this work item on the next tick (REQ-019 AC-2).""" +class LedgerPermanentError(LedgerError): + """Non-retryable client error (dcy-015 / REQ-017, issue #13). + + portfolio-hub returned a status that retrying cannot resolve — e.g. 404 + (portfolio not registered), 400/422 (malformed/invalid trade). The outbox + must move the row to a terminal `delivery_failed` state immediately instead + of retrying forever. Distinct from LedgerConflictError (409) which is its + own terminal state.""" + + class LedgerPort(Protocol): """Ledger adapter boundary (REQ-017 AC-1/2). diff --git a/trex_core/runtime/config.py b/trex_core/runtime/config.py index 93718b4..055bb1a 100644 --- a/trex_core/runtime/config.py +++ b/trex_core/runtime/config.py @@ -155,6 +155,11 @@ class DaemonConfig: # per-instance TOML (REQ-048); these are the env-only defaults. trading_timezone: str = "Asia/Seoul" daily_preallocation_time: str = "00:00" + # dcy-015 / REQ-019 (BAT-019): ledger outbox retry backoff. base*2^(n-1) + # capped at cap (+jitter); after max_attempts a retryable row dead-letters. + ledger_retry_base_sec: float = 1.0 + ledger_retry_cap_sec: float = 300.0 + ledger_max_attempts: int = 8 @staticmethod def from_env() -> "DaemonConfig": @@ -237,6 +242,13 @@ class DaemonConfig: pending_stale_threshold_sec=int( os.environ.get("TREX_PENDING_STALE_THRESHOLD", "0") ), + ledger_retry_base_sec=float( + os.environ.get("TREX_LEDGER_RETRY_BASE_SEC", "1.0") + ), + ledger_retry_cap_sec=float( + os.environ.get("TREX_LEDGER_RETRY_CAP_SEC", "300.0") + ), + ledger_max_attempts=int(os.environ.get("TREX_LEDGER_MAX_ATTEMPTS", "8")), ) @staticmethod diff --git a/trex_core/runtime/daemon.py b/trex_core/runtime/daemon.py index 3e1d59a..89081bb 100644 --- a/trex_core/runtime/daemon.py +++ b/trex_core/runtime/daemon.py @@ -147,7 +147,12 @@ class Daemon: ) self.broker = _build_broker(config) self.outbox = PostgreSQLLedgerOutbox(config.dsn) - self.ledger_writer = LedgerWriter(self.outbox, _build_ledger(config)) + self.ledger_writer = LedgerWriter( + self.outbox, _build_ledger(config), + retry_base_sec=config.ledger_retry_base_sec, + retry_cap_sec=config.ledger_retry_cap_sec, + max_attempts=config.ledger_max_attempts, + ) # BAT-008: portfolio-hub read + snapshot store + durable pause + scheduler. self.snapshot_store = SnapshotStore()