fix: ledger write idempotency for TimescaleDB hypertable (BAT-016 rework)
BAT-016's initial implementation passed only against mocked repositories and broke on a real database. Two defects fixed: 1. insert_ledger_trade used `ON CONFLICT (portfolio_id, order_id, execution_id) WHERE order_id IS NOT NULL` but portfolio_ledger is a TimescaleDB hypertable, which cannot have that partial unique index (must include the partitioning column recorded_at). The statement errored against Postgres. Replaced with application-level idempotency: SELECT-before-insert inside the existing `SELECT ... FOR UPDATE` portfolio row lock, which serializes concurrent same-portfolio writes (race-safe). Duplicate returns current authoritative portfolio state (dcy-006 OQ-4 b). 2. `with self._connect() as conn:` closes the shared psycopg3 connection on exit, leaving the reused repository connection unusable. Switched to `with conn.transaction():` (commits without closing). Added tests/test_ledger_consolidation_integration.py — real-Postgres integration tests (new / duplicate / negative-balance) that close the mock-only verification gap. Full suite: 376 passed. SDLC: REQ-036/037 CHG-20260605-03 reconcile OQ-7 (ii) (DB partial unique → application-level idempotency) since the hypertable makes the DB constraint infeasible. REQ-036/037/038 → Implemented; BAT-016 → release-candidate. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1342,7 +1342,11 @@ class PostgresPortfolioRepository:
|
||||
if side_normalized not in {"buy", "sell"}:
|
||||
raise ValueError("side는 buy 또는 sell 이어야 합니다")
|
||||
|
||||
with self._connect() as conn:
|
||||
conn = self._connect()
|
||||
# Use a transaction block (not `with conn:`) so the shared connection stays open:
|
||||
# psycopg3 closes the connection when it is used as a context manager, which would
|
||||
# leave the repository's reused connection unusable for subsequent calls.
|
||||
with conn.transaction():
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
@@ -1376,6 +1380,40 @@ class PostgresPortfolioRepository:
|
||||
total_value_after = cash_balance_after + asset_quantity_after * price_float
|
||||
strategy_set_id = portfolio_row[5] or "manual"
|
||||
|
||||
# Application-level idempotency under the portfolios row lock.
|
||||
# portfolio_ledger is a TimescaleDB hypertable, so a DB-level partial unique
|
||||
# index on (portfolio_id, order_id, execution_id) is impossible (a unique index
|
||||
# must include the partitioning column recorded_at). The `SELECT ... FOR UPDATE`
|
||||
# on the portfolios row above serializes concurrent writes for the same portfolio,
|
||||
# so this check-then-insert is race-safe for broker writes (order_id present).
|
||||
if order_id is not None and execution_id is not None:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
ledger_entry_id, portfolio_id, order_id, execution_id, side,
|
||||
quantity::float8, price::float8,
|
||||
fee_amount::float8, tax_amount::float8,
|
||||
executed_at, recorded_at,
|
||||
cash_balance_after::float8, asset_quantity_after::float8,
|
||||
total_value_after::float8, profit_buffer_after::float8,
|
||||
strategy_set_id, note
|
||||
FROM portfolio_ledger
|
||||
WHERE portfolio_id = %s AND order_id = %s AND execution_id = %s
|
||||
ORDER BY recorded_at DESC, ledger_entry_id DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(portfolio_id, order_id, execution_id),
|
||||
)
|
||||
existing = cur.fetchone()
|
||||
if existing is not None:
|
||||
# Duplicate: do not insert a new row, do not mutate the portfolio.
|
||||
# OQ-4 (b): return the CURRENT authoritative portfolio state so the
|
||||
# caller always syncs to the latest balances, not the first-call snapshot.
|
||||
entry = self._row_to_ledger_entry(existing)
|
||||
entry.cash_balance_after = cash_balance
|
||||
entry.asset_quantity_after = asset_quantity
|
||||
return entry, False
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO portfolio_ledger (
|
||||
@@ -1389,8 +1427,6 @@ class PostgresPortfolioRepository:
|
||||
%s, %s, %s,
|
||||
%s, %s, %s, %s
|
||||
)
|
||||
ON CONFLICT (portfolio_id, order_id, execution_id) WHERE order_id IS NOT NULL
|
||||
DO NOTHING
|
||||
RETURNING
|
||||
ledger_entry_id, portfolio_id, order_id, execution_id, side,
|
||||
quantity::float8, price::float8,
|
||||
@@ -1419,24 +1455,6 @@ class PostgresPortfolioRepository:
|
||||
),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
ledger_entry_id, portfolio_id, order_id, execution_id, side,
|
||||
quantity::float8, price::float8,
|
||||
fee_amount::float8, tax_amount::float8,
|
||||
executed_at, recorded_at,
|
||||
cash_balance_after::float8, asset_quantity_after::float8,
|
||||
total_value_after::float8, profit_buffer_after::float8,
|
||||
strategy_set_id, note
|
||||
FROM portfolio_ledger
|
||||
WHERE portfolio_id = %s AND order_id = %s AND execution_id = %s
|
||||
""",
|
||||
(portfolio_id, order_id, execution_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return self._row_to_ledger_entry(row), False
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user