Repository navigation
fix(cache): a read refuses a directory with no manifest instead of guessing its worthiness from the snapshot (#204) - #245
Conversation
…ed by its snapshot's presence (#204) cache_worthy fell back to "a snapshot exists" when the manifest was missing, so a worthy entry that had lost both was read as cheap and a child of it failed with "still missing after it was made again". A directory without a manifest is not an entry (ADR-007 D6), so these tests ask every read to refuse one and name the rebuild: - cache_worthy, for a worthy or cheap entry with or without a file at the snapshot path, and for a hash whose directory a reset retired; - cached_result_expr and ensure_materialized, for a worthy and a cheap entry, until the recipe runs again; - a child that reads the entry, and a new child built over it; - a source version that lost its manifest and snapshot (today a RecursionError); - the /api/data, /api/entry and /api/entry_cache routes (404 with the reason); - the #193 half-built retry test, which read the entry through the old fallback. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…essing its worthiness from the snapshot (#204) cache_worthy fell back to "a snapshot exists" when the manifest was missing. A worthy entry that had lost both was read as cheap: every read re-ran its aggregate, nothing healed the file, and a child of it failed with "still missing after it was made again". A source version in that state recursed without bound, and a cheap entry with a file at its snapshot path was reported worthy. A directory without a manifest is not an entry (ADR-007 D6), and every writer already treats it as absent. The manifest also holds the digest a heal is checked against, the pin and a source version's provenance, so recomputing the verdict would not make the entry servable. So: - result_cache.entry_manifest returns the manifest or raises NotAnEntryError (a BuildError), whose message names the rebuild: run the recipe in expr.py again with catalog_run, or import the file again for a source version. - cache_worthy reads the verdict through it and never looks at the snapshot path, so ensure_materialized, cached_result_expr, baked_snapshot_path, load_session and primary-key inheritance refuse such a directory before loading or writing anything. - _recreate adds the child's hash, so a child's read names the entry to build again. - /api/data, /api/entry and /api/entry_cache answer 404 with the message; /api/data no longer serves the rows with a total of 0 (#95). Adds a guard test that a complete entry's verdict is the manifest's whatever is at the snapshot path; it passes on the old code too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…dvice and no route or wrapper softening it (#204) An entry directory without a manifest is corrupt. The read raises "entry <hash> in <project> has no manifest.json: <dir>" and nothing else: no rebuild instructions, no separate message for a missing directory, no 404 mapping on the routes, no child hash prepended by _recreate. The notebook page errors too, where it used to render the cell with no metadata and a row count of 0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… into fix/204-worthiness-without-manifest # Conflicts: # src/tallyman_companion/app.py
… and nothing works around it (#204) entry_manifest wraps the FileNotFoundError in a BuildError that says what is missing and where. It carries no rebuild instructions, and a missing directory gets the same message. NotAnEntryError is gone. Removed the code that softened or worked around the error: - _require_manifest and its 404 mapping on /api/data, /api/entry and /api/entry_cache. These routes answer 500 now. - _recreate's rewrap, which prefixed the child's hash. A child read raises its parent's error unchanged. - _snapshot_cache_path's catch-all, and _compute_entry_cache's worthy=False and manifest={} fallbacks. - The notebook cell's manifest-exists guard, which rendered the cell with no metadata and 0 rows. - _load_timeout's exists check and bare except, which used 0 rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of
|
| # | Finding | Outcome |
|---|---|---|
| 1 | diff_keys(V2, V3) fails when an older version V1, which isn't part of the diff, has lost its manifest, because primary-key inheritance calls cache_worthy(V1) (probe-confirmed) |
Kept as an error, by decision. The suggested fix, treating V1 as having no inherited key and detecting one, works around the corrupt entry. |
| 2 | The advice to rebuild with catalog_run overwrites a pinned or unreproducible snapshot |
Fixed in 7ebeaf3. The error has no advice. |
| 3 | A NotAnEntryError from further down (a parent, a diff, promote-diff) became a 500 while the entry's own became a 404 (probe-confirmed) |
Fixed in 7ebeaf3. The 404 mapping is removed, so every route answers 500. |
| 4 | The catalog_run advice loops for a recipe that reads its own alias, and builds a different hash once a parent alias has moved on |
Fixed in 7ebeaf3. The advice is removed. |
| 5 | A cheap child is served while a manifest-less parent's snapshot is on disk, and fails once the file is gone (probe-confirmed) | Not in this PR. It's listed under "Not in this PR" in the description. |
| 6 | For a source version, the advice first offers catalog_run on a recipe that can't run |
Fixed in 7ebeaf3. The advice is removed. |
| 7 | assert "import" in str(info.value) could not fail |
Fixed in 67648c0. It's an exact-message assertion now. |
| 8 | The 404 detail sent absolute filesystem paths to the client | Fixed in 7ebeaf3. The 404 is gone, and a 500 carries no detail. |
| 9 | _recreate re-raised a parent's refusal as NotAnEntryError on a complete child, and added a prefix at each level |
Fixed in 7ebeaf3. The rewrap and the class are removed, so the child raises the parent's error unchanged (tested in 67648c0). |
| 10 | load_session reports the state as the retryable status error |
Declined. It's an error and is reported as one. A separate terminal status would be special handling for it. |
| 11 | _recreate's missing-parent-directory check duplicates a branch of entry_manifest |
Declined. The check was already on the base. entry_manifest no longer has a separate missing-directory branch. |
| 12 | The routes threw away the manifest _require_manifest returned, and _compute_entry_cache kept fallbacks that could no longer be reached |
Fixed in 7ebeaf3. _require_manifest is removed, and so are the worthy=False and manifest={} fallbacks and _snapshot_cache_path's catch-all. |
| 13 | The build fills entries/<hash> in place, so an interrupted build can reach this state. Build into a staging directory and rename it into place |
Not in this PR. It's listed under "Not in this PR". |
| 14 | Warm-up logs the full multi-sentence refusal for each manifest-less directory on every start | Partly addressed in 7ebeaf3. The message is one line now, and warm-up still logs it, because it's an error. |
The same pass also removed two guards the review didn't list, both in 7ebeaf3. The notebook cell rendered with no metadata and 0 rows when its entry had no manifest (/api/notebook_full answers 500 now, tested in 67648c0). _load_timeout had an exists check and a bare except that used 0 rows.
🤖 Generated with Claude Code
…implemented Brings in #242, #244, #245 and #241. Resolves docs/installing.md: keeps this branch's environment table (with TALLYMAN_COMPANION_URL now read from the data dir's server.lock) and its troubleshooting list, with #241's two bullets in place of the old port-7860 one, and without the stray tags this branch had already removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Stacked on #189: the base is
feat/adr-007-009-cache-redesign, notmain.Fixes #204
This closes the issue only once #189 reaches
main.Terms: a worthy entry is one tallyman materializes. Its rows are written once to its snapshot,
compute_cache/result_cache/<content_hash>.parquet. A cheap entry has no file of its own and re-runs its small plan on every read. The verdict is decided once, at build, byworthiness.classify_expr, and recorded in the entry's manifest (ADR-008 D4). A heal re-creates a missing snapshot and checks it against the manifest'sresult_digest(ADR-007 D5).Problem
cache_worthy(result_cache.py:40-55) read the manifest and, when it was missing, returned whether the snapshot existed. On1f8cb02, with a probe and then the tests below:cached_result_exprreturned its loaded build (the canonicalSortover theAggregate), so every read re-ran the aggregate, and nothing wrote the snapshot again.materialize.py:296). A new child built over it inlined the aggregate as if it were cheap.RecursionError. Its build reads its own snapshot, and with no manifest it had no provenance to heal from, so_recreaterecursed on it./api/dataserved a manifest-less entry's rows with a total of 0 (fix(api): guard api_data's manifest read so a missing manifest doesn't 500 (#90) #95), and/api/entryand/api/entry_cacheanswered 500 on the manifest read.The choice: reading an entry with no manifest is an error
An entry directory without a manifest is corrupt. Reading one raises a single plain error, and nothing in the read path catches it, softens it, or tells the caller how to repair it. The issue suggested classifying the loaded build when the manifest is missing. This PR doesn't, for these reasons:
result_digesta heal cannot be checked (_verify_self_healreturns early when nothing is recorded), so a healed snapshot would be served unverified, which ADR-007 D5 rules out. Withoutreproducibleandunfaithful_heal_digestthere is no pin. Withoutprovenancea source version cannot be healed from its clone, which is theRecursionErrorabove. Recomputing the verdict would fix the first and leave the other three.1f8cb02,classify_expr(load_entry_expr(...))agreed with the recorded verdict for an aggregate, a filter, a cheap child,random(),now(),row_number()and a source version. It would still be a second place that decides the verdict.The first version of this PR raised a
NotAnEntryErrorwhose message explained the state and named a rebuild (catalog_runwith the entry'sexpr.py, or a re-import). The three single-entry routes mapped it to a 404, and_recreateprefixed the child's hash. Review showed the advice was wrong in several cases: it would overwrite a pinned snapshot, it loops for a recipe that reads its own alias, and it builds a different hash once a parent alias has moved on. All of that is gone. The error says what is missing and where:Fix
result_cache.entry_manifest(project, hash)returns the manifest, or raises aBuildErrorwith the message above whenmanifest.jsonis not there. A hash with no directory at all gets the same message.cache_worthyreturns the manifest's verdict throughentry_manifest, and nothing else. It never looks at the snapshot path.ensure_materialized,cached_result_expr,baked_snapshot_path,load_sessionand primary-key inheritance all go through it, so they raise before loading or writing anything._recreatedoes not catch or rewrap it./api/data,/api/entry,/api/entry_cacheand/api/notebook_fullanswer 500. There is no 404 mapping./api/datano longer serves the page with a total of 0, which reverses what fix(api): guard api_data's manifest read so a missing manifest doesn't 500 (#90) #95 did for this case._snapshot_cache_path's catch-all,_compute_entry_cache'sworthy=Falseandmanifest={}fallbacks, the notebook cell's manifest-exists check (which rendered the cell with no metadata and 0 rows), and_load_timeout's exists check and bareexcept(which used 0 rows).Unchanged: enumeration still skips a directory without a manifest (
list_entries, the checkpoint,catalog_state._retire,recalcroots). That skip defines what is listed as an entry, and it has to pass over a build that is still running.load_entry_exprandmaterializedon't read the manifest, since a create calls them before it writes one. The startup warm-up catches errors per entry, so it logs the one-line error for a manifest-less directory. No new.execute()orto_pyarrow_batches()site is added insrc/(for #118).Tests
First red commit,
d45bee8, all failing on1f8cb02(names as67648c0left them):test_result_cache.py::test_cache_worthy_errors_on_an_entry_with_no_manifest, over a worthy and a cheap entry, each with and without a file at the snapshot path. It failed withDID NOT RAISEin all four cases.test_result_cache.py::test_cache_worthy_errors_on_a_hash_whose_entry_dir_is_gone: a retired entry's snapshot is not served. It failed withDID NOT RAISE.test_materialize.py::test_reading_an_entry_that_lost_its_manifest_is_an_error_until_its_recipe_runs_again, for a worthy and a cheap entry:cached_result_exprandensure_materializedraise, no snapshot is written, and running the recipe again by hand restores the entry under the same hash. It failed withDID NOT RAISE.test_materialize.py::test_a_child_of_an_entry_that_lost_its_manifest_raises_that_entrys_error, and a new child's build fails. It failed on the "still missing after it was made again" message.test_source_import.py::test_reading_a_source_version_that_lost_its_manifest_and_snapshot_is_an_error, which failed withRecursionError. A re-import by hand writes the version again.test_companion.py::test_entry_routes_error_on_an_entry_with_no_manifest, overdata,entryandentry_cache. It replacestest_api_data_missing_manifest_serves_page_without_500, which asserted the fix(api): guard api_data's manifest read so a missing manifest doesn't 500 (#90) #95 behaviour this PR reverses.test_materialize.py::test_a_failed_retry_of_a_half_built_entry_keeps_its_snapshot(a failed build deletes the snapshot already on disk for its hash — a reset-retired or crashed entry's only copy is lost #193) read the half-built entry through the old fallback. That line now expects the error.Second red commit,
67648c0, after review, against the first fixc3d7f8b. Every message check became an exact match on the one-line error, so rebuild advice, a separate missing-directory message or a prefixed child hash fails it. The routes expect 500 where the first fix answered 404.test_notebook.py::test_notebook_route_errors_when_a_cells_entry_has_no_manifestis new: the notebook answered 200 with an empty cell.Guard test in the first fix commit:
test_cache_worthy_is_the_manifests_verdict_whatever_is_at_the_snapshot_path. A worthy entry with its snapshot deleted is still worthy, and a cheap entry with a file at its snapshot path is still cheap. It passes on the old code too.How it was built
d45bee8adds the failing tests. CI run 36017486525: 13 failed and 952 passed, exactly the cases above.c3d7f8brefuses the directory with the explanatoryNotAnEntryError. CI run 36018443637 passed.c3d7f8b. The decision was that a read of a manifest-less entry is a plain error with no remedy and no code working around it.67648c0changes the tests to that.e7feef7merges the moved base in, because the PR conflicted with it and GitHub runs nopull_requestworkflow for a conflicting PR. The conflict was one hunk inapi_data: it keeps the manifest read and adds caching: concurrent .execute() on the shared default backend raises "Already borrowed" — api_data has no execute lock (second cause of #79 two-tab 500) #118'sexecution_lockaround the page's execute. CI run 36044243816: 13 failed and 967 passed, and the 13 are the tests from67648c0.7ebeaf3is the fix: 31 lines added and 91 removed. CI run 36044977363: ruff, the fast suite (997 passed, and vitest 15 passed) and the integration suite (7 passed) all pass. The fast count is 17 more than locally because the run tests the merge with the base, which had taken fix(csv): a zone on offset-less CSV text is attached to the wall-clock time (#231) #244 by then.Checked locally at
7ebeaf3: the fast suite had 980 passed, the integration suite 7 passed, anduvx ruff checkpassed.Not in this PR
entries/<hash>in place and writes the manifest last, so a Ctrl-C or a crash mid-build leaves the directory this PR treats as corrupt. Building into a staging directory and renaming it intoentries/after the manifest is written would mean only a hand-deleted manifest can produce it._ensure(child)only goes to the parent when a file its plan reads is missing, so with the parent's snapshot on disk the child is served without the parent's manifest being read. With the file gone it raises the parent's error.docs/architecture-new.mdlives ondocs/adr-007-009-implementedand should go when that branch is next updated.ruff formatwould reformat a few lines inbuild.py,result_cache.py,test_result_cache.pyandtest_source_import.pythat were already unformatted on the base. The repo has no.pre-commit-config.yaml, so I formatted only the lines this PR touches.🤖 Generated with Claude Code