diff --git a/.changeset/gc-waits-for-persisting-transactions.md b/.changeset/gc-waits-for-persisting-transactions.md new file mode 100644 index 0000000000..ffc1355b79 --- /dev/null +++ b/.changeset/gc-waits-for-persisting-transactions.md @@ -0,0 +1,5 @@ +--- +'@tanstack/db': patch +--- + +Garbage collection no longer cleans up a collection while a transaction that touches it is persisting. Cleaning it up dropped the transaction's optimistic rows while its mutation handler still ran, and rejected waits such as Electric's `awaitTxId`, so a write could fail after the screen that made it unmounted. The `gcTime` delay now starts again when the last such transaction completes or fails. An explicit `cleanup()` is unchanged. diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index a085dd75d9..c3f321e560 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -616,7 +616,7 @@ generic visitor behavior outside the named collectors. | -------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Ordered relations and BTree | [top-K relation oracle](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/operators/topk-relation-oracle.test.ts), [fractional-index window moves](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/operators/topKWithFractionalIndex-oracle.test.ts), [BTree/Map](https://github.com/TanStack/db/blob/main/packages/db/tests/btree-map-oracle.test.ts), [incrementalization laws](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/incrementalization-law-oracle.property.test.ts) | Independent ordered relations and cumulative signed output. Each generated incrementalization campaign sums the checker's delivery counts and requires one atomic delivery per checkpoint and more split deliveries than an unsplit run makes; a checker that stops splitting fails all six campaigns. Most ordered steps edit the previous world by one removal or insertion, and a reconstruction check counts promotions of retained rows over the executed fixed campaign (25 global and 21 per parity at 100 runs; independent worlds gave 2 and 2). A type test pins `join(other, type)`'s nullable pair sides for every join type. Top-K consolidation compares same-key values without hashing, including cyclic replacements and fresh transient cancellation. The fractional-index test checks an empty output message for a window move after all rows retract; it does not prove downstream lazy acquisition. Other hash-based operators retain hashing's declared domain. Algebra does not specify client readiness. The BTree/Map oracle checks point operations, neighbor pairs, and full, partial, boundary, exclusive-high, and empty range scans for integer keys and node sizes 4–8. It does not check balance or space, and it assumes consistent comparators ([code-weight BTree review](oracle-reviews/code-weight-btree-trim.md)). The generated groupBy law compares JSON values; a pinned contract test in `packages/db-ivm/tests/operators/groupBy.test.ts` records that after a delete, `min` and `max` return a value hash-equal to a remaining member, not necessarily the remaining instance (`-0` for `0`, an equal Date), which is why the query compiler keeps its own exact-value keys. It checks both insertion orders, reads the group's row by integrating output multiplicities, deletes the minimum and the maximum, and shows that an aggregate input that tells the rows apart returns the remaining instance, for -0/0 in both directions and for both `min` and `max`. The contract holds while every member has positive multiplicity. It pins the current behavior separately: the delete's retraction is the last record a shared hash entry gets, so the deleted instance comes back in either insertion order, for -0/0 in either direction and for an equal Date. For -0/0 and equal Dates the contract assertions only show hash equality, which is all the contract promises. Open: the incrementalization law's value policy compares JSON and clones by `structuredClone`, so no generated history sees -0 or Date identity; a value-identity policy with -0/0 and equal-time Dates would generalize this pinned contract. | | Includes and publication | [cross-formulation](https://github.com/TanStack/db/blob/main/packages/db/tests/query/includes-cross-formulation-oracle.property.test.ts), [temporal](https://github.com/TanStack/db/blob/main/packages/db/tests/query/includes-temporal-oracle.test.ts), [Collection includes](https://github.com/TanStack/db/blob/main/packages/db/tests/query/includes-collection-oracle.property.test.ts), [architecture and complete suite map](https://github.com/TanStack/db/blob/main/packages/db/src/query/live/ARCHITECTURE.md#executable-contracts) | Per-parent/flat-join/partition relations, callback-time rows, nested values, and route histories. Fragmented lazy-demand consolidation covers delta growth and successful, rejected/retried, and obsolete replacements at the Collection request boundary; it observes exact key unions and abort checkpoints. A compiled-includes fixture makes adapter unload evict owned child rows and proves a rejected union cannot remove established public child rows. Retry after that terminal live-query failure remains covered at the acquisition boundary; no automatic in-place recovery is claimed. Observe raw promised order; fresh queries do not establish continuous publication safety. The temporal owner's source-readiness lane enumerates every legal order of delivery, settlement, source readiness, and one later change for a source whose Collection readiness lags its settled subsets. It checks live-query readiness, preload settlement, and visible rows for direct, single-result, ordered-window, include-child, lazy-join, and eager-root sources; `queryOnce` results for the direct and eager-root shapes; `toArrayWhenReady()` and `stateWhenReady()` results for a read started after rows are visible but before readiness, over the direct, single-result, and eager-root shapes (and the lazy join for `toArrayWhenReady()`); and Effect `skipInitial` deltas for the direct, lazy-join, and eager-root shapes, and every on-demand cell of every consumer is crossed with a truncate or (join and include) a parent rekey before settlement. Each on-demand cell also compares its consumer with a metamorphic twin over a source that is ready from its start, which does not rely on the model's eager/on-demand rule. A live-query observer test checks that a hydration seed hands off at subset settlement, and an Electric test pins that a ready progressive query's rows update only at the full sync's swap. Effects over an ordered window or an include have no lane. It rejects both a design that waits for every source's Collection readiness and one that ignores source readiness. Electric live-query tests are the receiving witnesses for a real progressive source and an on-demand source that is ready before its subset loads; a React Suspense test checks that Suspense and `isReady` release together. Other framework adapters have no Suspense witness. | -| Collection lifecycle | [mutation startup](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-mutation-startup-oracle.test.ts), [change-event history](https://github.com/TanStack/db/blob/main/packages/db/tests/change-event-history-oracle.test.ts), [sync publication reentrancy](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-sync-reentrancy-oracle.test.ts), [history](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-lifecycle-history-oracle.property.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-lifecycle-publication-oracle.property.test.ts), [replay](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-replay-oracle.property.test.ts), [effect disposal](https://github.com/TanStack/db/blob/main/packages/db/tests/effect-disposal-oracle.test.ts), [PR #1902 review](oracle-reviews/pr-1902-change-event-history.md), [batch-order decision review](oracle-reviews/change-event-batch-order.md), [absent-key insert review](oracle-reviews/2026-10-02-insert-for-absent-key.md) | Core Collection `insert`/`update`/`delete` admission while `startSync:false` is idle; complete public-row/change-message agreement across bounded one- and two-key histories plus replayable four-key campaigns, where every message must be valid for a strict consumer (insert only for an absent key, update or delete only for a present one; [absent-key insert review](oracle-reviews/2026-10-02-insert-for-absent-key.md)); a two-key deferred sync history checks each key's causal insert/update/delete trace while allowing any cross-key interleaving. Reversing all deferred messages fails that check; reversing each sync transaction's distinct-key messages passes. Queued duplicate admission and cancellation, ownership and phase histories, exact caller/error/publication evidence, and late completion and restart have separate witnesses. Generated deferred histories with cancellation or reentrant callbacks need a witness in the change-event history owner before claiming general batch-shape coverage. Query write utilities and effect self-dependent disposal remain separate contracts. During a truncate, a ready callback can write to any key after a prior optimistic request. Each subscriber, with or without initial state or a filter, must receive valid messages after the Collection is ready and end at the Collection's rows. A throwing ready callback during sync entry surfaces once the sync function returns, whether it calls markReady or commits a truncate ([round 3 follow-ups](oracle-reviews/2026-10-05-round-3-followups.md)). Callback rollbacks, non-optimistic callback writes, and several writing callbacks remain open ([ready-callback truncate review](oracle-reviews/2026-10-05-truncate-ready-callback-delete.md)). | +| Collection lifecycle | [mutation startup](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-mutation-startup-oracle.test.ts), [change-event history](https://github.com/TanStack/db/blob/main/packages/db/tests/change-event-history-oracle.test.ts), [sync publication reentrancy](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-sync-reentrancy-oracle.test.ts), [history](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-lifecycle-history-oracle.property.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-lifecycle-publication-oracle.property.test.ts), [replay](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-replay-oracle.property.test.ts), [effect disposal](https://github.com/TanStack/db/blob/main/packages/db/tests/effect-disposal-oracle.test.ts), [GC and persisting transactions](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-gc-transaction-oracle.property.test.ts), [PR #1902 review](oracle-reviews/pr-1902-change-event-history.md), [batch-order decision review](oracle-reviews/change-event-batch-order.md), [absent-key insert review](oracle-reviews/2026-10-02-insert-for-absent-key.md) | Core Collection `insert`/`update`/`delete` admission while `startSync:false` is idle; complete public-row/change-message agreement across bounded one- and two-key histories plus replayable four-key campaigns, where every message must be valid for a strict consumer (insert only for an absent key, update or delete only for a present one; [absent-key insert review](oracle-reviews/2026-10-02-insert-for-absent-key.md)); a two-key deferred sync history checks each key's causal insert/update/delete trace while allowing any cross-key interleaving. Reversing all deferred messages fails that check; reversing each sync transaction's distinct-key messages passes. Queued duplicate admission and cancellation, ownership and phase histories, exact caller/error/publication evidence, and late completion and restart have separate witnesses. Generated deferred histories with cancellation or reentrant callbacks need a witness in the change-event history owner before claiming general batch-shape coverage. Query write utilities and effect self-dependent disposal remain separate contracts. During a truncate, a ready callback can write to any key after a prior optimistic request. Each subscriber, with or without initial state or a filter, must receive valid messages after the Collection is ready and end at the Collection's rows. A throwing ready callback during sync entry surfaces once the sync function returns, whether it calls markReady or commits a truncate ([round 3 follow-ups](oracle-reviews/2026-10-05-round-3-followups.md)). Callback rollbacks, non-optimistic callback writes, and several writing callbacks remain open ([ready-callback truncate review](oracle-reviews/2026-10-05-truncate-ready-callback-delete.md)). The GC property (`collection-lifecycle.gc-persisting-transaction`) checks on a fake clock that automatic garbage collection retires a Collection only after `gcTime` with no subscriber and no persisting transaction, that the delay restarts when the last transaction completes or fails, and that explicit `cleanup()` still retires it while a transaction persists. Histories run over two Collections, with inserts through one Collection's `onInsert` or through a manually committed transaction that touches both. After each subscribe, unsubscribe, insert, settlement, and clock step it compares each Collection's `cleaned-up` status and each persisting row's visibility with an independent deadline model. `main` before the fix, a hold that never restarts the delay, a restart only on completion, and a restart only for the first Collection a transaction touched each fail it. Paced mutations, offline transactions, and live-query Collections are not generated. | | Paced mutations | [virtual-clock oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/paced-mutations-oracle.test.ts) | `createPacedMutations` with queue front/back options, bounded waiting capacity at zero and one, cleanup draining in FIFO/LIFO order after a held write, cleanup timing at the configured wait for immediate writes, admitted writes after separate Collection cleanup, debounce and throttle schedules with explicit and omitted options, and first-leading throttle execution at epoch zero. Finite histories compare optimistic rows, returned transaction identity and receipt outcome, execution order and virtual-clock time. Held writes verify queue serialization. Leading-only throttle and debounce witnesses reject skipped calls immediately with their named dropped-call errors, including omitted edges, both edges disabled, and same-row rollback while a prior write is held. Capacity witnesses cover overflow rejection, distinct-key optimistic rollback, same-key admitted-write survival, and in-flight versus waiting admission. Cleanup witnesses check repeated queue cleanup, eventual admitted receipt settlement, direct queue strategy rejection of new callbacks, public post-cleanup rollback with `QueueDisposedError`, pending debounce transactions that wait until the last call's quiet edge after separate Collection cleanup, and a trailing throttle timer that runs at its regular edge after separate Collection cleanup. Deferred custom queue and batch callbacks check compatibility with `void` and `false` execute results, respectively. Frozen options verify factory non-mutation; a mutable option witness checks that debounce uses its construction-time trailing setting. Throwing `onMutate` histories check leading-edge reuse before any row change, failed-row restoration when a pending transaction already has admitted work, eligible leading writes behind a held handler, and throttle pending merges at the throw, start, receipt, and later-timer cuts. New debounce/throttle admission after cleanup, manual commit of a strategy-owned transaction, longer throwing/reentrant bursts, failed persistence, broader capacities and schedules remain outside this owner. | | React paced-mutation render cuts | [render-cut oracle](https://github.com/TanStack/db/blob/main/packages/react-db/tests/usePacedMutations-render-cuts-oracle.test.tsx), [PR #2170 review](oracle-reviews/pr-2170-react-paced-render-cuts.md) | `usePacedMutations` publishes callbacks only from committed renders and does so before descendant layout effects, while callback-only commits preserve mutate identity. An independent committed-document model crosses ordinary and StrictMode schedules with mutation before or after a suspended candidate, mutation after the candidate commits, and delayed or immediate mutation from a descendant layout effect. It compares ordered callback traces and public rows at the mutation cut, the trace after persistence, and mutate identity at the commit cut. Render-time publication, layout-effect-only publication, and split insertion/layout publication mutants fail at the intended comparisons. A commit between transaction admission and persistence, arbitrary React schedules, server delivery, and other frameworks remain outside this receiving owner. | | Optimistic state | [same-key composition](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-composition-oracle.test.ts), [history model](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-oracle.ts), [generated histories](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-transaction-oracle.property.test.ts), [outcomes](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-outcomes-oracle.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-publication-oracle.test.ts) | Optimistic state drops at settlement and held sync transactions publish with it ([settlement-drop review](oracle-reviews/2026-10-03-settlement-drop.md)); whole-row active overlays, metadata and prior-value events. A raw subscriber without initial state checks that each truncate batch inserts a key once ([2026-10-02 review](oracle-reviews/2026-10-02-truncate-raw-subscriber.md)). Never rebase a pending snapshot merely to simplify the model. Generated histories also write source batches inside mutation handlers, before they return; the Collection must own the request by then ([handler source batch review](oracle-reviews/2026-10-01-handler-source-batch.md)). The same-key transaction lane also authors a delete followed by a reinsert, which must be visible optimistically and net to the truth-table request ([state mutation round 2](oracle-reviews/2026-10-02-state-mutation-round-2.md)). Source rows are written as `insert` for a key the source lacks, and batches may delete keys the source holds ([state-stack mutation gaps](oracle-reviews/2026-10-02-state-stack-mutant-gaps.md)). Batches may also delete a key the source never held. That delete leaves an active request in place ([absent-key source delete review](oracle-reviews/2026-10-02-absent-key-source-delete.md)). A separate partial campaign keeps the default partial row update mode, where source updates may omit a field and merge into the source row, not into an optimistic row ([round 3 review](oracle-reviews/2026-10-05-state-mutation-round-3.md)). An aborted open batch restores the driver's own source rows as well as its keys, so a later partial update still omits the field ([aborted-batch review](oracle-reviews/2026-10-08-partial-update-aborted-batch.md)). A failed synchronous manual `mutate` callback is now checked after same-key update, distinct insert, and same-key delete, with and without earlier intent, plus two two-Collection cases, one with a restoration listener failure. The throw cut compares public rows and pending payload; the later commit checks only surviving intent. A restoration listener error retains the callback error as the aggregate cause and does not stop other Collection restoration. These witnesses allow transient notifications during the callback and do not cover nested throwing callbacks or async work after the synchronous scope. After every step the driver also checks, by transaction identity, that the Collection tracks exactly its unsettled transactions, so a failed or rolled-back transaction leaves the map ([failed transaction retention review](oracle-reviews/failed-transaction-retention.md)). The [transaction ownership oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/transaction-ownership-oracle.property.test.ts) owns release across two Collections: merged-away pairs, conflicting rollbacks with a throwing subscriber, asynchronous success and failure settlements with a throwing subscriber, rollbacks from a `truncate` listener during a sync commit, a reused transaction id, and a `mutate()` callback that throws, checked in the same call stack and after promises flush. It also checks the settlement error shape and that a settled transaction's set of tracking Collections is empty; its mutations still name their Collection, so neither owner claims the Collection is collectable. Focused [restoration ownership](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/restoration-ownership.test.ts) witnesses check that repeated offline `mutate()` calls add to one transaction, that a completed restoration settles `isPersisted`, and that a restoration one Collection cannot track leaves no Collection tracking it. Its grammar also has direct writes (`insert`, `update` and `delete` outside a transaction), optionally with a throwing subscriber that may have been sent the rows first; it captures each direct write's own transaction and requires it to fail when the admission throws and to complete otherwise, counts handler calls (none for a rejected write), and keeps a mirror subscriber on each Collection that must match the rows after every step. A write whose admission throws rolls back only its own transaction and rethrows. The [change-event history oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/change-event-history-oracle.test.ts) generates local-only direct writes that meet a throwing subscriber, registered after the mirror, and requires the call to rethrow with its transaction completed and the rows following the model. Focused witnesses cover a local-only subscriber that writes the key it was told about, and a subscriber that throws a value that is not an Error; whether a throwing subscriber may stop a later subscriber is not decided. Open (approved limits): a direct write made inside another write's change subscriber reports its subscriber failure to the outer write; on a local-only Collection a direct write whose subscriber throws completes on the fast path but rolls back on the transaction path, and the change-event oracle generates only the fast path; a direct write admitted from inside a sync-commit listener, where the rollback's recompute is deferred, is not generated; the generated ownership histories read every receipt themselves, so only a focused witness (rolling back an empty manual transaction) checks that an unread receipt reaches no `unhandledrejection`; two Collection instances with one id, a Collection cleaned up before its transaction settles, and offline restoration have focused witnesses only; no generated history combines a held completed row with a second Collection. | diff --git a/packages/db/package.json b/packages/db/package.json index f09af0c658..7a16e565a8 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -23,7 +23,7 @@ "test": "vitest --run", "test:dist": "vitest run --config vitest.dist.config.ts", "test:facade-retention": "node --expose-gc --import tsx tests/facade-retention.probe.ts", - "test:oracles": "vitest --run --coverage.enabled=false tests/reverse-index-oracle.property.test.ts tests/query/compiler/in-evaluator-oracle.property.test.ts tests/cleanup-queue-oracle.property.test.ts tests/comparison-oracle.property.test.ts tests/duration-query-equality-oracle.test.ts tests/temporal-index-order-oracle.test.ts tests/cursor-oracle.property.test.ts tests/index-update-oracle.property.test.ts tests/utils-oracle.property.test.ts tests/change-event-history-oracle.test.ts tests/index-suggestion-oracle.test.ts tests/btree-map-oracle.test.ts tests/paced-mutations-oracle.test.ts tests/db-client-hydration-authority-oracle.test.ts tests/local-storage-order-oracle.test.ts tests/local-storage-peer-oracle.test.ts tests/local-storage-value-fidelity-oracle.property.test.ts tests/collection-mutation-startup-oracle.test.ts tests/collection-cleanup-restart-oracle.test.ts tests/effect-disposal-oracle.test.ts tests/optimistic-transaction-oracle.property.test.ts tests/transaction-ownership-oracle.property.test.ts tests/optimistic-settlement-boundaries-oracle.test.ts tests/optimistic-history-publication-oracle.test.ts tests/optimistic-history-outcomes-oracle.test.ts tests/collection-metadata-publication-oracle.property.test.ts tests/collection-index-value-oracle.test.ts tests/collection-state-retention-oracle.property.test.ts tests/collection-subscription-lifecycle-history-oracle.property.test.ts tests/collection-subscription-lifecycle-oracle.test.ts tests/collection-subscription-reentrancy-oracle.test.ts tests/collection-subscription-lifecycle-publication-oracle.property.test.ts tests/collection-subscription-replay-oracle.property.test.ts tests/d2-source-reconciliation-oracle.property.test.ts tests/live-query-observer-history-oracle.property.test.ts tests/live-query-observer-snapshot-oracle.property.test.ts tests/query/cold-join-reconciliation-oracle.test.ts tests/query/identity-output-shape-oracle.test.ts tests/query/optimizer-semantics-oracle.test.ts tests/query/index-path-collision-oracle.test.ts tests/query/custom-index-order-selection-oracle.test.ts tests/query/mixed-index-order-oracle.test.ts tests/query/includes-collection-oracle.property.test.ts tests/query/includes-functional-projection-oracle.test.ts tests/query/includes-functional-input-boundary.test.ts tests/query/includes-context-transport-oracle.test.ts tests/query/includes-cross-formulation-oracle.property.test.ts tests/query/includes-optimistic-oracle.property.test.ts tests/query/includes-oracle.property.test.ts tests/query/includes-alias-shadowing-oracle.test.ts tests/query/includes-outer-join-route-work-oracle.test.ts tests/query/includes-publication-oracle.test.ts tests/query/includes-query-shape-oracle.test.ts tests/query/subquery-user-value-oracle.test.ts tests/query/includes-temporal-oracle.test.ts tests/query/includes-work-counter-oracle.test.ts tests/query/load-subset-oracle.property.test.ts tests/query/load-subset-replay-refinement-oracle.test.ts tests/query/load-subset-source-readiness-refinement-oracle.test.ts tests/query/load-subset-transaction-refinement-oracle.test.ts tests/query/ordered-source-loader-state-oracle.test.ts tests/query/ordered-demand-retirement.test.ts tests/query/ordered-default-work.test.ts tests/query/ordered-lifecycle-oracle.property.test.ts tests/query/ordered-work-oracle.property.test.ts tests/query/pagination-oracle.property.test.ts tests/query/includes-space-oracle.test.ts tests/query/virtual-row-fields-oracle.test.ts tests/query/where-predicate-publication-oracle.property.test.ts tests/query/join-result-key-oracle.property.test.ts tests/query/pooled-live-query-oracle.property.test.ts tests/live-query-deferred-acquisition-oracle.test.ts tests/flat-change-tracking-oracle.property.test.ts tests/truncate-readiness-oracle.test.ts tests/proxy-detachment-contract-oracle.test.ts tests/production-error-messages-oracle.test.ts tests/query/bucket-facade-rollback-oracle.property.test.ts tests/query/aggregate-select-oracle.property.test.ts", + "test:oracles": "vitest --run --coverage.enabled=false tests/reverse-index-oracle.property.test.ts tests/query/compiler/in-evaluator-oracle.property.test.ts tests/cleanup-queue-oracle.property.test.ts tests/comparison-oracle.property.test.ts tests/duration-query-equality-oracle.test.ts tests/temporal-index-order-oracle.test.ts tests/cursor-oracle.property.test.ts tests/index-update-oracle.property.test.ts tests/utils-oracle.property.test.ts tests/change-event-history-oracle.test.ts tests/index-suggestion-oracle.test.ts tests/btree-map-oracle.test.ts tests/paced-mutations-oracle.test.ts tests/db-client-hydration-authority-oracle.test.ts tests/local-storage-order-oracle.test.ts tests/local-storage-peer-oracle.test.ts tests/local-storage-value-fidelity-oracle.property.test.ts tests/collection-mutation-startup-oracle.test.ts tests/collection-cleanup-restart-oracle.test.ts tests/collection-gc-transaction-oracle.property.test.ts tests/effect-disposal-oracle.test.ts tests/optimistic-transaction-oracle.property.test.ts tests/transaction-ownership-oracle.property.test.ts tests/optimistic-settlement-boundaries-oracle.test.ts tests/optimistic-history-publication-oracle.test.ts tests/optimistic-history-outcomes-oracle.test.ts tests/collection-metadata-publication-oracle.property.test.ts tests/collection-index-value-oracle.test.ts tests/collection-state-retention-oracle.property.test.ts tests/collection-subscription-lifecycle-history-oracle.property.test.ts tests/collection-subscription-lifecycle-oracle.test.ts tests/collection-subscription-reentrancy-oracle.test.ts tests/collection-subscription-lifecycle-publication-oracle.property.test.ts tests/collection-subscription-replay-oracle.property.test.ts tests/d2-source-reconciliation-oracle.property.test.ts tests/live-query-observer-history-oracle.property.test.ts tests/live-query-observer-snapshot-oracle.property.test.ts tests/query/cold-join-reconciliation-oracle.test.ts tests/query/identity-output-shape-oracle.test.ts tests/query/optimizer-semantics-oracle.test.ts tests/query/index-path-collision-oracle.test.ts tests/query/custom-index-order-selection-oracle.test.ts tests/query/mixed-index-order-oracle.test.ts tests/query/includes-collection-oracle.property.test.ts tests/query/includes-functional-projection-oracle.test.ts tests/query/includes-functional-input-boundary.test.ts tests/query/includes-context-transport-oracle.test.ts tests/query/includes-cross-formulation-oracle.property.test.ts tests/query/includes-optimistic-oracle.property.test.ts tests/query/includes-oracle.property.test.ts tests/query/includes-alias-shadowing-oracle.test.ts tests/query/includes-outer-join-route-work-oracle.test.ts tests/query/includes-publication-oracle.test.ts tests/query/includes-query-shape-oracle.test.ts tests/query/subquery-user-value-oracle.test.ts tests/query/includes-temporal-oracle.test.ts tests/query/includes-work-counter-oracle.test.ts tests/query/load-subset-oracle.property.test.ts tests/query/load-subset-replay-refinement-oracle.test.ts tests/query/load-subset-source-readiness-refinement-oracle.test.ts tests/query/load-subset-transaction-refinement-oracle.test.ts tests/query/ordered-source-loader-state-oracle.test.ts tests/query/ordered-demand-retirement.test.ts tests/query/ordered-default-work.test.ts tests/query/ordered-lifecycle-oracle.property.test.ts tests/query/ordered-work-oracle.property.test.ts tests/query/pagination-oracle.property.test.ts tests/query/includes-space-oracle.test.ts tests/query/virtual-row-fields-oracle.test.ts tests/query/where-predicate-publication-oracle.property.test.ts tests/query/join-result-key-oracle.property.test.ts tests/query/pooled-live-query-oracle.property.test.ts tests/live-query-deferred-acquisition-oracle.test.ts tests/flat-change-tracking-oracle.property.test.ts tests/truncate-readiness-oracle.test.ts tests/proxy-detachment-contract-oracle.test.ts tests/production-error-messages-oracle.test.ts tests/query/bucket-facade-rollback-oracle.property.test.ts tests/query/aggregate-select-oracle.property.test.ts", "bench:nested-includes": "vitest bench tests/query/includes-performance.bench.ts --run", "test:indexed-db:package": "pnpm --filter @tanstack/db... build && vitest run --config vitest.indexed-db.package.config.ts", "test:indexed-db:browser": "playwright test --config playwright.indexed-db.config.ts", diff --git a/packages/db/src/collection/lifecycle.ts b/packages/db/src/collection/lifecycle.ts index 3c3a6730bc..428d6a01ba 100644 --- a/packages/db/src/collection/lifecycle.ts +++ b/packages/db/src/collection/lifecycle.ts @@ -288,11 +288,14 @@ export class CollectionLifecycleManager< this.startGCTimer(UNSUBSCRIBED_GC_FLOOR_MS) } + // A persisting transaction holds the Collection: collecting it would drop + // the rows the transaction shows and end the sync run its handler may wait on. private canGarbageCollect(): boolean { return ( !this.cleaningUp && this.changes.activeSubscribersCount === 0 && - !this.sync.hasPendingPreload + !this.sync.hasPendingPreload && + !this.state.hasPersistingTransaction() ) } @@ -324,6 +327,15 @@ export class CollectionLifecycleManager< ) } + /** + * A transaction that touched this Collection settled. When nothing else + * holds the Collection, its `gcTime` delay starts now. + */ + public startGCTimerAfterSettlement(): void { + if (this.status === `idle` || this.status === `cleaned-up`) return + this.startGCTimer() + } + /** * Cancel the garbage collection timer * Called when the collection becomes active again diff --git a/packages/db/src/transactions.ts b/packages/db/src/transactions.ts index 0b8764dc01..0d16bcdb8c 100644 --- a/packages/db/src/transactions.ts +++ b/packages/db/src/transactions.ts @@ -710,12 +710,13 @@ class Transaction> { touchCollection(): void { const collections = new Set(this.collections) for (const mutation of this.mutations) collections.add(mutation.collection) - if (this.state === `completed` || this.state === `failed`) - this.collections.clear() + const settled = this.state === `completed` || this.state === `failed` + if (settled) this.collections.clear() const errors: Array = [] for (const collection of collections) { try { collection._state.onTransactionStateChange() + if (settled) collection._lifecycle.startGCTimerAfterSettlement() // Only call commitPendingTransactions if there are pending sync transactions if (collection._state.pendingSyncedTransactions.length > 0) { diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 298543dc42..6d4c81cdb2 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -789,7 +789,9 @@ export interface BaseCollectionConfig< * when it has no active subscribers. Defaults to 5 minutes (300000ms). * Sync started without subscribers gets a minimum 50ms grace period. * Pending preloads retain the collection until they settle. Preloading ready - * data refreshes the retention period. A non-positive or non-finite value + * data refreshes the retention period. A persisting transaction that touches + * the collection also retains it, and the period starts again when the last + * one completes or fails. A non-positive or non-finite value * disables automatic garbage collection. */ gcTime?: number diff --git a/packages/db/tests/collection-gc-transaction-oracle.property.test.ts b/packages/db/tests/collection-gc-transaction-oracle.property.test.ts new file mode 100644 index 0000000000..0d748fffb2 --- /dev/null +++ b/packages/db/tests/collection-gc-transaction-oracle.property.test.ts @@ -0,0 +1,403 @@ +import fc from 'fast-check' +import { expect, it, vi } from 'vitest' +import { createCollection } from '../src/collection/index.js' +import { createDeferred } from '../src/deferred.js' +import { createTransaction } from '../src/transactions.js' +import { oraclePropertyOptions, readOracleRunConfig } from './oracle-config.js' +import { createOracleReach } from './oracle-reach.js' +import { resetCleanupQueue } from './utils.js' +import type { Deferred } from '../src/deferred.js' + +/** + * # When may garbage collection retire a Collection? + * + * Law: automatic garbage collection retires a Collection once it has had no + * subscriber and no persisting transaction for `gcTime` elapsed milliseconds. + * A transaction that touches the Collection holds it while it persists, and + * the `gcTime` delay starts again when the last such transaction settles, + * whether it completes or fails. A transaction that touches several + * Collections holds each of them. An explicit `cleanup()` retires the + * Collection at once, persisting transactions or not. + * + * Authority: `gcTime` is the time a Collection with no subscribers waits + * before it is collected (`BaseCollectionConfig.gcTime`), and collecting it + * clears its optimistic state. A persisting transaction is local work whose + * optimistic rows the Collection shows until the transaction settles, and + * which an adapter's mutation handler may still be waiting on through the + * Collection (Electric's `awaitTxId`, for example). Collecting the Collection + * in that window drops rows the user just wrote and fails the handler's wait. + * Explicit cleanup is a caller's decision, so it keeps its current contract. + * + * The model keeps an elapsed clock and, for each of two Collections, the + * subscriber count, the set of persisting transactions that touch it, and + * the deadline at which it becomes due. A deadline exists only while nothing + * holds its Collection. It is set when the last hold ends: the sync start, + * the last unsubscribe, or the last settlement. The model reads no production + * state and does not copy the cleanup queue or idle scheduling; the + * cleanup-queue oracle owns those. + * + * The history grammar, with `gcTime` 100: subscribe to either Collection, + * unsubscribe one of its current subscribers, insert a row whose handler + * stays pending, complete or fail the oldest pending handler, and advance the + * clock by 20, 60, or 120 ms. An insert writes to one Collection through its + * `onInsert` handler, or to both through `createTransaction` with a manual + * commit. A subscribe after retirement restarts that Collection. A history + * ends in one of two ways. It cleans up both Collections explicitly, or it + * drains: it unsubscribes everyone, settles every pending handler with one + * outcome, and advances past the deadlines, so each drained history ends + * with retirements that follow its last settlement. Inserting into a retired + * Collection is not generated: it would restart the sync, which is the + * subscribe step's job. Unsubscribing more subscribers than exist is not + * legal. + * + * The production driver uses two real Collections with a synchronous ready + * sync, public `subscribeChanges`, `insert`, `createTransaction`, and + * `cleanup()`, and controls each handler through a deferred. After each + * step, on a fake clock whose pending timers and microtasks have run, the + * comparison checks two public facts for each Collection: whether it is + * `cleaned-up`, and that each persisting transaction's row is still visible. + * + * Limits: paced mutations, offline transactions, live-query Collections, and + * wall-clock corrections are not generated. A transaction that never settles + * holds its Collections for as long as it persists. + */ + +const GC_TIME_MS = 100 +type Row = { id: number } +type Side = `a` | `b` +type Step = + | { kind: `subscribe`; side: Side } + | { kind: `unsubscribe`; side: Side } + | { kind: `insert`; into: Side | `both` } + | { kind: `settle`; outcome: `complete` | `fail` } + | { kind: `advance`; elapsedMs: 20 | 60 | 120 } +type History = { + steps: Array + end: `cleanup` | `drain-complete` | `drain-fail` +} +type ReachState = + | `gc-due-while-persisting` + | `retired-after-completion` + | `retired-after-failure` + | `retired-after-shared-settlement` + | `settled-while-subscribed` + | `cleanup-while-persisting` + | `restarted-after-retirement` + +type CollectionModel = { + subscribers: number + persisting: Array + deadline: number | undefined + // Which hold's end set the deadline, for reach only. + deadlineAfter: { outcome: `complete` | `fail`; shared: boolean } | undefined + retired: boolean + // When the Collection last lost its last subscriber, for reach only. + unsubscribedAt: number | undefined +} +type Model = { + now: number + collections: Record + // Pending handlers in settlement order, with the Collections each touches. + pending: Array<{ id: number; sides: Array }> +} + +const sides: Array = [`a`, `b`] + +const held = (collection: CollectionModel) => + collection.subscribers > 0 || collection.persisting.length > 0 + +// A hold just ended: the Collection becomes due `gcTime` from now. +function release( + model: Model, + side: Side, + after?: CollectionModel[`deadlineAfter`], +): void { + const collection = model.collections[side] + if (held(collection)) return + collection.deadline = model.now + GC_TIME_MS + collection.deadlineAfter = after +} + +// The cleanup queue's timer adds one tick, so production retires the +// Collection just after its deadline, not at it. Clock steps are at least +// 20 ms, so a step that passes the deadline also passes that tick. +function advanceModel(model: Model, elapsedMs: number): void { + model.now += elapsedMs + for (const collection of Object.values(model.collections)) { + if (collection.deadline !== undefined && collection.deadline < model.now) { + collection.retired = true + collection.deadline = undefined + } + } +} + +const runConfig = readOracleRunConfig() +let reach = createReach() +function createReach() { + return createOracleReach( + `collection-lifecycle.gc-persisting-transaction`, + [ + `gc-due-while-persisting`, + `retired-after-completion`, + `retired-after-failure`, + `retired-after-shared-settlement`, + `settled-while-subscribed`, + `cleanup-while-persisting`, + `restarted-after-retirement`, + ], + ) +} + +async function runHistory(history: History): Promise { + reach.beginHistory() + vi.useFakeTimers() + vi.setSystemTime(0) + resetCleanupQueue() + const handlers = new Map>() + const create = (side: Side) => + createCollection({ + id: `gc-persisting-transaction-${side}`, + getKey: (row) => row.id, + gcTime: GC_TIME_MS, + startSync: true, + sync: { + sync: ({ markReady }) => { + markReady() + return () => {} + }, + }, + onInsert: ({ transaction }) => + handlers.get(transaction.mutations[0].key)!.promise, + }) + const collections = { a: create(`a`), b: create(`b`) } + const subscriptions: Record void }>> = { + a: [], + b: [], + } + const startModel = (): CollectionModel => ({ + subscribers: 0, + persisting: [], + deadline: GC_TIME_MS, + deadlineAfter: undefined, + retired: false, + unsubscribedAt: 0, + }) + const model: Model = { + now: 0, + collections: { a: startModel(), b: startModel() }, + pending: [], + } + let nextId = 1 + + // Run every timer due by now, and the microtasks they start. + const settle = () => vi.advanceTimersByTimeAsync(0) + const check = (label: string) => { + for (const side of sides) { + const expected = model.collections[side] + const collection = collections[side] + expect( + collection.status === `cleaned-up`, + `${label}: ${side} cleaned up at ${model.now} ms`, + ).toBe(expected.retired) + for (const id of expected.persisting) { + expect(collection.has(id), `${label}: ${side} row ${id} visible`).toBe( + true, + ) + } + } + } + + const apply = async (step: Step, label: string) => { + if (step.kind === `subscribe`) { + const expected = model.collections[step.side] + if (expected.retired) reach.reach(`restarted-after-retirement`) + subscriptions[step.side].push( + collections[step.side].subscribeChanges(() => {}), + ) + expected.subscribers++ + expected.retired = false + expected.deadline = undefined + expected.unsubscribedAt = undefined + } else if (step.kind === `unsubscribe`) { + const subscription = subscriptions[step.side].pop() + if (!subscription) return + subscription.unsubscribe() + const expected = model.collections[step.side] + expected.subscribers-- + if (expected.subscribers === 0) expected.unsubscribedAt = model.now + release(model, step.side) + } else if (step.kind === `insert`) { + const into = step.into === `both` ? sides : [step.into] + if (into.some((side) => model.collections[side].retired)) return + const id = nextId++ + const handler = createDeferred() + handlers.set(id, handler) + if (step.into === `both`) { + const transaction = createTransaction({ + autoCommit: false, + mutationFn: () => handler.promise, + }) + transaction.mutate(() => { + collections.a.insert({ id }) + collections.b.insert({ id }) + }) + transaction.commit().catch(() => {}) + } else { + collections[step.into] + .insert({ id }) + .isPersisted.promise.catch(() => {}) + } + model.pending.push({ id, sides: into }) + for (const side of into) { + model.collections[side].persisting.push(id) + model.collections[side].deadline = undefined + } + } else if (step.kind === `settle`) { + const settled = model.pending.shift() + if (!settled) return + const handler = handlers.get(settled.id)! + if (step.outcome === `complete`) handler.resolve() + else handler.reject(new Error(`handler ${settled.id} failed`)) + for (const side of settled.sides) { + const expected = model.collections[side] + if (expected.subscribers > 0) reach.reach(`settled-while-subscribed`) + expected.persisting = expected.persisting.filter( + (id) => id !== settled.id, + ) + release(model, side, { + outcome: step.outcome, + shared: settled.sides.length > 1, + }) + } + } else { + const before = sides.map((side) => model.collections[side].retired) + advanceModel(model, step.elapsedMs) + await vi.advanceTimersByTimeAsync(step.elapsedMs) + for (const [index, side] of sides.entries()) { + const expected = model.collections[side] + if ( + expected.persisting.length > 0 && + expected.unsubscribedAt !== undefined && + model.now - expected.unsubscribedAt >= GC_TIME_MS + ) + reach.reach(`gc-due-while-persisting`) + const after = expected.deadlineAfter + if (before[index] || !expected.retired || !after) continue + reach.reach( + after.outcome === `complete` + ? `retired-after-completion` + : `retired-after-failure`, + ) + if (after.shared) reach.reach(`retired-after-shared-settlement`) + } + } + await settle() + check(label) + } + + try { + await settle() + check(`start`) + for (const [index, step] of history.steps.entries()) + await apply(step, `step ${index} ${step.kind}`) + if (history.end === `cleanup`) { + if (model.pending.length > 0) reach.reach(`cleanup-while-persisting`) + for (const side of sides) { + await collections[side].cleanup() + expect(collections[side].status, `explicit cleanup of ${side}`).toBe( + `cleaned-up`, + ) + } + } else { + const outcome = history.end === `drain-complete` ? `complete` : `fail` + const drain: Array = [ + ...sides.flatMap((side) => + subscriptions[side].map((): Step => ({ kind: `unsubscribe`, side })), + ), + ...model.pending.map((): Step => ({ kind: `settle`, outcome })), + { kind: `advance`, elapsedMs: 120 }, + ] + for (const step of drain) await apply(step, `drain ${step.kind}`) + } + } finally { + for (const handler of handlers.values()) handler.resolve() + await settle() + for (const side of sides) await collections[side].cleanup() + resetCleanupQueue() + vi.clearAllTimers() + vi.useRealTimers() + } +} + +const sideArbitrary = fc.constantFrom(`a` as const, `b` as const) + +// Clock steps and settlements are weighted up so that most histories reach a +// deadline after their last hold ends. +const stepArbitrary: fc.Arbitrary = fc.oneof( + { + arbitrary: fc.record({ + kind: fc.constant(`subscribe` as const), + side: sideArbitrary, + }), + weight: 1, + }, + { + arbitrary: fc.record({ + kind: fc.constant(`unsubscribe` as const), + side: sideArbitrary, + }), + weight: 2, + }, + { + arbitrary: fc.record({ + kind: fc.constant(`insert` as const), + into: fc.constantFrom(`a` as const, `b` as const, `both` as const), + }), + weight: 2, + }, + { + arbitrary: fc.record({ + kind: fc.constant(`settle` as const), + outcome: fc.constantFrom(`complete` as const, `fail` as const), + }), + weight: 2, + }, + { + arbitrary: fc.record({ + kind: fc.constant(`advance` as const), + elapsedMs: fc.constantFrom(20 as const, 60 as const, 120 as const), + }), + weight: 3, + }, +) + +const property = fc.asyncProperty( + fc.record({ + steps: fc.array(stepArbitrary, { minLength: 1, maxLength: 14 }), + end: fc.constantFrom( + `cleanup` as const, + `drain-complete` as const, + `drain-fail` as const, + ), + }), + (history) => runHistory(history), +) + +// Replay one history with TANSTACK_DB_ORACLE_SEED, TANSTACK_DB_ORACLE_PATH, +// and TANSTACK_DB_ORACLE_PROPERTY=collection-lifecycle.gc-persisting-transaction. +it.each([20261024, undefined])( + `retires a Collection only after gcTime with no subscriber and no persisting transaction (seed %s)`, + async (seed) => { + reach = createReach() + await fc.assert(property, { + ...oraclePropertyOptions( + 100, + `collection-lifecycle.gc-persisting-transaction`, + ), + ...(seed === undefined || runConfig.replayPath !== undefined + ? {} + : { seed }), + }) + reach.assertReached() + }, +) diff --git a/packages/db/tests/oracle-config.ts b/packages/db/tests/oracle-config.ts index 46e6efb6b2..c207dc51b8 100644 --- a/packages/db/tests/oracle-config.ts +++ b/packages/db/tests/oracle-config.ts @@ -51,6 +51,7 @@ const staticOracleProperties = [ `sqlite-ordinary.write-history`, `sqlite-ordinary.independent-work`, `collection-sync.reentrant-drain`, + `collection-lifecycle.gc-persisting-transaction`, `collection-state.retention`, `collection-state.accepted-snapshot.before-delete`, `collection-state.accepted-snapshot.during-delete`, diff --git a/packages/db/tests/oracle-replay-manifest.ts b/packages/db/tests/oracle-replay-manifest.ts index 7bcf5f9337..ec85fafd57 100644 --- a/packages/db/tests/oracle-replay-manifest.ts +++ b/packages/db/tests/oracle-replay-manifest.ts @@ -84,6 +84,11 @@ const ownerGroups: ReadonlyArray = [ `collection-sync`, `reentrant-drain`, ], + [ + `db/tests/collection-gc-transaction-oracle.property.test.ts`, + `collection-lifecycle`, + `gc-persisting-transaction`, + ], [ `db/tests/collection-state-retention-oracle.property.test.ts`, `collection-state`,