|
| 1 | +# ADR: Result-cache admission and eviction by measured cost vs size |
| 2 | + |
| 3 | +- **Status:** Proposed (2026-06-10) |
| 4 | +- **Context ticket:** buckaroo-data/tallyman#30 (measured cost/size cache rubric; supersedes #12, feeds #10 and #21) |
| 5 | +- **Affected code:** `src/tallyman_xorq/result_cache.py` (`classify_build`, `cache_worthy`, `cached_result_expr`, `ensure_result`), `src/tallyman_xorq/build.py` (`build_and_persist`, manifest write), `src/tallyman_core/manifest.py` |
| 6 | +- **Related ADR:** `plans/adr-source-identity-content-hash.md` (why `content_hash` is a stable cache key) |
| 7 | + |
| 8 | +## Problem |
| 9 | + |
| 10 | +`cache_worthy` decides whether to materialise an entry's result by scanning the |
| 11 | +serialized xorq build for ops (`classify_build` in `result_cache.py:51`): cache |
| 12 | +if the expression contains a Join / Aggregate / Sort / Window / UDF or reads a |
| 13 | +non-parquet source, otherwise treat it as cheap and skip the extra copy. |
| 14 | + |
| 15 | +This is a *structural* test, and it misclassifies a common authoring workflow. |
| 16 | +The motivating case: a trips ⋈ stations join (~2.5M rows, 100s of MB) followed |
| 17 | +by *iterative computed-column additions* — add a distance metric, tweak it, add |
| 18 | +another derived column. Every one of those descendant entries inherits the |
| 19 | +upstream `Join` in its op graph, so `classify_build` marks all of them |
| 20 | +"worthy". Each iteration writes a 100+ MB cache for a result that is trivial to |
| 21 | +recompute (a row-wise scalar pass over an already-cheap input). The cache fills |
| 22 | +with low-value copies. |
| 23 | + |
| 24 | +The structural test cannot tell *"expensive to produce"* from *"contains an |
| 25 | +expensive op upstream but is cheap to recompute"*. It also cannot tell a |
| 26 | +big-cheap result from a small-expensive one — exactly the distinction that |
| 27 | +matters. Concretely, two results in the same lineage: |
| 28 | + |
| 29 | +- the 2.5M-row computed-column intermediate: ~150 MB, recompute ≈ re-reading the |
| 30 | + source (I/O-bound on the same bytes) — caching saves ~nothing; |
| 31 | +- the ~6.4k-row route aggregate at the end: <1 MB, recompute requires rescanning |
| 32 | + 2.5M rows + join + distance + aggregate — caching saves almost all of it. |
| 33 | + |
| 34 | +Profiling the live path (see the buckaroo xorq-search investigation) showed |
| 35 | +per-view cost is dominated by re-compiling the large expression DAG to SQL on |
| 36 | +every execute; the win from caching a *reduced* result is that the viewed/diffed |
| 37 | +expression collapses to `read_parquet ⋈ read_parquet`, cheap to both compile and |
| 38 | +execute. The win exists only for results that reduce data; it is absent for |
| 39 | +passthroughs whose output is as large as their input. |
| 40 | + |
| 41 | +## Decision |
| 42 | + |
| 43 | +Replace the structural admission test with a measured cost-vs-size model, and |
| 44 | +move the admit/skip decision off a per-entry gate and onto a budget with |
| 45 | +cost-aware eviction. |
| 46 | + |
| 47 | +1. **Measure at build, always.** Every entry is executed on add (we never add an |
| 48 | + expression without executing it; unbound expressions are out of scope), so |
| 49 | + the cost is already available. Record both `execute_seconds` (already in |
| 50 | + `manifest.json`) and a new `compile_seconds` — the expr→backend-plan step |
| 51 | + (the `to_sqlglot`/sqlize path), timed separately from execution. Record the |
| 52 | + materialised result's size in bytes. |
| 53 | + |
| 54 | +2. **Admit permissively; do not gate per entry.** There is no structural or |
| 55 | + cost-floor admission test. Any built result is a cache candidate. The |
| 56 | + structural `classify_build` / `cache_worthy` path is removed (this supersedes |
| 57 | + #12, which sought to make that classifier robust — there is nothing left to |
| 58 | + robustify). |
| 59 | + |
| 60 | +3. **Rank by value-per-byte.** A cache's value is what reading it back saves |
| 61 | + over recomputing: `value ≈ recompute_cost − cache_read_cost`, divided by |
| 62 | + bytes occupied. This declines to ~0 for cheap row-wise passthroughs whose |
| 63 | + output ≈ input size (the 2.5M intermediate) and is high for reducing / |
| 64 | + aggregating expressions (the 6.4k aggregate). `recompute_cost` is |
| 65 | + `compile_seconds + execute_seconds`; `cache_read_cost` is estimated from the |
| 66 | + result's byte size. |
| 67 | + |
| 68 | +4. **Bound by a budget relative to raw data.** Cap the project's total result |
| 69 | + cache at K× the size of `data/` (start at 2×, matching #10). When the cache |
| 70 | + exceeds the budget, evict lowest value-per-byte first. Evicted results |
| 71 | + self-heal: `ensure_result` / `cached_result_expr` recompute from the build on |
| 72 | + the next miss. This is the eviction half of #10; admission and eviction now |
| 73 | + share one cost model. |
| 74 | + |
| 75 | +5. **Key on `content_hash`, never mtime.** The cache key is the entry's |
| 76 | + `content_hash`, frozen at build time (`build.py:271`) and stable across |
| 77 | + reset-to and reload. No modification-time comparison appears on the |
| 78 | + correctness path. (`content_hash` being a *content*-stable identifier is the |
| 79 | + subject of the related ADR.) |
| 80 | + |
| 81 | +Sub-expression / "cache at the divergence point" caching is explicitly **out of |
| 82 | +scope**. In the motivating lineage the divergence point is the 2.5M-row join — |
| 83 | +big and cheap to recompute — so caching there is the opposite of what the cost |
| 84 | +model wants. v0 caches per-entry results only. |
| 85 | + |
| 86 | +## Alternatives considered |
| 87 | + |
| 88 | +- **Keep the structural classifier (status quo).** Mis-caches the |
| 89 | + iterative-computed-column workflow (Join upstream ⇒ every descendant worthy) |
| 90 | + and cannot separate big-cheap from small-expensive. This is the bug. |
| 91 | +- **Make the structural classifier robust (#12).** Fixes silent |
| 92 | + misclassification from regex drift but keeps the structural model, so it still |
| 93 | + caches the 2.5M intermediates. Necessary only if we stay structural; this ADR |
| 94 | + removes that need. |
| 95 | +- **Cache at the divergence / lowest-common-ancestor sub-expression.** Would |
| 96 | + materialise the large shared intermediate (2.5M-row join) once and let both |
| 97 | + metric variants read it. Rejected: that intermediate is precisely what we do |
| 98 | + *not* want cached — it is large and cheap to recompute, and the variants |
| 99 | + diverge *on* it (haversine vs manhattan), not below it. |
| 100 | +- **Per-entry cost-floor admission gate.** Redundant once a budget plus |
| 101 | + value-per-byte eviction exists; a permissive admit + eviction expresses the |
| 102 | + same intent without a second threshold to tune. |
| 103 | + |
| 104 | +## Consequences |
| 105 | + |
| 106 | +- Storage is bounded by a multiple of raw data size instead of growing with edit |
| 107 | + count. The iterative-column workflow stops bloating the cache. |
| 108 | +- route_distance diffs stay fast: both 6.4k endpoints are small and |
| 109 | + high-value, so they are cached, and the diff is `read ⋈ read`. |
| 110 | +- Requires new instrumentation: `compile_seconds` in the build path and result |
| 111 | + byte-size in the manifest. Accurate cache accounting is a prerequisite for |
| 112 | + eviction — the `/api/disk_usage` pill currently omits `result_cache/` and |
| 113 | + `diff_stat_cache/` (noted in #10) and must be corrected. |
| 114 | +- Eviction depends on correct recompute self-healing, which already exists. |
| 115 | +- The `value ≈ recompute_cost − cache_read_cost` estimate is a heuristic; the |
| 116 | + read-cost term is approximated from bytes rather than measured. Acceptable |
| 117 | + because misranking only costs a recompute, never correctness. |
| 118 | +- Scope is `result_cache` only. Buckaroo's own stat caches |
| 119 | + (`.buckaroo_stat_cache`, `diff_stat_cache`) are out of scope; tallyman will |
| 120 | + only hint to Buckaroo (separate sketch). |
0 commit comments