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:
2026-09-13 10:48:57 +09:00
committed by GitHub
18 changed files with 817 additions and 133 deletions
+114 -21
View File
@@ -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';
+212 -19
View File
@@ -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
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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.)