Repository navigation
fix(cache): single-flight the cold-entry snapshot heal (#79) - #113
Conversation
cached_result_expr heals an evicted baked snapshot by executing the shared (lru-memoised) baked op with no single-flight guard. Two concurrent cold readers both run that materialisation: in-process they collide on the shared op (DataFusion "Already borrowed"); across processes they collide on xorq ParquetStorage's fixed <key>.parquet.tmp (FileNotFoundError on the loser's rename). Either surfaces as a 500 from api_data and, swallowed by ensure_session, an empty grid. The test fires two barrier-synced readers that each trigger the heal (building the expr, not executing it), isolating the materialisation race from concurrent .execute() on the shared backend (a separate backend-thread-safety concern). The heal must run once; the snapshot must end up materialised exactly once. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4e1b56d to
1733345
Compare
cached_result_expr healed an evicted baked snapshot with a bare `if not path.exists(): baked.count().execute()` and no guard. Because _resolve_result_plan is lru-cached, concurrent cold readers share one baked op; both miss and both materialise it, and the loser raises — DataFusion "Already borrowed" in-process, or FileNotFoundError on xorq ParquetStorage's fixed <key>.parquet.tmp across processes — swallowed by ensure_session into an empty grid. Serialize the heal with a per-(project, content_hash) threading.Lock, re-checking exists() inside the lock so the second caller waits and reads. For the cross-process case (no shared in-process lock), the heal catches FileNotFoundError/ValueError and reads if a peer landed the snapshot, else re-raises. Scope: this fixes the materialisation race #79 describes. Concurrent .execute() on the shared single DataFusion backend still raises "Already borrowed" for any concurrent read (warm or cold) — a separate backend-thread-safety concern, not this output-path race. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Scope note found while building this fix — a separate concurrent-read race remainsThis PR single-flights the materialisation (heal) race that #79 describes, and verifies it: two barrier-synced cold readers that trigger the heal no longer collide, and the snapshot is materialised exactly once. But while building it I confirmed a separate, broader concurrency bug that this fix does not address: concurrent
The test is deliberately scoped to the materialisation race (workers build the expr but don't execute it) so it isolates what this fix actually guarantees. The shared-backend concurrent-read race should be tracked separately. |
|
Filed the shared-backend concurrent- |
Code review — single-flight the cold-entry snapshot heal (#79)CI is green and the TDD split is clean: the test commit ( Overview
Correctness — looks right
Issues / suggestions
Conventions / security
VerdictCorrect, minimal, and TDD-clean — good to merge. The one change I'd push for first is a test for the cross-process 🤖 Generated with Claude Code |
…aise) test_concurrent_cold_heal_is_single_flighted exercised only the in-process collision; the cross-process except (FileNotFoundError, ValueError) arm in cached_result_expr was untested. Two interpreters can't share one in-process backend, so this stubs _resolve_result_plan to hand back a baked op whose execute() raises against the real evicted snapshot path. Landed case (peer process won xorq ParquetStorage's fixed <key>.parquet.tmp rename, snapshot back on disk): both caught exception types are swallowed and the materialised snapshot reads back. Not-landed case: the failure is real and the original error propagates rather than dangling on a missing-path deferred_read_parquet. Coverage test, not failing-first: it asserts behaviour the fix (e8d294b) already provides, so it is green on CI. Confirmed meaningful by mutation — dropping the except arm fails the landed case; swallowing unconditionally fails the not-landed case with the same ValueError: At least one path is required symptom the warm-then-evict test documents. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fixes #79.
Problem
cached_result_exprheals an evicted baked snapshot on the request thread with a bare check-then-write and no single-flight lock (src/tallyman_xorq/result_cache.py:321-322):_resolve_result_planis@functools.lru_cached, but the memo is on the plan, not the heal — so concurrent readers share onebakedop, both miss the evicted snapshot, and both run the heal. The loser raises:api_data+api_diff_data): both.execute()the shared op → DataFusionRuntimeError: "Already borrowed".compute_cache): xorqParquetStorage.putwrites a fixed<key>.parquet.tmpthen renames it (storage.py:134-137); the loser hits the missing tmp →FileNotFoundError.buckaroo_lifecycle.ensure_sessioncallscached_result_exprbefore the session lock insideexcept Exception: log.debug(...)(buckaroo_lifecycle.py:528-533), so the loser's failure is swallowed and Buckaroo replays against a half/never-written snapshot — an empty/wrong grid.The #104 removal of the on-demand
result.parquetlayer dissolved the issue's cheap-entry path (ensure_resultis gone); this PR closes the remaining expensive-entry heal race.Change
Serialize the heal per
(project, content_hash): a keyedthreading.Lock(a dict under a master lock) wraps theexists()-check + heal incached_result_expr, re-checkingexists()inside the lock so the second caller waits, then reads — "first request serviced, the rest get the materialised snapshot." For the cross-process case (no shared in-process lock), the heal'sexecute()catchesFileNotFoundError/ValueErrorand retries once if the snapshot landed (a peer process won the rename), else re-raises.Status — TDD split
Per the repo's test-vs-fix rule, the first commit is the failing test (watched red on CI —
RuntimeError: Already borrowed); the fix follows in the next commit (CI green).test_concurrent_cold_reads_of_expensive_entry_do_not_racefires two barrier-synced readers at the same evicted entry over a few rounds.Context
Surfaced during a multi-agent review of #74. Pre-#73
result.parquetwas written once at build, so reads were never materialisers; #73 moved materialisation onto the read path, which opened the race. The fixed-tmp root is upstream in xorqParquetStorage; this guards tallyman's side.🤖 Generated with Claude Code