TanStack

Content temporarily unavailable

Lazy bucket rollback copy review

Evidence by revision, on perf-lazy-bucket-snapshot from main at 2c98b4992: the RED results ran on main with the new oracle. The GREEN results, mutants and measurements ran on the branch commit that adds this record. The layout law, the ORC-010 harness and the second mutant run were added in a follow-up commit; "Follow-up: layout and cleanup" gives their results.

Contract and evidence

BucketFacadeAdapter.flush() writes child-facade rows through ordinary Collection transactions and defers their events. "Coherent publication" in packages/db/src/query/live/ARCHITECTURE.md requires the flush to be atomic: if a facade write or the root commit fails, every facade returns to its rows, order and key mapping from before the flush, and no facade publishes.

Before this change, each flush copied the stored rows of every facade, so that a rollback could restore them. The rollback read only the copies of the facades the flush wrote. Every successful flush paid for the other copies. A change to one child row in a list of 50 parents with 3 rows each copied 150 rows.

The change copies a facade's rows when the flush first defers that facade's publication. Both write paths defer before they open a sync transaction, so the copy holds the facade's rows from before the flush. The production diff in bucket-facade-adapter.ts is +19/−29.

The new owner is packages/db/tests/query/bucket-facade-rollback-oracle.property.test.ts. Its model maps each bucket to its ordered rows and keeps the operations sent since the last successful flush. It checks two laws after every flush:

  1. Flush atomicity. After a thrown facade commit or a rollback after prepare(), each facade shows its published rows in order, each row's key resolves through getKeyFromItem, and no facade published an event. A thrown flush keeps its graph output pending, and the next successful flush applies it.
  2. Bounded rollback work. During a flush, the stored rows of no facade outside the written buckets are read.
  3. Layout. A facade is a Collection-valued include, so an order-only change of a row it shows is a layout change ("Inline modes" in the architecture document). After a successful flush, a facade's layout revision advances once when a row it showed before and after the flush changed order and its key sequence changed, and stays put otherwise. A failed flush leaves the revision unchanged.

Results

CheckmainBranch
Fixed campaign (seed 2081)Fails: "step 1: reads of untouched b0: expected 1 to be +0"Passes
Random campaignFails with the same counterexamplePasses
Pinned wide list (50 buckets × 3 rows, one insert)Fails: 49 untouched facades readPasses
Pinned retried failure (two written buckets and a retire)PassesPasses

The generated histories reached 448 publishes, 188 rollbacks after prepare() and 84 thrown facade commits.

Rows copied per change, measured with a counter on the copy:

ShapemainBranch
Issue detail, 10 / 100 / 1,000 comments19.5 / 109.5 / 1,009.519.5 / 109.5 / 1,009.5
List of 50 issues, 3 recent comments each1503
All issues, 100 × 10 comments1,009.519.5
All issues, 1,000 × 10 comments10,009.519.5

The detail view does not improve, because its one bucket is the bucket the flush writes. An undo log of changed keys would cover that case.

Local scripts/bench timings could not distinguish the change: a same-code comparison gave 1.08× for writes, and reversing the run order moved the branch from 1.03–1.14× to 0.83×.

Mutants

MutantOutcome
Copy the rows after the first writeAssertion failure: the new oracle's fixed and random campaigns, and the existing "restores facade state without public effects when a flush fails" test
Skip the copy for buckets created in the flushEquivalent within the tested domain: restore() restores only facades that existed before the flush
Retire copies after its deletesEquivalent within legal histories: the graph retracts a retired bucket's rows in the same flush, so the row phase defers and copies the facade first
Do not restore currentOrderAssertion failure in the new oracle (fixed and random campaigns, and the pinned reorder retry) and in the existing bucket-facade-adapter.test.ts rollback test

ORC-012 requirement audit

RequirementOutcome
ORC-001Applicable. The atomicity law comes from "Coherent publication". The work law is this change's promise. The claim is limited to one ordered edge at the adapter boundary.
ORC-002Applicable. The model is a map of ordered rows and a list of pending operations. It does not import the adapter, its snapshot or its order maps.
ORC-003Applicable. The opening prose states both laws, the model, the grammar, the driver, the checkpoints and the limits.
ORC-004Applicable. The grammar covers activate, insert, update, delete and retire on buckets b0..b3 and ids 1..6, with publish, thrown commit and rollback outcomes. Operations are legal against the published rows plus pending operations. The outcome counts above show each outcome was reached.
ORC-005Applicable. The driver runs the real adapter over a D2 graph and compares public facade rows, keys, events and each facade's layout revision, which the live-query observer compares to detect a reorder. The work counter wraps each facade's stored-row iterators during the flush.
ORC-006Applicable. The mutants above, with outcome classes.
ORC-007Applicable. A fixed-seed campaign and a random or replayed campaign run the same property and budget, registered as bucket-facade.rollback-history.
ORC-008Applicable. The model keeps published rows and pending operations. Pending operations are needed because a thrown flush keeps its graph output for the next flush.
ORC-009Applicable. "Written buckets" means buckets with a pending row operation or a retire since the last successful flush.
ORC-010Applicable. withCleanup keeps a history's failure as the primary error: when cleanup also throws, it throws an AggregateError whose cause is the history's failure and whose errors list that failure first. A calibration test forces both failures; a harness mutant that rethrows the cleanup error fails it.
ORC-011Inapplicable. No shared fault between the model and the adapter was named.
ORC-013Inapplicable. No threshold law.
ORC-014Inapplicable. No controlled provider.

Unresolved

  • Nested facades and facade indexes are covered only by the existing tests in bucket-facade-adapter.test.ts and includes-collection-oracle.property.test.ts.
  • The layout law observes the layout revision, not layout-only listener calls. A layout listener runs only for a publication without row events, and an order-only update publishes an update event.
  • Dense single-bucket views still copy the whole written bucket.

Follow-up: layout and cleanup

The first version of the oracle did not observe a reorder. toArray sorts through the facade comparator, so the rows came back in order even when the adapter kept stale order state after a failed flush. The stale state stops the retried flush from marking a layout change, which the live-query observer reads through the layout revision.

A first attempt observed layout listener calls and failed on the fixed adapter (step 1: layout publications of b0: expected +0 to be 1): an order-only update publishes an update event, and layout listeners run only for a publication without row events. The revision is the observation the observer uses, so the law observes it.

CheckResult
Fixed adapter6 of 6 pass, no type errors
Do not restore currentOrderAssertion failures in both campaigns and the pinned reorder retry. The fixed campaign also found a row-order failure after a second thrown flush: [1, 3] instead of [3, 1].
Copy the rows after the first writeAssertion failures, 3 of 6 tests
Skip the copy for new buckets; retire copies after its deletesStill equivalent, as above
Harness mutant: rethrow the cleanup errorThe ORC-010 calibration fails

Follow-up: medium code review

Reviewed source: the commit that adds this section, on top of 3b9b74ba2. The review raised 10 findings. The evaluation ledger is outside the repository; its verdicts are below.

Laws. The oracle now states three laws. Atomicity also requires that a failed flush removes the facades it created. Bounded work now covers every read path of the stored rows. The layout law is now derived from the rows a reader sees, not from production's classifier: a revision is required when the rows shown before and after appear in a different relative order, forbidden when the shown key sequence is identical, and allowed otherwise.

FindingVerdictChange
1. Work counter did not see forEachConfirmedThe counter also wraps forEach, get and has.
2. Layout model copied the production classifierConfirmedThree-part layout law above.
3. Nested-facade rollback was claimed covered but was notConfirmedPinned case: the child edge commits, the parent edge throws. The child facade is restored, the parent keeps its reference to it, and the child facade created by the flush is removed.
4. Missing failure cutsConfirmedThe grammar adds a throw from a facade's first write (open sync transaction) and a throw from the commit of a facade the flush creates. The nested case covers a second edge after the first commits. A retire on a non-empty facade cannot occur in legal histories (see mutants).
5. check() skipped buckets the model lacksConfirmedcheck() compares the adapter's facade set with the model's buckets.
6. A rollback after prepare() ended the historyConfirmedThe history continues after a rollback; a pinned case sends a rolled-back reorder again.
7. ?? [] could restore a facade to emptyConfirmedrestore() iterates the copied rows, so a missing copy cannot occur.
8. deferredEntries duplicated the copied rows' keysConfirmedRemoved.
9. Copy-before-write was a conventionConfirmedOne write() helper copies, defers, begins and commits for both write paths.
10. snapshot() still copies the edge and bucket mapsConfirmed, scopedNot changed. A lazy copy would need a copy-on-write hook across the window from flush() to publish()/rollback(), including resolve() calls after flush() returns. The changeset now says the bucket bookkeeping still grows with the number of buckets.

Mutants (ORC-006), run against this oracle and against the previous one:

MutantThis oraclePrevious oracle
Eager copy through forEachAssertion failureSurvived
Layout revision without a key changeAssertion failureAssertion failure
No layout revision when membership also changesAssertion failureAssertion failure
Layout from common-row relative order onlySurvives: the contract allows itAssertion failure: the old model over-specified
Entries map not restoredAssertion failureAssertion failure
Stale order state after a rollbackAssertion failureSurvived
Rows copied after the first writeAssertion failureAssertion failure
Retired facade copied after its deletesEquivalent: a retired bucket's rows retract in the same flush, so retire finds an empty facadeEquivalent

Production delta: bucket-facade-adapter.ts is now +41/−54 against main, net −13.

Unresolved: the random campaign rarely sends a rolled-back reorder again; the pinned case covers it. The edge and bucket map copy is not measured.

Follow-up: child rows lost after a failed root commit

Found while evaluating the medium review, and confirmed on main (f6d65eace) and on this branch before the fix.

Law. After a failed root commit, the next successful flush publishes every change that was pending at the failed flush, child rows included, exactly once. The live-query builder keeps its pending root changes when the root commit fails. Before this fix, the facade adapter had already cleared its pending child rows and activations, so rollback() restored the facades but lost their pending changes.

Witness. A live query with a Collection-valued include. One write updates the parent and the child, and the root commit throws. A retry writes only a further parent change. On main and on the unfixed branch, the parent shows the retry while the child keeps value: 1 and publishes no event, although the source holds value: 2. With the fix, the child shows value: 2 and publishes one event.

Oracle. The rollback outcome now keeps the model's operations pending, as a thrown flush does. RED before the fix: both campaigns failed with "facades held by the adapter: expected [] to deeply equal [ 'b0' ]" (a lost activation), and a pinned case failed with "rows of b0: expected [ { id: 1, v: 1 } ] to deeply equal [ { id: 1, v: 2 } ]". The includes owner gained the public witness above.

Fix. flush() keeps the pending rows and activations it consumed; rollback() puts them back. No graph output arrives between a flush and its rollback, so the maps are empty when it does.

Mutant. Dropping the pending rows on rollback fails both campaigns, the pinned case, and the includes witness (assertion failures).

Not covered by the law. When no further write reaches the live query after a failed root commit, no flush runs, so the root and the child both keep their rows from before the failure until the next write. Whether a failed commit should schedule its own retry is a design question; this change does not add one.

Recorded limits (maintainer decision, 2026-10-08)

Bucket bookkeeping copy (finding 10). snapshot() still copies each edge's entries map and activeBuckets set on every flush. The maintainer accepted this as a documented limit. Measured per comment write, with a scratch variant that skips the copy (an upper bound, because that variant cannot roll back):

Unlimited includeEntries copiedCopy share of a write
10 parents20about 2%
100 parents200about 6%
1,000 parents2,000about 31%
10,000 parents20,000about 83%

A limited query ("list + 3 recent comments", limit 50) copies no entries, because only the parents in its window hold facades. A lazy copy for each edge does not help, because one edge holds all the buckets. The follow-up, if large unlimited include queries matter, is a per-bucket undo log (about +20/−15 lines plus oracle cases for activate, retire and restore of a new entry). Raw data: ~/.cw-perf/bucket-f10/RESULTS.md (local).

Retry after a failed root commit. The maintainer recorded this as a design question for later. A retry needs a public contract, a backoff and a stop condition before it can earn its code weight.

Follow-up: second medium code review (2026-10-08)

Reviewed head bf6caffb1. The review had nine findings. The evidence for each one is below.

FindingVerdictChange
1. Successful flushes do not check eventsConfirmedLaw 1 now checks events on each successful flush. The subscriber starts from the facade's current rows (includeInitialState). Its events name only rows that a pending operation touched, at most once each. A replay of the events on the previous rows gives the shown rows.
2. ARCHITECTURE.md says the adapter buffers no deltasConfirmedThe adapter paragraph and "Coherent publication" state the retention law and the rollback invariant.
3. rollback() can overwrite new pending deltasConfirmed as an unchecked invariantrollback() throws (code 235) when graph output arrived after the flush. The flush runs inside the graph run, so this cannot occur in a legal history. A pinned witness covers it.
4. Per-flush bookkeeping copyAccepted designThe maintainer decision is recorded below.
5. Per-flush write closureConfirmedRemoved. The copy, the deferral and the write are at the one write site.
6. The retire write branch is unreachableConfirmed, then refuted in the third reviewThe branch was removed, and a retire that found rows threw (code 231). A probe that threw on that branch ran the full @tanstack/db suite (11,267 tests) and reached it zero times. The third review showed that the probe measured a blind spot shared by every suite, not the contract. The branch is restored.
7. Pending maps are copied, then clearedConfirmedThe flush swaps the map references, and the rollback swaps them back.
8. The oracle reads adapter internalsConfirmed, documentedThe Limits section gives the reason: resolve creates facades, so the facade set and the new-facade failure need the private map and factory. Rows, events and layout revisions are public Collection state.
9. The "only way to write a facade" comment is wrongConfirmedThe comment now says this is the only place where a flush applies graph deltas.

An earlier draft of the event law subscribed without initial state. It reported missing delete events after a thrown flush. That was a wrong observation, not a product bug. A subscriber without initial state does not receive events for rows that it was never sent, and it receives an insert for an update to such a row. The same history gives the same events on main.

Mutants, against the extended oracle (with the includes oracle and the adapter tests) and against the oracle at bf6caffb1:

MutantExtended oraclePrevious oracle
Apply the consumed deltas again after publishAssertion failure (20 tests)Assertion failure (4 tests), through the work law
Drop the events of a delete-only facade writeAssertion failure at the successful flush (step 1)Assertion failure only at a later rollback
Rollback restores over new pending deltasAssertion failure (witness)Survives
Retire deletes rows and does not throwAssertion failure (witness)Survives
Copy rows after the first writeAssertion failure (12 tests)Assertion failure (7 tests)
Apply each change twiceEquivalent: the repeated identical update publishes no eventEquivalent

Production: bucket-facade-adapter.ts is +54/−64 against main (net −10).

Follow-up: third, targeted code review (2026-10-08)

Reviewed head 00ef3ea07. The review had ten findings.

Regression. Round 2 made retirement throw (code 231) when a facade still had rows. It assumed that a retired facade is empty in legal histories. That holds for synced rows only. A facade is a public Collection, so a user transaction can show an optimistic row in it, and a persisting transaction can hold its sync commits. main retracted every visible key. The oracle's grammar had no step that writes a facade outside the graph, so no generated history could refute the assumption. The reach probe counted only what the existing suites generate.

Law. Retirement deletes through sync every key the facade still shows. It is a facade write of the flush, so a failed flush restores it. The architecture text says so and explains why a non-empty retirement is legal.

FindingVerdictChange
1. Retire throws on a facade with optimistic or held rowsConfirmed regressionThe grammar gained a step that inserts an optimistic row into a held facade, and the oracle checks that the row stays visible. RED: both campaigns, plus pinned optimistic-row and held-commit cases, threw code 231. GREEN after retirement went back to retracting through the shared copy and deferral path. Code 231 is removed.
2. Created-facade disposal is not observedConfirmedAfter a failed flush, every facade it created must report status === 'cleaned-up'.
3. The rollback invariant wedges event deliveryConfirmedThe rollback restores the facades and discards the deferred events before it throws. The witness checks the rows and one event on the next flush.
4. Rollback after cleanup restores disposed facadesConfirmedRollback after cleanup() does nothing. Pinned witness.
5. The architecture text does not match the retire throwConfirmedUpdated, as above. Coherent publication also states the rollback ordering and the cleanup no-op.
6. The layout law over-specifiesOpen law questionProposed to the maintainer.
7. No liveness after a deterministic root-commit failureRecorded limitSee the recorded limits above.
8. currentOrder.clear() in restore is redundantRefutedRemoving it fails the adapter unit test. Only a private-state assertion catches it, so its public consequence is an open witness.
9. retiredEntries.clear() on rollback is redundantEquivalentRemoving it passes all 4,033 query tests. Restore puts each retired entry back, so the reference retains nothing. Kept, because it states that a rolled-back flush retires nothing.
10. Misplaced commentsConfirmedMoved.

On merge, main (#2074) had taken codes 230–232, so the rollback invariant is now code 235. (#2080 holds 233; it later dropped 234.)

Mutants, against the rollback oracle, the includes oracle and the adapter tests:

MutantResult
Retire throws when rows remainAssertion failures (6 tests, both campaigns)
Created facades are not disposedAssertion failures (both campaigns)
Rollback after cleanup restoresAssertion failure (witness)
The invariant check runs before restore and discardAssertion failure (witness)
Copy again on each writeAssertion failures (both campaigns)
Retire skips the rollback copyAssertion failure (pinned witness only)
Rollback drops the consumed pending changesAssertion failures (4 tests, includes oracle too)

Limits. Held commits and synced rows left at retirement have pinned witnesses only. The second is a contradictory graph signal. Disposal between a flush and its rollback is pinned, not generated.

Follow-up: fourth medium code review (2026-10-09)

Reviewed head c69662d3b. The review had ten findings. Each was checked with an executable probe on that head before any change.

Falsified law. Flush atomicity: after a failed flush, every facade shows its rows from before the flush until the next successful flush. A persisting user transaction on a facade holds its sync commits. The failed flush's insert of a new key is then not in the stored rows, so restore() sent no delete for it. When the transaction settled, the row appeared and published an insert event, before any retry. main has the same restore loop. Updates, deletes and retirement under a hold were already restored.

Why the oracle missed it. The grammar's optimistic rows came from a transaction that stays pending, which does not hold sync commits. Held commits had pinned witnesses only, and the held-commit witness did not check the facade after the transaction settled. The new hold step field holds one shown facade's commits during a flush and settles before the comparison.

RED on c69662d3b: both campaigns fail at step 1 (rollback): rows of b0 (fixed seed) and step 2 (throwNew): rows of b0 (random seed). GREEN after the fix.

FindingVerdictChange
1. Rollback misses held sync commitsConfirmed, narrower than statedThe leak is a transient before the retry; a retry writes the key again or deletes it. restore() now also deletes every key the flush wrote.
2. Retirement misses held rowsRefuted on legal historiesThe graph retracts every row it sent, including held ones, and those deletes are held in order. A probe of retire under a hold, settled after publish, ends empty. Rows the graph never retracted are finding 7.
3. The code-235 error masks the root error and drops deltasAcceptedIt fires only on an invariant violation, which fails loud by design (AGENTS.md fail-fast). Precise errors for such failures are not required (maintainer decision, 2026-10-09).
4. Abort is not exception-safeConfirmedOne abort() restores, then discards every deferral in a finally. Pinned witness: a restore whose commit throws, then a later flush publishes.
5. Readiness survives rollbackConfirmed state, low reachA facade that resolve() created before its bucket activates stays ready and empty after a failed flush. In the builder, only an old previousValue can create such a facade. Open design question.
6. One throwing subscriber stops other facadesConfirmedpublish() publishes every facade, then rethrows the first error. Pinned witness.
7. Leftover synced rows at retirement are retracted silentlyOpen design questionAlready on the maintainer's decision list.
8. Every flush copies the bookkeeping mapsAccepted limit, plus a fixThe per-flush copy is a recorded limit. A flush with no facade changes now returns before it copies anything. Pinned work witness.
9. The abort sequence is duplicatedConfirmedMerged into abort(), as in finding 4.
10. Restore deletes by visible keysConfirmedUnder a hold, the delete of the transaction's optimistic key published a layout revision after a failed flush. restore() now reads the stored rows.
MutantResult
Restore ignores the keys the flush wroteBoth campaigns fail
abort() without finallyRestore-throws witness fails
publish() stops at the first throwing facadeSubscriber witness fails
Snapshot on every flushWork witness fails
Restore deletes by visible keysBoth campaigns fail (layout revision after rollback)
Restore picks insert or update by visible rowsSurvives: no generated history deletes a synced row optimistically

Limits. One hold per step, settled before the comparison. A hold that stays open across several flushes is not generated.