fix(retain): close to_unit_id deferred-FK race on memory_links inserts (#1882)#1894
Conversation
#1882) The memory_links → memory_units FKs are DEFERRABLE INITIALLY DEFERRED (migration 9f8e7d6c5b4a), so an INSERT into memory_links takes no lock on the referenced parent rows until COMMIT. Temporal and ANN link inserts reference a *pre-existing* neighbor unit as to_unit_id (graph maintenance also references a pre-existing from_unit_id). A concurrent transaction that commits a DELETE of that unit in the window between the link INSERT and our COMMIT — consolidation pruning observation units, document re-tracking — makes the deferred check fail at COMMIT with fk_memory_links_to_unit_id_memory_units, failing the async op with no retry. #1795/#1805 only removed one *deleter* (sibling async children sharing a document_id) for the from_unit_id side; the to_unit_id side, and any other deleter, stayed uncovered. Fix: in the PostgreSQL bulk link insert, lock the referenced parent units FOR KEY SHARE via a CTE in the *same* INSERT statement. The lock blocks a concurrent DELETE until our transaction commits and is held through the deferred check; the INSERT only takes links whose endpoints are in the locked set, so endpoints that already vanished are dropped. Folding it into the one INSERT keeps this to a single round-trip — no extra query and no surrounding transaction — so retain's perf characteristics are unchanged. A WHERE EXISTS guard can't fix this (the row passes the check, then is deleted before the deferred check runs). Oracle's FK is immediate (no such window) and keeps its existing exists_clause path. Adds a deterministic regression test that hand-drives the connection interleaving (no sleeps): insert link on A (uncommitted) → delete neighbor on B → commit A. Pre-fix this raises the FK violation; post-fix B blocks on A's lock and the link commits cleanly.
824b853 to
40637d6
Compare
Perf evaluation of the fixRevised the implementation to a single-statement locking CTE (instead of a separate End-to-end retain throughput (mock LLM + pg0,
|
| run 1 | run 2 | |
|---|---|---|
| baseline | 24.72 it/s | 26.08 it/s |
| this fix | 24.09 it/s | 24.28 it/s |
~0–4%, within run-to-run noise — link inserts are a small slice of retain (embeddings + fact insert dominate).
Micro-benchmark of the changed statement (isolates the link insert; 1000 links/call, real schema, 60 iters)
Typical batch (~70 distinct referenced units):
| variant | p50 | mean |
|---|---|---|
baseline skip_exists_check=True (temporal/causal) |
5.39ms | 6.21ms |
baseline WHERE EXISTS (semantic/graph) |
6.18ms | 6.29ms |
| this fix (locking CTE) | 5.70ms | 5.86ms |
Worst case (~1050 distinct referenced units locked):
| variant | p50 | mean |
|---|---|---|
baseline skip_exists_check=True |
5.45ms | 5.85ms |
| this fix (locking CTE) | 7.53ms | 8.35ms |
Takeaways
- vs the cheapest baseline path (
skip_exists_check=True, used by temporal/causal links): +~0.3ms per insert for typical batches, up to +~2ms for large 1000-link batches with ~1000 distinct neighbors (theFOR KEY SHARElock acquisition + semi-join filter). Cost scales with the number of distinct referenced units. - vs the
WHERE EXISTSpath (semantic ANN / graph maintenance): roughly neutral to faster — one CTE lock-scan replaces two per-rowEXISTSsubqueries. - No extra round-trips, no extra transactions; the burst-write scenario from the issue uses single-item retains (small link batches), so the practical overhead there is the ~0.3ms end.
Net: a small, bounded cost on the link-insert step (invisible end-to-end within noise) in exchange for eliminating the FK-violation-at-COMMIT failure under concurrent retain.
Fixes #1882.
Problem
The
memory_links→memory_unitsFKs areDEFERRABLE INITIALLY DEFERRED(migration9f8e7d6c5b4a), so anINSERTintomemory_linkstakes no lock on the referenced parent row untilCOMMIT. Temporal and ANN link inserts reference a pre-existing neighbor unit asto_unit_id(graph maintenance also references a pre-existingfrom_unit_id). A concurrent transaction that commits aDELETEof that unit in the window between the linkINSERTand ourCOMMIT— consolidation pruning observation units (consolidator.py), document re-tracking (fact_storage.py) — makes the deferred check fail atCOMMIT:The async op is left
status='failed'with no auto-retry.#1795 / #1805 only removed one deleter (sibling async children sharing a
document_id) for thefrom_unit_idside. Theto_unit_idside — and any other deleter — stayed uncovered, which is the mirror case the issue reports.Fix
Before inserting links, lock the referenced parent units
FOR KEY SHARE(held until the surrounding transaction commits, which blocks a concurrentDELETE) and drop any link whose endpoint already vanished.DataAccessOps.lock_referenced_units— a no-op default for backends with immediate FKs (Oracle has no deferred-FK window), overridden on PostgreSQL to take the row-share lock and report which rows survived._bulk_insert_linkscalls it before the insert and filters vanished endpoints.COMMIT-time check. The other callers (Phase-2 temporal/causal, graph maintenance) already run inside a write transaction.Test
Adds a deterministic regression test (
test_memory_links_to_unit_id_concurrent_delete.py) that hand-drives the connection interleaving with no sleeps:_bulk_insert_linkspath — uncommitted.statement_timeout).Pre-fix: step 2 succeeds instantly and step 3 raises the FK violation. Post-fix: step 2 blocks on A's
FOR KEY SHARElock and is cut off by the timeout; the neighbor survives and the link commits cleanly.Verification
test_memory_links_deferred_fk.py,test_link_utils.py,test_retain.py,test_document_tracking.py,test_async_batch_retain.py,test_causal_relations.py,test_graph_maintenance.py,test_db_abstraction.pyall pass (233 tests)../scripts/hooks/lint.shanduv run ty checkpass.