Skip to content

fix(api): guard api_data's manifest read so a missing manifest doesn't 500 (#90) - #95

Merged
paddymul merged 3 commits into
mainfrom
fix/90-api-data-manifest-guard
Jun 16, 2026
Merged

paddymul merged 3 commits into
mainfrom
fix/90-api-data-manifest-guard

Conversation

@paddymul

Copy link
Copy Markdown
Contributor

Two follow-ups to #92 (which moved /api/data onto cached_result_expr), found while reviewing that PR.

Fix — a missing manifest no longer 500s the read

api_data guards on entry_build_dir(...).is_dir() (app.py:502) but then read manifest.json unguarded for total (app.py:512). build.py creates the build dir (build.py:380-384) before it writes the manifest (build.py:444), so a half-built or pruned entry passes the guard yet has no manifest — read_text() raises FileNotFoundError → 500.

cached_result_expr needs no manifest (it reads the recipe / baked snapshot), so only total depended on it. The read is now guarded: a present manifest gives total as before; a missing one serves the page off the expression with total = 0 rather than crashing. This mirrors the .exists()-then-read pattern api_entry_detail already uses for the latest-entry manifest.

Test — cheap-entry pagination consistency across pages

#92 paginates a cheap (unsorted) entry by re-executing the recipe per page, so two pages tile the result correctly only if the source scan order is stable across executions — the old path sliced a single on-disk result.parquet, stable by construction. The existing cheap-entry test fetched only offset=0; the deep-offset test used an explicit order_by. Neither covered the re-execution stability the new path relies on. test_api_data_cheap_entry_paginates_consistently_across_pages asserts two pages equal the matching slices of a single full read, locking that contract.

TDD

  • test(#90): the missing-manifest reproduction, committed first to fail on CI.
  • fix(#90): the guard.
  • test(#90): the pagination-consistency lock (passes today — characterization).

🤖 Generated with Claude Code

paddymul and others added 3 commits June 16, 2026 14:57
build.py creates an entry's build dir before it writes manifest.json, so a
half-built or pruned entry passes api_data's build-dir check yet has no
manifest. The handler reads manifest.json unguarded for `total`, raising
FileNotFoundError. cached_result_expr needs no manifest, so the page should
still serve with a best-effort total. Test asserts a 200 with rows; fails
today (FileNotFoundError) ahead of the guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t 500

api_data guards on entry_build_dir(...).is_dir() but then read manifest.json
unguarded for `total`. build.py creates the build dir before it writes the
manifest, so a half-built or pruned entry passes the guard yet has no
manifest — read_text() raised FileNotFoundError → 500. cached_result_expr
needs no manifest (recipe / baked snapshot), so only `total` depended on it.
Guard the read: present manifest → row_count as before; missing → serve the
page off the expression with total=0. Mirrors the .exists()-then-read pattern
api_entry_detail uses.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A cheap (unsorted) entry paginates by re-executing the recipe per page (no
baked snapshot — only Aggregate/Join/Sort entries bake one), so two pages
tile the result with no overlap or gap only if the source scan order is
stable across executions. The old path sliced one on-disk result.parquet,
stable by construction. Assert two pages equal the matching slices of a
single full read. Passes today — a characterization lock on the property #92
introduced, not a bug repro.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@paddymul
paddymul merged commit 1f3d827 into main Jun 16, 2026
4 checks passed
paddymul added a commit that referenced this pull request Sep 24, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant