Skip to content

Commit fc1b444

Browse files
paddymulclaude
andcommitted
fix(diff): crash-safe build expansion + restart-safe diff sessions
Review fixes for the load-expr caching work: - ensure_session gates expansion on a `.xorq_build_expanded.complete` marker written last, so a crash mid-expand re-expands rather than reusing a truncated dir (which fed buckaroo a zero-row build_dir, or raised FileExistsError out of the session lock). - Track diff-compare sessions on BuckarooManager and clear them on a buckaroo restart (same started_at signal as _sessions); a post-restart diff view now re-POSTs instead of returning a dead session id. - api_diff_data logs the full traceback and returns a concise type+message as the error detail. - Bound the result/compare lru_caches; drop a duplicate warmup import; expand_to_tmp delegates to expand_into_dir. New tests (heals_partial_expansion, diff_sessions_invalidated_on_buckaroo_restart) confirmed failing before the fix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 7eba3a4 commit fc1b444

5 files changed

Lines changed: 152 additions & 39 deletions

File tree

src/tallyman_companion/app.py

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -210,12 +210,14 @@ def _compute_disk_usage(project: str) -> dict:
210210
}
211211

212212

213-
@functools.lru_cache(maxsize=None)
213+
@functools.lru_cache(maxsize=128)
214214
def _build_compare_expr(project: str, a_hash: str, b_hash: str, keys: tuple[str, ...]) -> tuple[Path, dict]:
215215
"""Build and cache an xorq outer-join comparison expression for a diff pair.
216216
217217
Keyed by (project, a_hash, b_hash, keys) — both entries are immutable
218218
content-addressed artifacts so the result is stable for the process lifetime.
219+
Bounded so the cache can't grow without limit over a long-lived process;
220+
eviction just rebuilds the (deterministic) comparison on the next view.
219221
220222
Returns (build_path, column_config_overrides).
221223
"""
@@ -235,11 +237,6 @@ def _build_compare_expr(project: str, a_hash: str, b_hash: str, keys: tuple[str,
235237
return build_path, overrides
236238

237239

238-
# Session IDs for which Buckaroo's /load_expr has already succeeded this process.
239-
# Skips the re-POST on repeat diff views for the same pair.
240-
_loaded_diff_sessions: set[str] = set()
241-
242-
243240
def _annotate_entries(project: str, entries: list[dict]) -> list[dict]:
244241
"""Add alias/version fields and sort: named entries (by alias) first, then scratch."""
245242
aliases = load_aliases(project)
@@ -398,7 +395,6 @@ async def _warm_expr_cache():
398395
from starlette.concurrency import run_in_threadpool # noqa: PLC0415
399396

400397
from tallyman_core.paths import catalog_dir # noqa: PLC0415
401-
from tallyman_xorq.result_cache import cached_result_expr # noqa: PLC0415
402398

403399
project = _current_project()
404400
if project is None:
@@ -772,8 +768,6 @@ def diff_default_versions(project: str, alias: str):
772768
@app.get("/{project}/api/diff_data/{alias}/{va:int}/{vb:int}")
773769
def api_diff_data(project: str, alias: str, va: int, vb: int):
774770
"""Diff data as JSON for the React SPA."""
775-
import traceback as _tb # noqa: PLC0415
776-
777771
project = _validate_project(project)
778772
hashes = history_for(project, alias)
779773
if not hashes or va < 1 or vb < 1 or va > len(hashes) or vb > len(hashes):
@@ -801,8 +795,13 @@ def api_diff_data(project: str, alias: str, va: int, vb: int):
801795
b_expr=b_expr,
802796
keys=keys,
803797
)
804-
except Exception:
805-
raise HTTPException(500, detail=_tb.format_exc())
798+
except Exception as exc:
799+
# Full traceback to the server log; a concise type+message to the
800+
# client (the diff UI renders `detail`). Avoids dumping internal
801+
# paths/source if this endpoint ever sits behind anything but
802+
# localhost, while still surfacing the real error.
803+
log.exception("diff_data failed for %s/%s V%d→V%d", project, alias, va, vb)
804+
raise HTTPException(500, detail=f"{type(exc).__name__}: {exc}") from exc
806805

807806
compare_session = None
808807
buckaroo_ws_base_url = None
@@ -815,7 +814,7 @@ def api_diff_data(project: str, alias: str, va: int, vb: int):
815814
build_path, overrides = _build_compare_expr(
816815
project, a_hash, b_hash, tuple(keys)
817816
)
818-
if session_id in _loaded_diff_sessions:
817+
if buckaroo.diff_session_is_loaded(session_id):
819818
compare_session = session_id
820819
buckaroo_ws_base_url = buckaroo.ws_base_url
821820
else:
@@ -838,7 +837,7 @@ def api_diff_data(project: str, alias: str, va: int, vb: int):
838837
timeout=30.0,
839838
)
840839
if resp.status_code == 200:
841-
_loaded_diff_sessions.add(session_id)
840+
buckaroo.mark_diff_session_loaded(session_id)
842841
compare_session = session_id
843842
buckaroo_ws_base_url = buckaroo.ws_base_url
844843
except Exception as exc:

