Skip to content

fix(graph-maintenance): sort unit_ids to eliminate concurrent-insert deadlock#2353

Merged
nicoloboschi merged 1 commit into
mainfrom
fix/graph-maintenance-deadlock-ordered-insert
Jun 23, 2026
Merged

fix(graph-maintenance): sort unit_ids to eliminate concurrent-insert deadlock#2353
nicoloboschi merged 1 commit into
mainfrom
fix/graph-maintenance-deadlock-ordered-insert

Conversation

@cdbartholomew

Copy link
Copy Markdown
Contributor

Summary

enqueue_graph_maintenance (in both the PostgreSQL and Oracle data-access
ops impls) issues an INSERT INTO graph_maintenance_queue (bank_id, unit_id)
with ON CONFLICT DO NOTHING (PG) / IGNORE_ROW_ON_DUPKEY_INDEX (Oracle).
The unique-key check takes a short-lived row-level lock per (bank_id, unit_id) being inserted.

When two transactions on the same bank execute concurrently with
overlapping but differently-ordered unit_ids arrays, the per-row locks
get acquired in opposite orders. Postgres detects the cycle and aborts
one transaction with asyncpg.exceptions.DeadlockDetectedError, which
the FastAPI layer surfaces as an opaque 500.

This is straightforward to hit from two concurrent
PATCH /v1/default/banks/{bank_id}/memories/{id} requests where the
respective victim sets (surviving units whose outgoing links pointed at
the patched units) intersect — enqueue_relink_victims then enqueues
each transaction's victim list in arbitrary SELECT DISTINCT order.

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, and the abstract method docstring already documents "Order is
unspecified". This change picks a deterministic implementation-internal
order without strengthening the public contract.

Test plan

hindsight-api-slim/tests/test_enqueue_graph_maintenance_ordered.py
(new file, 4 tests):

  • 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.
  • test_pg_empty_unit_ids_short_circuits /
    test_oracle_empty_unit_ids_short_circuits — pin the existing
    early-return when unit_ids == [].

Regression sweep on the existing graph-maintenance suite —
14/14 passing in tests/test_graph_maintenance.py (relink semantics
unchanged).

ruff check / ruff format clean. Pre-commit hooks pass.

Compatibility

  • Postgres: behavioural identity except the (intentional) lock-order
    determinism. No schema changes. ON CONFLICT DO NOTHING semantics
    unchanged.
  • Oracle: same. IGNORE_ROW_ON_DUPKEY_INDEX hint unchanged.
  • Public API: no externally-visible change beyond the deadlock
    no longer firing under concurrent load.

…deadlock

`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.

@nicoloboschi nicoloboschi left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. I reproduced the deadlock against real Postgres with two concurrent transactions inserting overlapping keys in opposite order (PG aborts one with DeadlockDetectedError after deadlock_timeout), and confirmed that a shared sort order eliminates the cycle — so the fix is correct for the queue-insert path.

One caveat for the record: the PR description's "deadlock is mathematically impossible" is overstated. The same PATCH transaction also issues an unsorted entities ... ON CONFLICT ... DO UPDATE per resolved entity (entity_resolver.py:936) before reaching the queue insert, which can still deadlock on overlapping entity sets. This PR correctly fixes the queue insert; the entity-upsert path is a separate follow-up (a deadlock-retry around the transaction would cover all sites at once, since acquire_with_retry deliberately does not retry in-transaction errors).

@nicoloboschi nicoloboschi merged commit cabcb3b into main Jun 23, 2026
195 of 196 checks passed
@nicoloboschi nicoloboschi deleted the fix/graph-maintenance-deadlock-ordered-insert branch June 23, 2026 09:26
@nicoloboschi

Copy link
Copy Markdown
Collaborator

Correction to my approval note above: I went and audited the full ON CONFLICT / multi-row-write surface of the engine, and my claim that the entity-upsert path "can still deadlock" was wrong — so don't go chasing it.

The entities ... ON CONFLICT ... DO UPDATE I pointed at (entity_resolver.py:936, via _create_entity/resolve_entity) is dead code — zero live callers. The live entity path is already deadlock-safe by the same ordering trick this PR applies to the queue:

  • entity create (bulk_insert_entities) — caller sorts by lower(name) + DO NOTHING
  • entity-stats UPDATE & co-occurrence DO UPDATE (flush_pending_stats) — both sorted() with explicit "prevents deadlocks" comments
  • bulk_insert_links, bulk_insert_unit_entities — sorted at the caller

So graph_maintenance_queue was the last site that hadn't received the sort-for-lock-order treatment the rest of the write paths already had. This PR closes that gap — it's the complete fix for this deadlock class as it exists in the code, not a partial one. Apologies for the misdirection in the original review.

Follow-up: I'll open a small PR to delete the dead resolve_entity/_create_entity/link_unit_to_entity methods so that misleading DO UPDATE stops tripping up future readers (and reviewers).

nicoloboschi added a commit that referenced this pull request Jun 23, 2026
…queue (#2368)

Deterministic, DB-level regression guard for the deadlock fixed in #2353.
Two concurrent transactions insert overlapping graph_maintenance_queue keys in
opposite order (with a barrier between the two per-row locks) and Postgres
aborts one with DeadlockDetectedError; the sorted-order companion test shows a
shared lock order eliminates the cycle. Unlike #2353's tests — which only assert
the Python list handed to execute() is sorted — this exercises the actual lock.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants