Skip to content

Commit 1f8d7b1

Browse files
authored
Merge pull request #124 from buckaroo-data/fix/80-96-reset-invalidates-result-plan-lru
fix(#80,#96): invalidate the in-process result-plan & compare-expr LRUs on reset
2 parents caff922 + 717c323 commit 1f8d7b1

3 files changed

Lines changed: 184 additions & 0 deletions

File tree

src/tallyman_companion/app.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,27 @@ def _build_compare_expr(project: str, a_hash: str, b_hash: str, keys: tuple[str,
212212
return build_path, overrides
213213

214214

215+
def _invalidate_reset_caches() -> None:
216+
"""Drop the companion's process-global result/compare caches after a reset.
217+
218+
A reset prunes baked snapshots (``catalog_state.reset_to`` →
219+
``prune_compute_cache``). ``_build_compare_expr`` serializes those snapshot
220+
paths into a build with no per-call ``exists()`` recheck, so a warmed diff
221+
pair would otherwise serve a build over a parquet the prune deleted —
222+
buckaroo reads zero files → empty compare grid (#80). ``cached_result_expr``
223+
self-heals on every read so clearing its memo is hygiene, not load-bearing,
224+
but we clear it for parity. Blunt global clear is correct and cheap: entries
225+
are content-addressed, so the next call rebuilds an identical expression.
226+
227+
Called from BOTH reset paths — the in-server ``/api/reset`` and the
228+
cross-process CLI ``reset-to`` that arrives as a ``project_reset`` on
229+
``/internal/notify`` — so the two can't drift. (The in-server path having the
230+
clear while the notify path lacked it was exactly #80's "Communication gap".)
231+
"""
232+
_build_compare_expr.cache_clear()
233+
cached_result_expr.cache_clear()
234+
235+
215236
def _annotate_entries(project: str, entries: list[dict]) -> list[dict]:
216237
"""Add alias/version fields and sort: named entries (by alias) first, then scratch."""
217238
aliases = load_aliases(project)
@@ -1135,6 +1156,10 @@ async def api_reset(project: str, payload: dict):
11351156
await run_in_threadpool(reset_to, project, ref)
11361157
except RuntimeError as exc:
11371158
raise HTTPException(404, str(exc))
1159+
# reset_to ran in-process and cleared the result-plan memo; the
1160+
# compare-expr LRU is a companion-layer cache it can't reach, so invalidate
1161+
# the companion caches here (#80). Shared with the cross-process notify path.
1162+
_invalidate_reset_caches()
11381163
reloaded = buckaroo.reload_project_sessions(project) if buckaroo else 0
11391164
step = current_step(project)
11401165
await publish({"kind": "project_reset", "step": step})
@@ -1157,6 +1182,13 @@ async def notify(payload: NotifyPayload):
11571182
getattr(payload, "hash", None),
11581183
)
11591184
if payload.kind in ("post_processing_changed", "summary_stat_changed", "display_changed", "project_reset"):
1185+
if payload.kind == "project_reset":
1186+
# Out-of-process CLI `reset-to` ran reset_to in the CLI process, so
1187+
# this long-lived companion's result/compare LRUs are still warm and
1188+
# may point at snapshots the prune deleted. Clear them here, matching
1189+
# the in-server /api/reset path (#80). Not gated on `buckaroo`: the
1190+
# LRUs are process memory independent of the buckaroo subprocess.
1191+
_invalidate_reset_caches()
11601192
if buckaroo:
11611193
reloaded = buckaroo.reload_project_sessions(project_name)
11621194
log.info("reloaded klasses in %d buckaroo session(s) for project %s", reloaded, project_name)

src/tallyman_core/catalog_state.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,20 @@ def reset_to(project: str, ref: int | str) -> None:
344344
restore_from_bullpen(project)
345345
_gc_cas_clones(project)
346346
catalog.assert_catalog_consistent(project, set(read_tallyman_state(project)["entry_hashes"]))
347+
# The prune above retires baked snapshots on disk. Clear the in-process
348+
# result-plan memo that resolved their paths: #96's literal complaint is that
349+
# the LRU outlives the prune, so drop it. For the dangling-read *symptom* this
350+
# clear is belt-and-suspenders — cached_result_expr re-checks path.exists()
351+
# and self-heals on every read (ee0a90a), so its own reads already survive a
352+
# prune. The load-bearing protection against the symptom is the companion's
353+
# _build_compare_expr clear (#80): that is the one LRU that bakes the resolved
354+
# path into a serialized build with no per-call recheck, and it lives in the
355+
# companion layer (see app._invalidate_reset_caches), not here. Blunt global
356+
# clear is correct and cheap: entries are content-addressed, so the next read
357+
# rebuilds an identical plan. Lazy import avoids a core->xorq import cycle.
358+
from tallyman_xorq.result_cache import cached_result_expr # noqa: PLC0415
359+
360+
cached_result_expr.cache_clear()
347361

348362

349363
def _gc_cas_clones(project: str) -> int:

tests/test_result_cache.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -870,3 +870,141 @@ def _just_raise():
870870
with pytest.raises(FileNotFoundError):
871871
cached_result_expr(project, agg_h)
872872
assert not p.exists()
873+
874+
875+
def test_reset_to_clears_result_plan_memo(project, orders_parquet, monkeypatch):
876+
"""reset_to retires the on-disk compute cache to the target revision's warm
877+
set; it must also invalidate the in-process ``_resolve_result_plan`` memo,
878+
which holds reconstructed plans — including the baked snapshot paths the
879+
prune can delete (#80 / #96). A reactive reset layer needs this cache
880+
invalidated alongside the prune, not left to ``cached_result_expr``'s
881+
per-call self-heal: ``_build_compare_expr`` bakes a cached snapshot path with
882+
no per-call existence re-check, so a stale plan compounds downstream.
883+
"""
884+
from tallyman_core import catalog_state as cs
885+
from tallyman_xorq.result_cache import cached_result_expr
886+
887+
monkeypatch.setenv("TALLYMAN_PROJECT", project)
888+
catalog_create("agg", _agg_code(project))
889+
agg_h = _hash_of(project)
890+
step = cs.current_step(project)
891+
892+
cached_result_expr(project, agg_h).execute() # warm the memo
893+
assert cached_result_expr.cache_info().currsize >= 1
894+
895+
cs.reset_to(project, step)
896+
assert cached_result_expr.cache_info().currsize == 0
897+
898+
899+
def test_reset_endpoint_clears_compare_expr_memo(fresh_companion_app, project, orders_parquet, monkeypatch):
900+
"""The diff compare-expr LRU (``_build_compare_expr``) caches a serialized
901+
build that bakes in each entry's baked snapshot path and — unlike
902+
``cached_result_expr`` — never re-checks ``path.exists()`` on a hit. A reset
903+
that prunes a snapshot would otherwise leave that cached build pointing at a
904+
deleted parquet, so ``/api/reset`` must clear it alongside the result-plan
905+
memo (#80).
906+
"""
907+
from fastapi.testclient import TestClient
908+
909+
from tallyman_companion.app import _build_compare_expr
910+
from tallyman_core import catalog_state as cs
911+
from tallyman_core.aliases import history_for
912+
913+
monkeypatch.setenv("TALLYMAN_PROJECT", project)
914+
catalog_create("shoe_sales", _agg_code(project))
915+
step = cs.current_step(project)
916+
catalog_revise("shoe_sales", _agg_avg_code(project))
917+
a_h, b_h = history_for(project, "shoe_sales")
918+
919+
_build_compare_expr(project, a_h, b_h, ("region",)) # warm the compare memo
920+
assert _build_compare_expr.cache_info().currsize == 1
921+
922+
c = TestClient(fresh_companion_app)
923+
r = c.post(f"/{project}/api/reset", json={"ref": step})
924+
assert r.status_code == 200, r.text
925+
assert _build_compare_expr.cache_info().currsize == 0
926+
927+
928+
def test_notify_project_reset_evicts_compare_build_over_pruned_snapshot(
929+
fresh_companion_app, project, orders_parquet, monkeypatch
930+
):
931+
"""Load-bearing #80/#96 regression — stronger than ``currsize == 0``: it
932+
proves the cross-process ``project_reset`` notify evicts a compare build that
933+
would otherwise *execute over a deleted snapshot*, not merely that the LRU is
934+
emptied.
935+
936+
``reset-to`` is normally run out of process (the ``tallyman reset-to`` CLI):
937+
the CLI runs ``reset_to`` — which prunes snapshots — in its own short-lived
938+
process, then signals the long-lived companion via ``POST /internal/notify
939+
{kind: project_reset}``. ``_build_compare_expr`` froze each entry's baked
940+
snapshot path into a serialized build with no per-call ``exists()`` recheck,
941+
so the companion's warm build now points at a parquet the prune deleted.
942+
Executing that stale build raises ``ValueError: At least one path is
943+
required`` (the #73/#74 dangling-read symptom). ``reset_to`` itself clears
944+
only ``cached_result_expr`` (which self-heals anyway), never this
945+
companion-layer LRU — so without the notify-path clear the gap is real and
946+
the next diff view serves the broken build to buckaroo.
947+
948+
The flow mirrors production: ``cs.reset_to`` stands in for the CLI process's
949+
prune (it does NOT reach the companion LRU), then the ``/internal/notify``
950+
POST is the signal that must close the gap.
951+
"""
952+
import pytest
953+
from fastapi.testclient import TestClient
954+
from xorq.ibis_yaml.compiler import load_expr
955+
956+
from tallyman_companion.app import _build_compare_expr
957+
from tallyman_core import catalog_state as cs
958+
from tallyman_core.aliases import history_for
959+
from tallyman_xorq.result_cache import baked_snapshot_path
960+
961+
monkeypatch.setenv("TALLYMAN_PROJECT", project)
962+
catalog_create("shoe_sales", _agg_code(project))
963+
step = cs.current_step(project) # only v1 is warm at this step
964+
catalog_revise("shoe_sales", _agg_avg_code(project))
965+
a_h, b_h = history_for(project, "shoe_sales")
966+
967+
build_path, _ = _build_compare_expr(project, a_h, b_h, ("region",)) # warm + freeze b_h's path
968+
snap_b = baked_snapshot_path(project, b_h) # capture BEFORE the prune deletes it
969+
assert snap_b is not None and snap_b.exists()
970+
971+
# Out-of-process CLI work: reset_to prunes (retires b_h's snapshot) but does
972+
# not reach the companion's _build_compare_expr LRU — the stale build stays.
973+
cs.reset_to(project, step)
974+
assert not snap_b.exists() # the prune deleted b_h's snapshot
975+
assert _build_compare_expr.cache_info().currsize == 1 # stale build survives reset_to
976+
977+
# The stale build is genuinely broken: it executes over the deleted parquet.
978+
with pytest.raises(ValueError, match="At least one path is required"):
979+
load_expr(str(build_path)).execute()
980+
981+
# The notify signal closes the gap — it must evict that broken build so the
982+
# next /api/diff_data can't POST a dangling build_dir to buckaroo.
983+
c = TestClient(fresh_companion_app)
984+
r = c.post("/internal/notify", json={"kind": "project_reset", "project": project})
985+
assert r.status_code == 200, r.text
986+
assert _build_compare_expr.cache_info().currsize == 0
987+
988+
989+
def test_notify_project_reset_clears_result_plan_memo(fresh_companion_app, project, orders_parquet, monkeypatch):
990+
"""Parity with the in-server reset: the cross-process ``project_reset`` notify
991+
clears the companion's ``cached_result_expr`` memo too. Hygiene rather than
992+
correctness — ``cached_result_expr`` self-heals on every read — but both reset
993+
paths share one invalidation helper, so the notify path clears it alongside
994+
the compare-expr LRU (#80).
995+
"""
996+
from fastapi.testclient import TestClient
997+
998+
from tallyman_xorq.result_cache import cached_result_expr
999+
1000+
monkeypatch.setenv("TALLYMAN_PROJECT", project)
1001+
catalog_create("agg", _agg_code(project))
1002+
agg_h = _hash_of(project)
1003+
1004+
cached_result_expr(project, agg_h).execute() # warm the memo
1005+
assert cached_result_expr.cache_info().currsize >= 1
1006+
1007+
c = TestClient(fresh_companion_app)
1008+
r = c.post("/internal/notify", json={"kind": "project_reset", "project": project})
1009+
assert r.status_code == 200, r.text
1010+
assert cached_result_expr.cache_info().currsize == 0

0 commit comments

Comments
 (0)