src/tallyman_companion/buckaroo_lifecycle.py

Lines changed: 61 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,10 @@ def __init__(
122122
self._client = httpx.Client(timeout=5.0)
123123
# Schema: {<content_hash>: {"session_id": str, "project": str}}.
124124
self._sessions: dict[str, dict] = {}
125+
# Diff-compare session_ids (``diff-<a>-<b>``) Buckaroo has loaded this
126+
# lifetime. Reset on a Buckaroo restart, same as ``_sessions`` — these
127+
# sessions live only in the subprocess's RAM.
128+
self._loaded_diff_sessions: set[str] = set()
125129
self._session_lock = threading.Lock()
126130
self._buckaroo_started_at: float | None = None
127131
# Tmp dirs we created by expanding ${TALLYMAN_PROJECT_ROOT} placeholders
@@ -224,17 +228,9 @@ def start(self) -> None:
224228
started_at = health.get("started")
225229
log.info("buckaroo ready on %s (started=%s)", self.base_url, started_at)
226230
# If this Buckaroo started fresh (different start time from
227-
# what we last saw), the cached session_ids are stale and
228-
# we drop them so /load runs again on first hit.
229-
if started_at != self._buckaroo_started_at:
230-
if self._sessions:
231-
log.info(
232-
"buckaroo restart detected; invalidating %d cached sessions",
233-
len(self._sessions),
234-
)
235-
self._sessions = {}
236-
self._buckaroo_started_at = started_at
237-
self._persist_sessions()
231+
# what we last saw), its in-RAM sessions are gone — drop our
232+
# bookkeeping so /load_expr runs again on first hit.
233+
self._reset_session_bookkeeping_if_restarted(started_at)
238234
threading.Thread(target=self._drain_stdout, daemon=True).start()
239235
return
240236
except httpx.HTTPError:
@@ -282,6 +278,46 @@ def _cleanup_expanded_dirs(self) -> None:
282278
shutil.rmtree(p, ignore_errors=True)
283279
self._expanded_dirs.clear()
284280

281+
# ------------------------------------------------------------------
282+
# diff-compare session bookkeeping
283+
# ------------------------------------------------------------------
284+
285+
def diff_session_is_loaded(self, session_id: str) -> bool:
286+
"""True if Buckaroo has loaded this diff-compare session this lifetime."""
287+
return session_id in self._loaded_diff_sessions
288+
289+
def mark_diff_session_loaded(self, session_id: str) -> None:
290+
"""Record a successful diff ``/load_expr``.
291+
292+
Dropped on a Buckaroo restart by ``_reset_session_bookkeeping_if_restarted``
293+
— the session exists only in the subprocess's RAM, so a stale entry would
294+
point a client at a session the restarted process never loaded.
295+
"""
296+
self._loaded_diff_sessions.add(session_id)
297+
298+
def _reset_session_bookkeeping_if_restarted(self, started_at) -> None:
299+
"""Drop in-RAM session bookkeeping when a fresh Buckaroo is detected.
300+
301+
Buckaroo's ``/load_expr`` sessions — entry (``_sessions``) and
302+
diff-compare (``_loaded_diff_sessions``) alike — live only in the
303+
subprocess's memory, so a restart (a new ``started_at`` from ``/health``)
304+
invalidates every session_id we've handed out. Clearing both forces a
305+
re-POST on next access; that reload is cheap because the on-disk
306+
stat/result cache survives the restart. Keeping a stale entry instead
307+
would hand a client a dead session.
308+
"""
309+
if started_at == self._buckaroo_started_at:
310+
return
311+
if self._sessions:
312+
log.info(
313+
"buckaroo restart detected; invalidating %d cached sessions",
314+
len(self._sessions),
315+
)
316+
self._sessions = {}
317+
self._loaded_diff_sessions.clear()
318+
self._buckaroo_started_at = started_at
319+
self._persist_sessions()
320+
285321
# ------------------------------------------------------------------
286322
# session map (persisted to disk)
287323
# ------------------------------------------------------------------
@@ -486,10 +522,22 @@ def ensure_session(
486522
# Entries are content-addressed and immutable, so the expansion is
487523
# always correct once written.
488524
expanded = entry_dir(project, content_hash) / ".xorq_build_expanded"
489-
if not expanded.exists():
490-
expanded.mkdir(parents=True, exist_ok=True)
525+
expanded_marker = entry_dir(project, content_hash) / ".xorq_build_expanded.complete"
526+
if not expanded_marker.exists():
527+
# Re-expand whenever the completion marker is absent: either this
528+
# is the first load, or a previous expansion was interrupted
529+
# (crash/kill/OOM) and left a partial dir. The marker is written
530+
# last, so its presence is the only proof every file landed. An
531+
# `exists()` check on the dir itself would treat a truncated
532+
# expansion as complete — feeding buckaroo a build_dir missing
533+
# files (zero rows), or raising FileExistsError out of the lock
534+
# if a subdir landed half-copied — on every subsequent load.
535+
if expanded.exists():
536+
shutil.rmtree(expanded)
537+
expanded.mkdir(parents=True)
491538
from tallyman_xorq.portable import expand_into_dir # noqa: PLC0415
492539
expand_into_dir(build_dir, project_dir(project), expanded)
540+
expanded_marker.write_text("")
493541
stat_cache = entry_dir(project, content_hash) / ".buckaroo_stat_cache"
494542
stat_cache.mkdir(parents=True, exist_ok=True)
495543
payload: dict = {

src/tallyman_xorq/portable.py

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -66,17 +66,7 @@ def expand_into_dir(build_dir: Path, project_root: Path, target: Path) -> None:
6666
def expand_to_tmp(build_dir: Path, project_root: Path) -> Path:
6767
"""Copy `build_dir` to a tmp dir, expanding placeholders. Caller must clean up."""
6868
target = Path(tempfile.mkdtemp(prefix="tallyman_load_"))
69-
project_root_str = str(project_root)
70-
for item in build_dir.iterdir():
71-
if item.is_dir():
72-
shutil.copytree(item, target / item.name)
73-
continue
74-
try:
75-
text = item.read_text()
76-
except UnicodeDecodeError:
77-
shutil.copyfile(item, target / item.name)
78-
continue
79-
(target / item.name).write_text(text.replace(PLACEHOLDER, project_root_str))
69+
expand_into_dir(build_dir, project_root, target)
8070
return target
8171

8272

src/tallyman_xorq/result_cache.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,12 +86,14 @@ def cache_worthy(project: str, content_hash: str) -> bool:
8686
return classify_build(entry_dir(project, content_hash) / "xorq_build")["worthy"]
8787

8888

89-
@functools.lru_cache(maxsize=None)
89+
@functools.lru_cache(maxsize=16)
9090
def result_cache(project: str):
9191
"""A ParquetSnapshotCache rooted under the project's result_cache dir.
9292
9393
One connection per project per process — lru_cache ensures xo.connect() is
9494
called once rather than on every cached_result_expr / ensure_result call.
95+
Bounded (a process touches only a handful of projects); an evicted project
96+
just reopens its connection on next use.
9597
"""
9698
import xorq.api as xo
9799

@@ -102,10 +104,14 @@ def result_cache(project: str):
102104
return xo.ParquetSnapshotCache.from_kwargs(source=xo.connect(), base_path=base)
103105

104106

105-
@functools.lru_cache(maxsize=None)
107+
@functools.lru_cache(maxsize=256)
106108
def cached_result_expr(project: str, content_hash: str):
107109
"""The entry's result as an expression that resolves its own materialisation.
108110
111+
Bounded so the cache can't grow without limit across a long-lived process;
112+
entries are content-addressed, so an evicted hash rebuilds an identical
113+
expression on the next access.
114+
109115
* Expensive entry → its expression wrapped in the snapshot cache: a hit
110116
reads the cached parquet, a miss recomputes from the build and stores.
111117
* Cheap entry → a read of its in-place ``result.parquet`` (the

tests/test_buckaroo.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,76 @@ def json(self):
318318
assert expanded.is_dir()
319319

320320

321+
def test_unit_ensure_session_heals_partial_expansion(project: str, orders_parquet: Path, monkeypatch):
322+
"""A crash mid-expansion must not poison the stable expanded dir forever.
323+
324+
The expansion writes files into .xorq_build_expanded; a kill/OOM partway
325+
leaves a half-written dir. The old ``if not expanded.exists()`` guard then
326+
treated that truncated dir as complete and fed buckaroo a build_dir missing
327+
files (zero rows), or — if a subdir landed half-copied — raised
328+
FileExistsError out of the lock on every later load. ensure_session must key
329+
off a completion marker written last, and re-expand when it's absent.
330+
"""
331+
res = build_and_persist(project, _code(project))
332+
333+
mgr = BuckarooManager()
334+
mgr.bound_port = 65000
335+
mgr.proc = type("FakeProc", (), {"poll": staticmethod(lambda: None)})()
336+
337+
class FakeResponse:
338+
def raise_for_status(self):
339+
pass
340+
341+
def json(self):
342+
return {"session": "s"}
343+
344+
monkeypatch.setattr(mgr._client, "post", lambda *a, **kw: FakeResponse())
345+
346+
# Simulate an interrupted expansion: a dir with stray contents, no marker.
347+
expanded = entry_dir(project, res.content_hash) / ".xorq_build_expanded"
348+
marker = entry_dir(project, res.content_hash) / ".xorq_build_expanded.complete"
349+
expanded.mkdir(parents=True)
350+
(expanded / "stale_partial.txt").write_text("interrupted")
351+
assert not marker.exists()
352+
353+
mgr.ensure_session(res.content_hash, project)
354+
355+
# Healed: the stray file is gone, the marker exists, and every file from the
356+
# source build_dir was expanded in.
357+
assert marker.exists()
358+
assert not (expanded / "stale_partial.txt").exists()
359+
build_dir = entry_dir(project, res.content_hash) / "xorq_build"
360+
for item in build_dir.iterdir():
361+
assert (expanded / item.name).exists(), item.name
362+
363+
364+
def test_unit_diff_sessions_invalidated_on_buckaroo_restart(project: str):
365+
"""A buckaroo restart must drop loaded diff-compare sessions.
366+
367+
Buckaroo's /load_expr sessions live only in the subprocess's RAM, so a
368+
restart (fresh ``started_at`` from /health) invalidates every session_id —
369+
entry sessions (``_sessions``) and diff-compare sessions alike. The diff
370+
bookkeeping used to be a module global that nothing reset, so a post-restart
371+
diff view skipped the re-POST and handed the client a session the new
372+
process never loaded.
373+
"""
374+
mgr = BuckarooManager()
375+
mgr._buckaroo_started_at = 1000.0
376+
mgr._sessions = {"h": {"session_id": "s", "project": project}}
377+
mgr.mark_diff_session_loaded("diff-aaaa-bbbb")
378+
assert mgr.diff_session_is_loaded("diff-aaaa-bbbb")
379+
380+
# Same started_at → not a restart → bookkeeping preserved.
381+
mgr._reset_session_bookkeeping_if_restarted(1000.0)
382+
assert mgr.diff_session_is_loaded("diff-aaaa-bbbb")
383+
assert mgr._sessions
384+
385+
# Fresh started_at → restart → entry and diff sessions both dropped.
386+
mgr._reset_session_bookkeeping_if_restarted(2000.0)
387+
assert not mgr.diff_session_is_loaded("diff-aaaa-bbbb")
388+
assert mgr._sessions == {}
389+
390+
321391
def test_unit_stop_cleans_many_expanded_dirs(project: str):
322392
"""Leak guard: stop() must clean every tracked tmp dir, not just one.
323393

0 commit comments

Comments
 (0)