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:
@@ -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 무관).
|
||||
@@ -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
@@ -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
|
||||
@@ -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 |
|
||||
@@ -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 (어댑터 구현)
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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]}"
|
||||
)
|
||||
|
||||
@@ -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';
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user