Skip to content

Commit cabcb3b

Browse files
fix(graph-maintenance): sort unit_ids in enqueue to eliminate insert deadlock (#2353)
`enqueue_graph_maintenance` is called inside the same transaction as the mutation that produced its `unit_ids` list (see `enqueue_relink_victims` after a memory update, document delete, etc.). The INSERT it issues takes a short-lived row-level lock per `(bank_id, unit_id)` for the unique-key check (`ON CONFLICT DO NOTHING` on Postgres, the `IGNORE_ROW_ON_DUPKEY_INDEX` hint on Oracle). Under load, two concurrent transactions on the same bank can produce overlapping `unit_ids` sets in different orders — most easily reproduced by two concurrent `PATCH /v1/default/banks/{bank_id}/memories/{id}` requests where the victim sets (surviving units linking to the patched unit) intersect. When the two transactions try to acquire their per-row locks in opposite orders, Postgres detects the cycle and aborts one transaction with `asyncpg.exceptions.DeadlockDetectedError`, which the FastAPI layer surfaces as an opaque 500. Fix: sort `unit_ids` inside both `PostgreSQLOps.enqueue_graph_maintenance` and `OracleOps.enqueue_graph_maintenance` before issuing the INSERT. With a total order over the lock set, deadlock is mathematically impossible — both transactions queue cleanly on the first conflicting row, then proceed in lockstep. The only public caller (`enqueue_relink_victims` in `hindsight_api/engine/graph_maintenance.py`) doesn't rely on insertion order, so this is a pure correctness improvement with no API-visible effect. The abstract contract docstring already said "Order is unspecified" — implementations now happen to pick a deterministic order, but that's an internal invariant, not part of the public contract. Tests: - `tests/test_enqueue_graph_maintenance_ordered.py` (new): - `test_pg_enqueue_graph_maintenance_inserts_in_sorted_order` — captures the array passed to `conn.execute` from a deliberately shuffled input and asserts it is sorted. - `test_oracle_enqueue_graph_maintenance_inserts_in_sorted_order` — same assertion against `conn.executemany`'s tuples. - Two empty-input tests pin the early-return short-circuit (no INSERT when `unit_ids == []`). - Verified existing `tests/test_graph_maintenance.py` still passes (14/14) — the relink-victim enqueue and drain semantics are unchanged. Compatibility: identical on both dialects. No schema changes. No externally-visible behavior change beyond the deadlock no longer firing.
1 parent 5f0b715 commit cabcb3b

3 files changed

Lines changed: 134 additions & 2 deletions

File tree

hindsight-api-slim/hindsight_api/engine/db/ops_oracle.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,13 +256,21 @@ async def enqueue_graph_maintenance(
256256
# Oracle doesn't support ON CONFLICT; rely on the PK and the
257257
# IGNORE_ROW_ON_DUPKEY_INDEX hint to skip duplicates server-side.
258258
# The hint name must match the PK constraint exactly.
259+
#
260+
# Sort to enforce a global lock-acquisition order on the
261+
# (bank_id, unit_id) PK. Without this, two concurrent
262+
# transactions inserting overlapping unit_id sets in different
263+
# orders can deadlock on the unique-check row locks. Sorting
264+
# gives every concurrent caller the same lock order, so
265+
# conflicting inserts queue cleanly instead of cycling.
266+
sorted_unit_ids = sorted(unit_ids)
259267
await conn.executemany(
260268
f"""
261269
INSERT /*+ IGNORE_ROW_ON_DUPKEY_INDEX({table}, pk_graph_maintenance_queue) */
262270
INTO {table} (bank_id, unit_id)
263271
VALUES ($1, $2)
264272
""",
265-
[(bank_id, uid) for uid in unit_ids],
273+
[(bank_id, uid) for uid in sorted_unit_ids],
266274
)
267275

268276
async def claim_graph_maintenance_batch(

hindsight-api-slim/hindsight_api/engine/db/ops_postgresql.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -348,14 +348,23 @@ async def enqueue_graph_maintenance(
348348
) -> None:
349349
if not unit_ids:
350350
return
351+
# Sort to enforce a global lock-acquisition order on the
352+
# (bank_id, unit_id) unique-key. Without this, two concurrent
353+
# transactions inserting overlapping unit_id sets in different
354+
# orders can deadlock on the ON CONFLICT row locks — Postgres
355+
# acquires a short-lived lock per row being checked, and cycle
356+
# detection then aborts one transaction. Sorting gives every
357+
# concurrent caller the same lock order, so conflicting inserts
358+
# queue cleanly instead of cycling.
359+
sorted_unit_ids = sorted(unit_ids)
351360
await conn.execute(
352361
f"""
353362
INSERT INTO {table} (bank_id, unit_id)
354363
SELECT $1, v FROM unnest($2::uuid[]) AS t(v)
355364
ON CONFLICT (bank_id, unit_id) DO NOTHING
356365
""",
357366
bank_id,
358-
unit_ids,
367+
sorted_unit_ids,
359368
)
360369

361370
async def claim_graph_maintenance_batch(
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
"""Regression test: `enqueue_graph_maintenance` must insert unit_ids in a
2+
deterministic sorted order so concurrent transactions can't deadlock on the
3+
graph_maintenance_queue unique-key check.
4+
5+
Symptom (production): under load, concurrent `PATCH /memories/{id}` requests
6+
on the same bank generate overlapping `victim_ids` sets (the surviving units
7+
whose outgoing links pointed at the updated unit). Each transaction inserts
8+
those victims into `graph_maintenance_queue` with
9+
`ON CONFLICT (bank_id, unit_id) DO NOTHING`. The conflict check takes a
10+
short-lived row-level lock per (bank_id, unit_id) being inserted, and when
11+
two transactions insert overlapping sets in different orders Postgres
12+
detects a deadlock and aborts one of them — surfacing as
13+
`asyncpg.exceptions.DeadlockDetectedError` from the API, which becomes a 500.
14+
15+
The fix sorts the input list inside both `ops_postgresql` and `ops_oracle`
16+
before passing it to the INSERT, so every transaction acquires the per-row
17+
locks in the same global (sorted-UUID) order. With a total order over the
18+
lock set, deadlock is mathematically impossible — Postgres still serializes
19+
the conflicting inserts but they queue cleanly instead of cycling.
20+
21+
This test pins that post-condition by capturing the array passed to the
22+
underlying `conn.execute` (PG path) / `conn.executemany` (Oracle path) and
23+
asserting it's sorted.
24+
"""
25+
26+
from __future__ import annotations
27+
28+
import uuid
29+
from unittest.mock import AsyncMock
30+
31+
import pytest
32+
33+
from hindsight_api.engine.db.ops_oracle import OracleOps
34+
from hindsight_api.engine.db.ops_postgresql import PostgreSQLOps
35+
36+
37+
def _shuffled_uuids(n: int) -> list[uuid.UUID]:
38+
"""Generate n UUIDs in a deliberately non-monotonic order. Hex literals
39+
avoid `uuid.uuid4()` because uuid4 is random and we want determinism."""
40+
raw = [
41+
"ffffffff-ffff-4fff-8fff-ffffffffffff",
42+
"00000000-0000-4000-8000-000000000001",
43+
"88888888-8888-4888-8888-888888888888",
44+
"11111111-1111-4111-8111-111111111111",
45+
"ccccccccc-cccc-4ccc-8ccc-cccccccccccc"[:36],
46+
"44444444-4444-4444-8444-444444444444",
47+
]
48+
return [uuid.UUID(s) for s in raw[:n]]
49+
50+
51+
@pytest.mark.asyncio
52+
async def test_pg_enqueue_graph_maintenance_inserts_in_sorted_order():
53+
"""The PostgreSQL ops impl must pass the unit_ids to the INSERT in
54+
sorted order, regardless of how the caller ordered them."""
55+
ops = PostgreSQLOps()
56+
conn = AsyncMock()
57+
58+
unit_ids = _shuffled_uuids(6)
59+
assert unit_ids != sorted(unit_ids), "test inputs must be unsorted"
60+
61+
await ops.enqueue_graph_maintenance(
62+
conn=conn,
63+
table="graph_maintenance_queue",
64+
bank_id="test-bank",
65+
unit_ids=unit_ids,
66+
)
67+
68+
assert conn.execute.await_count == 1
69+
_sql, bank_id_arg, ids_arg = conn.execute.await_args.args
70+
assert bank_id_arg == "test-bank"
71+
assert ids_arg == sorted(unit_ids), f"expected sorted unit_ids for deadlock-free concurrent inserts, got {ids_arg}"
72+
73+
74+
@pytest.mark.asyncio
75+
async def test_oracle_enqueue_graph_maintenance_inserts_in_sorted_order():
76+
"""The Oracle ops impl applies the same sort. `executemany` receives a
77+
list of (bank_id, unit_id) tuples; the unit_id projection must be
78+
sorted."""
79+
ops = OracleOps()
80+
conn = AsyncMock()
81+
82+
unit_ids = _shuffled_uuids(6)
83+
assert unit_ids != sorted(unit_ids), "test inputs must be unsorted"
84+
85+
await ops.enqueue_graph_maintenance(
86+
conn=conn,
87+
table="graph_maintenance_queue",
88+
bank_id="test-bank",
89+
unit_ids=unit_ids,
90+
)
91+
92+
assert conn.executemany.await_count == 1
93+
_sql, rows = conn.executemany.await_args.args
94+
assert [r[0] for r in rows] == ["test-bank"] * len(unit_ids)
95+
assert [r[1] for r in rows] == sorted(unit_ids), (
96+
f"expected sorted unit_ids for deadlock-free concurrent inserts, got {[r[1] for r in rows]}"
97+
)
98+
99+
100+
@pytest.mark.asyncio
101+
async def test_pg_empty_unit_ids_short_circuits():
102+
"""Empty input must remain a no-op — the early return predates this fix
103+
and must continue to skip the INSERT entirely."""
104+
ops = PostgreSQLOps()
105+
conn = AsyncMock()
106+
await ops.enqueue_graph_maintenance(conn, "graph_maintenance_queue", "b", [])
107+
conn.execute.assert_not_awaited()
108+
109+
110+
@pytest.mark.asyncio
111+
async def test_oracle_empty_unit_ids_short_circuits():
112+
ops = OracleOps()
113+
conn = AsyncMock()
114+
await ops.enqueue_graph_maintenance(conn, "graph_maintenance_queue", "b", [])
115+
conn.executemany.assert_not_awaited()

0 commit comments

Comments
 (0)