Skip to content

Commit cc718de

Browse files
authored
Merge pull request #127 from buckaroo-data/fix/115-digest-pinned-reconstruction
fix(source-identity): digest-pinned reconstruction — cold reads serve built bytes (#115)
2 parents 1f8d7b1 + 0429e97 commit cc718de

4 files changed

Lines changed: 327 additions & 0 deletions

File tree

src/tallyman_xorq/io.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,19 @@ def from_project(rel_path: str, project: str | None = None):
5252

5353
proj = resolve_project(project)
5454
path = project_path(rel_path, proj)
55+
if si.mode() == "cas":
56+
recorded = _reconstructing_source_digest(proj, rel_path)
57+
if recorded is not None:
58+
# Reconstruction (#115): this from_project is re-running an already-built
59+
# entry's recipe (expr.py), not authoring a new entry. Resolve to the FROZEN
60+
# .cas clone the entry was built from — named by the digest in its
61+
# manifest.sources — instead of re-digesting the live file, which may have
62+
# been edited in place since the build. Note that same frozen digest (the
63+
# source collector is independent of from_catalog's _RECONSTRUCTING-gated
64+
# parent capture, so both fire) so a child build reconstructing this entry
65+
# records the frozen digest and gc_cas keeps the clone alive.
66+
si.note_source(rel_path, recorded)
67+
return deferred_read_parquet(str(si.recon_cas_path(proj, path, recorded)))
5568
if si.mode() != "off":
5669
digest = si.digest_for(proj, path)
5770
si.note_source(rel_path, digest)
@@ -60,6 +73,26 @@ def from_project(rel_path: str, project: str | None = None):
6073
return deferred_read_parquet(str(path))
6174

6275

76+
def _reconstructing_source_digest(proj: str, rel_path: str) -> str | None:
77+
"""The digest *rel_path* was built with, if a recipe for *proj* is being
78+
reconstructed on this call stack (#115); otherwise None.
79+
80+
Reads the per-entry ``{rel_path: digest}`` map ``result_cache._recipe_expr``
81+
threads through ``_RECON_SOURCES``. Returns None outside reconstruction (the build
82+
path), when the reconstructing entry belongs to a different project, or when this
83+
source was not recorded — from_project then falls through to its live-file path.
84+
"""
85+
from tallyman_xorq.result_cache import _RECON_SOURCES
86+
87+
ctx = _RECON_SOURCES.get()
88+
if ctx is None:
89+
return None
90+
recon_proj, sources = ctx
91+
if recon_proj != proj:
92+
return None
93+
return sources.get(rel_path)
94+
95+
6396
def from_catalog(alias_or_hash: str, project: str | None = None):
6497
"""Load an existing catalog entry's result as a xorq expression.
6598

src/tallyman_xorq/result_cache.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,35 @@ def cache_worthy(project: str, content_hash: str) -> bool:
114114
# machine-locking memory blowup into a fast, clear error instead.
115115
_MAX_RECON_DEPTH = 64
116116

117+
# The source digests ({rel_path: digest} from manifest.sources) the entry being
118+
# reconstructed on this stack was BUILT from, threaded into from_project so a cold
119+
# read resolves each source to the frozen data/.cas/<digest> clone it was built from —
120+
# not a re-digest of the (possibly edited-in-place) live file (#115). Set per-entry in
121+
# _recipe_expr alongside _RECONSTRUCTING and OVERWRITTEN (then restored) by each nested
122+
# reconstruction, so a grandparent's from_project sees the grandparent's recorded
123+
# sources. Carries the project so a from_project resolving a *different* project falls
124+
# through to the live path. None outside reconstruction (the build path); an entry that
125+
# recorded no sources (mode=off / pre-#86) yields an empty map, so from_project also
126+
# falls through.
127+
_RECON_SOURCES: contextvars.ContextVar[tuple[str, dict] | None] = contextvars.ContextVar(
128+
"_recon_sources", default=None
129+
)
130+
131+
132+
def _recorded_sources(project: str, content_hash: str) -> dict:
133+
"""The entry's recorded ``manifest.sources`` ({rel_path: digest}), or ``{}``.
134+
135+
Empty when the manifest is unreadable or the entry recorded no sources (mode=off,
136+
or a build predating #86); from_project then falls through to its live-file path.
137+
"""
138+
from tallyman_core import read_manifest
139+
from tallyman_core.paths import entry_dir
140+
141+
try:
142+
return read_manifest(entry_dir(project, content_hash)).sources or {}
143+
except (OSError, ValueError):
144+
return {}
145+
117146

118147
def _resolve_noncyclic_hash(project: str, requested: str, content_hash: str) -> str:
119148
"""The hash ``from_catalog`` should load, stepped out of any reconstruction cycle.
@@ -176,10 +205,17 @@ def _recipe_expr(project: str, content_hash: str):
176205
code = (entry_dir(project, content_hash) / "expr.py").read_text().replace(PLACEHOLDER, str(project_dir(project)))
177206
# Mark this entry in-flight for the duration of the recipe exec, so any
178207
# from_catalog the recipe issues can step back out of a self-reference (#74).
208+
# In the SAME window, pin this entry's recorded sources so any from_project the
209+
# recipe issues resolves to the frozen .cas clone it was built from, not a
210+
# re-digest of the edited-in-place live file (#115). Both contextvars must wrap
211+
# _import_script — that is where the recipe's from_catalog / from_project run —
212+
# and reset in this finally, NOT the sys.modules-cleanup finally below.
179213
token = _RECONSTRUCTING.set(active | {(project, content_hash)})
214+
recon_token = _RECON_SOURCES.set((project, _recorded_sources(project, content_hash)))
180215
try:
181216
module, tmp = _import_script(code)
182217
finally:
218+
_RECON_SOURCES.reset(recon_token)
183219
_RECONSTRUCTING.reset(token)
184220
try:
185221
expr = getattr(module, _RECIPE_VAR, None)

src/tallyman_xorq/source_identity.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
import contextvars
3737
import hashlib
3838
import json
39+
import logging
3940
import os
4041
import shutil
4142
import subprocess
@@ -44,6 +45,11 @@
4445

4546
from tallyman_core import artifacts_dir, data_dir
4647

48+
# Shares tallyman.perf with result_cache so the one branch where reconstruction
49+
# can't be content-faithful surfaces on the same namespace as the #83 self-heal
50+
# faithfulness warning.
51+
_log = logging.getLogger("tallyman.perf")
52+
4753
_MODE_ENV = "TALLYMAN_SOURCE_IDENTITY"
4854
_REHASH_ENV = "TALLYMAN_SOURCE_REHASH"
4955
_MODES = ("off", "cas", "salt")
@@ -134,6 +140,40 @@ def ensure_cas_path(project: str, src: Path, digest: str) -> Path:
134140
return dst
135141

136142

143+
def recon_cas_path(project: str, live_src: Path, digest: str) -> Path:
144+
"""The frozen ``.cas`` clone named by *digest*, for digest-pinned reconstruction (#115).
145+
146+
Returns ``data/.cas/<digest><suffix>`` — the bytes the entry was built from —
147+
WITHOUT re-digesting the live source, so a cold read after an in-place edit serves
148+
what was built, not the edited file. The clone is normally already on disk: the
149+
``.cas`` GC (``gc_cas``) preserves any clone a live entry's ``manifest.sources``
150+
references.
151+
152+
If the clone is absent — manually deleted, or a fresh cross-machine catalog clone,
153+
since ``.cas`` lives under ``data/`` outside the catalog git repo — re-materialise
154+
it from the live source IFF the live bytes still hash to *digest*. The check hashes
155+
file *content* (``_digest_file``), never ``digest_for``, so the stat-keyed memo
156+
can't certify an in-place edit that preserved ``(mtime_ns, size, inode)`` as the
157+
original (the documented memo hole). When the clone is gone AND the live source has
158+
drifted, the original bytes are unrecoverable: serve the live source best-effort
159+
(never crash a read) but warn — this is the one branch that is NOT content-faithful.
160+
"""
161+
cas_dir = data_dir(project) / ".cas"
162+
dst = cas_dir / f"{digest}{live_src.suffix}"
163+
if dst.exists():
164+
return dst
165+
if live_src.exists() and _digest_file(live_src) == digest:
166+
return ensure_cas_path(project, live_src, digest)
167+
_log.warning(
168+
"recon_cas_path: frozen .cas clone %s%s missing and live source %s drifted — "
169+
"serving live bytes; this cold read is NOT faithful to the build (#115)",
170+
digest,
171+
live_src.suffix,
172+
live_src,
173+
)
174+
return live_src
175+
176+
137177
def gc_cas(project: str, live_digests: set[str]) -> int:
138178
"""Delete ``.cas`` clones whose digest no live entry references.
139179

tests/test_result_cache.py

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -872,6 +872,224 @@ def _just_raise():
872872
assert not p.exists()
873873

874874

875+
# ---------------------------------------------------------------------------
876+
# #115: digest-pinned reconstruction — a cold read must serve the bytes the entry
877+
# was BUILT from (the frozen data/.cas/<digest> clone), not a re-digest of the
878+
# live source after it is edited in place. #86 made *build* identity content-aware
879+
# (a rebuild forks the hash) but left cold *reconstruction* re-running the recipe's
880+
# from_project over the live file. These pin the closure for both #74 hazards: a
881+
# cheap entry's cold read, and an expensive entry self-healing an evicted snapshot.
882+
# ---------------------------------------------------------------------------
883+
884+
885+
def _write_ints(path, values) -> None:
886+
import pandas as pd
887+
888+
pd.DataFrame({"x": list(values)}).to_parquet(path)
889+
890+
891+
def _src_read_code(project: str, rel: str) -> str: # cheap: a bare project read
892+
return f"from tallyman_xorq.io import from_project\nexpr = from_project({rel!r}, project={project!r})\n"
893+
894+
895+
def _src_group_code(project: str, rel: str) -> str: # expensive: group_by → Aggregate → baked snapshot
896+
# order_by('x') pins a deterministic row order so the #83 result_digest is stable
897+
# build-to-self-heal (an unordered aggregate would reshuffle and read as drift).
898+
return (
899+
"from tallyman_xorq.io import from_project\n"
900+
f"t = from_project({rel!r}, project={project!r})\n"
901+
"expr = t.group_by('x').aggregate(c=t.count()).order_by('x')\n"
902+
)
903+
904+
905+
def test_cas_cold_read_after_inplace_edit_serves_built_bytes(project, monkeypatch):
906+
"""#115: a cheap entry's cold read serves the rows it was built from, even after
907+
the source is edited in place.
908+
909+
Pre-#115 the cold read re-imports the recipe and re-runs from_project, which under
910+
cas re-digests the now-edited live file and clones it under a NEW digest — serving
911+
the edited rows under the entry's ORIGINAL content_hash. Digest-pinned
912+
reconstruction resolves to the frozen .cas clone the entry recorded instead.
913+
"""
914+
from tallyman_core import data_dir
915+
from tallyman_xorq import build_and_persist
916+
from tallyman_xorq import source_identity as si
917+
from tallyman_xorq.result_cache import cached_result_expr
918+
919+
monkeypatch.setenv("TALLYMAN_PROJECT", project)
920+
monkeypatch.setenv("TALLYMAN_SOURCE_IDENTITY", "cas") # several sibling tests force off
921+
assert si.mode() == "cas"
922+
923+
src = data_dir(project) / "src.parquet"
924+
_write_ints(src, [0, 1, 2])
925+
h = build_and_persist(project, _src_read_code(project, "src.parquet")).content_hash
926+
927+
# Edit the source in place AFTER building. The 3→5 row edit changes the file
928+
# size, so the stat-keyed digest memo invalidates without TALLYMAN_SOURCE_REHASH.
929+
_write_ints(src, [0, 1, 2, 3, 4])
930+
cached_result_expr.cache_clear() # force a genuinely COLD read (re-import the recipe)
931+
932+
df = cached_result_expr(project, h).execute()
933+
# Assert on VALUES, not just the count, so a fix that read some other 3-row file
934+
# can't false-green: only the frozen clone holds exactly {0,1,2} now.
935+
assert sorted(df["x"].tolist()) == [0, 1, 2]
936+
937+
938+
def test_cas_expensive_self_heal_after_edit_serves_built_bytes(project, monkeypatch, caplog):
939+
"""#115: an expensive entry self-healing an evicted snapshot recomputes from the
940+
FROZEN source, not the edited live file.
941+
942+
The self-heal re-imports the recipe (cold _resolve_result_plan) and re-executes
943+
the baked op; pre-#115 that recompute read the edited source, so the snapshot
944+
self-healed to different bytes under the original content_hash — exactly the #83
945+
UNFAITHFUL signal. Pinned reconstruction reads the frozen clone, so the heal is
946+
faithful and the warning does not fire.
947+
"""
948+
import logging
949+
950+
from tallyman_core import data_dir
951+
from tallyman_xorq import build_and_persist
952+
from tallyman_xorq import source_identity as si
953+
from tallyman_xorq.result_cache import baked_snapshot_path, cached_result_expr
954+
955+
monkeypatch.setenv("TALLYMAN_PROJECT", project)
956+
monkeypatch.setenv("TALLYMAN_SOURCE_IDENTITY", "cas")
957+
assert si.mode() == "cas"
958+
959+
src = data_dir(project) / "src.parquet"
960+
_write_ints(src, [0, 1, 2])
961+
h = build_and_persist(project, _src_group_code(project, "src.parquet")).content_hash
962+
963+
# Capture the snapshot path BEFORE editing. Post-edit, baked_snapshot_path
964+
# re-imports the recipe and (on current code) re-digests the edited source, so it
965+
# returns a NEW, non-existent path — unlinking that would error in setup, not test
966+
# the fix.
967+
p = baked_snapshot_path(project, h)
968+
assert p is not None and p.exists()
969+
970+
_write_ints(src, [0, 1, 2, 3, 4]) # edit the source in place
971+
p.unlink() # evict the baked snapshot → the next read must self-heal
972+
cached_result_expr.cache_clear()
973+
974+
caplog.clear()
975+
with caplog.at_level(logging.WARNING, logger="tallyman.perf"):
976+
df = cached_result_expr(project, h).execute()
977+
978+
# group_by('x') over the frozen {0,1,2} → 3 groups; the edited {0,1,2,3,4} would
979+
# give 5. The self-heal must recompute from the frozen clone.
980+
assert sorted(df["x"].tolist()) == [0, 1, 2]
981+
assert p.exists() # the heal rewrote the ORIGINAL-keyed snapshot, not a drifted one
982+
# The #83 faithfulness check must NOT fire: the recompute matches the build digest
983+
# because it read the frozen bytes (pre-fix it self-healed to edited bytes and
984+
# logged UNFAITHFUL).
985+
msgs = [r.getMessage() for r in caplog.records if r.name == "tallyman.perf"]
986+
assert not any("UNFAITHFUL" in m for m in msgs), msgs
987+
988+
989+
def test_cas_nested_chain_reconstruction_pins_each_entrys_sources(project, monkeypatch):
990+
"""#115: nested reconstruction restores the per-entry source map across the chain.
991+
992+
B unions from_catalog(A) with its own from_project(b). A cold read of B
993+
reconstructs A — whose from_project must resolve to A's recorded a.parquet — then
994+
reads B's own recorded b.parquet. The frozen-source contextvar is overwritten for
995+
A's reconstruction and restored for B's remaining reads, so each from_project
996+
pins ITS OWN built bytes even after both sources are edited in place.
997+
"""
998+
from tallyman_core import data_dir
999+
from tallyman_xorq import source_identity as si
1000+
from tallyman_xorq.result_cache import cached_result_expr
1001+
1002+
monkeypatch.setenv("TALLYMAN_PROJECT", project)
1003+
monkeypatch.setenv("TALLYMAN_SOURCE_IDENTITY", "cas")
1004+
assert si.mode() == "cas"
1005+
1006+
srca = data_dir(project) / "a.parquet"
1007+
srcb = data_dir(project) / "b.parquet"
1008+
_write_ints(srca, [0, 1, 2])
1009+
_write_ints(srcb, [10, 11])
1010+
1011+
catalog_create("pa", _src_read_code(project, "a.parquet")) # cheap parent over a.parquet
1012+
b_code = (
1013+
"from tallyman_xorq.io import from_project, from_catalog\n"
1014+
"a = from_catalog('pa')\n"
1015+
f"b = from_project('b.parquet', project={project!r})\n"
1016+
"expr = a.union(b)\n"
1017+
)
1018+
res = catalog_create("pb", b_code)
1019+
assert "error" not in res, res
1020+
b_h = _hash_of(project)
1021+
1022+
_write_ints(srca, [0, 1, 2, 3, 4]) # edit BOTH sources in place after building
1023+
_write_ints(srcb, [10, 11, 12])
1024+
cached_result_expr.cache_clear()
1025+
1026+
df = cached_result_expr(project, b_h).execute()
1027+
# Frozen a = {0,1,2} ∪ frozen b = {10,11}. A leaked contextvar would serve the
1028+
# edited {0,1,2,3,4} and/or {10,11,12}.
1029+
assert sorted(df["x"].tolist()) == [0, 1, 2, 10, 11]
1030+
1031+
1032+
def test_cas_child_records_reconstructed_parent_frozen_digest(project, monkeypatch):
1033+
"""#115: building a child records the parent's FROZEN source digest, keeping the
1034+
parent's .cas clone alive for GC.
1035+
1036+
Building a child reconstructs the parent's recipe, re-running the parent's
1037+
from_project. That must record the parent's frozen digest (the bytes actually
1038+
read) into the child's manifest.sources — not a re-digest of the edited live file
1039+
— so gc_cas keeps the frozen clone the child's reconstruction depends on.
1040+
"""
1041+
from tallyman_core import data_dir, entry_dir, read_manifest
1042+
from tallyman_xorq import source_identity as si
1043+
1044+
monkeypatch.setenv("TALLYMAN_PROJECT", project)
1045+
monkeypatch.setenv("TALLYMAN_SOURCE_IDENTITY", "cas")
1046+
assert si.mode() == "cas"
1047+
1048+
src = data_dir(project) / "src.parquet"
1049+
_write_ints(src, [0, 1, 2])
1050+
catalog_create("pp", _src_read_code(project, "src.parquet"))
1051+
p_h = _hash_of(project)
1052+
p_digest = read_manifest(entry_dir(project, p_h)).sources["src.parquet"]
1053+
1054+
_write_ints(src, [0, 1, 2, 3, 4]) # edit in place, then build a child off the parent
1055+
cc_code = "from tallyman_xorq.io import from_catalog\nt = from_catalog('pp')\nexpr = t.mutate(y=t.x * 2)\n"
1056+
res = catalog_create("cc", cc_code)
1057+
assert "error" not in res, res
1058+
c_h = _hash_of(project)
1059+
1060+
child_sources = read_manifest(entry_dir(project, c_h)).sources or {}
1061+
assert child_sources.get("src.parquet") == p_digest
1062+
# Sanity: the frozen digest the child pinned is NOT the edited live digest.
1063+
assert si.digest_for(project, src) != p_digest
1064+
1065+
1066+
def test_off_built_entry_reads_under_cas_default_without_crash(project, monkeypatch):
1067+
"""#115 graceful degradation: an entry built under mode=off (no recorded sources)
1068+
read under the cas default falls through to a live read, not a crash.
1069+
1070+
The digest-pinned hook is a no-op when manifest.sources is None — there is no
1071+
frozen clone to pin — so reconstruction reads the live source as before. (Per the
1072+
single-user rebuild-always policy, rebuilding such an entry under cas is the path
1073+
to pinning; this only pins the no-crash contract.)
1074+
"""
1075+
from tallyman_core import data_dir, entry_dir, read_manifest
1076+
from tallyman_xorq import build_and_persist
1077+
from tallyman_xorq.result_cache import cached_result_expr
1078+
1079+
monkeypatch.setenv("TALLYMAN_PROJECT", project)
1080+
src = data_dir(project) / "src.parquet"
1081+
_write_ints(src, [0, 1, 2])
1082+
1083+
monkeypatch.setenv("TALLYMAN_SOURCE_IDENTITY", "off")
1084+
h = build_and_persist(project, _src_read_code(project, "src.parquet")).content_hash
1085+
assert read_manifest(entry_dir(project, h)).sources is None # off records no sources
1086+
1087+
monkeypatch.setenv("TALLYMAN_SOURCE_IDENTITY", "cas") # flip to the default
1088+
cached_result_expr.cache_clear()
1089+
df = cached_result_expr(project, h).execute() # must not raise; reads the live source
1090+
assert sorted(df["x"].tolist()) == [0, 1, 2]
1091+
1092+
8751093
def test_reset_to_clears_result_plan_memo(project, orders_parquet, monkeypatch):
8761094
"""reset_to retires the on-disk compute cache to the target revision's warm
8771095
set; it must also invalidate the in-process ``_resolve_result_plan`` memo,

0 commit comments

Comments
 (0)