Skip to content

Commit 5bb2eab

Browse files
committed
Merge remote-tracking branch 'origin/main' into feat/89-determinism-prereqs
# Conflicts: # tests/test_result_cache.py
2 parents 570518f + 1f8d7b1 commit 5bb2eab

6 files changed

Lines changed: 830 additions & 73 deletions

File tree

docs/caching.md

Lines changed: 84 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ would break both the isolation and the reset semantics.
8686

8787
Tallyman decides what to materialize at *build* time and bakes the decision
8888
into the entry's recipe, so every later read takes the same path. Before a
89-
build, `rewrite_for_build` (`source_cache.py:82`) rewrites the submitted
89+
build, `rewrite_for_build` (`source_cache.py:89`) rewrites the submitted
9090
expression in three steps: reject in-memory reads, inject a source-read cache
9191
after each non-parquet file read, and — for an expensive expression — wrap the
9292
whole thing in a top-level result cache. Both kinds of cache node are
@@ -103,12 +103,19 @@ path), so there is no separate `result_cache/` directory; both land under
103103
exempt (`_EXEMPT_READS`): re-reading a columnar source is already a pushdown.
104104
- **Baked result snapshot** — an expression is *worthy* when it contains an
105105
Aggregate / Join / Sort / window / UDF (`_EXPENSIVE_OPS`,
106-
`result_cache.py:48`). A worthy expression is wrapped in a result cache with
107-
`relative_path="result_cache"` (`source_cache.py:153`), so its snapshot lands
106+
`result_cache.py:49`). A worthy expression is wrapped in a result cache with
107+
`relative_path="result_cache"` (`source_cache.py:160`), so its snapshot lands
108108
under `compute_cache/result_cache/`; executing the entry at build time
109-
materializes it once (`build.py:487`). A non-parquet read is *not* by itself
110-
worthy (`classify_build`, `result_cache.py:61`) — its parse is already
111-
handled by the source-read cache.
109+
materializes it once (`build.py:491`). A non-parquet read is *not* by itself
110+
worthy (`classify_build`, `result_cache.py:62`) — its parse is already
111+
handled by the source-read cache. Worthiness is decided by two predicates that
112+
must agree: `_is_worthy_expr` gates the bake (`source_cache.py:62`) and
113+
`classify_build` records `cache_worthy` in the manifest. `_is_worthy_expr`
114+
matches a UDF by class ancestry (`type(node).__mro__`, `source_cache.py:84`)
115+
and `classify_build` by the serialized op name containing `UDF`
116+
(`result_cache.py:85`; a scalar UDF serializes as `op: ScalarUDF`); the two now
117+
agree for a scalar UDF, so a scalar-UDF-only entry is worthy under both and
118+
bakes its snapshot instead of recomputing the whole graph on every read (#81).
112119

113120
A cheap entry — a source read plus projections / renames / row-wise scalar
114121
math — bakes no result snapshot. Recompute costs about the same as reading a
@@ -118,13 +125,17 @@ written for any entry, cheap or expensive, at build time or on demand (#73 and
118125
follow-up).
119126

120127
Every consumer reads an entry's result through one function,
121-
`cached_result_expr` (`result_cache.py:273`):
128+
`cached_result_expr` (`result_cache.py:403`):
122129

123130
- Expensive entry → `deferred_read_parquet` of the baked snapshot, on the
124131
default backend. The snapshot was written when the entry built, so reading it
125132
skips re-running the graph. If it was evicted since, the cache node recomputes
126-
it once and the read proceeds — a self-heal re-checked on every call
127-
(`result_cache.py:321`).
133+
it once and the read proceeds — a self-heal re-checked on every call,
134+
single-flighted per `(project, content_hash)` so concurrent cold readers don't
135+
both run the shared cache op; after a repopulate the snapshot's digest is
136+
checked against the recorded `result_digest` and a mismatch is logged as
137+
recompute drift (`result_cache.py:451-473`, `_heal_lock`,
138+
`_warn_if_self_heal_unfaithful`; #79, #83).
128139
- Cheap entry → the live expression, re-imported from the entry's `expr.py`
129140
and recomputed on read.
130141

@@ -136,10 +147,20 @@ reads a pre-existing `result.parquet`, because none is written.
136147
Snapshot strategy is the right one here because an entry is an immutable,
137148
content-addressed artifact — its result must not invalidate just because an
138149
upstream file's mtime drifts. No TTL: entries are permanent history, not
139-
expiring scratch. The one exception is `salt` source-identity mode, where
140-
path-only snapshot keys would collide across entries with identical paths but
141-
different content; under salt, `rewrite_for_build` bakes no cache at all
142-
(`source_cache.py:123`) and every read recomputes.
150+
expiring scratch.
151+
152+
How a source file's content reaches the key is set by `TALLYMAN_SOURCE_IDENTITY`
153+
(`source_identity.py:52`). The default is `cas`: `from_project` reads through a
154+
content-addressed clone at `<project>/data/.cas/<digest>` (`io.py:58`), so the
155+
path xorq tokenizes embeds the content digest and every xorq-level key — build
156+
hash and snapshot keys alike — is content-honest, and a rebuild over an edited
157+
source forks the hash instead of deduping to the stale entry. `off` is the
158+
historical path-only mode: no digest, no clone, an edit collides with the prior
159+
entry. `salt` is the remaining exception to baking: it folds source digests into
160+
the entry hash but leaves xorq's own keys path-only, so a baked snapshot's
161+
path-only key would collide across entries with identical paths but different
162+
content. Under salt, `rewrite_for_build` bakes no cache at all
163+
(`source_cache.py:130`) and every read recomputes.
143164

144165
### Compute cache (`compute_cache_dir` in `src/tallyman_core/paths.py`)
145166

@@ -156,7 +177,11 @@ file captured at each checkpoint; `reset_to`
156177
(`src/tallyman_core/catalog_state.py:327`) prunes or restores the cache to
157178
match the target step. Evicted files move to `bullpen/` rather than being
158179
deleted, so a forward reset restores them instead of recomputing, while a
159-
re-added entry still computes honestly cold.
180+
re-added entry still computes honestly cold. `reset_to` also reclaims orphaned
181+
`cas` clones: `<project>/data/.cas` lives outside the catalog git repo, so
182+
`git reset` can't touch it, and `_gc_cas_clones` (`catalog_state.py:349`,
183+
`source_identity.gc_cas`) deletes any `.cas` file no surviving entry's
184+
`manifest.sources` still references (#86).
160185

161186
### In-memory caches in the companion
162187

@@ -165,14 +190,18 @@ and staleness is impossible:
165190

166191
- `cached_result_expr` — its reconstruction work is memoized on
167192
`_resolve_result_plan`, `lru_cache(256)` keyed `(project, content_hash)`
168-
(`result_cache.py:232`); `cached_result_expr` re-exposes that memo's
193+
(`result_cache.py:345`); `cached_result_expr` re-exposes that memo's
169194
`cache_clear` / `cache_info`. The memo saves re-importing the entry's
170195
`expr.py` and, for an expensive entry, re-deriving its baked-snapshot path.
171196
`cached_result_expr` itself is a thin wrapper that re-checks the snapshot's
172-
on-disk presence on every call, so an evicted snapshot self-heals.
197+
on-disk presence on every call, so an evicted snapshot self-heals; the heal
198+
runs under a per-entry lock (`_heal_lock`, `result_cache.py:393`) so only the
199+
first of several concurrent cold readers executes it, and a peer process that
200+
wins xorq's fixed-`.tmp` rename is caught and retried once if the snapshot
201+
landed, else re-raised (#79).
173202
- `_build_compare_expr``lru_cache(128)` keyed
174203
`(project, a_hash, b_hash, keys)`; saves rebuilding diff outer-join
175-
expressions (`src/tallyman_companion/app.py:192`). Build dirs land under
204+
expressions (`src/tallyman_companion/app.py:189`). Build dirs land under
176205
`$TMPDIR/tallyman_diff_builds/`.
177206
- Disk-usage payload — per-project, 3-second TTL
178207
(`_DISK_USAGE_TTL` in `src/tallyman_companion/app.py`). The only
@@ -231,12 +260,23 @@ catalog store, `catalog.py` / `catalog_state.py`, for the full tracked surface.)
231260
omits it rather than symlinking it.)
232261
- **Manifest / schema**`<entry>/manifest.json`, `<entry>/schema.json`:
233262
row counts, timings (including #87's cache-admission fields:
234-
`compile_seconds`, `cache_worthy`, `cache_bytes`), and the schema, all
263+
`compile_seconds`, `cache_worthy`, `cache_bytes`), the #83 `result_digest`
264+
(a content hash of the executed result bytes), and the schema, all
235265
derived from the expression at build so later reads don't re-walk it.
236266
- **Alias history**`<catalog>/aliases.jsonl`, one line per alias holding
237267
its current head and the append-only version log (`aliases.py`). It is a
238268
git-tracked file in the catalog repo, not a per-entry artifact, so it rolls
239269
back with `git reset` on a `reset_to`.
270+
- **Per-hash config**`<catalog>/chart_specs/<hash>.vl.json` and
271+
`<catalog>/display_configs/<hash>.json` hold an entry's chart and display
272+
config, keyed by content hash. These are mutable (set or cleared from the UI),
273+
not build outputs. Because the key is the hash, a revise mints a new hash and
274+
would orphan them, so `carry_forward_entry_config` (`entry_config.py:21`)
275+
copies each from the prior version unless the new version already defines its
276+
own — called from `catalog_revise` (`server.py:572`) and the companion's
277+
`put_code`. Post-processing and summary stats are not carried: they are
278+
project-global (keyed by name, not hash) and already apply to every version
279+
(#109).
240280

241281
## Buckaroo
242282

@@ -296,11 +336,23 @@ all rely on "same key, same bytes, forever". Cleanup for those is a
296336
space concern (manual delete, bullpen moves on reset), and a deleted file
297337
self-heals by recomputing.
298338

299-
The one documented hole in "never stale" is execution nondeterminism: an
300-
entry whose recipe calls `now()` / `random()` / an unseeded `sample()`
301-
produces different bytes each run under one content hash, so a cold recompute
302-
can disagree with what was built (#88). The build flags these as advisory lint
303-
warnings (`_nondeterminism_warnings`, `build.py`); it does not block them.
339+
Two documented holes break "never stale". The first is execution
340+
nondeterminism: an entry whose recipe calls `now()` / `random()` / an unseeded
341+
`sample()` produces different bytes each run under one content hash, so a cold
342+
recompute can disagree with what was built (#88). The build flags these as
343+
advisory lint warnings (`_nondeterminism_warnings`, `build.py`); it does not
344+
block them. As of #83 the build also records a `result_digest` of the executed
345+
bytes in the manifest, and the self-heal compares the repopulated snapshot
346+
against it (`verify_result_faithful`, `result_cache.py:299`;
347+
`_warn_if_self_heal_unfaithful`, `result_cache.py:315`), so this drift is now
348+
detected on recompute even though it is still not prevented. The second is
349+
cold-reconstruction faithfulness under the `cas`
350+
default: `cas` makes *build* identity content-aware (a rebuild over an edited
351+
source forks the hash) but not *reconstruction*. `cached_result_expr` re-runs a
352+
cheap entry's `expr.py` on every read, and `from_project` re-digests the live
353+
source, so a cheap entry — or an expensive entry self-healing an evicted
354+
snapshot — serves the edited bytes under its original `content_hash`. Closing
355+
this needs digest-pinned reconstruction, tracked in #115.
304356

305357
Where staleness is actually possible, it is handled explicitly:
306358

@@ -313,9 +365,12 @@ Where staleness is actually possible, it is handled explicitly:
313365
`purgeInfiniteCache` on sort/ops change, the session map clearing on
314366
Buckaroo restart, and the stat-cache deletion on reload.
315367

316-
The one rule to remember: snapshot-strategy caches do not notice upstream
317-
data changes. Tallyman is safe because entries are immutable by
318-
construction; anyone pointing xorq's snapshot caches at mutable source
319-
files has to manage invalidation themselves. Tallyman's one opt-out is `salt`
320-
source-identity mode, which bakes no snapshot cache at all (path-only keys
321-
would collide across salted entries) and recomputes on every read.
368+
The one rule to remember: snapshot-strategy caches do not notice upstream data
369+
changes. The `cas` default papers over this at *build* time — an edited source
370+
forks the entry hash rather than deduping to the stale one — but a cold read of
371+
a cheap or evicted entry still re-digests the live source and can serve edited
372+
bytes under the old hash (#74, above). Anyone who needs reconstruction to be
373+
content-faithful today should treat a source as immutable once an entry is built.
374+
`off` reverts to xorq's path-only identity (an edit collides silently); `salt`
375+
mixes digests into the entry hash but bakes no snapshot cache at all (path-only
376+
keys would collide across salted entries) and recomputes on every read.

0 commit comments

Comments
 (0)