Problem
Concurrent note indexing deadlocks on the tenant relation table in production, and the deadlocked jobs exhaust all 5 PGQueuer attempts and terminally fail — leaving notes unindexed until a later unrelated touch.
16 distinct Logfire fingerprints in the basic-memory-cloud project over 2026-08-01 → 2026-08-09 (#2256 #2310 #2323 #2328 #2339 #2348 #2367 #2377 #2378 #2380 #2382 #2384 #2385 #2387 #2388 #2395), across both index_file and index_file_batch entrypoints.
Trace-proven shapes (representative traces, auth-gated):
- #2395:
index_file attempt 5/5, deadlock in relation_repository.delete_outgoing_relations_from_entity (DELETE FROM relation WHERE from_id = $1 AND project_id = $2), blocking PIDs correlated to concurrent index_file jobs for the same tenant, including a three-process cycle.
- #2388:
index_file_batch attempt 5/5, deadlock in relation_repository.add_all_ignore_duplicates (bulk INSERT INTO relation ... ON CONFLICT DO NOTHING RETURNING id) — with two simultaneous index.markdown_file.persist children inside the same batch job, one succeeding and one deadlocking (self-inflicted intra-job concurrency).
- #2378: same bulk-insert shape, four simultaneous persist children, three complete, one deadlocks.
Root cause
Markdown persistence performs multi-phase set replacement inside one transaction: delete-all-outgoing-relations, then bulk insert the new set, alongside entity upserts. Concurrent persists for notes whose relation sets overlap (mutual links, shared targets) acquire row and FK KEY SHARE locks in note-specific order across those phases. There is no global acquisition order to impose — the same entity legitimately appears in different jobs' write sets — so ordering or serialization cannot fix this class.
Design: generation-versioned, deadlock-free relation persistence
Mirror the in-repo precedent already used by the pgvector layer (source_hash generations + superseded-row cleanup):
- Generation column.
relation rows carry an indexed generation — the note's existing generation token (db_version/content checksum, the same token the cloud supersession gate uses). No new coordination concept.
- Upserts replace delete-then-insert. Persist computes the desired relation set in memory, then upserts rows with
ON CONFLICT DO UPDATE SET generation = G guarded by WHERE relation.generation < G. A stale writer's rows become no-ops — terminal-wins, optimistic versioning, no locks.
- Cleanup is a separate short statement, after upserts.
DELETE FROM relation WHERE from_id = ? AND generation < G — deletes only rows older than the writer's own generation, so it can never destroy newer work. Upsert-before-cleanup means the visible window is a brief union of generations, never a gap; relations are derived, eventually consistent state.
- Transaction shrinking is the actual deadlock kill. Entity upsert, sorted relation-upsert chunks, and cleanup each commit separately. The observed cycles required two multi-phase transactions each holding part of the other's lock set; short single-purpose transactions holding one small sorted set at a time cannot form them. In-chunk sorting stays as cheap insurance but is no longer load-bearing.
resolve_relations adopts the same guarded-update shape (it also appears in production MaxTimeExceeded exhaustion — Logfire #2341 — so shrinking its transactions pays twice).
Follow-up candidate (not this issue): the observations table has the same delete-and-replace persistence shape with lower observed collision frequency.
Scope
Core repo: relation_repository, markdown persist flow (batch_indexer / local_dependencies), resolve_relations planning, plus a tenant-DB migration (relation.generation + index + backfill). Cloud composes these in-process; no cloud-side schema.
Acceptance criteria
Problem
Concurrent note indexing deadlocks on the tenant
relationtable in production, and the deadlocked jobs exhaust all 5 PGQueuer attempts and terminally fail — leaving notes unindexed until a later unrelated touch.16 distinct Logfire fingerprints in the basic-memory-cloud project over 2026-08-01 → 2026-08-09 (#2256 #2310 #2323 #2328 #2339 #2348 #2367 #2377 #2378 #2380 #2382 #2384 #2385 #2387 #2388 #2395), across both
index_fileandindex_file_batchentrypoints.Trace-proven shapes (representative traces, auth-gated):
index_fileattempt 5/5, deadlock inrelation_repository.delete_outgoing_relations_from_entity(DELETE FROM relation WHERE from_id = $1 AND project_id = $2), blocking PIDs correlated to concurrentindex_filejobs for the same tenant, including a three-process cycle.index_file_batchattempt 5/5, deadlock inrelation_repository.add_all_ignore_duplicates(bulkINSERT INTO relation ... ON CONFLICT DO NOTHING RETURNING id) — with two simultaneousindex.markdown_file.persistchildren inside the same batch job, one succeeding and one deadlocking (self-inflicted intra-job concurrency).Root cause
Markdown persistence performs multi-phase set replacement inside one transaction: delete-all-outgoing-relations, then bulk insert the new set, alongside entity upserts. Concurrent persists for notes whose relation sets overlap (mutual links, shared targets) acquire row and FK KEY SHARE locks in note-specific order across those phases. There is no global acquisition order to impose — the same entity legitimately appears in different jobs' write sets — so ordering or serialization cannot fix this class.
Design: generation-versioned, deadlock-free relation persistence
Mirror the in-repo precedent already used by the pgvector layer (
source_hashgenerations + superseded-row cleanup):relationrows carry an indexedgeneration— the note's existing generation token (db_version/content checksum, the same token the cloud supersession gate uses). No new coordination concept.ON CONFLICT DO UPDATE SET generation = Gguarded byWHERE relation.generation < G. A stale writer's rows become no-ops — terminal-wins, optimistic versioning, no locks.DELETE FROM relation WHERE from_id = ? AND generation < G— deletes only rows older than the writer's own generation, so it can never destroy newer work. Upsert-before-cleanup means the visible window is a brief union of generations, never a gap; relations are derived, eventually consistent state.resolve_relationsadopts the same guarded-update shape (it also appears in productionMaxTimeExceededexhaustion — Logfire #2341 — so shrinking its transactions pays twice).Follow-up candidate (not this issue): the observations table has the same delete-and-replace persistence shape with lower observed collision frequency.
Scope
Core repo:
relation_repository, markdown persist flow (batch_indexer/local_dependencies),resolve_relationsplanning, plus a tenant-DB migration (relation.generation+ index + backfill). Cloud composes these in-process; no cloud-side schema.Acceptance criteria
generationfor existing rows and adds the supporting index.