Merge pull request #90 from fourblocks2020/copilot/bug-ledger-shared-outbox-consumption
Isolate ledger outbox delivery by instance and add lease-based claims
This commit is contained in:
@@ -13,22 +13,24 @@ domain layer (NFR-1).
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Iterator, Optional
|
||||
from typing import Any, Optional
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from trex_core.application.ledger_outbox import (
|
||||
LedgerWorkItem,
|
||||
OutboxClaimLostError,
|
||||
OutboxOutcome,
|
||||
WorkItemStatus,
|
||||
)
|
||||
from trex_core.application.ledger_port import LedgerTrade
|
||||
from trex_core.application.ports import IntentRecord
|
||||
from trex_core.application.strategy_state import (
|
||||
_STATE_CAP_BYTES,
|
||||
SetResult,
|
||||
StrategyStateEntry,
|
||||
_STATE_CAP_BYTES,
|
||||
)
|
||||
from trex_core.domain.slot import Slot, SlotNotFoundError, SlotState, SlotTransitionRecord
|
||||
|
||||
@@ -565,16 +567,44 @@ 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, next_retry_at, venue, order_venue"
|
||||
"created_at, updated_at, next_retry_at, venue, order_venue, instance_key, "
|
||||
"claim_expires_at"
|
||||
)
|
||||
_OUTBOX_RETURN_COLUMNS = ", ".join(
|
||||
f"o.{column.strip()}" for column in _OUTBOX_COLUMNS.split(",")
|
||||
)
|
||||
|
||||
|
||||
class PostgreSQLLedgerOutbox:
|
||||
"""ledger_outbox-backed LedgerOutbox. correlation_id PK is the dedup gate."""
|
||||
|
||||
def __init__(self, dsn: str, *, psycopg_module: Any = None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
dsn: str,
|
||||
*,
|
||||
instance_key: str,
|
||||
claim_ttl_sec: float = 60.0,
|
||||
worker_id: str | None = None,
|
||||
clock=None,
|
||||
psycopg_module: Any = None,
|
||||
) -> None:
|
||||
if not isinstance(instance_key, str) or not instance_key.strip():
|
||||
raise ValueError("instance_key must be a non-empty string")
|
||||
if claim_ttl_sec <= 0:
|
||||
raise ValueError("claim_ttl_sec must be positive")
|
||||
self._psycopg = _require_psycopg(psycopg_module)
|
||||
self._dsn = dsn
|
||||
self._instance_key = instance_key
|
||||
self._claim_ttl_sec = claim_ttl_sec
|
||||
self._worker_id = worker_id or f"ledger-writer-{uuid4().hex}"
|
||||
self._clock = clock or (lambda: datetime.now(tz=timezone.utc))
|
||||
|
||||
def _now(self) -> datetime:
|
||||
return self._clock()
|
||||
|
||||
@property
|
||||
def instance_key(self) -> str:
|
||||
return self._instance_key
|
||||
|
||||
def enqueue(self, trade: LedgerTrade) -> LedgerWorkItem:
|
||||
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
|
||||
@@ -582,8 +612,8 @@ class PostgreSQLLedgerOutbox:
|
||||
cur.execute(
|
||||
"INSERT INTO ledger_outbox "
|
||||
"(correlation_id, asset_id, order_id, execution_id, side, "
|
||||
" qty, price, fee, tax, executed_at, venue, order_venue) "
|
||||
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) "
|
||||
" qty, price, fee, tax, executed_at, venue, order_venue, instance_key) "
|
||||
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) "
|
||||
"ON CONFLICT (correlation_id) DO NOTHING",
|
||||
(
|
||||
trade.correlation_id,
|
||||
@@ -598,6 +628,7 @@ class PostgreSQLLedgerOutbox:
|
||||
trade.executed_at,
|
||||
trade.venue,
|
||||
trade.order_venue,
|
||||
self._instance_key,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
@@ -606,17 +637,59 @@ class PostgreSQLLedgerOutbox:
|
||||
return existing
|
||||
|
||||
def list_pending(self, limit: int = 32) -> list[LedgerWorkItem]:
|
||||
now = self._now()
|
||||
with self._psycopg.connect(self._dsn) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
f"SELECT {_OUTBOX_COLUMNS} FROM ledger_outbox "
|
||||
"WHERE status = 'pending' "
|
||||
"AND (next_retry_at IS NULL OR next_retry_at <= now()) "
|
||||
"ORDER BY created_at LIMIT %s",
|
||||
(limit,),
|
||||
"AND instance_key = %s "
|
||||
"AND (next_retry_at IS NULL OR next_retry_at <= %s) "
|
||||
"AND (claim_expires_at IS NULL OR claim_expires_at <= %s) "
|
||||
"ORDER BY created_at, correlation_id LIMIT %s",
|
||||
(self._instance_key, now, now, limit),
|
||||
)
|
||||
return [self._row_to_item(r) for r in cur.fetchall()]
|
||||
|
||||
def claim_pending(
|
||||
self,
|
||||
limit: int = 32,
|
||||
*,
|
||||
correlation_id: str | None = None,
|
||||
) -> list[LedgerWorkItem]:
|
||||
now = self._now()
|
||||
lease_until = now + timedelta(seconds=self._claim_ttl_sec)
|
||||
correlation_sql = ""
|
||||
params: list[Any] = [self._instance_key, now, now]
|
||||
if correlation_id is not None:
|
||||
correlation_sql = "AND correlation_id = %s "
|
||||
params.append(correlation_id)
|
||||
params.extend([limit, self._worker_id, lease_until, now])
|
||||
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"WITH candidates AS ("
|
||||
"SELECT correlation_id FROM ledger_outbox "
|
||||
"WHERE status = 'pending' "
|
||||
"AND instance_key = %s "
|
||||
"AND (next_retry_at IS NULL OR next_retry_at <= %s) "
|
||||
"AND (claim_expires_at IS NULL OR claim_expires_at <= %s) "
|
||||
f"{correlation_sql}"
|
||||
"ORDER BY created_at, correlation_id "
|
||||
"LIMIT %s "
|
||||
"FOR UPDATE SKIP LOCKED"
|
||||
") "
|
||||
"UPDATE ledger_outbox o "
|
||||
"SET claim_owner = %s, claim_expires_at = %s, updated_at = %s "
|
||||
"FROM candidates "
|
||||
"WHERE o.correlation_id = candidates.correlation_id "
|
||||
f"RETURNING {_OUTBOX_RETURN_COLUMNS}",
|
||||
params,
|
||||
)
|
||||
rows = [self._row_to_item(r) for r in cur.fetchall()]
|
||||
conn.commit()
|
||||
return rows
|
||||
|
||||
def pending_cash_delta(self) -> dict[str, float]:
|
||||
# dcy-018 / REQ-029 (OQ-4): estimated cash delta of all pending rows by
|
||||
# asset_id. SUM in SQL: sell → +(qty*price - fee - tax), buy → negative.
|
||||
@@ -627,7 +700,10 @@ class PostgreSQLLedgerOutbox:
|
||||
"SUM(CASE WHEN side='sell' "
|
||||
" THEN (qty*price - fee - tax) "
|
||||
" ELSE -(qty*price + fee + tax) END) "
|
||||
"FROM ledger_outbox WHERE status='pending' GROUP BY asset_id"
|
||||
"FROM ledger_outbox "
|
||||
"WHERE status='pending' AND instance_key=%s "
|
||||
"GROUP BY asset_id",
|
||||
(self._instance_key,),
|
||||
)
|
||||
return {r[0]: float(r[1]) for r in cur.fetchall() if r[1] is not None}
|
||||
|
||||
@@ -648,6 +724,7 @@ class PostgreSQLLedgerOutbox:
|
||||
*,
|
||||
error: Optional[str] = None,
|
||||
next_retry_at: Optional[datetime] = None,
|
||||
expected_claim_expires_at: datetime | None = None,
|
||||
) -> LedgerWorkItem:
|
||||
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
|
||||
with conn.cursor() as cur:
|
||||
@@ -669,20 +746,34 @@ class PostgreSQLLedgerOutbox:
|
||||
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
|
||||
params: list[Any] = [
|
||||
status_value,
|
||||
attempts_delta,
|
||||
outcome.value,
|
||||
error,
|
||||
nra,
|
||||
self._now(),
|
||||
correlation_id,
|
||||
self._instance_key,
|
||||
]
|
||||
where = "WHERE correlation_id=%s AND instance_key=%s "
|
||||
if expected_claim_expires_at is None:
|
||||
where += "AND claim_owner IS NULL AND claim_expires_at IS NULL"
|
||||
else:
|
||||
where += (
|
||||
"AND claim_owner=%s "
|
||||
"AND (claim_expires_at=%s OR (status='pending' AND claim_expires_at<=%s))"
|
||||
)
|
||||
params.extend([self._worker_id, expected_claim_expires_at, self._now()])
|
||||
cur.execute(
|
||||
"UPDATE ledger_outbox SET status=%s, attempts=attempts+%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,
|
||||
nra,
|
||||
datetime.now(tz=timezone.utc),
|
||||
correlation_id,
|
||||
),
|
||||
"last_outcome=%s, last_error=%s, next_retry_at=%s, updated_at=%s, "
|
||||
"claim_owner=NULL, claim_expires_at=NULL "
|
||||
f"{where}",
|
||||
params,
|
||||
)
|
||||
if cur.rowcount != 1:
|
||||
raise OutboxClaimLostError(correlation_id)
|
||||
conn.commit()
|
||||
row = self.get(correlation_id)
|
||||
assert row is not None
|
||||
@@ -714,6 +805,8 @@ class PostgreSQLLedgerOutbox:
|
||||
created_at=row[14],
|
||||
updated_at=row[15],
|
||||
next_retry_at=row[16],
|
||||
instance_key=row[19],
|
||||
claim_expires_at=row[20],
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
-- GitHub #84: persist outbox owner instance and add claim/lease fields so
|
||||
-- shared-DB daemon writers only deliver their own rows and can safely recover
|
||||
-- abandoned work after lease expiry. Existing rows remain fail-closed when the
|
||||
-- owner instance cannot be inferred.
|
||||
ALTER TABLE ledger_outbox ADD COLUMN IF NOT EXISTS instance_key TEXT;
|
||||
ALTER TABLE ledger_outbox ADD COLUMN IF NOT EXISTS claim_owner TEXT;
|
||||
ALTER TABLE ledger_outbox ADD COLUMN IF NOT EXISTS claim_expires_at TIMESTAMPTZ;
|
||||
|
||||
UPDATE ledger_outbox
|
||||
SET instance_key='__unowned__'
|
||||
WHERE instance_key IS NULL;
|
||||
|
||||
ALTER TABLE ledger_outbox ALTER COLUMN instance_key SET DEFAULT '__unowned__';
|
||||
ALTER TABLE ledger_outbox ALTER COLUMN instance_key SET NOT NULL;
|
||||
|
||||
DROP INDEX IF EXISTS idx_ledger_outbox_instance_pending;
|
||||
CREATE INDEX IF NOT EXISTS idx_ledger_outbox_instance_pending
|
||||
ON ledger_outbox (instance_key, status, created_at, correlation_id)
|
||||
WHERE status = 'pending';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ledger_outbox_instance_claim_expires
|
||||
ON ledger_outbox (instance_key, claim_expires_at)
|
||||
WHERE status = 'pending';
|
||||
@@ -66,6 +66,10 @@ class WorkItemStatus(str, Enum):
|
||||
DELIVERY_FAILED = "delivery_failed" # dcy-015 — permanent or dead-lettered (terminal)
|
||||
|
||||
|
||||
class OutboxClaimLostError(RuntimeError):
|
||||
"""The caller no longer owns the lease required for an outcome update."""
|
||||
|
||||
|
||||
# Outcomes that move a row to the terminal delivery_failed state.
|
||||
_DELIVERY_FAILED_OUTCOMES = frozenset(
|
||||
{OutboxOutcome.PERMANENT_FAILURE, OutboxOutcome.DEAD_LETTERED}
|
||||
@@ -104,11 +108,18 @@ class LedgerWorkItem:
|
||||
next_retry_at: Optional[datetime] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
instance_key: Optional[str] = None
|
||||
claim_expires_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class LedgerOutbox(Protocol):
|
||||
"""Durable work-item store backing the ledger writer."""
|
||||
|
||||
@property
|
||||
def instance_key(self) -> str:
|
||||
"""Immutable owner partition for rows handled by this outbox."""
|
||||
...
|
||||
|
||||
def enqueue(self, trade: LedgerTrade) -> LedgerWorkItem:
|
||||
"""Enqueue a trade. Idempotent on correlation_id (returns the
|
||||
existing row unchanged if it is already present)."""
|
||||
@@ -118,6 +129,15 @@ class LedgerOutbox(Protocol):
|
||||
"""Return pending work items in FIFO order, up to `limit`."""
|
||||
...
|
||||
|
||||
def claim_pending(
|
||||
self,
|
||||
limit: int = 32,
|
||||
*,
|
||||
correlation_id: str | None = None,
|
||||
) -> list[LedgerWorkItem]:
|
||||
"""Atomically claim pending items for delivery."""
|
||||
...
|
||||
|
||||
def pending_cash_delta(self) -> dict[str, float]:
|
||||
"""Sum the estimated cash delta of ALL not-yet-settled (pending) rows,
|
||||
grouped by asset_id (dcy-018 / REQ-029, OQ-4 estimate overlay).
|
||||
@@ -136,6 +156,7 @@ class LedgerOutbox(Protocol):
|
||||
*,
|
||||
error: Optional[str] = None,
|
||||
next_retry_at: Optional[datetime] = None,
|
||||
expected_claim_expires_at: Optional[datetime] = None,
|
||||
) -> LedgerWorkItem:
|
||||
"""Record the outcome of a writer attempt and advance status.
|
||||
|
||||
@@ -151,6 +172,7 @@ class WriterStats:
|
||||
retryable: int = 0
|
||||
conflicts: int = 0
|
||||
delivery_failed: int = 0 # dcy-015: permanent + dead-lettered terminals
|
||||
claim_lost: int = 0
|
||||
|
||||
|
||||
class LedgerWriter:
|
||||
@@ -190,15 +212,46 @@ class LedgerWriter:
|
||||
# dcy-033 / REQ-100: best-effort alarm emitter.
|
||||
self._alarm_emitter = alarm_emitter
|
||||
self._ledger_skip_store = ledger_skip_store
|
||||
self._instance_key = instance_key
|
||||
resolved_instance_key = outbox.instance_key if instance_key is None else instance_key
|
||||
if not isinstance(resolved_instance_key, str) or not resolved_instance_key.strip():
|
||||
raise ValueError("instance_key must be a non-empty string")
|
||||
if resolved_instance_key != outbox.instance_key:
|
||||
raise ValueError("writer instance_key must match outbox instance_key")
|
||||
self._instance_key = resolved_instance_key
|
||||
self.counters: dict[str, int] = {}
|
||||
|
||||
def _count(self, key: str) -> None:
|
||||
self.counters[key] = self.counters.get(key, 0) + 1
|
||||
|
||||
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)
|
||||
items = self._outbox.claim_pending(limit)
|
||||
claim_result = "claimed" if items else "empty"
|
||||
self._count(f"claim:{claim_result}")
|
||||
_log.info(
|
||||
"ledger outbox claim: instance_key=%s claim_result=%s count=%d limit=%d",
|
||||
self._instance_key,
|
||||
claim_result,
|
||||
len(items),
|
||||
limit,
|
||||
)
|
||||
for item in items:
|
||||
if not self._validate_instance(item):
|
||||
continue
|
||||
try:
|
||||
outcome = self._process(item)
|
||||
except OutboxClaimLostError:
|
||||
self._count("claim:lost")
|
||||
stats.claim_lost += 1
|
||||
_log.warning(
|
||||
"ledger outbox claim lost: instance_key=%s correlation_id=%s",
|
||||
self._instance_key,
|
||||
item.correlation_id,
|
||||
)
|
||||
continue
|
||||
if outcome is OutboxOutcome.COMPLETED:
|
||||
stats.completed += 1
|
||||
elif outcome is OutboxOutcome.DUPLICATE_COMPLETED:
|
||||
@@ -216,7 +269,50 @@ class LedgerWriter:
|
||||
item = self._outbox.get(correlation_id)
|
||||
if item is None:
|
||||
return None
|
||||
return self._process(item)
|
||||
if not self._validate_instance(item):
|
||||
return None
|
||||
if item.status is WorkItemStatus.PENDING:
|
||||
claimed = self._outbox.claim_pending(1, correlation_id=correlation_id)
|
||||
if not claimed:
|
||||
self._count("claim:unavailable")
|
||||
_log.info(
|
||||
"ledger outbox claim: instance_key=%s claim_result=unavailable "
|
||||
"correlation_id=%s",
|
||||
self._instance_key,
|
||||
correlation_id,
|
||||
)
|
||||
return None
|
||||
item = claimed[0]
|
||||
self._count("claim:claimed")
|
||||
_log.info(
|
||||
"ledger outbox claim: instance_key=%s claim_result=claimed "
|
||||
"count=%d correlation_id=%s",
|
||||
self._instance_key,
|
||||
len(claimed),
|
||||
correlation_id,
|
||||
)
|
||||
try:
|
||||
return self._process(item)
|
||||
except OutboxClaimLostError:
|
||||
self._count("claim:lost")
|
||||
_log.warning(
|
||||
"ledger outbox claim lost: instance_key=%s correlation_id=%s",
|
||||
self._instance_key,
|
||||
correlation_id,
|
||||
)
|
||||
return None
|
||||
|
||||
def _validate_instance(self, item: LedgerWorkItem) -> bool:
|
||||
if item.instance_key == self._instance_key:
|
||||
return True
|
||||
self._count("claim:foreign_instance")
|
||||
_log.warning(
|
||||
"ledger outbox claim rejected: instance_key=%s "
|
||||
"claim_result=foreign_or_unowned correlation_id=%s",
|
||||
self._instance_key,
|
||||
item.correlation_id,
|
||||
)
|
||||
return False
|
||||
|
||||
def _backoff_delay(self, attempts: int) -> float:
|
||||
"""Exponential backoff with jitter: min(base*2^(n-1), cap) + up to 25%."""
|
||||
@@ -228,14 +324,18 @@ class LedgerWriter:
|
||||
# outbox row. Skip the adapter call.
|
||||
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)
|
||||
self._log_outcome(item, OutboxOutcome.DUPLICATE_COMPLETED)
|
||||
return OutboxOutcome.DUPLICATE_COMPLETED
|
||||
try:
|
||||
outcome = self._ledger.record_trade(item.trade)
|
||||
except LedgerConflictError as exc:
|
||||
self._outbox.mark_outcome(
|
||||
item.correlation_id, OutboxOutcome.CONFLICT_BLOCKED, error=str(exc)
|
||||
item.correlation_id,
|
||||
OutboxOutcome.CONFLICT_BLOCKED,
|
||||
error=str(exc),
|
||||
expected_claim_expires_at=item.claim_expires_at,
|
||||
)
|
||||
self._log_outcome(item, OutboxOutcome.CONFLICT_BLOCKED)
|
||||
# dcy-033 / REQ-100: CRITICAL alarm — 409 conflict, operator resolution needed.
|
||||
if self._alarm_emitter is not None:
|
||||
self._alarm_emitter.publish_alarm(
|
||||
@@ -250,11 +350,16 @@ class LedgerWriter:
|
||||
# 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.PERMANENT_FAILURE, error=str(exc)
|
||||
item.correlation_id,
|
||||
OutboxOutcome.PERMANENT_FAILURE,
|
||||
error=str(exc),
|
||||
expected_claim_expires_at=item.claim_expires_at,
|
||||
)
|
||||
self._log_outcome(item, OutboxOutcome.PERMANENT_FAILURE)
|
||||
_log.warning(
|
||||
"ledger delivery_failed (permanent): correlation_id=%s reason=%s",
|
||||
item.correlation_id, exc,
|
||||
"ledger delivery_failed (permanent): instance_key=%s correlation_id=%s",
|
||||
item.instance_key or self._instance_key,
|
||||
item.correlation_id,
|
||||
)
|
||||
# dcy-033 / REQ-100: CRITICAL alarm — permanent failure, no retry.
|
||||
if self._alarm_emitter is not None:
|
||||
@@ -271,12 +376,18 @@ class LedgerWriter:
|
||||
new_attempts = item.attempts + 1
|
||||
if new_attempts >= self._max_attempts:
|
||||
self._outbox.mark_outcome(
|
||||
item.correlation_id, OutboxOutcome.DEAD_LETTERED, error=str(exc)
|
||||
item.correlation_id,
|
||||
OutboxOutcome.DEAD_LETTERED,
|
||||
error=str(exc),
|
||||
expected_claim_expires_at=item.claim_expires_at,
|
||||
)
|
||||
self._log_outcome(item, OutboxOutcome.DEAD_LETTERED)
|
||||
_log.warning(
|
||||
"ledger delivery_failed (dead-letter, attempts=%d): "
|
||||
"correlation_id=%s reason=%s",
|
||||
new_attempts, item.correlation_id, exc,
|
||||
"instance_key=%s correlation_id=%s",
|
||||
new_attempts,
|
||||
item.instance_key or self._instance_key,
|
||||
item.correlation_id,
|
||||
)
|
||||
# dcy-033 / REQ-100: CRITICAL alarm — dead-lettered after max retries.
|
||||
if self._alarm_emitter is not None:
|
||||
@@ -294,8 +405,11 @@ class LedgerWriter:
|
||||
)
|
||||
self._outbox.mark_outcome(
|
||||
item.correlation_id, OutboxOutcome.RETRYABLE_FAILURE,
|
||||
error=str(exc), next_retry_at=next_retry_at,
|
||||
error=str(exc),
|
||||
next_retry_at=next_retry_at,
|
||||
expected_claim_expires_at=item.claim_expires_at,
|
||||
)
|
||||
self._log_outcome(item, OutboxOutcome.RETRYABLE_FAILURE)
|
||||
return OutboxOutcome.RETRYABLE_FAILURE
|
||||
# dcy-018 / REQ-019/030 (option D): write back portfolio-hub authoritative
|
||||
# cash/quantity to the snapshot store on new+duplicate (OQ-2 b). *_after
|
||||
@@ -306,13 +420,37 @@ class LedgerWriter:
|
||||
# duplicate_completed; the ledger has exactly one entry for this
|
||||
# execution_id either way (REQ-019 AC-1, AC-4).
|
||||
if outcome.result is LedgerWriteResult.DUPLICATE_OK:
|
||||
self._outbox.mark_outcome(item.correlation_id, OutboxOutcome.DUPLICATE_COMPLETED)
|
||||
self._outbox.mark_outcome(
|
||||
item.correlation_id,
|
||||
OutboxOutcome.DUPLICATE_COMPLETED,
|
||||
expected_claim_expires_at=item.claim_expires_at,
|
||||
)
|
||||
self._resolve_ledger_skip(item.trade)
|
||||
self._log_outcome(item, OutboxOutcome.DUPLICATE_COMPLETED)
|
||||
return OutboxOutcome.DUPLICATE_COMPLETED
|
||||
self._outbox.mark_outcome(item.correlation_id, OutboxOutcome.COMPLETED)
|
||||
self._outbox.mark_outcome(
|
||||
item.correlation_id,
|
||||
OutboxOutcome.COMPLETED,
|
||||
expected_claim_expires_at=item.claim_expires_at,
|
||||
)
|
||||
self._resolve_ledger_skip(item.trade)
|
||||
self._log_outcome(item, OutboxOutcome.COMPLETED)
|
||||
return OutboxOutcome.COMPLETED
|
||||
|
||||
def _log_outcome(
|
||||
self,
|
||||
item: LedgerWorkItem,
|
||||
outcome: OutboxOutcome,
|
||||
) -> None:
|
||||
self._count(f"outcome:{outcome.value}")
|
||||
msg = "ledger outbox outcome: instance_key=%s correlation_id=%s outcome=%s"
|
||||
args: tuple[object, ...] = (
|
||||
item.instance_key or self._instance_key,
|
||||
item.correlation_id,
|
||||
outcome.value,
|
||||
)
|
||||
_log.info(msg, *args)
|
||||
|
||||
def _resolve_ledger_skip(self, trade: LedgerTrade) -> None:
|
||||
if (
|
||||
self._ledger_skip_store is None
|
||||
@@ -349,7 +487,7 @@ class LedgerWriter:
|
||||
except Exception: # noqa: BLE001 - write-back must not block the ledger path
|
||||
_log.warning(
|
||||
"snapshot cash/position write-back failed: asset_id=%s",
|
||||
trade.asset_id, exc_info=True,
|
||||
trade.asset_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -358,7 +496,13 @@ class LedgerWriter:
|
||||
class InMemoryLedgerOutbox:
|
||||
"""Process-local outbox. Thread-safe; idempotent on correlation_id."""
|
||||
|
||||
def __init__(self, *, clock: Optional[Callable[[], datetime]] = None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
clock: Optional[Callable[[], datetime]] = None,
|
||||
instance_key: str,
|
||||
claim_ttl_sec: float = 60.0,
|
||||
) -> None:
|
||||
import threading
|
||||
|
||||
self._rows: dict[str, LedgerWorkItem] = {}
|
||||
@@ -367,6 +511,16 @@ class InMemoryLedgerOutbox:
|
||||
# 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))
|
||||
if not isinstance(instance_key, str) or not instance_key.strip():
|
||||
raise ValueError("instance_key must be a non-empty string")
|
||||
if claim_ttl_sec <= 0:
|
||||
raise ValueError("claim_ttl_sec must be positive")
|
||||
self._instance_key = instance_key
|
||||
self._claim_ttl_sec = claim_ttl_sec
|
||||
|
||||
@property
|
||||
def instance_key(self) -> str:
|
||||
return self._instance_key
|
||||
|
||||
def _now(self) -> datetime:
|
||||
return self._clock()
|
||||
@@ -383,6 +537,7 @@ class InMemoryLedgerOutbox:
|
||||
status=WorkItemStatus.PENDING,
|
||||
created_at=self._now(),
|
||||
updated_at=self._now(),
|
||||
instance_key=self._instance_key,
|
||||
)
|
||||
self._rows[cid] = row
|
||||
self._order.append(cid)
|
||||
@@ -396,14 +551,45 @@ class InMemoryLedgerOutbox:
|
||||
row = self._rows[cid]
|
||||
# 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
|
||||
if (
|
||||
row.status is WorkItemStatus.PENDING
|
||||
and row.instance_key == self._instance_key
|
||||
and (row.next_retry_at is None or row.next_retry_at <= now)
|
||||
and (row.claim_expires_at is None or row.claim_expires_at <= now)
|
||||
):
|
||||
out.append(row)
|
||||
if len(out) >= limit:
|
||||
break
|
||||
return out
|
||||
|
||||
def claim_pending(
|
||||
self,
|
||||
limit: int = 32,
|
||||
*,
|
||||
correlation_id: str | None = None,
|
||||
) -> list[LedgerWorkItem]:
|
||||
now = self._now()
|
||||
lease_until = now + timedelta(seconds=self._claim_ttl_sec)
|
||||
with self._lock:
|
||||
claimed: list[LedgerWorkItem] = []
|
||||
candidates = [correlation_id] if correlation_id is not None else self._order
|
||||
for cid in candidates:
|
||||
row = self._rows.get(cid)
|
||||
if row is None or row.status is not WorkItemStatus.PENDING:
|
||||
continue
|
||||
if row.instance_key != self._instance_key:
|
||||
continue
|
||||
if row.next_retry_at is not None and row.next_retry_at > now:
|
||||
continue
|
||||
if row.claim_expires_at is not None and row.claim_expires_at > now:
|
||||
continue
|
||||
updated = replace(row, claim_expires_at=lease_until, updated_at=now)
|
||||
self._rows[cid] = updated
|
||||
claimed.append(updated)
|
||||
if len(claimed) >= limit:
|
||||
break
|
||||
return claimed
|
||||
|
||||
def pending_cash_delta(self) -> dict[str, float]:
|
||||
with self._lock:
|
||||
out: dict[str, float] = {}
|
||||
@@ -424,9 +610,15 @@ class InMemoryLedgerOutbox:
|
||||
*,
|
||||
error: Optional[str] = None,
|
||||
next_retry_at: Optional[datetime] = None,
|
||||
expected_claim_expires_at: Optional[datetime] = None,
|
||||
) -> LedgerWorkItem:
|
||||
with self._lock:
|
||||
row = self._rows[correlation_id]
|
||||
if expected_claim_expires_at is None:
|
||||
if row.claim_expires_at is not None:
|
||||
raise OutboxClaimLostError(correlation_id)
|
||||
elif row.claim_expires_at != expected_claim_expires_at:
|
||||
raise OutboxClaimLostError(correlation_id)
|
||||
if outcome is OutboxOutcome.COMPLETED:
|
||||
new_status = WorkItemStatus.COMPLETED
|
||||
elif outcome is OutboxOutcome.CONFLICT_BLOCKED:
|
||||
@@ -448,6 +640,7 @@ class InMemoryLedgerOutbox:
|
||||
next_retry_at if outcome is OutboxOutcome.RETRYABLE_FAILURE else None
|
||||
),
|
||||
updated_at=self._now(),
|
||||
claim_expires_at=None,
|
||||
)
|
||||
self._rows[correlation_id] = updated
|
||||
return updated
|
||||
|
||||
@@ -1341,7 +1341,7 @@ def _cmd_ledger_backfill(args, cfg, confirm) -> int:
|
||||
_emit_pretty_json(report)
|
||||
return 1
|
||||
|
||||
outbox = PostgreSQLLedgerOutbox(cfg.dsn)
|
||||
outbox = PostgreSQLLedgerOutbox(cfg.dsn, instance_key=instance)
|
||||
if not cfg.portfolio_hub or not cfg.portfolio_hub.base_url:
|
||||
report["result"] = {"status": "rejected", "reason": "portfolio-hub ledger adapter is not configured"}
|
||||
report["audit_path"] = _write_ledger_backfill_audit(args, report)
|
||||
|
||||
@@ -190,7 +190,7 @@ class Daemon:
|
||||
instance_key=config.instance_key,
|
||||
)
|
||||
|
||||
self.outbox = PostgreSQLLedgerOutbox(config.dsn)
|
||||
self.outbox = PostgreSQLLedgerOutbox(config.dsn, instance_key=config.instance_key)
|
||||
self.ledger_skip_store = PostgreSQLLedgerSkipStore(config.dsn)
|
||||
# BAT-008: portfolio-hub read + snapshot store + durable pause + scheduler.
|
||||
# (Created before the ledger writer so option-D write-back can target it.)
|
||||
|
||||
Reference in New Issue
Block a user