- REQ-017: LedgerPort contract (application/ledger_port.py) + portfolio-hub adapter + in-memory stub (adapters/ledger/), TREX_PORTFOLIO_HUB_* config - REQ-018: fill→ledger recording in execution loop; FillStatus carries execution_id (deterministic synth) + fee; paper/kis adapters updated - REQ-019: ledger outbox + retry worker (application/ledger_outbox.py), correlation_id idempotency, outcome vocabulary; 002_create_ledger_outbox.sql - Tests: in-memory/portfolio-hub ledger, execution ledger hook, outbox, domain import sweep Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
231 lines
7.9 KiB
Python
231 lines
7.9 KiB
Python
"""Ledger outbox + writer (REQ-019, dcy-004 FR-3/FR-4).
|
|
|
|
The execution fill path does NOT call portfolio-hub directly. Instead it
|
|
enqueues a durable `LedgerWorkItem` keyed by `correlation_id`
|
|
(`{asset_id}:{order_id}:{execution_id}`). A separate `LedgerWriter` drains
|
|
the outbox into the `LedgerPort` adapter and classifies each attempt as one
|
|
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.
|
|
conflict_blocked -- portfolio-hub 409; operator must resolve, no retry.
|
|
|
|
This separation (orbit `LedgerWriter` pattern) keeps slot transitions on the
|
|
critical path while making ledger writes eventually-consistent and idempotent.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, replace
|
|
from datetime import datetime, timezone
|
|
from enum import Enum
|
|
from typing import Optional, Protocol
|
|
|
|
from trex_core.application.ledger_port import (
|
|
LedgerConflictError,
|
|
LedgerPort,
|
|
LedgerRetryableError,
|
|
LedgerTrade,
|
|
LedgerWriteResult,
|
|
)
|
|
|
|
|
|
class OutboxOutcome(str, Enum):
|
|
COMPLETED = "completed"
|
|
DUPLICATE_COMPLETED = "duplicate_completed"
|
|
RETRYABLE_FAILURE = "retryable_failure"
|
|
CONFLICT_BLOCKED = "conflict_blocked"
|
|
|
|
|
|
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)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class LedgerWorkItem:
|
|
"""One outbox row.
|
|
|
|
Identity is `correlation_id`; the outbox MUST upsert/dedupe on it so the
|
|
same fill enqueued twice produces a single row.
|
|
"""
|
|
|
|
correlation_id: str
|
|
trade: LedgerTrade
|
|
status: WorkItemStatus = WorkItemStatus.PENDING
|
|
attempts: int = 0
|
|
last_outcome: Optional[OutboxOutcome] = None
|
|
last_error: Optional[str] = None
|
|
created_at: Optional[datetime] = None
|
|
updated_at: Optional[datetime] = None
|
|
|
|
|
|
class LedgerOutbox(Protocol):
|
|
"""Durable work-item store backing the ledger writer."""
|
|
|
|
def enqueue(self, trade: LedgerTrade) -> LedgerWorkItem:
|
|
"""Enqueue a trade. Idempotent on correlation_id (returns the
|
|
existing row unchanged if it is already present)."""
|
|
...
|
|
|
|
def list_pending(self, limit: int = 32) -> list[LedgerWorkItem]:
|
|
"""Return pending work items in FIFO order, up to `limit`."""
|
|
...
|
|
|
|
def get(self, correlation_id: str) -> Optional[LedgerWorkItem]:
|
|
...
|
|
|
|
def mark_outcome(
|
|
self,
|
|
correlation_id: str,
|
|
outcome: OutboxOutcome,
|
|
*,
|
|
error: Optional[str] = None,
|
|
) -> LedgerWorkItem:
|
|
"""Record the outcome of a writer attempt and advance status."""
|
|
...
|
|
|
|
|
|
@dataclass
|
|
class WriterStats:
|
|
completed: int = 0
|
|
duplicates: int = 0
|
|
retryable: int = 0
|
|
conflicts: int = 0
|
|
|
|
|
|
class LedgerWriter:
|
|
"""Drains the outbox into a `LedgerPort`. One process-wide writer.
|
|
|
|
Local dedup is by correlation_id: if a work item is already marked
|
|
`completed`, the writer reports `duplicate_completed` without touching
|
|
the adapter (REQ-019 AC-4).
|
|
"""
|
|
|
|
def __init__(self, outbox: LedgerOutbox, ledger: LedgerPort) -> None:
|
|
self._outbox = outbox
|
|
self._ledger = ledger
|
|
|
|
def enqueue_fill(self, trade: LedgerTrade) -> LedgerWorkItem:
|
|
return self._outbox.enqueue(trade)
|
|
|
|
def run_once(self, limit: int = 32) -> WriterStats:
|
|
stats = WriterStats()
|
|
for item in self._outbox.list_pending(limit):
|
|
outcome = self._process(item)
|
|
if outcome is OutboxOutcome.COMPLETED:
|
|
stats.completed += 1
|
|
elif outcome is OutboxOutcome.DUPLICATE_COMPLETED:
|
|
stats.duplicates += 1
|
|
elif outcome is OutboxOutcome.RETRYABLE_FAILURE:
|
|
stats.retryable += 1
|
|
elif outcome is OutboxOutcome.CONFLICT_BLOCKED:
|
|
stats.conflicts += 1
|
|
return stats
|
|
|
|
def _process(self, item: LedgerWorkItem) -> OutboxOutcome:
|
|
# Local dedup: another writer pass (or restart) already completed this.
|
|
existing = self._outbox.get(item.correlation_id)
|
|
if existing is not None and existing.status is WorkItemStatus.COMPLETED:
|
|
self._outbox.mark_outcome(item.correlation_id, OutboxOutcome.DUPLICATE_COMPLETED)
|
|
return OutboxOutcome.DUPLICATE_COMPLETED
|
|
try:
|
|
self._ledger.record_trade(item.trade)
|
|
except LedgerConflictError as exc:
|
|
self._outbox.mark_outcome(
|
|
item.correlation_id, OutboxOutcome.CONFLICT_BLOCKED, error=str(exc)
|
|
)
|
|
return OutboxOutcome.CONFLICT_BLOCKED
|
|
except LedgerRetryableError as exc:
|
|
self._outbox.mark_outcome(
|
|
item.correlation_id, OutboxOutcome.RETRYABLE_FAILURE, error=str(exc)
|
|
)
|
|
return OutboxOutcome.RETRYABLE_FAILURE
|
|
self._outbox.mark_outcome(item.correlation_id, OutboxOutcome.COMPLETED)
|
|
return OutboxOutcome.COMPLETED
|
|
|
|
|
|
# --- in-memory outbox (tests + slice-1 runtime) ------------------------------
|
|
|
|
class InMemoryLedgerOutbox:
|
|
"""Process-local outbox. Thread-safe; idempotent on correlation_id."""
|
|
|
|
def __init__(self) -> None:
|
|
import threading
|
|
|
|
self._rows: dict[str, LedgerWorkItem] = {}
|
|
self._order: list[str] = []
|
|
self._lock = threading.Lock()
|
|
|
|
@staticmethod
|
|
def _now() -> datetime:
|
|
return datetime.now(tz=timezone.utc)
|
|
|
|
def enqueue(self, trade: LedgerTrade) -> LedgerWorkItem:
|
|
with self._lock:
|
|
cid = trade.correlation_id
|
|
existing = self._rows.get(cid)
|
|
if existing is not None:
|
|
return existing
|
|
row = LedgerWorkItem(
|
|
correlation_id=cid,
|
|
trade=trade,
|
|
status=WorkItemStatus.PENDING,
|
|
created_at=self._now(),
|
|
updated_at=self._now(),
|
|
)
|
|
self._rows[cid] = row
|
|
self._order.append(cid)
|
|
return row
|
|
|
|
def list_pending(self, limit: int = 32) -> list[LedgerWorkItem]:
|
|
with self._lock:
|
|
out: list[LedgerWorkItem] = []
|
|
for cid in self._order:
|
|
row = self._rows[cid]
|
|
if row.status is WorkItemStatus.PENDING:
|
|
out.append(row)
|
|
if len(out) >= limit:
|
|
break
|
|
return out
|
|
|
|
def get(self, correlation_id: str) -> Optional[LedgerWorkItem]:
|
|
with self._lock:
|
|
return self._rows.get(correlation_id)
|
|
|
|
def mark_outcome(
|
|
self,
|
|
correlation_id: str,
|
|
outcome: OutboxOutcome,
|
|
*,
|
|
error: Optional[str] = None,
|
|
) -> LedgerWorkItem:
|
|
with self._lock:
|
|
row = self._rows[correlation_id]
|
|
if outcome is OutboxOutcome.COMPLETED:
|
|
new_status = WorkItemStatus.COMPLETED
|
|
elif outcome is OutboxOutcome.CONFLICT_BLOCKED:
|
|
new_status = WorkItemStatus.CONFLICT_BLOCKED
|
|
elif outcome is OutboxOutcome.DUPLICATE_COMPLETED:
|
|
# already terminal; keep COMPLETED status
|
|
new_status = WorkItemStatus.COMPLETED
|
|
else: # retryable
|
|
new_status = WorkItemStatus.PENDING
|
|
updated = replace(
|
|
row,
|
|
status=new_status,
|
|
attempts=row.attempts + (0 if outcome is OutboxOutcome.DUPLICATE_COMPLETED else 1),
|
|
last_outcome=outcome,
|
|
last_error=error,
|
|
updated_at=self._now(),
|
|
)
|
|
self._rows[correlation_id] = updated
|
|
return updated
|
|
|
|
# --- introspection ---
|
|
def all_items(self) -> list[LedgerWorkItem]:
|
|
with self._lock:
|
|
return [self._rows[cid] for cid in self._order]
|