Skip to content

Generation-versioned relation persistence: eliminate indexing deadlocks on the relation table #1213

Description

@phernandez

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):

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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

  • Relation persistence performs no multi-phase delete-then-insert transaction; upserts are generation-guarded and cleanup is a separate bounded statement.
  • A stale generation's writes are no-ops and its cleanup cannot delete newer-generation rows (regression tests for the race in both directions).
  • Concurrent persists of mutually-linked notes (including within one batch job) complete without deadlock under a real-Postgres concurrency test.
  • Migration backfills generation for existing rows and adds the supporting index.
  • Production soak shows the 16 deadlock fingerprints stop recurring and terminal index-job failures from this class cease.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingcloudBasic Memory ClouddocumentationImprovements or additions to documentationduplicateThis issue or pull request already existsenhancementNew feature or requestquestionFurther information is requested

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions