Repository navigation
fix(core): attach table generations only to failed reads that read records - #400
Merged
Merged
Conversation
…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
force-pushed
the
fix/generations-only-after-reads
branch
from
October 11, 2026 12:30
7b86038 to
fd59773
Compare
nickruigrok
marked this pull request as ready for review
October 11, 2026 12:31
Contributor
Author
|
@greptileai review |
|
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follows up GRA-243 (#394). Fixes GRA-422.
StoreHost.#withGenerationsattached the table's generation to every data error a read raised, includingdata.access_deniedfromreadableTable, raised before any record is read. A handler that caught the refusal then depended on that table: while other callers kept committing to it,readConsistentlyreran it and answeredquery.concurrent_changeinstead of the fallback, or instead of the refusal itself.Now the host hands the read a counted view of SQLite (
RecordSql,execonly) 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_expiredon 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):data.access_deniedwhile another caller commits to that table each run answers its fallback in one run.data.access_deniedin one run.data.index_requiredin one run: the read counts as having read only once SQLite accepted the statement.Mutation checks: attaching generations regardless of
readRecordsfails both new tests; never settingreadRecordsfails the not_unique rerun test and the "failed read's error" test; marking the read beforeexecinstead 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 laterdata.index_requiredon a damaged store carries a generation and may rerun.