qa: providence_cache INSERT — ON CONFLICT(cache_key) DO NOTHING

The cache lookup in ask()/query()/canonical-persist runs outside the
write transaction, so two concurrent callers on the same cache_key can
both miss and both reach the providence_cache INSERT — the loser raised
`UNIQUE constraint failed: providence_cache.cache_key` and ask() crashed
(MOAD-0005 / TOCTOU; sibling of the af870bb append_audit fix). All three
write sites (qa/runner.py, qa/query.py, qa/canonical_cache.py) now end in
`ON CONFLICT(cache_key) DO NOTHING`, so the loser no-ops (its answer is
equivalent — same question/model/policy ⇒ same cache_key; canonical
answers are deterministic). With busy_timeout on every connection
(af870bb) the loser waits on the writer's lock then no-ops.

test_qa.py::test_concurrent_ask_same_cache_key_no_unique_crash — 6
threads run ask() on the same question concurrently; must not raise;
exactly one cache row lands. Verified it fails without the fix (5 of 6
threads raise IntegrityError).
This commit is contained in:
russell@unturf.com 2026-05-11 17:29:12 -04:00
parent f6ac72270c
commit bbfd2ddc17
No known key found for this signature in database
4 changed files with 77 additions and 3 deletions

View file

@ -233,3 +233,61 @@ def test_cache_key_is_pure_function():
"|".join([src_root, qh, mh, ch, gh, "v1", "v1", "v1"]).encode()
).hexdigest()
assert k1 == expected
def test_concurrent_ask_same_cache_key_no_unique_crash(tmp_path):
"""Two threads run ``ask()`` on the same question/model concurrently.
The cache lookup happens *outside* the write transaction, so both miss
and both reach the ``providence_cache`` INSERT — ``ON CONFLICT(cache_key)
DO NOTHING`` makes the loser no-op instead of raising
``UNIQUE constraint failed: providence_cache.cache_key``. Exactly one
cache row lands. (Without the fix the losers raise; with ``busy_timeout``
on every connection the loser waits on the writer's lock then no-ops.)
"""
import threading
db = tmp_path / "race-qa.db"
conn = connect(db)
try:
root = _ingest_one(conn)
finally:
conn.close()
n_threads = 6
barrier = threading.Barrier(n_threads)
errors: list[BaseException] = []
lock = threading.Lock()
def worker() -> None:
try:
c = connect(db)
try:
barrier.wait(timeout=20)
ask(
c,
document_root=root,
question="What are the forms of capital?",
client=StubClient(answer="The forms include living and social."),
model_id="test-model",
)
finally:
c.close()
except BaseException as exc: # noqa: BLE001 — surface it
with lock:
errors.append(exc)
threads = [threading.Thread(target=worker) for _ in range(n_threads)]
for t in threads:
t.start()
for t in threads:
t.join(timeout=40)
assert not errors, f"concurrent ask() raised: {errors!r}"
conn = connect(db)
try:
n_rows = conn.execute("SELECT COUNT(*) FROM providence_cache").fetchone()[0]
finally:
conn.close()
assert n_rows == 1, f"expected exactly one providence_cache row, got {n_rows}"