Skip to content

Fix unbounded expression growth in DeduplicatedLoadSubset#1348

Merged
KyleAMathews merged 4 commits intomainfrom
claude/fix-task-acls-500-error-3w9II
Mar 10, 2026
Merged

Fix unbounded expression growth in DeduplicatedLoadSubset#1348
KyleAMathews merged 4 commits intomainfrom
claude/fix-task-acls-500-error-3w9II

Conversation

@KyleAMathews
Copy link
Collaborator

@KyleAMathews KyleAMathews commented Mar 10, 2026

Fix unbounded WHERE expression growth in DeduplicatedLoadSubset when loading all data after accumulating specific predicates — prevents production 500 errors from oversized query bodies.

How the bug happens

When multiple components query the same collection with specific filters (e.g., eq(task_id, uuid) as a user navigates between tasks), DeduplicatedLoadSubset accumulates those predicates into an IN(task_id, [uuid-1, ..., uuid-N]) expression — this is correct and working as designed.

The bug triggers when a page then loads the entire collection with no WHERE clause (e.g., a list/index page). The deduplication layer correctly computes a difference query — "give me everything I don't already have" → NOT(IN(task_id, [...])) — and sends it to the backend. But it incorrectly records that optimized expression as what was "loaded" instead of recording that all data was requested. So the system never learns it has everything, and each subsequent unfiltered load compounds the expression further, eventually producing a 193KB deeply nested WHERE clause that crashes downstream parsers.

Root Cause

