Skip to content

feat(core): validate a query's reads by table generations, rerunning up to three times - #394

Merged
nickruigrok merged 3 commits into
mainfrom
feat/store-read-consistency
Oct 11, 2026
Merged

nickruigrok merged 3 commits into
mainfrom
feat/store-read-consistency

Conversation

@nickruigrok

Copy link
Copy Markdown
Contributor

Fifth and last stacked PR for GRA-243. Stacked on #391 and #393; this PR's own change is the last commit.

What

Spec 6.3: a multi-read query validates its dependency generations when it completes, reruns up to three times, then returns the retryable query.concurrent_change. It is never answered from an inconsistent read set.

  • Table generations (sdk_tables.generation, migration 0005). Every commit that changes a table's records moves its generation on once. A commit that changes nothing, or changes other tables, doesn't.
  • Reads answer their dependencies. StoreHost.read returns { value, generations }, the table's generation read in the same transaction as the answer. StoreHost.generations reports the current generations by name.
  • StoreReader (apps/core/src/store-reader.ts) is the host-side per-invocation reader from decision D1-A. It records the generation each read saw (two reads of one table at different generations make the set stale), and it records get revision assertions on found records as guards for GRA-244's commit.
  • readConsistently(store, scope, operation) runs the operation with a fresh reader and answers only once isCurrent() holds. It allows 1 run plus 3 reruns (queryMaxReruns), then throws query.concurrent_change with details: { retryable: true }.
  • Invalidation: a read that matched nothing still depends on its table, so an insert invalidates an empty query.
  • RPC: a read's result crosses RPC as JSON text. RPC types can't carry its documents nested beside the generations; openStore validates the shape.

Boundary

GRA-244 wraps query handlers in readConsistently, builds ctx.db with databaseReader(schema, (request) => reader.read(request)) and hands reader.guards() to its commit. Live-query delivery of invalidations is a later slice.

Tests

apps/core/test/store-reader.test.ts, in workerd through OpenStore and the DO. Commits land between reads through the store, exactly as another caller's would.

  • One run when nothing changes.
  • A commit between two reads gives a second run, answered from a consistent state, never the first run's.
  • Persistent contention gives query.concurrent_change, retryable, after 4 runs.
  • An empty query goes stale after an insert.
  • Other-table and no-op commits keep reads current; a real change doesn't.
  • Guards are recorded only for found records that asserted a revision.

Results: vp check is clean. vp test on store-reader, store-cursors, store-queries, store-indexes, store-expressions, the data-store suites, SDK database and shared store-queries passes 177 tests.

@nickruigrok
nickruigrok force-pushed the feat/store-read-consistency branch 2 times, most recently from c7e0b29 to 340b491 Compare October 11, 2026 03:16
nickruigrok added a commit that referenced this pull request Oct 11, 2026
…rd each record once

From the review of #394: a query operation that threw was never rerun,
so a failure a concurrent commit caused (a record missing between two
reads, `unique` across an insert) was served as though from one state.
A failure now stands only if the run's reads are still current;
otherwise the run is stale and reruns like any other. A reader records
one guard per record, however often it is read, so a loop can't exceed a
commit's guards, and a table's generations are read in one select. New
tests: a torn failure that succeeds on rerun, a handler's own failure
that isn't rerun, deletes and two-table commits moving generations, and
a page recording its generation.
@nickruigrok
nickruigrok force-pushed the feat/store-read-consistency branch from 340b491 to fd323f0 Compare October 11, 2026 03:22
nickruigrok added a commit that referenced this pull request Oct 11, 2026
…rd each record once

From the review of #394: a query operation that threw was never rerun,
so a failure a concurrent commit caused (a record missing between two
reads, `unique` across an insert) was served as though from one state.
A failure now stands only if the run's reads are still current;
otherwise the run is stale and reruns like any other. A reader records
one guard per record, however often it is read, so a loop can't exceed a
commit's guards, and a table's generations are read in one select. New
tests: a torn failure that succeeds on rerun, a handler's own failure
that isn't rerun, deletes and two-table commits moving generations, and
a page recording its generation.
@nickruigrok
nickruigrok force-pushed the feat/store-read-consistency branch from fd323f0 to 87548f6 Compare October 11, 2026 11:05
…up to three times

