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/) |
+5 -4
View File
@@ -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
@@ -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
+2 -1
View File
@@ -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 |
| 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 |
+3 -2
View File
@@ -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 (어댑터 구현)
+7 -4
View File
@@ -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
@@ -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 대조.
@@ -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 단위/인프라 게이트.
@@ -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 단위.
@@ -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) + 카운터 단위.
+1 -1
View File
@@ -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)
@@ -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())
@@ -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
+18 -6
View File
@@ -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
+8 -3
View File
@@ -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
@@ -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
+9
View File
@@ -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")
+15 -1
View File
@@ -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]}"
)
+14 -4
View File
@@ -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],
)
@@ -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';
+108 -12
View File
@@ -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
+10
View File
@@ -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).
+12
View File
@@ -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
+6 -1
View File
@@ -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()