When loadSubset({}) (load all data) was called after accumulating many eq(task_id, uuid) predicates, the code computed an optimized difference query (NOT(IN(task_id, [...]))) and sent it to the backend — correct. But it then passed that same optimized expression to updateTracking(), which meant:

  1. hasLoadedAllData was never set to true (because where wasn't undefined)
  2. unlimitedWhere became IN([...]) OR NOT(IN([...])) — logically tautological but not simplified
  3. Each subsequent "load all" compounded the expression further
  4. After enough cycles: 193KB deeply nested expression that crashes downstream parsers

Approach

Separate cloneOptions into two copies at the point where the difference is computed:

  • trackingOptions: Preserves the original predicate (e.g., where: undefined) for updateTracking() — so "load all" correctly sets hasLoadedAllData = true
  • loadOptions: May be narrowed with the difference expression for the actual backend request

Both the sync return path (return true) and async path (.then()) use trackingOptions for tracking and loadOptions for the backend call and in-flight matching.

Key Invariants

  • trackingOptions.where always equals the caller's original where value
  • loadOptions.where may differ (narrowed by minusWherePredicates)
  • In-flight entries store loadOptions (matching must reflect what data the backend will actually return)
  • updateTracking receives trackingOptions (tracking must reflect what was requested)

Non-goals

  • Fixing the downstream parser crash (separate concern — Electric should return 400 not 500 for unparseable expressions)
  • Adding a max-size threshold for accumulated predicates (possible future improvement)
  • Optimizing unionWherePredicates to simplify tautological expressions

Verification

pnpm vitest run packages/db/tests/query/subset-dedupe.test.ts

27 tests pass, including 4 new regression tests:

  • Async: eq accumulation → load all → verify deduplication
  • Sync: same scenario with synchronous _loadSubset return
  • Repeated "load all" without expression growth
  • Extended existing test to verify post-load-all deduplication

Files Changed

  • packages/db/src/query/subset-dedupe.ts — Split single cloneOptions into trackingOptions + loadOptions; use appropriate copy for tracking vs backend calls
  • packages/db/tests/query/subset-dedupe.test.ts — 4 new tests covering the production bug scenario (async + sync paths, repeated loads, expression structure verification); removed debug artifacts

https://claude.ai/code/session_01Vp75RhjVR4tV5FdjKJbBWE

…nbounded WHERE growth

updateTracking() was called with the modified loadOptions (containing the
minusWherePredicates difference expression) instead of the original request
options. When a "load all" request (where=undefined) was made after
accumulating eq() predicates, the difference expression NOT(IN([...]))
was tracked instead of setting hasLoadedAllData=true. Each subsequent
"load all" request compounded the expression: NOT(IN(...) OR NOT(IN(...))),
producing deeply nested 193KB+ WHERE clauses that crashed Electric's parser.

The fix separates loadOptions (sent to backend, may contain optimized
difference query) from trackingOptions (used for tracking, preserves the
original predicate). This ensures "load all" correctly sets
hasLoadedAllData=true regardless of the optimization applied to the
actual request.

https://claude.ai/code/session_01Vp75RhjVR4tV5FdjKJbBWE
@changeset-bot
Copy link

changeset-bot bot commented Mar 10, 2026

🦋 Changeset detected

Latest commit: 3a17319

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

This PR includes changesets to release 12 packages
Name Type
@tanstack/db Patch
@tanstack/angular-db Patch
@tanstack/electric-db-collection 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/trailbase-db-collection Patch
@tanstack/vue-db 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

@pkg-pr-new
Copy link

pkg-pr-new bot commented Mar 10, 2026

More templates

@tanstack/angular-db

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

@tanstack/db

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

@tanstack/db-ivm

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

@tanstack/electric-db-collection

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

@tanstack/offline-transactions

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

@tanstack/powersync-db-collection

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

@tanstack/query-db-collection

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

@tanstack/react-db

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

@tanstack/rxdb-db-collection

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

@tanstack/solid-db

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

@tanstack/svelte-db

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

@tanstack/trailbase-db-collection

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

@tanstack/vue-db

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

commit: d643e0d

@github-actions
Copy link
Contributor

github-actions bot commented Mar 10, 2026

Size Change: +6 B (+0.01%)

Total Size: 93.2 kB

Filename Size Change
./packages/db/dist/esm/query/subset-dedupe.js 927 B +6 B (+0.65%)
ℹ️ View Unchanged
Filename Size
./packages/db/dist/esm/collection/change-events.js 1.39 kB
./packages/db/dist/esm/collection/changes.js 1.22 kB
./packages/db/dist/esm/collection/events.js 388 B
./packages/db/dist/esm/collection/index.js 3.32 kB
./packages/db/dist/esm/collection/indexes.js 1.1 kB
./packages/db/dist/esm/collection/lifecycle.js 1.75 kB
./packages/db/dist/esm/collection/mutations.js 2.34 kB
./packages/db/dist/esm/collection/state.js 3.49 kB
./packages/db/dist/esm/collection/subscription.js 3.71 kB
./packages/db/dist/esm/collection/sync.js 2.41 kB
./packages/db/dist/esm/deferred.js 207 B
./packages/db/dist/esm/errors.js 4.83 kB
./packages/db/dist/esm/event-emitter.js 748 B
./packages/db/dist/esm/index.js 2.72 kB
./packages/db/dist/esm/indexes/auto-index.js 742 B
./packages/db/dist/esm/indexes/base-index.js 766 B
./packages/db/dist/esm/indexes/btree-index.js 2.17 kB
./packages/db/dist/esm/indexes/lazy-index.js 1.1 kB
./packages/db/dist/esm/indexes/reverse-index.js 538 B
./packages/db/dist/esm/local-only.js 808 B
./packages/db/dist/esm/local-storage.js 2.1 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.75 kB
./packages/db/dist/esm/query/builder/functions.js 733 B
./packages/db/dist/esm/query/builder/index.js 4.1 kB
./packages/db/dist/esm/query/builder/ref-proxy.js 1.05 kB
./packages/db/dist/esm/query/compiler/evaluators.js 1.43 kB
./packages/db/dist/esm/query/compiler/expressions.js 430 B
./packages/db/dist/esm/query/compiler/group-by.js 2.23 kB
./packages/db/dist/esm/query/compiler/index.js 2.05 kB
./packages/db/dist/esm/query/compiler/joins.js 2.11 kB
./packages/db/dist/esm/query/compiler/order-by.js 1.45 kB
./packages/db/dist/esm/query/compiler/select.js 1.09 kB
./packages/db/dist/esm/query/expression-helpers.js 1.43 kB
./packages/db/dist/esm/query/ir.js 673 B
./packages/db/dist/esm/query/live-query-collection.js 360 B
./packages/db/dist/esm/query/live/collection-config-builder.js 5.55 kB
./packages/db/dist/esm/query/live/collection-registry.js 264 B
./packages/db/dist/esm/query/live/collection-subscriber.js 2.42 kB
./packages/db/dist/esm/query/live/internal.js 145 B
./packages/db/dist/esm/query/optimizer.js 2.62 kB
./packages/db/dist/esm/query/predicate-utils.js 2.97 kB
./packages/db/dist/esm/query/query-once.js 359 B
./packages/db/dist/esm/scheduler.js 1.3 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 2.9 kB
./packages/db/dist/esm/utils.js 924 B
./packages/db/dist/esm/utils/browser-polyfills.js 304 B
./packages/db/dist/esm/utils/btree.js 5.61 kB
./packages/db/dist/esm/utils/comparison.js 952 B
./packages/db/dist/esm/utils/cursor.js 457 B
./packages/db/dist/esm/utils/index-optimization.js 1.51 kB
./packages/db/dist/esm/utils/type-guards.js 157 B

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

@github-actions
Copy link
Contributor

github-actions bot commented Mar 10, 2026

Size Change: 0 B

Total Size: 3.85 kB

ℹ️ View Unchanged
Filename Size
./packages/react-db/dist/esm/index.js 225 B
./packages/react-db/dist/esm/useLiveInfiniteQuery.js 1.32 kB
./packages/react-db/dist/esm/useLiveQuery.js 1.34 kB
./packages/react-db/dist/esm/useLiveSuspenseQuery.js 559 B
./packages/react-db/dist/esm/usePacedMutations.js 401 B

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

KyleAMathews and others added 3 commits March 10, 2026 15:53
- Condense block comment explaining trackingOptions/loadOptions split
- Remove duplicate inline comments at updateTracking call sites
- Remove stray console.log and unused minusWherePredicates import
- Fix "exponentially" → "unboundedly" in test comment (growth is linear)
- Remove product-specific "sessions index page" reference from test
- Strengthen expression assertion to verify full NOT(IN(...)) structure
- Add sync return path test for the tracking fix
- Simplify test by removing unused calls array

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@KyleAMathews KyleAMathews requested a review from samwillis March 10, 2026 21:59
@KyleAMathews KyleAMathews merged commit 495abc2 into main Mar 10, 2026
6 checks passed
@KyleAMathews KyleAMathews deleted the claude/fix-task-acls-500-error-3w9II branch March 10, 2026 22:08
@github-actions github-actions bot mentioned this pull request Mar 10, 2026
@github-actions
Copy link
Contributor

🎉 This PR has been released!

Thank you for your contribution!

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.

2 participants