Repository navigation
perf(cache): source-cache + baked result-cache, drop blanket result.parquet (#73) - #74
Conversation
…result.parquet (#73) Rewrite-then-build: before building, rewrite the submitted expression to (1) inject a snapshot .cache() after each non-parquet source read so the CSV/JSON parse is materialised once and shared, and (2) bake a top-level result cache when the expression is expensive (Aggregate/Join/Sort/window/ UDF). The baked cache becomes part of the durable recipe, so every loader — the Buckaroo viewer, diffs, from_catalog chaining — reads the cached result instead of re-running the whole DAG (the read-time-only path #71 worked around). In-memory reads (read_in_memory / memtable) are a hard BuildError. Drop the blanket per-entry result.parquet. A cheap entry materialises nothing at build (schema from the expression, row count from a pushdown count); an expensive entry's rows are materialised once via its baked cache. result.parquet becomes a regenerable on-demand cache (ensure_result) for the few callers that need a parquet path (downloads, promote-diff, post-processing), not an immutable build artifact. - source_cache.rewrite_for_build: in-memory rejection, source-read caching, expensive-result baking; memo breaks xorq's CachedNode re-descent recursion. - classify_build worthiness is expensive-ops-only (dropped non-parquet read). - cached_result_expr collapses to load_entry (the build carries the cache); ensure_result regenerates result.parquet in place on demand. - from_catalog composes the parent's expression (cached_result_expr) instead of reading result.parquet; chaining off a cheap entry now works. The inter-entry catalog DAG goes dark (no result.parquet path to match) — deferred to a future semantic-dependency mechanism. - All baked caches co-locate in the per-project compute cache (xorq's loader rewrites every cache node's base path uniformly); the separate result_cache/ dir is retired. - paths: result.parquet moves from ENTRY_ARTIFACT_NAMES to ENTRY_CACHE_NAMES. - Consumers updated: companion /api/data + total_rows reads, post_processing. Tests and the result.parquet bulk-delete migration are still to come. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…p; on-demand diff - test_source_cache: source-read caching, parquet exemption, expensive bakes both caches, in-memory read rejected. - result_cache/build/paths/lineage_view tests updated for: no build-time result.parquet, baked cache (CachedNode at load), catalog DAG dark, and result.parquet now a cache (not an artifact). - diff: full_diff materialises a missing result.parquet on demand (_ensure_entry_parquet); path-taking diff tests use ensure_result. - migration: build.migrate_drop_result_parquet (once per project, marker-guarded) run from create_app to drop old build-time result.parquet on upgrade. - lineage: contract baked CachedNode/RemoteTable out of the internal-lineage view so it shows logical ops only. - doc/comment fixes in the MCP diff + post-processing tools. Full suite: 454 passed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The wired diff path (MCP catalog_diff, companion api_diff_data) passes both entries' cache-resolving expressions, which compose at the expression level. full_diff must not eagerly materialise a result.parquet for either entry on that path. Asserts neither entry dir gains one; fails on the current eager materialisation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review fixes for the #73 source-cache / drop-result-parquet work. full_diff no longer eagerly materialises both entries' result.parquet at the top. The xorq backend materialises only in the no-expr fallback; polars/pandas materialise inside their own branches. The wired diff path (MCP catalog_diff, companion api_diff_data) passes cache-resolving expressions and now writes nothing — fixes the regression the preceding test caught, and drops a dead b_pq reassignment that clobbered its own prior line. _ensure_entry_parquet logs a failed ensure_result at debug (exc_info) instead of swallowing it silently, so a genuine materialisation failure is diagnosable. Retire the result_cache_dir / result_cache vestige entirely: baked results now live under compute_cache/result_cache, already covered by the compute_cache pointer, so the separate catalog/result_cache dir and its catalog.yaml key were fully redundant. Removed result_cache_dir(), the result_cache state key (read, capture, prune_result_cache, reset_to, restore_from_bullpen), and the result_cache field from the disk_usage payload (the SPA only reads formatted.total). Tests assert the dir is never re-created. classify_build docstring now cross-references source_cache._is_worthy_expr as the live-expression twin of the same predicate (shared _EXPENSIVE_OPS). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Tier-A now measures the companion's real ensure_session -> /load_expr path (production 10s cap) instead of a long-timeout bypass, so the report shows whether a cold open returns a live session under the cap (cold ok / CAPPED), the uncapped server-side cost from the #926 span, and same-lifetime warm-cached vs server-restart warm-reload timings. Tier-B discovery and row-counts key on manifest.json instead of result.parquet, which #73 no longer writes at build time -- without this the harness skips a #73-built corpus. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Initial performance findings (
|
| entry | rows | main | branch |
|---|---|---|---|
camera_violations_by_plate (Aggregate) |
726,203 | 0.56s cold / 0.06s warm-reload | 0.58s / 0.08s |
camera_select_columns (raw read) |
1,000,000 | 0.39s / 0.05s | 0.38s / 0.05s |
Neutral — within ~20ms. Expected: /load_expr already pushes down and never read result.parquet (#71), so dropping it / baking a result cache doesn't move this path at 1m scale. Neither side trips the 10s cap.
Execute path (load_entry → to_parquet) — what the result.parquet drop touches
| entry | rows | main cold/warm | branch cold/warm |
|---|---|---|---|
camera_violations_by_plate (Aggregate) |
726,203 | 0.13 / 0.11 | 0.27 / 0.14 |
camera_select_columns (raw) |
1,000,000 | 0.17 / 0.17 | 0.17 / 0.17 |
camera_violations (raw, 20 col) |
1,000,000 | 0.63 / 0.62 | 0.62 / 0.63 |
Raw reads identical; the aggregate is ~0.14s slower cold on the branch — main reads a materialised result.parquet (parquet→parquet copy) while the branch materialises through the baked-cache expr. Small absolute cost at this scale.
Bottom line
At the 1m single-parent scale this change is performance-neutral on the production ensure_session path and slightly negative on the cold execute of an aggregate. It can neither confirm nor refute the intended win, which targets large multi-parent aggregates over 100M+ row sources — exactly the entries that (a) can't be rebuilt here (#75) and (b) are where main's /load_expr re-runs the whole DAG (#71). Worth re-running on the full multi-parent corpus once #75 is fixed.
Harness: tests/test_perf_tier_a.py (ensure_session, cap-aware), tests/test_perf_integration.py (manifest-keyed). Run: uv run pytest -m perf_tier_a tests/test_perf_tier_a.py -s.
Two from_catalog parents combined in one expression (union/join) must resolve to a single backend and build end-to-end. #73's expression-level from_catalog loaded each parent into its own backend, so this raised "Multiple backends found for this expression". Failing on this branch; fix follows. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-read Adds the cases #73's load_entry-based from_catalog couldn't compose on one backend, and updates the expensive-entry assertion to the single-backend contract: - from_project + from_catalog mix (cheap parent): one backend; builds (400 rows). - from_project + from_catalog mix (expensive parent): one backend — exercises the deferred read of the baked snapshot, which a CachedNode would not. - test_expensive_entry_bakes_result_cache now asserts cached_result_expr returns a single-backend Read (the baked snapshot), not the CachedNode itself. These fail on the current source (load_entry roots each parent on its own backend, so the mix raises "Multiple backends found"; the expensive read is a CachedNode). The fix follows. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…baked-snapshot read) #73's from_catalog loaded each parent's build into its own xorq backend, so combining two parents — or a from_catalog with a from_project — in a union/join raised "Multiple backends found". cached_result_expr now reconstitutes every entry on the single in-process default backend: - Cheap entry: re-import its recipe (expr.py via _recipe_expr) — from_project / deferred readers bind to the default backend; recompute on read is pushdown-cheap. - Expensive entry: read its baked result_cache snapshot directly as one deferred_read_parquet on the default backend. The path is derived from the same rewrite_for_build the build used (storage.get_path / calc_key), so it hits the file baked at build time — no recompute, and no CachedNode dragging its own storage backend into the caller's expression. Self-heals an evicted snapshot via the cache node once, then reads. salt mode is unchanged (still routes through ensure_result). Child content hashes stay stable across processes; the diff path's two sides now share one backend, so into_backend bridging becomes a no-op. The experimental shared_backend get_con memo is dropped — recipe re-execution means from_catalog never composes loaded entries, so it was unnecessary. Follow-up #76 tracks the expensive-parent child's dependency on the untracked compute_cache snapshot. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#73 stopped writing a build-time result.parquet, so child_build's result_mb stat raised FileNotFoundError and every build scenario became an ERROR row (cascading to the exec/buckaroo/companion scenarios that need a build). CI's perf-report job hid it — the report always exits 0. Report None when the file is absent; the comparable signal is wall/exec. Mirrors the #73-safe discovery already applied to test_perf_integration and test_perf_tier_a. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…construct A revise-in-place recipe that chains off from_catalog of its own alias (the pattern catalog_revise's docstring recommends) resolves to the previous revision at build time but to itself afterwards — the alias is repointed only after the build. Post-#75 cached_result_expr re-imports the raw recipe and re-resolves from_catalog against the live head, so reconstructing such an entry (diff, further chaining) recurses forever: a ~50GB RSS spike that locked a 48GB machine while running the Tier-B perf suite. The test pins the recursion limit just above the current stack depth so a failing run raises RecursionError in tens of MB rather than OOMing CI. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
cached_result_expr reconstructs an entry by re-importing its recipe, which re-runs from_catalog against the *live* alias head. A revise-in-place entry whose recipe reads from_catalog of its own alias therefore resolves to itself and recurses without bound — the ~50GB RSS spike that locked the machine. Track the entries under reconstruction on a contextvar stack; when from_catalog resolves to one already in flight, walk back through alias history to the build-time parent (what the alias pointed at when the entry was built — the alias is repointed only after the build). A _MAX_RECON_DEPTH backstop converts any cycle the resolver misses into a fast BuildError rather than an OOM. Factor the alias-history step-back into tallyman_core.aliases.previous_version, shared with primary_key._parent_hash. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
cached_result_expr / from_catalog reconstruct an entry by re-importing its recipe (expr.py), so the perf overlay — which symlinks each entry's artifacts into a scratch TALLYMAN_HOME — now needs expr.py too. Pre-#73 the read path resolved via xorq_build/ + result.parquet alone. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
macOS ulimit -v caps address space, not RSS, so it can't stop a process ballooning resident memory. Polls process-tree RSS via psutil and SIGKILLs the tree at a GB cap, streaming child output through. Guards the Tier-B perf harness and the parking-corpus rebuild; backstop for unknown spikes (the known from_catalog cycle also has result_cache._MAX_RECON_DEPTH). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Truncating to --rows N needs only the first N rows, but _stage_sources read the whole parquet (pq.read_table) then sliced. On parking_plt_only4 (107M rows) that spikes RSS to tens of GB — the watchdog killed the rebuild at 16GB mid-staging. Test stages under a monkeypatched pq.read_table that raises, asserting no full materialisation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pull row-group batches until N rows collected, then stop, rather than reading the whole source and slicing. Bounds staging memory to ~N rows regardless of source size; rebuild peaks ~2GB at --rows 1000000 where staging previously blew past 16GB. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t self-heal On a cold compute cache (fresh clone / write-isolated overlay / not-yet-warmed entry) an entry that from_catalogs an expensive parent fails: its build embeds a *bare* deferred read of the parent's baked snapshot (#75 strips the parent CachedNode to keep one backend), so the read resolves to zero files. - ensure_result (download / promote-diff / post-processing / paginated grid) raises "At least one path is required" — single-parent chain and the multi-parent tickets_and_ghosts join. - ensure_session (buckaroo /load_expr build replay) serves an empty grid. Each test clears the reconstruction lru after evicting the snapshot to model a fresh process; all three fail until ensure_result / ensure_session self-heal. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
run_under_watchdog.py shipped with three >120-char f-string log writes that fail `ruff check` — and ruff gates the test jobs, so the suite was being skipped on CI. Wrap the heartbeat/kill lines and hoist peak_gb/elapsed locals for the summary line; no behaviour change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…s on cold cache An entry that from_catalogs an expensive parent has a build that embeds a *bare* deferred read of the parent's baked snapshot (#75 strips the parent CachedNode to keep the composed expression on one backend), with no recipe or source to recompute from. So replaying that build on a cold compute cache — fresh clone, write-isolated overlay, or an entry the startup warmup didn't reach — fails: the read resolves to zero files. - ensure_result reconstructs via cached_result_expr (re-runs the recipe, re-resolves from_catalog, recomputes the parent) instead of load_entry, so an evicted snapshot anywhere in the chain self-heals rather than raising "At least one path is required". salt mode keeps load_entry to avoid recursing through cached_result_expr's salt branch. - ensure_session repopulates the snapshot via cached_result_expr before POSTing the build to buckaroo /load_expr, so the grid isn't empty on a cold cache. Healed before the session lock: a cold recompute mustn't block other sessions, a warm one is a cheap cache hit. Also point the perf harness's execute measurement at cached_result_expr (the #73 reconstruct-on-read path) rather than load_entry, which couldn't materialise these chains at all — the three ERRORs that surfaced this. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The first cut only checked that *some* result_cache snapshot existed after ensure_session — too weak, since the buckaroo /load_expr build replay reads a specific snapshot keyed on the build path, not any snapshot. Replay the build the same way (load_entry) and assert it serves real rows. Holds when build and read share a project root (the same-machine cold cache the heal targets); a cross-machine clone keys the snapshot differently and still needs the reconstruct path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
cached_result_expr -> _recipe_expr -> _import_script registers each recipe under a unique sys.modules name and never removes it. Since #73 reconstruction runs per-read, repeated reconstruction of one entry accumulates module objects that pin their expression graphs. Failing test asserting it does not. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
_recipe_expr pops the recipe module out of sys.modules once the expression is extracted. The returned expr still holds the module object (and any UDF __globals__) while in use; only the unbounded name->module accumulation goes. _import_script (build path) is unchanged — its caller still needs the registration for serialization. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…son parity Adds the two review-identified coverage gaps: the cache_worthy/CachedNode disagreement fallback in cached_result_expr, and read_csv/read_json parity. The latter extracts _should_cache_read (membership over _EXEMPT_READS) so the parity is unit-testable — this xorq has no deferred_read_json, so the read_json branch has no end-to-end producer to build a fixture from. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review pass (multi-agent, adversarially verified)Reviewed across 6 dimensions (source-cache rewrite, recursion/cycles, self-heal + snapshot resolution, concurrency/lifecycle, API/state/lineage, tests/scripts) with each finding re-checked against the code by an independent verifier. 20 findings raised, 17 confirmed, 3 dismissed. Disposition below. Fixed in this PR
Filed as follow-up issues
Noted on the PR body for separate review
Low — no action
Dismissed (3)
Also independently checked and cleared (no finding): dropping CI green on |
Persist both cache verdicts per entry so #30's structural→measured flip is data-driven instead of decided on first principles. build.py: record the structural cache_worthy verdict + why (classify_build already computed it and discarded it), compile_seconds (the author DAG's to_sql / expr→backend-plan step, the dominant per-view cost per #30, timed separate from execute), and cache_bytes (the baked snapshot size, the value-per-byte denominator; None for a cheap entry). All land in manifest.json and BuildResult, and the decision is logged once on the tallyman.perf namespace with both verdicts side by side. result_cache.py: tag each cold cached_result_expr read on tallyman.perf (DEBUG) with the #74 path it took — cheap-recompute / baked-read / evicted-self-heal (plus salt and the worthiness-disagreement fallback) — and its reconstruct wall-clock (#82's lever). New baked_snapshot_path() names the snapshot a cold read returns, extracted from the inline derivation now shared with the read path. app.py: /api/disk_usage now counts the per-project compute_cache/ (where #74's baked results and source-read caches live), which it omitted — supplying the byte denominator and closing the #74 disk-usage follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…, in-process reuse Stress-testing the rebuild against the real corpus (a disposable copy of the 15-entry parking_ticket_analysis_1m) surfaced three gaps, all fixed: - Most real projects are catalog.yaml-era, not aliases.json-era: aliases live in alias_map/alias_history and charts/post_processing/stats/notebook are embedded in catalog.yaml with no decomposed files. read_old_catalog now reads each section from its decomposed file if present, else from catalog.yaml, and reconstructs the notebook (alias-keyed cells, no remap). - A self-chaining revise (from_catalog of one's own alias, #74) made the topo sort see a false self-cycle. parse_deps now resolves a self-alias-ref to the PRIOR revision in history (the build-time parent), and the rebuild re-points an alias at each revision as it is rebuilt, oldest-first. - An in-process rebuild left the process-global cached_result_expr memo populated with pre-wipe plans, so a from_catalog child re-execed against a stale parent. Clear the memo after wiping the catalog (a fresh-process CLI run is unaffected). Documents the remaining build-time-alias-revision limitation: a recipe chains a parent by alias with no version pin, so an alias revised after a child was built (with a schema change) re-points to its latest revision and the child can miss a column; pinning cross-alias refs to manifest.parents (#84) is the next step. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Flip mode() default off -> cas, add a cp --reflink=auto arm to _clone() for Linux (CoW where available, plain copy on ext4) keeping macOS cp -c and the copy fallback, and add gc_cas() wired into reset_to to reclaim .cas clones no live entry's manifest.sources references (.cas lives outside the catalog git repo, so git reset never reclaims it). test_append_invalidates flips to passing; the fast-suite pins guard it in CI. The flip makes build identity content-aware; it does NOT make cold reconstruction content-faithful (#74 hazard) — that needs digest-pinned reconstruction, tracked in #115. ADR moved to Accepted. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…/result LRUs The two existing reset tests only assert `currsize == 0` — they pin the clear mechanism, not #80/#96's symptom (a memoized build executing over a deleted snapshot). These add the cross-process leg and a load-bearing proof: - test_notify_project_reset_evicts_compare_build_over_pruned_snapshot: warms `_build_compare_expr` (freezing b_h's baked snapshot path into a serialized build with no per-call exists() recheck), runs `reset_to` to prune b_h's snapshot (standing in for the out-of-process CLI reset, which does NOT reach the companion LRU), then proves the stale build executes over the deleted parquet (`ValueError: At least one path is required`, the #73/#74 symptom) and that only the `/internal/notify {project_reset}` signal evicts it. - test_notify_project_reset_clears_result_plan_memo: parity hygiene for the result-plan memo on the same cross-process path. Both fail today: the notify handler reloads buckaroo sessions but never clears the companion's process-global LRUs, so a CLI `reset-to` leaves a long-lived companion serving a diff build over a pruned snapshot (#80's "Communication gap"). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…equisites #84-#89 Resolutions from the 2026-06-16 design review reconciling the reactive-recalc plan with merged PR #74: the materialization boundary is policy (structural cache_worthy stays live, measured value-per-byte runs in shadow, flip on data — #30 softened from remove-now); the cross-entry DAG is restorable now via a manifest parents field (only the follow-the-concept layer stays deferred); cas is a prerequisite, not an open question (recompute-on-cold-read); read-intent rides the same parents field {hash, ref, follow} with previous_version disambiguation; determinism is detect-and-warn (structural via a hash predicate, execution via a lint + #83). Touches all four ADRs in plans/. Prerequisites filed #84-#88, epic #89; measurement #82, soundness #83. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e entry was built from (#115) Cold reconstruction re-runs the recipe's from_project, which under the cas default re-digested the live source and cloned it under a new digest — so a cheap entry's cold read, or an expensive entry self-healing an evicted snapshot, served bytes edited in place under the entry's original content_hash (the #74 hazard #86's flip left open). Thread the reconstructing entry's recorded sources into the reconstruction context and resolve from_project to the frozen clone: - result_cache._RECON_SOURCES: a contextvar set per-entry in _recipe_expr to (project, manifest.sources), paired with _RECONSTRUCTING around the _import_script window and reset in the same finally. Nested reconstruction overwrites then restores it, so each entry's from_project sees its own sources. - io.from_project: in cas mode, when reconstructing, resolve to the recorded .cas/<digest> clone instead of re-digesting the live file, and note that frozen digest (keeps a child's manifest.sources honest for gc_cas liveness). Build path, off, salt, and unrecorded sources are unchanged. - source_identity.recon_cas_path: returns the recorded clone; re-materialises it from the live source only if the live *content* (not the stat-keyed memo) still hashes to the recorded digest; otherwise serves live bytes best-effort and warns on tallyman.perf — the one branch that is not content-faithful. Closes the cas cold-read/self-heal faithfulness hazard on the same machine with .cas present. salt reconstruction, the digest_for stat-collision hole, off mode, and cross-machine reads where .cas is absent AND the source drifted remain open. Also pins the expensive test's aggregate with order_by('x') so its #83 result_digest is stable build-to-self-heal, and adds an off->cas graceful-degradation guard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a "How the dependency graph is recorded and read" section answering the questions that recur about the reactive system: - the three data-sourcing functions (read_project_file / tracked_ / pinned_ expr_from_alias) and the edge each records, as a table - where the graph lives (manifest.parents / manifest.sources, now defined before first use) - roots/leaves direction (data-flow convention, not build-system) - cheap vs expensive parents = non-materialized vs materialized, and why a cheap parent's sources land in the child's manifest.sources - build-time side-channel capture, why it's tallyman not xorq, and why the #73/#74 expression flattening makes the edge unrecoverable from xorq's output - dependents.py as the single read seam Also fixes errors the rename's blanket from_catalog->tracked_expr_from_alias substitution introduced: the hash-pin paths now correctly say pinned_expr_from_alias (tracked_expr_from_alias rejects hashes). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…notated links Each recorded catalog ref (e.g. "parking_2015") becomes a link to the exact entry it resolved to at build time, with a caption listing what each ref loaded (hash @ alias-head/pinned). Addresses the cognitive burden of an opaque self-referential alias. The inline-the-source-from-read_csv alternative is deliberately left out — it defeats the #73/#74 shallow-graph design and needs grilling first. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ge via direct build catalog_revise now rejects self-referential recipes (#135), so the two tests that documented self-ref revise as the recommended pattern (anti-recursion #73, staleness classification #74/#85) built their self-ref head via catalog_revise — now rejected. Build it directly (build_and_persist + set_alias) instead, so the #73 anti-recursion engine and the 'self-referential not UNEXPLAINED' classification stay covered for historical heads / indirect cycles, which the guard (a tool-boundary policy) deliberately doesn't block. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses #73.
What
Replaces the blanket per-entry
result.parquetwith a rewrite-then-build caching model. Before building, tallyman rewrites the submitted expression (source_cache.rewrite_for_build):.cache()is injected after each non-parquet file read (read_csv/read_json), so the parse is materialised once and shared across every entry that reads that source (path-only snapshot key).read_parquet/read_deltaare exempt..cache()is baked into the build. The cache node is part of the durable recipe, so every loader — the Buckaroo viewer, diffs,from_catalogchaining — reads the cached result instead of re-running the whole DAG (the read-time-only path perf: viewer /load_expr sends the bare recipe for cache-worthy entries — count + every page re-runs the whole DAG instead of reading result.parquet #71 worked around, fixed at the source).read_in_memory/ibis.memtable) are a hardBuildError, steering authors back todeferred_read_csv.result.parquetis no longer written at build. A cheap entry materialises nothing (schema from the expression, row count from a pushdowncount()); an expensive entry's rows live in its baked cache.ensure_resultregeneratesresult.parqueton demand for the few callers that need a parquet path (downloads, promote-diff, post-processing), so it's now a regenerable cache, not an immutable artifact.The content hash is computed from the rewritten expression;
xorq_build/carries the cache nodes whileexpr.pykeeps the author's literal source.Notable decisions
from_catalogcomposes expressions (viacached_result_expr) rather than readingresult.parquet, so chaining off a cheap entry works. Consequence: the inter-entry catalog DAG (catalog_parents) goes dark — there's noresult.parquetpath string to match. Deferred to a future semantic-dependency mechanism (depend on the concept a parent computes, not its materialised hash), per discussion on the issue.result_cache/retired. xorq's loader (load_expr(cache_dir=…)) rewrites every baked cache node's base path uniformly, so source parses and baked results co-locate in the per-project compute cache (content-addressed, already reset-reconciled). The separateresult_cache/dir is retired (kept as a now-empty vestige so reset/disk-reporting are untouched).CachedNode/RemoteTableso it still shows the author's logical pipeline.main(does not stack on perf: viewer /load_expr sends the bare recipe for cache-worthy entries — count + every page re-runs the whole DAG instead of reading result.parquet #71).Migration
build.migrate_drop_result_parquetdeletes old build-timeresult.parquetfiles once per project (marker-guarded), run at companioncreate_app. Cheap entries recompute trivially on demand; expensive entries repopulate their cache lazily on first view.Tests
Full suite: 454 passed. New
test_source_cache.pycovers injection, parquet exemption, expensive double-bake, and in-memory rejection. Updated result_cache / build / lineage / diff / cache-inspector / paths tests for the new behaviour.Follow-ups (noted, not in scope)
result_cache/and doesn't count the compute cache where baked results live.result.parquetrather than the baked compute-cache results.Review follow-ups (#74 review)
Filed from the review of this PR; the first is to be reviewed in a separate PR, the rest are deferred:
previous_versioncross-alias ambiguity (src/tallyman_core/aliases.py).version_of_hashreturns the first alias (dict order) whose history contains a hash, andprevious_versionsteps back within that one alias. A hash shared across alias histories therefore steps back through whichever alias comes first, which may not be the lineage afrom_catalogmeant. Not a regression —previous_versionis behaviourally identical to the prior inlineprimary_key._parent_hash— but the new cycle resolver (_resolve_noncyclic_hash) now depends on it for correctness, so the stakes are higher._resolve_noncyclic_hashhas the requested alias in scope and could disambiguate. To be reviewed separately.cached_result_exprLRU — caching: reset-to-revision doesn't invalidate the in-process cached_result_expr LRU — a warmed expensive entry then reads a pruned snapshot path and fails #80.🤖 Generated with Claude Code