Skip to content

fix(db): keep a collection from garbage collection while its transaction persists - #2258

Open
Nabil372 wants to merge 3 commits into
TanStack:mainfrom
Nabil372:fix/db-gc-waits-for-persisting-transactions
Open

Nabil372 wants to merge 3 commits into
TanStack:mainfrom
Nabil372:fix/db-gc-waits-for-persisting-transactions

Conversation

@Nabil372

@Nabil372 Nabil372 commented Oct 25, 2026 •

Copy link
Copy Markdown

Changes

Automatic garbage collection no longer cleans up a collection while a transaction that touches it is persisting. The gcTime delay starts again when the last such transaction completes or fails. An explicit cleanup() is unchanged.

Before this change, canGarbageCollect() checked for subscribers and a pending preload, but not for transactions. A screen could insert a row, unmount, and leave the collection with no subscriber while its mutation handler was still waiting on the server. After gcTime the collection was cleaned up: the optimistic row disappeared while the transaction was still persisting, and waits on the sync run were rejected. With the Electric collection, the handler's awaitTxId rejected with StreamAbortedError, so the write failed even though the server had accepted it.

We run TanStack DB with Electric in production, in web apps and in Expo apps on iOS and Android, where screens close and apps go to the background while writes are in flight, so this window is common for us. We don't run this fix ourselves yet: today we work around the bug by holding the collection with a subscription for the length of each write, as the issue describes. This change would let us remove that hold.

Closes #2256

The change:

  • canGarbageCollect() also requires that no transaction touching the collection is persisting. The existing timer and idle callback already re-check it before they clean up.
  • When a transaction settles, touchCollection() asks each collection it touched to start its GC timer again. The timer starts only if the collection has started and is not cleaned up, and only if nothing else holds it, so a collection with subscribers is unaffected.
  • The gcTime docstring says that a persisting transaction retains the collection.

A transaction that never settles now holds its collection for as long as it persists. That matches what the collection shows: its optimistic rows stay visible for that time.

Oracle Coverage

Coverage-map owner: Collection lifecycle. No existing owner modeled GC against transactions: the subscription lifecycle oracles cover acquisition and publication, and the cleanup-queue oracle covers appointment timing. So this adds a small independent owner, collection-gc-transaction-oracle.property.test.ts, listed in that row.

  • Law: automatic GC retires a collection once it has had no subscriber and no persisting transaction for gcTime elapsed milliseconds. The delay starts again when the last such transaction completes or fails. Explicit cleanup() retires the collection at once. Authority: BaseCollectionConfig.gcTime (a delay for collections with no subscribers), and the optimistic contract that a persisting transaction's rows stay visible until it settles.

  • Model: an elapsed clock and, for each of two collections, the subscriber count, the set of persisting transactions that touch it, and a deadline that exists only while nothing holds that collection. The deadline is set when the last hold ends: the sync start, the last unsubscribe, or the last settlement. It reads no production state and does not copy the cleanup queue or idle scheduling.

  • Histories: property collection-lifecycle.gc-persisting-transaction, with gcTime 100 on a fake clock. Each history has 1 to 14 steps: subscribe to either collection, unsubscribe one of its subscribers, insert a row whose handler stays pending, complete or fail the oldest pending handler, and advance by 20, 60, or 120 ms. An insert goes to one collection through its onInsert, or to both through createTransaction with a manual commit. A subscribe after retirement restarts that collection. Every history ends with an explicit cleanup() of both, or with a drain: unsubscribe everyone, settle every pending handler with one outcome, and advance past the deadlines.

  • Production path and observation: two real collections with a synchronous ready sync, public subscribeChanges, insert, createTransaction, and cleanup(). After each step, once due timers and their microtasks have run, the test compares whether each collection is cleaned-up with the model, and checks that every persisting transaction's row is still visible in each collection it touched. The cleanup queue retires a collection one tick after its deadline. The model treats the deadline itself as not yet due, and every clock step is at least 20 ms, so no comparison falls inside that tick.

  • Reach (the property fails if any state is reached fewer than once):

    State Fixed seed 20261024
    gc-due-while-persisting (old gcTime passed since the last unsubscribe while a transaction persisted) 16
    retired-after-completion 16
    retired-after-failure 26
    retired-after-shared-settlement (the delay was restarted by a transaction that touched both collections) 20
    settled-while-subscribed 10
    cleanup-while-persisting 13
    restarted-after-retirement 13

    Random campaigns reached every state in each of my runs; the lowest count I saw was 3.

  • RED on main: both campaigns fail. The fixed-seed counterexample is an insert into one collection, then two 60 ms steps: at 120 ms the collection is cleaned-up while the transaction persists.

  • GREEN: both campaigns pass.

  • Mutants (all assertion failures):

    Design Result
    main fails: cleaned up at 120 ms while a transaction persists
    Hold while persisting, but never restart the timer on settlement fails: never cleaned up after a drain
    Restart the timer only when a transaction completes fails: never cleaned up after a failed handler
    Restart the timer only for the first collection a transaction touched fails: a transaction over both collections fails, and the second is never cleaned up
  • Limits: paced mutations, offline transactions, live-query collections, and wall-clock corrections are not generated. Offline restoration transactions never commit, so they stay pending and do not hold a collection.