Spec 6.3: a multi-read query validates its dependency generations at
completion. Each logical table now has a generation (`sdk_tables`,
migration 0005) that every commit changing its records moves on once,
and every read answers the generation of the table it read, in the same
transaction. A `StoreReader` keeps, on the host, the generations an
invocation's reads saw and the revisions its gets asserted (guards for a
mutation's commit), and `readConsistently` runs a query operation with a
reader of its own, checks that no table it read has moved on, and runs
it again up to three times before failing with the retryable
`query.concurrent_change`. A read that matched nothing still depends on
its table, so an insert into it is seen as a change; a commit to another
table, or one that changes nothing, isn't.

A read's result crosses RPC as JSON text: RPC types can't carry its
documents nested beside the generations.
…rd each record once

From the review of #394: a query operation that threw was never rerun,
so a failure a concurrent commit caused (a record missing between two
reads, `unique` across an insert) was served as though from one state.
A failure now stands only if the run's reads are still current;
otherwise the run is stale and reruns like any other. A reader records
one guard per record, however often it is read, so a loop can't exceed a
commit's guards, and a table's generations are read in one select. New
tests: a torn failure that succeeds on rerun, a handler's own failure
that isn't rerun, deletes and two-table commits moving generations, and
a page recording its generation.
@nickruigrok
nickruigrok force-pushed the feat/store-read-consistency branch from 87548f6 to 2cb6a57 Compare October 11, 2026 11:17
@nickruigrok
nickruigrok marked this pull request as ready for review October 11, 2026 11:17
@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

[Critical impact] The PR appears safe to merge, with a non-blocking issue that can cause needless retries for refused reads.

Findings

  1. P2 Refused reads retry unnecessarily ▶
Fix with agent prompt
### Issue 1
apps/core/src/store-host.ts:589-594
`#withGenerations` adds a table dependency even when `readableTable` throws `data.access_denied` before reading any records. If other callers keep changing that table, the query can run four times and return `query.concurrent_change` instead of the permission error or the handler's fallback answer.

Only attach generations when the failed read actually depended on records.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

The PR adds table generations and StoreReader so multi-read queries can retry when their reads span different states.

  • Queries rerun when their reads span different table generations.

Diagram

sequenceDiagram
  participant Handler
  participant Reader as StoreReader
  participant Store as StoreHost
  Handler->>Reader: read(request)
  Reader->>Store: read(scope, request)
  alt Read succeeds
    Store-->>Reader: value and generations
    Reader-->>Handler: value
  else Read fails
    Store-->>Reader: error with generations
    Reader->>Reader: Record and remove generations
    Reader-->>Handler: error
  end
  Reader->>Store: generations(tables)
  Store-->>Reader: Current generations
  alt Reads are current
    Reader-->>Handler: Keep answer or error
  else Reads are stale
    Reader->>Handler: Run again, up to three times
  end
Loading

Reviews (2) · Last reviewed commit: "fix(core): count a failed read's generat..." · Reviewed by Greptile

Comment thread apps/core/src/store-reader.ts Outdated
From the review of #394: a read that failed after looking at records
(`data.not_unique`, `data.result_limit`) recorded no generation, so a
handler that caught it and read the table again after a concurrent
commit could be answered from two states. A data error a read raises now
carries the generation of the table it read, taken in the same
transaction; the host's reader counts it before rethrowing and takes it
off the error, so App code never sees it.
@nickruigrok

Copy link
Copy Markdown
Contributor Author

@greptileai review

Comment on lines +589 to +594
if (error instanceof Error && dataErrors.codeOf(error) !== undefined) {
const details: unknown = Reflect.get(error, "details");
Reflect.set(error, "details", {
...(typeof details === "object" && details !== null ? details : {}),
generations: this.#generationsOf(pinned, [table]),
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Refused reads retry unnecessarily

#withGenerations adds a table dependency even when readableTable throws data.access_denied before reading any records. If other callers keep changing that table, the query can run four times and return query.concurrent_change instead of the permission error or the handler's fallback answer.

Only attach generations when the failed read actually depended on records.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/core/src/store-host.ts
Line: 589-594

Comment:
**Refused reads retry unnecessarily**

`#withGenerations` adds a table dependency even when `readableTable` throws `data.access_denied` before reading any records. If other callers keep changing that table, the query can run four times and return `query.concurrent_change` instead of the permission error or the handler's fallback answer.

Only attach generations when the failed read actually depended on records.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

@nickruigrok
nickruigrok merged commit 3eab6af into main Oct 11, 2026
9 checks passed
@nickruigrok
nickruigrok deleted the feat/store-read-consistency branch October 11, 2026 11:38
nickruigrok added a commit that referenced this pull request Oct 11, 2026
…cords (#400)

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.
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