Skip to content

fix(db): reclaim unused sync without aborting preloads - #1810

Merged
KyleAMathews merged 9 commits into
mainfrom
codex/review-1744
Sep 11, 2026
Merged

fix(db): reclaim unused sync without aborting preloads#1810
KyleAMathews merged 9 commits into
mainfrom
codex/review-1744

Conversation

@KyleAMathews

@KyleAMathews KyleAMathews commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Collections that start syncing before anything subscribes now release their source subscriptions after the GC delay. This fixes query graphs retained by discarded React renders while keeping pending preload() calls alive until they settle.

Builds on @edzis's work in #1744 and the reproduction in #1178, preserving the original commits.

Root cause and approach

GC previously started only when the last subscriber left. A live query constructed during rendering starts sync immediately, but a suspended or throwing render may never commit and subscribe. With no subscriber departure, that query retained its source subscriptions indefinitely.

Arm GC when sync starts without an owner. A subscriber or pending preload owns the collection; automatic cleanup requires neither to exist. Starting a preload cancels both queued GC and pending idle cleanup, and settlement starts a fresh unused retention period. Preloading already-ready data also renews that period, including when returning a cached preload promise. Explicit cleanup() can still abort a preload.

The initial delay is max(gcTime, 50ms). This grace period reduces cleanup between render and commit; it does not guarantee React will commit within 50ms. A later subscriber restarts a reclaimed query. Once the last subscriber leaves, the configured gcTime applies without the floor. gcTime: 0 continues to disable automatic GC.

The shared cleanup queue clears its timer when its last task is canceled and calls unref() on Node timer handles so background GC does not keep a finished process alive. Browser timer handles remain supported.

Cleanup also advances the observable state revision. A detached observer that misses the entire cleanup/restart cycle therefore discards its old snapshot even when the reload is empty and returns to the same ready status.

This keeps ownership and reclamation in the collection lifecycle rather than adding framework-specific disposal. It does not change adapter defaults or promise to retain a query for an arbitrarily delayed commit.

Tests and verification

Lifecycle tests use fake-clock boundaries, source subscription counts, and projection-call assertions. The changed fixtures release their collections. Electric refresh tests verify cancellation of the specific refresh timer instead of counting unrelated shared timers.

Coverage includes discarded renders and late subscribers, orphan reclamation and restart, disabled GC, slow/shared preloads, repeated warm preloads and cached-promise reuse, preload failure and explicit cleanup, pending idle cleanup, Node process exit, and detached wholesale/granular snapshots across an unobserved cleanup and empty reload. Red/green controls reproduced the pending/warm preload, detached-observer, and Node regressions, disabled orphan GC, omitted final-task timer retirement, omitted Electric refresh timeout cancellation, and enabled erroneous child-facade GC while its root remained subscribed; the corresponding regressions failed and passed after restoration.

  • DB suite: 5,145 tests across 176 files passed.
  • React suite: 183 tests across 14 files passed.
  • Electric suite passed; typechecking passed for all three suites.
  • React and Electric builds, including dependencies, passed.
  • Changed-file lint and formatting checks passed; existing Electric no-shadow warnings remain.
pnpm --filter @tanstack/react-db... --filter @tanstack/electric-db-collection... --workspace-concurrency=2 build
pnpm --filter @tanstack/db exec vitest run --pool-options.threads.maxThreads=2 --maxWorkers=2 --coverage.enabled=false
pnpm --filter @tanstack/react-db exec vitest run --pool-options.threads.maxThreads=2 --maxWorkers=2 --coverage.enabled=false
pnpm --filter @tanstack/electric-db-collection exec vitest run --pool-options.threads.maxThreads=2 --maxWorkers=2 --coverage.enabled=false

Review map

  • collection/sync.ts and collection/lifecycle.ts: initial GC scheduling and preload ownership through all cleanup stages.
  • collection/changes.ts: cleanup revision invalidation for detached snapshots.
  • collection/cleanup-queue.ts: last-task cancellation and Node process lifetime.
  • DB, React, and Electric tests: lifecycle laws, adapter behavior, and precise resource assertions.
  • Public gcTime docs, skills, and changeset: retention behavior and unchanged GC opt-out.

Fixes #1178

Summary by CodeRabbit

  • Bug Fixes

    • Improved automatic cleanup of unused collections and live queries.
    • Added a minimum 50 ms grace period for collections that begin syncing without subscribers.
    • Pending preloads remain active until completion, and preloading ready data refreshes retention.
    • Automatic cleanup can be disabled with non-positive or non-finite gcTime values.
    • Background cleanup no longer prevents Node.js processes from exiting.
    • Detached observers now receive fresh snapshots after collections are reloaded.
  • Documentation

    • Clarified garbage-collection timing, preload retention, and configuration behavior.
    • Updated guidance on when synchronization begins during rendering.
  • Tests

    • Expanded coverage for cleanup, preloading, process exit, refresh lifecycle, and interrupted renders.

edzis and others added 6 commits August 18, 2026 11:20
`startGCTimer` had one caller: `removeSubscriber`, on the edge where the
last subscriber leaves. A collection whose subscriber count went from zero
straight back to zero never crossed that edge and so never armed the timer.

Sync starts without a subscriber in three places -- `startSync: true`,
`preload()` and `startSyncImmediate()`. A live query started that way keeps
a subscription on every collection it reads from, so it both survives and
reprocesses every source change forever, however short its `gcTime` is.

Framework adapters build their live query collection while rendering and
subscribe when that render commits, which makes every render React discards
before committing -- a suspended subtree, a render that throws, a
time-sliced render restarted by an interleaved update -- leak one compiled
query graph rooted at a long-lived source collection.

`startSync` is the single point every route into sync passes through, so it
now arms the timer whenever it runs unsubscribed. The delay is floored at
50ms so a subscriber arriving with the commit cancels it first; the floor
does not apply to the last-subscriber-leaves timer, which still fires on
`gcTime`. `gcTime: 0` still disables GC.

`CleanupQueue.cancel` also retires the shared root timer once it empties the
queue, instead of leaving it armed with nothing to run.
The comments introduced with the unsubscribed-sync fix explained the defect
three times over, at a length the surrounding methods do not use. What is
left is the part that is invisible from the code: why the floor exists and
cannot be `gcTime`, why a non-empty cleanup queue keeps a timer that may
wake early, and that `addSubscriber` counts itself in before starting sync.

`startGCTimerIfUnsubscribed` now reads like its siblings -- what it does,
then when it runs. Its history belongs in the log, not above the method.
`meta-framework` said an unpreloaded collection starts syncing when the
component mounts. It starts on the component's first render -- `useLiveQuery`
constructs with `startSync: true` from the render body -- which is why a render
that never commits can still start sync. Mount is only where teardown is keyed.

`live-queries` documented that `gcTime: 0` disables collection for a derived
collection. It now also opts out of reclaiming a collection that synced without
ever gaining a subscriber, so say so, along with the fact that `gcTime: 0`
means the opposite in TanStack Query -- prompt collection there, none here.
@coderabbitai

coderabbitai Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 6ac480aa-963d-4b33-9cbf-3138e67160dc

📥 Commits

Reviewing files that changed from the base of the PR and between d508418 and 3f17c5e.

📒 Files selected for processing (4)
  • .changeset/lucky-donkeys-repeat.md
  • packages/db/src/collection/changes.ts
  • packages/db/src/collection/index.ts
  • packages/db/tests/observer-cleanup-restart.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • .changeset/lucky-donkeys-repeat.md

Included review availability: Your plan provides up to 8 included reviews per hour; 5 remain after this review.


📝 Walkthrough

Walkthrough

The change updates collection garbage collection for unsubscribed sync, pending and warm preloads, cleanup timers, detached observer revisions, and React rendering. It adds regression tests and documents the updated behavior.

Changes

Collection garbage-collection lifecycle

Layer / File(s) Summary
GC eligibility and preload coordination
packages/db/src/collection/lifecycle.ts, packages/db/src/collection/sync.ts
Collections started without subscribers receive a 50 ms minimum GC delay. Pending preloads block GC. Warm preloads renew retention.
Cleanup timer retirement
packages/db/src/collection/cleanup-queue.ts, packages/db/tests/cleanup-queue.test.ts, packages/db/tests/gc-process-exit.test.ts
Empty queue timers are cleared. Node timeout handles are unreferences. Tests verify cancellation and process exit.
Observer state revision on cleanup
packages/db/src/collection/changes.ts, packages/db/src/collection/index.ts, packages/db/tests/observer-cleanup-restart.test.ts
Cleanup advances state revisions. Detached observers detect empty state after a live query reload.
GC and cleanup regression coverage
packages/db/tests/orphaned-live-query-gc.test.ts, packages/db/tests/unsubscribed-sync-gc.test.ts, packages/db/tests/preload-gc.test.ts, packages/db/tests/query/includes.test.ts, packages/electric-db-collection/tests/electric.test.ts, packages/react-db/tests/useLiveQuery.uncommitted-render.test.tsx
Tests cover orphaned queries, preload lifecycle, resubscription, disabled GC, child facades, refresh timers, process exit, and uncommitted React renders.
GC contracts and release documentation
packages/db/src/types.ts, packages/db/skills/db-core/live-queries/SKILL.md, packages/db/skills/meta-framework/SKILL.md, .changeset/lucky-donkeys-repeat.md
Documentation describes GC grace periods, preload retention, disabled GC values, observer freshness, and cleanup behavior.

Priority: ➖ Normal

Estimated code review effort: 4 (Complex) | ~45 minutes

Change: Bug fix · Severity of issue fixed: Medium

Sequence Diagram(s)

sequenceDiagram
  participant CollectionSyncManager
  participant CollectionLifecycleManager
  participant CleanupQueue
  participant DetachedObserver
  CollectionSyncManager->>CollectionLifecycleManager: Start sync or preload
  CollectionLifecycleManager->>CleanupQueue: Schedule or cancel GC
  CleanupQueue->>CollectionLifecycleManager: Recheck GC eligibility
  CollectionLifecycleManager->>CollectionSyncManager: Clean up eligible collection
  CollectionSyncManager->>DetachedObserver: Publish updated state revision
Loading

Suggested reviewers: tannerlinsley, kevin-dp

Merge Risk: ⚪ Minimal · up to 3f17c