Replay: TANSTACK_DB_ORACLE_SEED=<seed> TANSTACK_DB_ORACLE_PATH=<path> TANSTACK_DB_ORACLE_PROPERTY=collection-lifecycle.gc-persisting-transaction pnpm --dir packages/db exec vitest run tests/collection-gc-transaction-oracle.property.test.ts

The property is registered in oracle-config.ts, the replay manifest, and the package's test:oracles campaign.

Shipped size: pnpm size:compare reports +47 B gzip for @tanstack/db and +38 B for the app scenario. The hold is one more condition in canGarbageCollect(). The rest is the timer restart on settlement, which the hold needs: without it, a collection whose last hold was a transaction would never be collected.

Checklist

  • I have tested this code locally with pnpm test.

@tanstack/db: 13,793 tests pass, and tsc on the test config reports no errors. The suites of @tanstack/electric-db-collection, @tanstack/query-db-collection, @tanstack/offline-transactions, @tanstack/react-db, and @tanstack/db-sqlite-persistence-core pass against this build. pnpm lint reports nothing in the touched files, and pnpm lint:changed is clean.

Release Impact

  • This change affects published code, and I have generated a changeset.
  • This change is docs/CI/dev-only (no release).

Summary by CodeRabbit

  • Bug Fixes
    • Collections are no longer automatically garbage-collected while a transaction that touches them is persisting. After the transaction completes or fails, the gcTime countdown starts again.
    • Explicit cleanup() continues to retire a collection even while a transaction is persisting.
  • Documentation
    • Clarified how pending preloads and persisting transactions affect collection retention and automatic garbage collection.

…ion persists

Automatic GC checked subscribers and pending preloads but not transactions. A collection whose last subscriber left while a mutation handler was still running was cleaned up after gcTime: its optimistic rows disappeared and waits on the sync run, such as Electric's awaitTxId, rejected, so the write failed even when the server accepted it.

A persisting transaction that touches the collection now holds it, and the gcTime delay starts again when the last one completes or fails. Explicit cleanup() is unchanged.

A new generated-history oracle checks retirement against an independent deadline model across subscribe, unsubscribe, insert, settlement, clock steps and explicit cleanup.
…ections

The GC oracle now runs every history over two Collections. An insert writes
to one Collection through its onInsert handler, or to both through
createTransaction with a manual commit, and the model keeps a deadline per
Collection. A settlement that restarts the delay only for the first
Collection a transaction touched now fails the oracle; the one-Collection
grammar let it pass. The oracle also runs in the package's test:oracles
campaign.
@changeset-bot

changeset-bot Bot commented Oct 25, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: f19f490

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 24 packages
Name Type
@tanstack/db Patch
@tanstack/angular-db Patch
@tanstack/capacitor-db-sqlite-persistence-e2e-app Patch
@tanstack/db-sqlite-persistence-core Patch
@tanstack/electric-db-collection Patch
@tanstack/expo-db-sqlite-persistence-e2e-app Patch
@tanstack/offline-transactions Patch
@tanstack/powersync-db-collection Patch
@tanstack/query-db-collection Patch
@tanstack/react-db Patch
@tanstack/rxdb-db-collection Patch
@tanstack/solid-db Patch
@tanstack/svelte-db Patch
@tanstack/tauri-db-sqlite-persistence-e2e-app Patch
@tanstack/trailbase-db-collection Patch
@tanstack/vue-db Patch
@tanstack/browser-db-sqlite-persistence Patch
@tanstack/capacitor-db-sqlite-persistence Patch
@tanstack/cloudflare-durable-objects-db-sqlite-persistence Patch
@tanstack/electron-db-sqlite-persistence Patch
@tanstack/expo-db-sqlite-persistence Patch
@tanstack/node-db-sqlite-persistence Patch
@tanstack/react-native-db-sqlite-persistence Patch
@tanstack/tauri-db-sqlite-persistence Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai

coderabbitai Bot commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review.

Recent review info
Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 8679d17e-2218-4f3e-ac6a-3ee72e74f5a2
Commits

Reviewing files that changed from the base of the PR and between d2935d0 and f19f490.

Files selected for processing: 9
  • .changeset/gc-waits-for-persisting-transactions.md
  • docs/contributing/oracle-coverage.md
  • packages/db/package.json
  • packages/db/src/collection/lifecycle.ts
  • packages/db/src/transactions.ts
  • packages/db/src/types.ts
  • packages/db/tests/collection-gc-transaction-oracle.property.test.ts
  • packages/db/tests/oracle-config.ts
  • packages/db/tests/oracle-replay-manifest.ts

Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 6 remain after this review.

Walkthrough

Automatic collection garbage collection now waits while a touching transaction is persisting. When a transaction completes or fails, the collection’s GC timer starts again. Explicit cleanup() remains unchanged. A property-based oracle tests generated collection and transaction histories.

Changes: Collection garbage-collection lifecycle

Layer / File(s) Summary
Retain collections through transaction settlement
packages/db/src/types.ts, packages/db/src/transactions.ts, packages/db/src/collection/lifecycle.ts
The gcTime documentation describes transaction-based retention. Settled transactions start the GC timer. The garbage-collection check excludes collections with persisting transactions.
Validate and register GC timing histories
packages/db/tests/collection-gc-transaction-oracle.property.test.ts, packages/db/tests/oracle-config.ts, packages/db/tests/oracle-replay-manifest.ts, packages/db/package.json, docs/contributing/oracle-coverage.md, .changeset/gc-waits-for-persisting-transactions.md
A property-based oracle checks retirement timing, persisting-row visibility, and explicit cleanup. The oracle is added to the test command, registry, replay manifest, and coverage documentation. A patch changeset records the behavior.

Priority: Normal

Estimated code review effort: 3 (Moderate) | ~20 minutes

Change: Bug fix · Severity of issue fixed: Medium

Suggested reviewers: kyleamathews

Merge Risk: Minimal · up to f19f4

Automatic collection GC now waits while a transaction touching the collection is persisting, then restarts the gcTime delay when the transaction completes or fails. Explicit cleanup behavior is unchanged. No concrete merge-blocking issue was identified, and a new oracle test covers the lifecycle.

Pre-merge checks: Passed 4 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 4 functions across 6 files. (3 skipped: 3… Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 4 passed
Check name Status Explanation
Title check Passed The title clearly and concisely describes the primary change: preventing collection garbage collection while a transaction persists.
Description check Passed The description includes all required sections, explains the change and motivation, documents oracle coverage and limitations, reports test results, and records the changeset release impact.
Linked Issues check Passed Issue #2256 requires automatic GC to retain a collection while a touching transaction is persisting, restart the gcTime delay after completion or failure, and leave explicit cleanup() unchanged. The r…
Out of Scope Changes check Passed The production changes, gcTime documentation, changeset, oracle test, oracle registry, replay manifest, and test script all support Issue #2256. The supplied change summary shows no unrelated product …
Full details: Linked Issues check

Explanation

Issue #2256 requires automatic GC to retain a collection while a touching transaction is persisting, restart the gcTime delay after completion or failure, and leave explicit cleanup() unchanged. The reviewed lifecycle code adds !this.state.hasPersistingTransaction to canGarbageCollect(), and the transaction path restarts each touched collection through startGCTimerAfterSettlement() for completed or failed transactions. The PR also adds the gcTime documentation and a registered property-based oracle that checks retention, settlement timing, failure timing, shared transactions, and explicit cleanup. These changes satisfy the coding and automated-test requirements.

Full details: Out of Scope Changes check

Explanation

The production changes, gcTime documentation, changeset, oracle test, oracle registry, replay manifest, and test script all support Issue #2256. The supplied change summary shows no unrelated product behavior or unrelated files. The added settlement method is an internal lifecycle mechanism for the required GC restart.

Full details: Docstring Coverage

Explanation

Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 4 functions across 6 files. (3 skipped: 3 unsupported.)

Resolution

Write docstrings for the functions missing them to satisfy the coverage threshold.

Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

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.

Garbage collection cleans up a collection while one of its transactions is still persisting

1 participant