Environment
- hindsight-api 0.9.2 (
ebad47824), PostgreSQL backend, pgvector
- Single dataplane process,
HINDSIGHT_API_RUN_MIGRATIONS_ON_STARTUP=false, migrations applied by hindsight-admin run-db-migration
Symptom
Two concurrent DELETE /v1/{ns}/banks/{bank_id}/documents/{document_id} requests for different documents in the same bank, issued ~20 ms apart, both aborted:
ERROR - hindsight_api.api.http - Error in /v1/default/banks/<bank>/documents/0c5b1f9c-…: deadlock detected
ERROR - hindsight_api.api.http - Error in /v1/default/banks/<bank>/documents/7531f069-…: deadlock detected
asyncpg.exceptions.DeadlockDetectedError propagates out of api_delete_document and surfaces as HTTP 500. It is intermittent — a retry succeeds.
The workload is an ordinary "delete these N documents" batch: the client fans out ~20 single-document deletes into one bank with asyncio.gather. Since there is no bulk document-delete endpoint, any multi-document deletion is necessarily N concurrent single-document deletes, so this is the expected shape for that operation rather than an unusual access pattern.
Why this is not covered by the existing fixes
This looks adjacent to #2560 / #2570 / #3393 / #3396, but those all fixed paths that delete_document does not use:
delete_document issues a bare parent-row delete and lets Postgres cascade:
deleted = await conn.fetchval(
f"DELETE FROM {fq_table('documents')} WHERE id = $1 AND bank_id = $2 RETURNING id",
document_id, bank_id,
)
engine/memory_engine.py:7929-7936 (v0.9.2)
The cascade order is chosen by Postgres per-tuple and is not reachable from SQL, so no amount of ordering elsewhere constrains it. The whole body runs in one transaction under acquire_with_retry + conn.transaction() (:7884-7885), and acquire_with_retry retries only the connection acquire — its own docstring states exceptions raised inside the async with block are not retried (engine/db_utils.py:118-125). So there is no deadlock retry on this path.
Two independent cycles
Either alone is sufficient.
A. FK cascade through memory_links
memory_units.(document_id, bank_id) → documents is ON DELETE CASCADE, and memory_links cascades from memory_units on both from_unit_id and to_unit_id (alembic/versions/5a366d414dce_initial_schema.py:285-290, :457-468). Temporal links are written as explicit bidirectional pairs (engine/retain/link_utils.py:499-501).
For units a ∈ docX, b ∈ docY with links L = (a→b) and M = (b→a):
DELETE docX cascades from a: from_unit_id removes L, to_unit_id removes M → L then M
DELETE docY cascades from b: from_unit_id removes M, to_unit_id removes L → M then L
The DEFERRABLE INITIALLY DEFERRED FKs from 9f8e7d6c5b4a move when the cascade runs, not the order within it.
B. The stale-observation sweep touches other documents' rows
delete_stale_observations runs after the cascade, deliberately (engine/memory_engine.py:7929-7932). In engine/memories/pg/writes.py:
obs_ids = [uuid.UUID(obs.unit_id) for obs in affected_obs]
...
for obs in affected_obs:
for src_str in obs.source_memory_ids:
if src_str not in deleted_set and src_str not in seen_remaining:
remaining_source_ids.append(uuid.UUID(src_str))
...
await conn.execute(f"DELETE FROM {fq_table('memory_units')} WHERE id = ANY($1::uuid[])", obs_ids)
...
await conn.execute(f"UPDATE {fq_table('memory_units')} SET consolidated_at = NULL WHERE id = ANY($1::uuid[]) ...",
remaining_source_ids)
remaining_source_ids is by construction the observation sources that are not in this document — i.e. memory units belonging to other documents in the bank. Both arrays are built by unordered iteration over an unordered SELECT and passed straight to ANY(...): no ORDER BY, no sort, no FOR UPDATE.
t1 A: DELETE FROM documents (docX) -> exclusive locks on docX units
t2 B: DELETE FROM documents (docY) -> exclusive locks on docY units
t3 A: UPDATE memory_units ... ANY(remaining_A) -- contains docY units -> waits on B
t4 B: UPDATE memory_units ... ANY(remaining_B) -- contains docX units -> waits on A
-> 40P01
An observation co-sourced from both documents adds a second edge, since both transactions delete that same row in step obs_ids.
Worth noting the contrast: enqueue_relink_victims and enqueue_entity_prune_candidates, called a few lines earlier in the same transaction, are carefully sorted for exactly this reason (#3034). The sweep below them is not.
Still present on main
Checked origin/main at 6f441b0ae (~1250 commits past v0.9.2): delete_document has the same acquire_with_retry + conn.transaction() shape with no advisory lock and no deadlock retry, and delete_stale_observations is unchanged — obs_ids and remaining_source_ids are still unordered.
Reproduction sketch
- Create bank
B.
- Retain documents
X and Y into B with event times inside the temporal-link window, so memory_links holds cross-document bidirectional pairs. Run consolidation so at least one observation draws source_memory_ids from both.
- Concurrently:
DELETE …/documents/X and DELETE …/documents/Y.
- Repeat ~20×, or issue 20 document deletes into one bank at once.
Expect one request to fail with DeadlockDetectedError (40P01) → HTTP 500.
Suggested fix
- Retry on 40P01.
retry_with_backoff already classifies DeadlockDetectedError (engine/db_utils.py:40) and is already used for index DDL, the entity-prune batch and bank creation. Wrapping the delete_document transaction body would make this transparent. The body re-reads unit ids and uses DELETE … RETURNING, so it is safe to re-run.
- Serialise same-bank deletes.
SELECT pg_advisory_xact_lock(hashtext($bank_id)) at the top of the transaction eliminates both cycles and is per-bank, so it does not contend across banks. Sorting obs_ids / remaining_source_ids and adding ORDER BY id closes B only — A is a cascade and cannot be ordered from SQL — so ordering alone is not sufficient here.
A regression test shaped like tests/test_memory_links_to_unit_id_concurrent_delete.py, but with two concurrent delete_document calls on two linked documents in one bank, would pin it. The existing test covers insert-vs-delete, not delete-vs-delete.
Caveat
Our captured log line carries only deadlock detected; the server-side DETAIL: naming the relations and tuples was not retained, so which of A or B fired is read off the code rather than observed. Both are present in 0.9.2 and either is sufficient. Happy to re-run with log_lock_waits = on and deadlock_timeout = 1s and attach the deadlock report if that would help pin it.
Environment
ebad47824), PostgreSQL backend, pgvectorHINDSIGHT_API_RUN_MIGRATIONS_ON_STARTUP=false, migrations applied byhindsight-admin run-db-migrationSymptom
Two concurrent
DELETE /v1/{ns}/banks/{bank_id}/documents/{document_id}requests for different documents in the same bank, issued ~20 ms apart, both aborted:asyncpg.exceptions.DeadlockDetectedErrorpropagates out ofapi_delete_documentand surfaces as HTTP 500. It is intermittent — a retry succeeds.The workload is an ordinary "delete these N documents" batch: the client fans out ~20 single-document deletes into one bank with
asyncio.gather. Since there is no bulk document-delete endpoint, any multi-document deletion is necessarily N concurrent single-document deletes, so this is the expected shape for that operation rather than an unusual access pattern.Why this is not covered by the existing fixes
This looks adjacent to #2560 / #2570 / #3393 / #3396, but those all fixed paths that
delete_documentdoes not use:delete_chunks_by_idsa sortedmemory_linkspre-delete.delete_documentnever calls it._bulk_insert_linksand the chunk-delete order.delete_documentissues a bare parent-row delete and lets Postgres cascade:engine/memory_engine.py:7929-7936(v0.9.2)The cascade order is chosen by Postgres per-tuple and is not reachable from SQL, so no amount of ordering elsewhere constrains it. The whole body runs in one transaction under
acquire_with_retry+conn.transaction()(:7884-7885), andacquire_with_retryretries only the connection acquire — its own docstring states exceptions raised inside theasync withblock are not retried (engine/db_utils.py:118-125). So there is no deadlock retry on this path.Two independent cycles
Either alone is sufficient.
A. FK cascade through
memory_linksmemory_units.(document_id, bank_id) → documentsisON DELETE CASCADE, andmemory_linkscascades frommemory_unitson bothfrom_unit_idandto_unit_id(alembic/versions/5a366d414dce_initial_schema.py:285-290,:457-468). Temporal links are written as explicit bidirectional pairs (engine/retain/link_utils.py:499-501).For units
a ∈ docX,b ∈ docYwith linksL = (a→b)andM = (b→a):DELETE docXcascades froma:from_unit_idremovesL,to_unit_idremovesM→ L then MDELETE docYcascades fromb:from_unit_idremovesM,to_unit_idremovesL→ M then LThe
DEFERRABLE INITIALLY DEFERREDFKs from9f8e7d6c5b4amove when the cascade runs, not the order within it.B. The stale-observation sweep touches other documents' rows
delete_stale_observationsruns after the cascade, deliberately (engine/memory_engine.py:7929-7932). Inengine/memories/pg/writes.py:remaining_source_idsis by construction the observation sources that are not in this document — i.e. memory units belonging to other documents in the bank. Both arrays are built by unordered iteration over an unorderedSELECTand passed straight toANY(...): noORDER BY, no sort, noFOR UPDATE.An observation co-sourced from both documents adds a second edge, since both transactions delete that same row in step
obs_ids.Worth noting the contrast:
enqueue_relink_victimsandenqueue_entity_prune_candidates, called a few lines earlier in the same transaction, are carefully sorted for exactly this reason (#3034). The sweep below them is not.Still present on
mainChecked
origin/mainat6f441b0ae(~1250 commits pastv0.9.2):delete_documenthas the sameacquire_with_retry+conn.transaction()shape with no advisory lock and no deadlock retry, anddelete_stale_observationsis unchanged —obs_idsandremaining_source_idsare still unordered.Reproduction sketch
B.XandYintoBwith event times inside the temporal-link window, somemory_linksholds cross-document bidirectional pairs. Run consolidation so at least one observation drawssource_memory_idsfrom both.DELETE …/documents/XandDELETE …/documents/Y.Expect one request to fail with
DeadlockDetectedError(40P01) → HTTP 500.Suggested fix
retry_with_backoffalready classifiesDeadlockDetectedError(engine/db_utils.py:40) and is already used for index DDL, the entity-prune batch and bank creation. Wrapping thedelete_documenttransaction body would make this transparent. The body re-reads unit ids and usesDELETE … RETURNING, so it is safe to re-run.SELECT pg_advisory_xact_lock(hashtext($bank_id))at the top of the transaction eliminates both cycles and is per-bank, so it does not contend across banks. Sortingobs_ids/remaining_source_idsand addingORDER BY idcloses B only — A is a cascade and cannot be ordered from SQL — so ordering alone is not sufficient here.A regression test shaped like
tests/test_memory_links_to_unit_id_concurrent_delete.py, but with two concurrentdelete_documentcalls on two linked documents in one bank, would pin it. The existing test covers insert-vs-delete, not delete-vs-delete.Caveat
Our captured log line carries only
deadlock detected; the server-sideDETAIL:naming the relations and tuples was not retained, so which of A or B fired is read off the code rather than observed. Both are present in 0.9.2 and either is sufficient. Happy to re-run withlog_lock_waits = onanddeadlock_timeout = 1sand attach the deadlock report if that would help pin it.