fix(llm): per-call DuckDB cursor for the cached replica handle; lock the cache; concurrency test (refs P48)

A /chat stream is a sync generator, so Starlette runs it in a threadpool
worker: concurrent turns shared the one cached DuckDBPyConnection and
read each other's result sets ("not enough values to unpack"), which the
broad except swallowed as "valuation evidence skipped" — the table just
vanished. Each call now runs on its own cursor off the shared handle and
closes it in a finally, and the cache check-and-open is under a lock so
a race on the first open cannot leak a connection.
This commit is contained in:
kert
2026-09-08 23:36:01 -04:00
parent 456aee26bb
commit 418fab29c3
2 changed files with 131 additions and 20 deletions

View File

@@ -12,6 +12,7 @@ from __future__ import annotations
import logging import logging
import os import os
import threading
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass
from pathlib import Path from pathlib import Path
from typing import Any, Sequence from typing import Any, Sequence
@@ -98,6 +99,7 @@ class ValuationEvidence:
#: so the handle stays valid on the old inode until we notice the new #: so the handle stays valid on the old inode until we notice the new
#: mtime and reopen. #: mtime and reopen.
_REPLICA: tuple[str, int, Any] | None = None _REPLICA: tuple[str, int, Any] | None = None
_REPLICA_LOCK = threading.Lock()
def _mtime(path: str) -> int: def _mtime(path: str) -> int:
@@ -112,18 +114,19 @@ def _connect(path: str) -> Any:
replica file has been republished since we opened it.""" replica file has been republished since we opened it."""
global _REPLICA global _REPLICA
mtime = _mtime(path) mtime = _mtime(path)
if _REPLICA is not None: with _REPLICA_LOCK: # two chat threads racing the first open would leak one
cached_path, cached_mtime, con = _REPLICA if _REPLICA is not None:
if (cached_path, cached_mtime) == (path, mtime): cached_path, cached_mtime, con = _REPLICA
return con if (cached_path, cached_mtime) == (path, mtime):
_REPLICA = None return con
try: _REPLICA = None
con.close() try:
except Exception as e: # noqa: BLE001 — a stale handle must not break the chat con.close()
log.warning("stale replica handle not closed: %s", e) except Exception as e: # noqa: BLE001 — a stale handle must not break the chat
con = duckdb.connect(path, read_only=True) log.warning("stale replica handle not closed: %s", e)
_REPLICA = (path, mtime, con) con = duckdb.connect(path, read_only=True)
return con _REPLICA = (path, mtime, con)
return con
def _replica_path(cfg: LlmConfig) -> str: def _replica_path(cfg: LlmConfig) -> str:
@@ -141,7 +144,10 @@ def valuation_evidence(question: str, cfg: LlmConfig) -> ValuationEvidence | Non
return None return None
path = _replica_path(cfg) path = _replica_path(cfg)
try: try:
con = _connect(path) # A chat turn runs in a threadpool worker, and DuckDB gives each
# thread its own cursor off the shared connection — without one,
# concurrent turns read each other's result sets.
cur = _connect(path).cursor()
except Exception as e: # noqa: BLE001 — duckdb raises several types except Exception as e: # noqa: BLE001 — duckdb raises several types
log.warning("valuation evidence skipped (%s): %s", path, e) log.warning("valuation evidence skipped (%s): %s", path, e)
return None return None
@@ -149,10 +155,12 @@ def valuation_evidence(question: str, cfg: LlmConfig) -> ValuationEvidence | Non
# crowd the excerpts out of the prompt. # crowd the excerpts out of the prompt.
years = cfg.valuation_years if len(det.codes) <= 3 else 2 years = cfg.valuation_years if len(det.codes) <= 3 else 2
try: try:
rows, unpriced = valuation(con, list(det.codes), years=years) rows, unpriced = valuation(cur, list(det.codes), years=years)
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
log.warning("valuation evidence skipped (query): %s", e) log.warning("valuation evidence skipped (query): %s", e)
return None return None
finally:
cur.close() # the cached parent connection stays open
return ValuationEvidence(det.codes, det.families, tuple(rows), tuple(unpriced)) return ValuationEvidence(det.codes, det.families, tuple(rows), tuple(unpriced))

View File

