@@ -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