The cleanup revision change and detached-observer reload coverage do not leave an identified merge-blocking risk.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 3 functions across 15 files. (1 skipped: 1… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Issue #1178 requires a conditional useLiveQuery collection to stop source synchronization after the last subscriber leaves and gcTime expires. The lifecycle changes start a grace-period GC for col…
Out of Scope Changes check ✅ Passed The changes remain within the linked issue scope. Cleanup-queue, preload, timer, process-exit, disabled-GC, detached-snapshot, React, and Electric changes support the collection GC lifecycle or verify…
Title check ✅ Passed The title clearly summarizes the main change: reclaiming unused synchronization without aborting pending preloads.
Description check ✅ Passed The description is detailed and covers the change, motivation, implementation, testing, release impact, and changeset. It does not use the template's exact section headings or include the checklist ch…
Full details: Docstring Coverage

Explanation

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

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/review-1744

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.

@pkg-pr-new

pkg-pr-new Bot commented Sep 11, 2026

Copy link
Copy Markdown
More templates

@tanstack/angular-db

npm i https://pkg.pr.new/@tanstack/angular-db@1810

@tanstack/browser-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/browser-db-sqlite-persistence@1810

@tanstack/capacitor-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/capacitor-db-sqlite-persistence@1810

@tanstack/cloudflare-durable-objects-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/cloudflare-durable-objects-db-sqlite-persistence@1810

@tanstack/db

npm i https://pkg.pr.new/@tanstack/db@1810

@tanstack/db-ivm

npm i https://pkg.pr.new/@tanstack/db-ivm@1810

@tanstack/db-sqlite-persistence-core

npm i https://pkg.pr.new/@tanstack/db-sqlite-persistence-core@1810

@tanstack/electric-db-collection

npm i https://pkg.pr.new/@tanstack/electric-db-collection@1810

@tanstack/electron-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/electron-db-sqlite-persistence@1810

@tanstack/expo-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/expo-db-sqlite-persistence@1810

@tanstack/node-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/node-db-sqlite-persistence@1810

@tanstack/offline-transactions

npm i https://pkg.pr.new/@tanstack/offline-transactions@1810

@tanstack/powersync-db-collection

npm i https://pkg.pr.new/@tanstack/powersync-db-collection@1810

@tanstack/query-db-collection

npm i https://pkg.pr.new/@tanstack/query-db-collection@1810

@tanstack/react-db

npm i https://pkg.pr.new/@tanstack/react-db@1810

@tanstack/react-native-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/react-native-db-sqlite-persistence@1810

@tanstack/react-router-with-db

npm i https://pkg.pr.new/@tanstack/react-router-with-db@1810

@tanstack/rxdb-db-collection

npm i https://pkg.pr.new/@tanstack/rxdb-db-collection@1810

@tanstack/solid-db

npm i https://pkg.pr.new/@tanstack/solid-db@1810

@tanstack/svelte-db

npm i https://pkg.pr.new/@tanstack/svelte-db@1810

@tanstack/tauri-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/tauri-db-sqlite-persistence@1810

@tanstack/trailbase-db-collection

npm i https://pkg.pr.new/@tanstack/trailbase-db-collection@1810

@tanstack/vue-db

npm i https://pkg.pr.new/@tanstack/vue-db@1810

commit: 3f17c5e

@github-actions

github-actions Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Size Change: +250 B (+0.15%)

Total Size: 164 kB

📦 View Changed
Filename Size Change
packages/db/dist/esm/collection/changes.js 2.23 kB +3 B (+0.13%)
packages/db/dist/esm/collection/cleanup-queue.js 794 B +37 B (+4.89%) 🔍
packages/db/dist/esm/collection/index.js 4.25 kB +4 B (+0.09%)
packages/db/dist/esm/collection/lifecycle.js 2.12 kB +112 B (+5.57%) 🔍
packages/db/dist/esm/collection/sync.js 4.62 kB +94 B (+2.08%)
ℹ️ View Unchanged
Filename Size
packages/db/dist/esm/client.js 3.66 kB
packages/db/dist/esm/collection-options.js 236 B
packages/db/dist/esm/collection/change-events.js 1.44 kB
packages/db/dist/esm/collection/events.js 481 B
packages/db/dist/esm/collection/indexes.js 1.99 kB
packages/db/dist/esm/collection/mutations.js 2.54 kB
packages/db/dist/esm/collection/state.js 6.47 kB
packages/db/dist/esm/collection/subscription.js 8.72 kB
packages/db/dist/esm/collection/transaction-metadata.js 144 B
packages/db/dist/esm/deferred.js 207 B
packages/db/dist/esm/errors.js 5.26 kB
packages/db/dist/esm/event-emitter.js 964 B
packages/db/dist/esm/index.js 3.65 kB
packages/db/dist/esm/indexes/auto-index.js 829 B
packages/db/dist/esm/indexes/base-index.js 1.14 kB
packages/db/dist/esm/indexes/basic-index.js 2.07 kB
packages/db/dist/esm/indexes/btree-index.js 2.26 kB
packages/db/dist/esm/indexes/index-registry.js 820 B
packages/db/dist/esm/indexes/reverse-index.js 376 B
packages/db/dist/esm/live-query-adapter.js 318 B
packages/db/dist/esm/live-query-observer.js 3.69 kB
packages/db/dist/esm/live-query-options.js 702 B
packages/db/dist/esm/live-query-window-controller.js 4.36 kB
packages/db/dist/esm/local-only.js 975 B
packages/db/dist/esm/local-storage.js 2.15 kB
packages/db/dist/esm/optimistic-action.js 359 B
packages/db/dist/esm/paced-mutations.js 496 B
packages/db/dist/esm/proxy.js 3.11 kB
packages/db/dist/esm/query/builder/functions.js 1.47 kB
packages/db/dist/esm/query/builder/index.js 6.69 kB
packages/db/dist/esm/query/builder/query-ir.js 116 B
packages/db/dist/esm/query/builder/ref-proxy.js 1.24 kB
packages/db/dist/esm/query/compiler/evaluators.js 1.92 kB
packages/db/dist/esm/query/compiler/expressions.js 560 B
packages/db/dist/esm/query/compiler/group-by.js 4.13 kB
packages/db/dist/esm/query/compiler/index.js 9.06 kB
packages/db/dist/esm/query/compiler/joins.js 3 kB
packages/db/dist/esm/query/compiler/lazy-targets.js 1.1 kB
packages/db/dist/esm/query/compiler/order-by.js 1.91 kB
packages/db/dist/esm/query/compiler/parent-routes.js 319 B
packages/db/dist/esm/query/compiler/route-metadata.js 1.24 kB
packages/db/dist/esm/query/compiler/select.js 1.58 kB
packages/db/dist/esm/query/effect.js 4.6 kB
packages/db/dist/esm/query/equality-value-identity.js 591 B
packages/db/dist/esm/query/expression-helpers.js 1.43 kB
packages/db/dist/esm/query/ir-stable-identity.js 4.04 kB
packages/db/dist/esm/query/ir.js 1.59 kB
packages/db/dist/esm/query/live-query-collection.js 391 B
packages/db/dist/esm/query/live/bucket-facade-adapter.js 2.73 kB
packages/db/dist/esm/query/live/collection-config-builder.js 6.97 kB
packages/db/dist/esm/query/live/collection-registry.js 264 B
packages/db/dist/esm/query/live/collection-subscriber.js 2.25 kB
packages/db/dist/esm/query/live/internal.js 145 B
packages/db/dist/esm/query/live/materialized-pipeline.js 2.32 kB
packages/db/dist/esm/query/live/ordered-source-loader.js 3.14 kB
packages/db/dist/esm/query/live/subset-demand-controller.js 1.26 kB
packages/db/dist/esm/query/live/utils.js 1.14 kB
packages/db/dist/esm/query/optimizer.js 2.91 kB
packages/db/dist/esm/query/query-once.js 359 B
packages/db/dist/esm/query/runtime-reference-identity.js 572 B
packages/db/dist/esm/query/subset-dedupe.js 486 B
packages/db/dist/esm/scheduler.js 1.34 kB
packages/db/dist/esm/SortedMap.js 1.3 kB
packages/db/dist/esm/strategies/debounceStrategy.js 247 B
packages/db/dist/esm/strategies/queueStrategy.js 428 B
packages/db/dist/esm/strategies/throttleStrategy.js 246 B
packages/db/dist/esm/transactions.js 3.51 kB
packages/db/dist/esm/utils.js 1.01 kB
packages/db/dist/esm/utils/array-utils.js 270 B
packages/db/dist/esm/utils/browser-polyfills.js 304 B
packages/db/dist/esm/utils/btree.js 4.51 kB
packages/db/dist/esm/utils/callbacks.js 174 B
packages/db/dist/esm/utils/comparison.js 1.49 kB
packages/db/dist/esm/utils/cursor.js 676 B
packages/db/dist/esm/utils/error.js 167 B
packages/db/dist/esm/utils/get-or-create.js 155 B
packages/db/dist/esm/utils/index-optimization.js 2.42 kB
packages/db/dist/esm/utils/type-guards.js 230 B
packages/db/dist/esm/utils/uuid.js 449 B
packages/db/dist/esm/virtual-props.js 360 B

compressed-size-action::db-package-size

@github-actions

github-actions Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Size Change: 0 B

Total Size: 7.34 kB

ℹ️ View Unchanged
Filename Size
packages/react-db/dist/esm/DbProvider.js 317 B
packages/react-db/dist/esm/HydrationBoundary.js 263 B
packages/react-db/dist/esm/index.js 330 B
packages/react-db/dist/esm/live-query-internals.js 282 B
packages/react-db/dist/esm/useLiveInfiniteQuery.js 1.9 kB
packages/react-db/dist/esm/useLiveQuery.js 2.68 kB
packages/react-db/dist/esm/useLiveQueryEffect.js 355 B
packages/react-db/dist/esm/useLiveSuspenseQuery.js 812 B
packages/react-db/dist/esm/usePacedMutations.js 401 B

compressed-size-action::react-db-package-size

@tannerlinsley tannerlinsley left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Reviewed unused sync collection, pending preload retention, timer cleanup, and the abandoned render coverage. The lifecycle guards preserve pending preloads while allowing unused sync to be collected. The selected core suites passed locally, 176 tests, along with all 4 uncommitted render tests. CI is green. Looks good to me.

@KyleAMathews
KyleAMathews merged commit ddc129e into main Sep 11, 2026
11 checks passed
@KyleAMathews
KyleAMathews deleted the codex/review-1744 branch September 11, 2026 15:07
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.

react-db's useLiveQuery seems to leave an extra subscriber in StrictMode

3 participants