@@ -2,8 +2,11 @@
from __future__ import annotations from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor
from dataclasses import replace
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import duckdb
import pytest import pytest
from llm import evidence from llm import evidence
@@ -32,6 +35,14 @@ CFG = LlmConfig(
) )
@pytest.fixture(autouse=True)
def _clear_replica_cache():
"""The replica handle is process-global; no test may inherit another's."""
evidence._REPLICA = None
yield
evidence._REPLICA = None
def _row(**kw) -> ValuationRow: def _row(**kw) -> ValuationRow:
base = dict( base = dict(
code="G0556", code="G0556",
@@ -161,12 +172,6 @@ class TestEvidenceText:
class TestValuationEvidence: class TestValuationEvidence:
@pytest.fixture(autouse=True)
def _clear_replica_cache(self):
evidence._REPLICA = None
yield
evidence._REPLICA = None
def test_none_when_no_codes(self): def test_none_when_no_codes(self):
assert ( assert (
valuation_evidence("what did commenters say about telehealth?", CFG) is None valuation_evidence("what did commenters say about telehealth?", CFG) is None
@@ -223,6 +228,104 @@ class TestValuationEvidence:
assert mock_val.call_args.kwargs == {"years": 2} assert mock_val.call_args.kwargs == {"years": 2}
RVU_COLS = (
"hcpcs VARCHAR, mod VARCHAR, description VARCHAR, status_code VARCHAR, "
"work_rvu DOUBLE, non_fac_pe_rvu DOUBLE, fac_pe_rvu DOUBLE, mp_rvu DOUBLE, "
"non_fac_total DOUBLE, fac_total DOUBLE, conv_factor DOUBLE, year INTEGER"
)
PROPOSED_COLS = (
"hcpcs VARCHAR, mod VARCHAR, description VARCHAR, status_code VARCHAR, "
"work_rvu DOUBLE, non_fac_pe_rvu DOUBLE, fac_pe_rvu DOUBLE, mp_rvu DOUBLE, "
"cms_rule_id VARCHAR"
)
class TestConcurrentChats:
"""/chat streams run in Starlette's threadpool, so several turns share
the cached replica handle. Without a per-call cursor their result sets
cross-talk and the broad except swallows it as "no valuation"."""
@pytest.fixture
def replica(self, tmp_path):
db = tmp_path / "aco.ro.duckdb"
con = duckdb.connect(str(db))
con.execute("CREATE SCHEMA pfs")
con.execute(f"CREATE TABLE pfs.rvu ({RVU_COLS})")
con.execute(f"CREATE TABLE pfs.rvu_proposed ({PROPOSED_COLS})")
# two unfamilied codes with different row counts, so a swapped
# result set is visible as a wrong count
con.executemany(
"INSERT INTO pfs.rvu VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
[
(
"99213",
None,
"Office visit",
"A",
1.0,
1.0,
0.5,
0.05,
2.05,
1.55,
30.0,
2026,
),
*[
(
"99214",
None,
"Office visit",
"A",
1.5,
1.0,
0.5,
0.05,
2.55,
2.05,
30.0,
y,
)
for y in (2024, 2025, 2026)
],
],
)
con.close()
return db
def test_parallel_turns_share_one_handle_without_cross_talk(self, replica):
cfg = replace(CFG, duckdb_replica=str(replica))
real_connect, opens = duckdb.connect, []
def counting_connect(*a, **kw):
opens.append(a)
return real_connect(*a, **kw)
expected = {"99213": 1, "99214": 3}
questions = ["What does 99213 pay?", "What does 99214 pay?"]
def one(i):
q = questions[i % 2]
ev = valuation_evidence(q, cfg)
assert ev is not None, "valuation dropped under concurrency"
(code,) = ev.codes
return code, len(ev.rows)
with patch("llm.evidence.duckdb.connect", counting_connect):
with ThreadPoolExecutor(max_workers=8) as pool:
results = list(pool.map(one, range(160)))
assert all(n == expected[code] for code, n in results), sorted(set(results))
assert len(opens) == 1 # one handle, shared
def test_cursor_is_closed_but_the_handle_is_kept(self, replica):
cfg = replace(CFG, duckdb_replica=str(replica))
assert valuation_evidence("99213?", cfg) is not None
assert evidence._REPLICA is not None
# the cached parent still answers — it was never closed
assert valuation_evidence("99214?", cfg) is not None
def _engine(rows): def _engine(rows):
engine = MagicMock() engine = MagicMock()
conn = engine.begin.return_value.__enter__.return_value conn = engine.begin.return_value.__enter__.return_value