Repository navigation
feat(core): validate a query's reads by table generations, rerunning up to three times - #394
Conversation
c7e0b29 to
340b491
Compare
…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.
340b491 to
fd323f0
Compare
…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.
fd323f0 to
87548f6
Compare
…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.
87548f6 to
2cb6a57
Compare
|
@greptileai review |
|
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.
|
@greptileai review |
| 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]), | ||
| }); |
There was a problem hiding this 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.
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.…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.
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.sdk_tables.generation, migration0005). Every commit that changes a table's records moves its generation on once. A commit that changes nothing, or changes other tables, doesn't.StoreHost.readreturns{ value, generations }, the table's generation read in the same transaction as the answer.StoreHost.generationsreports 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 recordsgetrevision 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 onceisCurrent()holds. It allows 1 run plus 3 reruns (queryMaxReruns), then throwsquery.concurrent_changewithdetails: { retryable: true }.openStorevalidates the shape.Boundary
GRA-244 wraps query handlers in
readConsistently, buildsctx.dbwithdatabaseReader(schema, (request) => reader.read(request))and handsreader.guards()to its commit. Live-query delivery of invalidations is a later slice.Tests
apps/core/test/store-reader.test.ts, in workerd throughOpenStoreand the DO. Commits land between reads through the store, exactly as another caller's would.query.concurrent_change, retryable, after 4 runs.Results:
vp checkis clean.vp teston store-reader, store-cursors, store-queries, store-indexes, store-expressions, the data-store suites, SDK database and shared store-queries passes 177 tests.