Skip to content

fix(server): /load_expr cache_dir — read the embedder's baked snapshots (#972) - #973

Merged
paddymul merged 7 commits into
mainfrom
fix/972-load-expr-cache-dir
Sep 24, 2026
Merged

paddymul merged 7 commits into
mainfrom
fix/972-load-expr-cache-dir

Conversation

@paddymul

@paddymul paddymul commented Sep 18, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #972. POST /load_expr rehydrated builds with no cache_dir, so every CachedNode resolved 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_expr takes an optional cache_dir, which must be an absolute path (400 invalid_cache_dir otherwise). load_expr_build_dir(build_dir, cache_dir=...) redirects every parquet-backed cache node to it (redirect_cache_dir). Unset, behaviour is unchanged.
  • The redirect uses xorq's replace_nodes rather than load_expr(cache_dir=...). xorq's ExprLoader.replace_base_path doesn't descend into CachedNode.parent, so a nested node keeps base_path=None. Since a cache key hashes its parent's storage, the outer node then misses too.
  • cache_dir is used as given, not resolved. xorq hashes base_path into the key of every cache node above another, so it has to be spelled the way the embedder baked with. tallyman bakes with the unresolved compute_cache_dir.
  • After loading, the handler runs heal_missing_snapshots in its own firstpull.cache_heal span (attribute snapshots_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 means cache_dir is spelled differently from the bake.
  • The heal writes each snapshot itself, to a temp file unique to that write, then moves it into place with os.replace. xorq's ParquetStorage.put writes 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_dir stores the value. A re-POST that omits cache_dir keeps the session's value, and one that changes it reloads. /reload_expr reuses session.expr, which already carries the redirect.
  • The warm-session early-exit also requires session.backend == "xorq". /load leaves build_dir in 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 an isdir check before loading. xorq raises OSError for a missing build dir, so the old except FileNotFoundError mapping never fired, and a FileNotFoundError from 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

TestLoadExprCacheDir in tests/unit/server/test_load_expr.py, using cached builds and xorq's default cache dir patched to a temp dir:

  • nested and outer nodes both resolve under cache_dir and find their snapshots; without cache_dir, default resolution is unchanged
  • POST with cache_dir writes nothing to the default dir and nothing new to the host cache
  • a warm re-POST with a new cache_dir reloads; one that omits it keeps the session's value, warm or forced
  • a relative or non-string cache_dir is a 400, a missing build dir is a 404, and a FileNotFoundError from the heal is a 500
  • the heal leaves another writer's <key>.parquet.tmp alone and leaves no .lock files
  • a missing inner snapshot under a baked outer one is not recomputed
  • in a DAG where a cached filter is shared, nothing is written through xorq's put
  • a healed root snapshot has the same provenance as a baked one, each heal write is logged, and the heal has its own span

Plus test_warm_exit_requires_a_xorq_session in TestLoadExprPerfFixes.

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

/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>
@github-actions

github-actions Bot commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

📦 TestPyPI package published

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.dev36011242915

or 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.dev36011242915

MCP server for Claude Code

claude 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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread tests/unit/server/test_load_expr.py Outdated
Comment on lines +1096 to +1099
worker = threading.Thread(target=load)
worker.start()
worker.join(timeout=1.0)
self.assertTrue(worker.is_alive(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 calls cache.set_default unlocked; the only point buckaroo controls is before the expression is handed off.
  • /load_expr executes in the same request regardless — get_xorq_metadata calls _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>
paddymul and others added 2 commits September 24, 2026 10:01
…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>
Resolves the test_load_expr.py conflict with #969's /reload_expr config test: both appended to the end of TestReloadExpr's section; #969's method stays in that class, ahead of this branch's cache_dir helpers and TestLoadExprCacheDir.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
paddymul and others added 2 commits September 24, 2026 10:09
…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>
@paddymul

Copy link
Copy Markdown
Collaborator Author

/code-review of #973: findings and resolution

The 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.

  1. Heal order with a shared cache node. reversed(walk_nodes(...)) is not descendants-first in a DAG, so a shared inner snapshot was written inside the outer node's lock with no lock of its own. Fixed in ac2262b: the heal recurses into a missing node's parent before writing the node, so a shared node is always written first. Test: test_heal_writes_shared_inner_before_outer.
  2. flock with no timeout blocked the IOLoop. Fixed in ac2262b by removing the lock (see 10).
  3. Inner snapshots under a baked outer one were recomputed. Fixed in ac2262b: the walk stops at any cache node whose snapshot exists, as xorq's execute() does. Test: test_heal_leaves_inner_snapshot_under_baked_outer.
  4. The warm-session early-exit didn't check for a xorq session. This predates the PR. Fixed in ac2262b: the exit now requires backend == "xorq". Test: test_warm_exit_requires_a_xorq_session.
  5. The outer cache key depends on how cache_dir is spelled. Partly addressed. cache_dir is still used as given, on purpose: tallyman bakes with the unresolved compute_cache_dir, so resolving on the server would make every outer node miss. Each heal write is now logged with its path and counted in the span's snapshots_written, so a spelling mismatch shows up. Test: test_heal_logs_each_snapshot_it_writes.
  6. cache_dir wasn't validated. Fixed in ac2262b: anything other than an absolute string path gets 400 invalid_cache_dir. Test: test_cache_dir_must_be_an_absolute_path.
  7. A FileNotFoundError from the heal was reported as a 404. Fixed in ac2262b: the 404 is now decided by an isdir check before loading. This also fixes an older bug: xorq raises OSError for a missing build dir, so the old except FileNotFoundError never fired and a missing dir returned a 500. Tests: test_heal_file_not_found_is_a_load_error, test_missing_build_dir_is_not_found.
  8. cache_dir was dropped when a re-POST omitted it. Fixed in ac2262b: an omitted cache_dir now keeps the session's value. Test: test_repost_without_cache_dir_keeps_the_sessions.
  9. A healed root snapshot had no provenance. Fixed in ac2262b: the root gets build_provenance_metadata, as xorq's executor does. Test: test_heal_stamps_root_provenance.
  10. Altitude: the lock only covered load-time writes, and the real cause is the fixed tmp name. Fixed in ac2262b: the heal writes each snapshot to a temp file unique to that write and moves it into place with os.replace, with no lock. Test: test_heal_writes_through_its_own_tmp_file. One gap remains: query-time writes through xorq's executor, such as a snapshot deleted or TTL-expired after load, still use xorq's fixed tmp. That is xorq's code, and this PR doesn't change it.
  11. The lock test was timing-dependent. No longer applies: the test was removed along with the lock in ac2262b.
  12. calc_key ran again for each node. Fixed in ac2262b: each visited node's key is computed once and reused for its write, and nodes under an existing snapshot are never keyed.
  13. Heal time counted inside firstpull.expr_load. Fixed in ac2262b: the heal has its own firstpull.cache_heal span, and docs/perf-testing-guide.md is updated. Test: test_cache_heal_has_its_own_span.
  14. Imports inside test functions. Fixed in 541b7a6.
  15. Redundant skipif and leftover .lock files. Both went away with the lock test and the lock in ac2262b.

Also checked outside the unit tests:

  • Two processes healing the same missing snapshots at once both finished, leaving two valid snapshots and no stray files.
  • Against a real server on :8791:
    • A baked cache was served with no writes.
    • A deleted outer snapshot was healed, with provenance and a log line naming it.
    • A re-POST without cache_dir took the warm-session exit.
    • A relative cache_dir returned 400, and a missing build dir returned 404.

🤖 Generated with Claude Code

@paddymul
paddymul added this pull request to the merge queue Sep 24, 2026
Merged via the queue into main with commit a440ef9 Sep 24, 2026
28 checks passed

This branch was successfully deployed

1 active deployment
testpypi — 541b7a6c Deployed Sep 24, 2026 by paddymul via Publish to TestPyPI #1569
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

server: /load_expr loads builds without a cache_dir — cache nodes resolve to ~/.cache/xorq, so an embedder's baked snapshots are never read

1 participant