Skip to content

fix(core): attach table generations only to failed reads that read records - #400

Merged
nickruigrok merged 1 commit into
mainfrom
fix/generations-only-after-reads
Oct 11, 2026
Merged

nickruigrok merged 1 commit into
mainfrom
fix/generations-only-after-reads

Conversation

@nickruigrok

@nickruigrok nickruigrok commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Follows up GRA-243 (#394). Fixes GRA-422.

StoreHost.#withGenerations attached the table's generation to every data error a read raised, including data.access_denied from readableTable, raised before any record is read. A handler that caught the refusal then depended on that table: while other callers kept committing to it, readConsistently reran it and answered query.concurrent_change instead of the fallback, or instead of the refusal itself.

Now the host hands the read a counted view of SQLite (RecordSql, exec only) and attaches the generation only when the read ran a statement before it failed. Errors raised before reading (access denied, unknown table or index, invalid query, an index SQLite refused) carry none; errors after reading (not_unique, result_limit, scan_limit, conflict, cursor_expired on a vanished anchor) still carry it, so the store-reader guarantee from #394 holds.

Tests, in workerd through the real store (apps/core/test/store-reader.test.ts):

  • A handler catching data.access_denied while another caller commits to that table each run answers its fallback in one run.
  • An uncaught refusal, with a commit to its table after it, fails with data.access_denied in one run.
  • A read through an index a damaged store lacks, with a commit to its table after it, fails with data.index_required in one run: the read counts as having read only once SQLite accepted the statement.
  • Existing "a read that fails after reading" (not_unique, then count across a commit) still reruns.

Mutation checks: attaching generations regardless of readRecords fails both new tests; never setting readRecords fails the not_unique rerun test and the "failed read's error" test; marking the read before exec instead of after fails the index_required test.

Accepted edge, noted in pagePlans: a cursor carrying only the record ID reads its anchor first, so a later data.index_required on a damaged store carries a generation and may rerun.

…cords

A read refused before it ran a statement (data.access_denied, an unknown
table or index, an invalid query) depended on no record, yet carried its
table's generation, so a handler catching it reran under concurrent
commits and ended in query.concurrent_change. The host now counts a
failed read's table only once the read ran a statement against it.
@nickruigrok
nickruigrok force-pushed the fix/generations-only-after-reads branch from 7b86038 to fd59773 Compare October 11, 2026 12:30
@nickruigrok
nickruigrok marked this pull request as ready for review October 11, 2026 12:31
@nickruigrok

Copy link
Copy Markdown
Contributor Author

@greptileai review

@greptile-apps

greptile-apps Bot commented Oct 11, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

[Medium impact] The PR appears safe to merge; no actionable issues were found.

Summary

The PR attaches table generations to failed reads only after SQLite accepts a record statement.

  • Failed reads count a table only after SQLite accepts a statement.

Acknowledged by nickruigrok: a cursor carrying only a record ID reads its anchor first. A later missing-index refusal therefore keeps its generation and may rerun; this damaged-store edge is intentionally accepted.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Read request] --> B{SQLite accepts a record statement?}
  B -->|No| C[Data error without generations]
  B -->|Yes| D{Read succeeds?}
  D -->|No| E[Data error with table generation]
  D -->|Yes| F[Answer with table generation]
  E --> G[Reader checks for changes]
  F --> G
  C --> H[No dependency added]
Loading

Reviews (1) · Last reviewed commit: "fix(core): attach table generations only..." · Reviewed by Greptile

@nickruigrok
nickruigrok merged commit 3328494 into main Oct 11, 2026
9 checks passed
@nickruigrok
nickruigrok deleted the fix/generations-only-after-reads branch October 11, 2026 12:43
nickruigrok added a commit that referenced this pull request Oct 11, 2026
Follow-ups from the post-merge audit of the operation host (GRA-442,
after #400, #402 and #405). The audit found the composition sound. These
close the gaps it named.

## What changes
1. **The read limits apply per run** (`apps/core/src/operations.ts`).
- Each run of the call's own function gets a fresh read budget, shared
by everything it calls: a query run again for stale reads, or a
mutation's next attempt.
- Before this, reruns spent from one budget. A 100-read query rerun
twice under contention answered the non-retryable `operation.read_limit`
on its third run. A mutation that staged about 700 KB could run out of
`stagedReadMaxBytes` across attempts.
- Nested calls still share their run's budget, so fan-out stays bounded
(`callMaxNested` and the depth limit). The rerun limit bounds the whole
call.
2. **A refused staged write fails the mutation.**
- The failure is reproduced first: a write the schema refuses, not
awaited (`.catch(() => null)`), rejected before the handler settled. The
mutation committed without it and answered ok.
- Now the host remembers the first write it refused, and the mutation
fails with that error, whether or not App code awaited or caught it.
- Staging refusals mean the change set is wrong: invalid fields, an
access denial, an unknown table, too large. A mutation that wants a
conditional write checks before writing.
   - Nested calls and resource requests keep their catchable failures.
3. **The guard limit is enforced at the read.** The read that would
assert a revision for a 101st record now answers `data.guard_limit`.
Before, the mutation ran in full and then failed at commit with an
opaque `data.invalid`.
4. **Cleanup.**
- Two stale comments are corrected (actions do run; `#withGenerations`
became `#readIn`).
   - `callMaxDepth` now comes from `appCallLimits.depth`.
- Refusals that can never fire are gone; a channel without a refusal
falls back to `operation.not_found`.
   - `settle` now cancels its deadline timer once the calls settle.
5. **SDK design notes** (on `ActionCtx`, `packages/sdk/src/server.ts`):
- an action isn't atomic across its calls: a query's reads don't fence a
later mutation;
- derived-key places follow the data: the same mutation at a place with
other arguments answers `submission.key_conflict`.

## Tests (workerd, through the real store and host)
New in `apps/core/test/operations.test.ts`, under "audit follow-ups".
Each one reproduced its finding before the fix:
- a 100-read query rerun twice under contention answers, after 3 runs;
- a mutation staging 6×100 KB with 20 reads answers on its second
attempt;
- a floating refused write fails the mutation with `data.invalid`, and
nothing is stored;
- 100 asserted revisions pass, and 101 answer `data.guard_limit` at the
read.

Mutation-checked: removing the per-run reset fails the first two tests;
ignoring the refused write fails the third; removing the read-time guard
check fails the fourth.

754 targeted tests pass (actions, operations, operation-roles,
data-stores, data-store-receipts, store-reader, store-queries, shared,
sdk), run with at most 3 workers. `vp check` is clean.
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