fix(server): /load_expr cache_dir — read the embedder's baked snapshots (#972) - #973
Conversation
/load_expr rehydrates a build with no cache_dir, so every CachedNode resolves to ~/.cache/xorq and an embedder's baked snapshots are never read. These tests pin the fix: - load_expr_build_dir(cache_dir=...) redirects every cache node, the one nested in the outer node's parent included, and finds the snapshots. - POST /load_expr with cache_dir writes nothing under the default cache dir and adds no files to the host cache; the session keeps cache_dir. - A warm re-POST that changes cache_dir reloads instead of early-exiting. - A missing snapshot is healed under an flock on <snapshot>.lock and re-checked after acquiring it, so a copy another writer produced is read rather than overwritten. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
📦 TestPyPI package publishedpip install --index-strategy unsafe-best-match --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ buckaroo==0.15.6.dev36011242915or with uv: uv pip install --index-strategy unsafe-best-match --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ buckaroo==0.15.6.dev36011242915MCP server for Claude Codeclaude mcp add buckaroo-table -- uvx --from "buckaroo[mcp]==0.15.6.dev36011242915" --index-strategy unsafe-best-match --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ buckaroo-table📖 Docs preview🎨 Storybook preview |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: df7ef98fc3
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| worker = threading.Thread(target=load) | ||
| worker.start() | ||
| worker.join(timeout=1.0) | ||
| self.assertTrue(worker.is_alive(), |
There was a problem hiding this comment.
Exercise the cache miss instead of only deserializing
The worker only calls load_expr_build_dir, whose implementation simply returns xorq.api.load_expr(build_dir) and therefore rehydrates a bare expression; cached snapshots are read or written when the expression is executed (which this test does only after releasing the lock). Consequently, a correct lazy implementation should not block on this lock, so this test will remain red or require an unnecessary eager cache fill. Exercise the POST/dataflow path or execute the expression in the worker while the lock is held.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Declining — this reads the tests-only commit (df7ef98), the red half of the TDD split. There load_expr_build_dir was indeed a bare load_expr(build_dir). The fix commit 5517352 makes it eager: load_expr_build_dir(build_dir, cache_dir=...) redirects the cache nodes and then calls heal_missing_snapshots(expr), which writes each missing snapshot under the flock. The worker blocks inside that call, and the test passes on xorq 0.3.25 and 0.4.3.
The eager fill is the intended contract, not an artifact of the test:
- Deferring to execute time isn't reachable from here. The write happens in xorq's
_register_and_transform_cache_tables, which callscache.set_defaultunlocked; the only point buckaroo controls is before the expression is handed off. /load_exprexecutes in the same request regardless —get_xorq_metadatacalls_expr_count(expr)and the dataflow runs the stat pipeline (handlers.py:504-520). The heal moves that compute a few ms earlier and wraps it in the lock. With the snapshots present it is existence checks only.
test_load_expr_reads_embedder_snapshots covers the POST path: it asserts nothing lands in the default cache dir and the host cache gains no files.
#972) POST /load_expr accepts an optional cache_dir. load_expr_build_dir redirects every parquet-backed CachedNode's storage to it with xorq's replace_nodes, which, unlike ExprLoader.replace_base_path, descends into CachedNode.parent. That matters beyond nesting depth: a cache key hashes its parent's storage, so a nested node left at base_path=None makes the outer node miss as well. With a shared directory the server is a second writer, and ParquetStorage.put writes through a fixed <key>.parquet.tmp with no lock. So after the redirect, each missing snapshot is written descendants-first under an flock on <snapshot>.lock, re-checking existence after acquiring it. Embedders can take the same lock around their own writes. cache_dir is stored on SessionState, and the warm-session early-exit now requires it to match. /reload_expr reuses session.expr, which already carries the redirect. Unset, behaviour is unchanged. The perf-fix tests' load_expr_build_dir stubs now forward kwargs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…from the build (#972) - Move the new xorq imports in xorq_loading back inside the functions that use them, like the rest of the module. Test modules import xorq_loading on Python 3.14, where buckaroo[xorq] isn't installed, and collection failed with ModuleNotFoundError. - The cache-dir test fixture now bakes the host cache by executing the loaded build with every cache node redirected, as tallyman does. It used to execute the in-memory expression. On xorq>=0.4 build_expr copies local reads into the build, so the loaded graph (and every cache key) differs from the in-memory one, and those snapshots were unreachable. - Patch get_xorq_cache_dir where each xorq version looks it up (caching.storage on 0.3.x, caching_utils at call time on 0.4.x). The tests still fail against the pre-fix source and pass with the fix on both xorq 0.3.25 and 0.4.3. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ngs (#972) Tests for the PR #973 review findings, each failing against the current branch: - the heal writes through xorq's fixed <key>.parquet.tmp and leaves .lock files in the embedder's cache - a missing inner snapshot under a baked outer one is recomputed - in a DAG whose cached filter is shared, a snapshot is written through xorq's put instead of the heal - a healed root snapshot has no provenance metadata - heal writes are not logged - a relative or non-string cache_dir is accepted - a re-POST that omits cache_dir drops it - the warm-session early-exit fires for a session /load swapped to pandas - a FileNotFoundError from the heal returns 404 build_dir_not_found - a missing build dir returns 500, since xorq raises OSError, not FileNotFoundError - the heal has no span of its own Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rder (#972) Addresses the PR #973 review of /load_expr cache_dir. Heal (xorq_loading): - Snapshots are written by the heal itself: rows go to a temp file no other writer uses, then os.replace moves it into place. xorq's put writes through a fixed <key>.parquet.tmp, which is where the corruption came from. The flock is gone, along with its .lock files and its unbounded wait on the IOLoop. - The walk follows xorq's lazy read path. It stops at a cache node whose snapshot exists, and heals a missing node's inner snapshots before the node itself. Snapshots under a baked outer node are no longer recomputed, and a cache node shared across branches is written before any node that reads it. - The root's snapshot gets xorq's provenance metadata; each write is logged; each key is computed once. Handler: - cache_dir must be an absolute string (400 invalid_cache_dir). It is used verbatim, not resolved: xorq hashes base_path into outer keys and tallyman bakes with the unresolved compute_cache_dir, so resolving here would miss its snapshots. - A re-POST that omits cache_dir keeps the session's. - The warm-session early-exit requires a xorq session. /load leaves build_dir, so xorq -> pandas -> xorq returned the pandas metadata. - build_dir_not_found (404) is checked up front with isdir. xorq raises OSError for a missing dir, so the FileNotFoundError mapping never fired, while a FileNotFoundError from the heal was reported as 404. - The heal runs in its own firstpull.cache_heal span (snapshots_written), outside firstpull.expr_load. Removes the lock test along with the lock. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The #972 helpers and TestLoadExprCacheDir imported pathlib, attr and xorq inside functions. They now use module-level imports after the importorskip, per the project CLAUDE.md. No assertion changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
/code-review of #973: findings and resolutionThe failing tests went in first as 9b8716a. CI run 36010547106, on the main merge 4e30137, showed all 11 failing on xorq 0.3.25 and 0.4.4. The fix is ac2262b, and the test cleanup is 541b7a6.
Also checked outside the unit tests:
🤖 Generated with Claude Code |
Fixes #972.
POST /load_exprrehydrated builds with nocache_dir, so everyCachedNoderesolved to~/.cache/xorq. An embedder's baked snapshots were never read, and the first query re-executed the cached sub-graph and wrote a second copy there.What
/load_exprtakes an optionalcache_dir, which must be an absolute path (400invalid_cache_dirotherwise).load_expr_build_dir(build_dir, cache_dir=...)redirects every parquet-backed cache node to it (redirect_cache_dir). Unset, behaviour is unchanged.replace_nodesrather thanload_expr(cache_dir=...). xorq'sExprLoader.replace_base_pathdoesn't descend intoCachedNode.parent, so a nested node keepsbase_path=None. Since a cache key hashes its parent's storage, the outer node then misses too.cache_diris used as given, not resolved. xorq hashesbase_pathinto the key of every cache node above another, so it has to be spelled the way the embedder baked with. tallyman bakes with the unresolvedcompute_cache_dir.heal_missing_snapshotsin its ownfirstpull.cache_healspan (attributesnapshots_written), before the dataflow queries anything. The heal follows xorq's lazy read path: it stops at a cache node whose snapshot exists, and writes a missing node's inner snapshots before the node itself. The root's snapshot gets xorq's provenance metadata. Each write is logged, because a heal against a baked cache usually meanscache_diris spelled differently from the bake.os.replace. xorq'sParquetStorage.putwrites through a fixed<key>.parquet.tmp, so two writers of one key can corrupt the file. With a unique temp file, a concurrent writer costs duplicate work but can't corrupt the snapshot. No lock is taken, so nothing an embedder holds can stall the server's IOLoop, and embedders don't have to follow any locking protocol.SessionState.cache_dirstores the value. A re-POST that omitscache_dirkeeps the session's value, and one that changes it reloads./reload_exprreusessession.expr, which already carries the redirect.session.backend == "xorq"./loadleavesbuild_dirin place, so xorq, then pandas, then xorq again on one session used to return the pandas metadata.build_dir_not_found(404) is now decided by anisdircheck before loading. xorq raisesOSErrorfor a missing build dir, so the oldexcept FileNotFoundErrormapping never fired, and aFileNotFoundErrorfrom the heal would have been reported as a missing build dir.{"session": "...", "build_dir": "/path/to/build", "cache_dir": "/project/artifacts/catalog/compute_cache"}Tests
TestLoadExprCacheDirintests/unit/server/test_load_expr.py, using cached builds and xorq's default cache dir patched to a temp dir:cache_dirand find their snapshots; withoutcache_dir, default resolution is unchangedcache_dirwrites nothing to the default dir and nothing new to the host cachecache_dirreloads; one that omits it keeps the session's value, warm or forcedcache_diris a 400, a missing build dir is a 404, and aFileNotFoundErrorfrom the heal is a 500<key>.parquet.tmpalone and leaves no.lockfilesputPlus
test_warm_exit_requires_a_xorq_sessioninTestLoadExprPerfFixes.The first round of tests landed in df7ef98 and failed on CI before 5517352. The review round's tests landed in 9b8716a and failed on CI (run 36010547106, on the main merge 4e30137) before the fix in ac2262b. Tests pass on the locked xorq 0.3.25 and on xorq 0.4.4 (the Max Versions job).
🤖 Generated with Claude Code