Skip to content

Commit 8cf0dcb

Browse files
authored
fix(retain): close to_unit_id deferred-FK race on memory_links inserts (#1882) (#1894)
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.
1 parent 4280ac3 commit 8cf0dcb

2 files changed

Lines changed: 160 additions & 4 deletions

File tree

hindsight-api-slim/hindsight_api/engine/db/ops_postgresql.py

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,23 @@ async def bulk_insert_links(
200200
exists_clause: str,
201201
chunk_size: int = 5000,
202202
) -> None:
203+
# exists_clause is unused on PostgreSQL: the memory_links → memory_units
204+
# FKs are DEFERRABLE INITIALLY DEFERRED, so an INSERT takes no lock on the
205+
# referenced parent rows until COMMIT — a concurrent committed DELETE in
206+
# that window (consolidation pruning observations, document re-tracking)
207+
# trips fk_memory_links_{to,from}_unit_id_memory_units at COMMIT (#1882),
208+
# and a WHERE EXISTS guard can't prevent it (the row passes the check,
209+
# then is deleted before the deferred check runs). Instead a CTE locks the
210+
# referenced units FOR KEY SHARE in the *same statement*: the lock blocks a
211+
# concurrent DELETE until our transaction commits and is held through the
212+
# deferred check, and the INSERT only takes links whose endpoints are in
213+
# the locked set, so rows that already vanished are dropped. Folding it
214+
# into the one INSERT keeps this to a single round-trip — no extra query
215+
# and no surrounding transaction needed. (Oracle's immediate FK has no
216+
# such window and uses exists_clause via its own bulk_insert_links.)
217+
from ..schema import fq_table
218+
219+
mu_table = fq_table("memory_units")
203220
from_ids = [lnk[0] for lnk in sorted_links]
204221
to_ids = [lnk[1] for lnk in sorted_links]
205222
types = [lnk[2] for lnk in sorted_links]
@@ -208,24 +225,37 @@ async def bulk_insert_links(
208225

209226
for chunk_start in range(0, len(sorted_links), chunk_size):
210227
chunk_end = min(chunk_start + chunk_size, len(sorted_links))
228+
chunk_from = from_ids[chunk_start:chunk_end]
229+
chunk_to = to_ids[chunk_start:chunk_end]
230+
# Distinct referenced parents, sorted so concurrent inserters acquire
231+
# the row-share locks in a consistent order (avoids deadlocks; same
232+
# convention as the (from, to) link sort).
233+
referenced = sorted({str(x) for x in chunk_from} | {str(x) for x in chunk_to})
211234
await conn.execute(
212235
f"""
236+
WITH locked AS (
237+
SELECT id FROM {mu_table}
238+
WHERE id = ANY($7::uuid[])
239+
ORDER BY id
240+
FOR KEY SHARE
241+
)
213242
INSERT INTO {table}
214243
(from_unit_id, to_unit_id, link_type, weight, entity_id, bank_id)
215244
SELECT f, t, tp, w, e, $6
216245
FROM unnest($1::uuid[], $2::uuid[], $3::text[], $4::float8[], $5::uuid[])
217-
AS t(f, t, tp, w, e)
218-
{exists_clause}
246+
AS u(f, t, tp, w, e)
247+
WHERE f IN (SELECT id FROM locked) AND t IN (SELECT id FROM locked)
219248
ON CONFLICT (from_unit_id, to_unit_id, link_type,
220249
COALESCE(entity_id, '{nil_entity_uuid}'::uuid))
221250
DO NOTHING
222251
""",
223-
from_ids[chunk_start:chunk_end],
224-
to_ids[chunk_start:chunk_end],
252+
chunk_from,
253+
chunk_to,
225254
types[chunk_start:chunk_end],
226255
weights[chunk_start:chunk_end],
227256
entity_ids[chunk_start:chunk_end],
228257
bank_id,
258+
referenced,
229259
timeout=300,
230260
)
231261

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
"""Regression for #1882 — the ``to_unit_id`` side of the memory_links FK race.
2+
3+
Mirror of #1795 (which covered ``from_unit_id``). Temporal / ANN link inserts
4+
reference a **pre-existing neighbor** unit as ``to_unit_id`` and bulk-insert with
5+
``skip_exists_check=True`` (see ``create_temporal_links_batch_per_fact`` /
6+
``compute_semantic_links_ann``). The FK
7+
``fk_memory_links_to_unit_id_memory_units`` is ``DEFERRABLE INITIALLY DEFERRED``
8+
(migration ``9f8e7d6c5b4a``), so the INSERT takes **no lock** on the parent
9+
``memory_units`` row — the check is pushed to COMMIT. A concurrent committed
10+
DELETE of that neighbor (consolidation pruning observation units at
11+
``consolidator.py``; document re-tracking at ``fact_storage.py``) then makes the
12+
deferred check fail at COMMIT with the exact violation reported in #1882.
13+
14+
This is normally a timing race, but it is made **fully deterministic** here by
15+
driving the two connections by hand (no sleeps):
16+
17+
1. conn A opens a txn and inserts a temporal link ``(from, neighbor)`` via the
18+
real ``_bulk_insert_links`` path — but does NOT commit.
19+
2. conn B deletes the neighbor unit and commits.
20+
3. conn A commits.
21+
22+
Pre-fix: step 2 succeeds instantly (A holds no lock on the neighbor), the
23+
neighbor is gone, and step 3 raises ``ForeignKeyViolationError`` → this test
24+
fails, reproducing #1882.
25+
26+
Post-fix (A locks referenced parents ``FOR KEY SHARE`` before inserting): step 2
27+
blocks on A's lock and is cut off by ``statement_timeout``; the neighbor
28+
survives and step 3 commits cleanly → this test passes.
29+
"""
30+
31+
import uuid
32+
33+
import asyncpg
34+
import pytest
35+
36+
from hindsight_api.engine.db.ops_postgresql import PostgreSQLOps
37+
from hindsight_api.engine.db.postgresql import PostgresConnection
38+
from hindsight_api.engine.retain.link_utils import _bulk_insert_links
39+
40+
41+
async def _insert_unit(conn: asyncpg.Connection, bank_id: str) -> str:
42+
"""Insert one committed memory_unit (autocommit) and return its id as text."""
43+
return await conn.fetchval(
44+
"""
45+
INSERT INTO memory_units (bank_id, text, event_date, fact_type)
46+
VALUES ($1, 'unit', now(), 'world')
47+
RETURNING id::text
48+
""",
49+
bank_id,
50+
)
51+
52+
53+
@pytest.mark.asyncio
54+
async def test_to_unit_id_survives_concurrent_neighbor_delete(pg0_db_url):
55+
bank_id = f"fk-to-race-{uuid.uuid4().hex}"
56+
ops = PostgreSQLOps()
57+
58+
# Three independent connections: a setup/cleanup conn, the retain txn (A),
59+
# and the concurrent deleter (B). Independent connections give us full,
60+
# deterministic control over transaction boundaries.
61+
setup = await asyncpg.connect(pg0_db_url)
62+
conn_a = await asyncpg.connect(pg0_db_url)
63+
conn_b = await asyncpg.connect(pg0_db_url)
64+
try:
65+
# Two committed units: `from_id` plays the freshly-inserted fact,
66+
# `neighbor_id` plays the pre-existing temporal neighbor (to_unit_id).
67+
from_id = await _insert_unit(setup, bank_id)
68+
neighbor_id = await _insert_unit(setup, bank_id)
69+
70+
# --- Step 1: conn A inserts the temporal link, does NOT commit ---
71+
tx_a = conn_a.transaction()
72+
await tx_a.start()
73+
await _bulk_insert_links(
74+
PostgresConnection(conn_a),
75+
[(from_id, neighbor_id, "temporal", 1.0, None)],
76+
bank_id=bank_id,
77+
skip_exists_check=True, # the racy path: no EXISTS guard
78+
ops=ops,
79+
)
80+
81+
# --- Step 2: conn B deletes the neighbor and commits (autocommit) ---
82+
# statement_timeout bounds the post-fix path, where A holds FOR KEY
83+
# SHARE on the neighbor and this DELETE must block.
84+
await conn_b.execute("SET statement_timeout = '1000ms'")
85+
neighbor_deleted = False
86+
try:
87+
await conn_b.execute("DELETE FROM memory_units WHERE id = $1", uuid.UUID(neighbor_id))
88+
neighbor_deleted = True
89+
except asyncpg.QueryCanceledError:
90+
# Blocked on A's lock until statement_timeout — the fixed behavior.
91+
neighbor_deleted = False
92+
93+
# --- Step 3: conn A commits — deferred FK check fires here ---
94+
fk_error: Exception | None = None
95+
try:
96+
await tx_a.commit()
97+
except asyncpg.ForeignKeyViolationError as exc:
98+
fk_error = exc
99+
await conn_a.execute("ROLLBACK")
100+
101+
assert fk_error is None, (
102+
"memory_links.to_unit_id FK violation at COMMIT (#1882): a concurrent "
103+
"DELETE removed the referenced neighbor unit between the deferred-FK "
104+
"link INSERT and COMMIT. The link insert must lock referenced parent "
105+
f"units (FOR KEY SHARE) or drop vanished ones. neighbor_deleted="
106+
f"{neighbor_deleted}. {fk_error}"
107+
)
108+
109+
# Post-fix end state: the neighbor was protected, so the link persists.
110+
assert not neighbor_deleted, (
111+
"Expected the concurrent DELETE to block on A's FOR KEY SHARE lock "
112+
"and be cut off by statement_timeout; it succeeded instead, meaning "
113+
"the parent row was not locked before the link insert."
114+
)
115+
link_count = await setup.fetchval(
116+
"SELECT count(*) FROM memory_links WHERE from_unit_id = $1 AND to_unit_id = $2",
117+
uuid.UUID(from_id),
118+
uuid.UUID(neighbor_id),
119+
)
120+
assert link_count == 1
121+
finally:
122+
await setup.execute("DELETE FROM memory_links WHERE bank_id = $1", bank_id)
123+
await setup.execute("DELETE FROM memory_units WHERE bank_id = $1", bank_id)
124+
await setup.close()
125+
await conn_a.close()
126+
await conn_b.close()

0 commit comments

Comments
 (0)