Skip to content

fix(db): a key in BasicIndex/BTreeIndex lives in at most one bucket#1517

Open
kevin-dp wants to merge 3 commits into
mainfrom
kevin/stale-index-bucket-repro
Open

fix(db): a key in BasicIndex/BTreeIndex lives in at most one bucket#1517
kevin-dp wants to merge 3 commits into
mainfrom
kevin/stale-index-bucket-repro

Conversation

@kevin-dp

@kevin-dp kevin-dp commented May 5, 2026

Copy link
Copy Markdown
Contributor

Summary

Both BasicIndex and BTreeIndex could leave a key in two buckets at once whenever update/remove was called with an oldItem that evaluated to a value other than the one the index actually had recorded for that key — remove would silently no-op on the bucket map while the subsequent add placed the key in a new bucket. Calling add twice for the same key (e.g. when an upstream "update" change message arrives without a previousValue, so the collection treats it as an insert) had the same effect.

This is the underlying cause of the user-reported "stale auto-index after optimistic→synced update on the indexed column" bug, where a kanban card snaps back to its old column because the auto-index still has the row in the old stage's bucket.

The fix: each index now tracks every key's currently-indexed value internally and uses that on remove/update instead of trusting the caller-supplied oldItem. add also drops the key from its current bucket first if it's already indexed.

The first commit adds a regression test that demonstrates the invariant violation (and fails on main); the second commit lands the fix.

Test plan

  • New tests/index-key-bucket-tracking.test.ts fails on main and passes after the fix
  • All existing @tanstack/db index, auto-index, subscribe-changes tests still pass
  • Full @tanstack/db + @tanstack/offline-transactions + @tanstack/electric-db-collection suites still pass locally

🤖 Generated with Claude Code

Pin down the invariant that calling update or add on an index never
leaves the same key in two buckets at once, even when the caller-
supplied oldItem disagrees with what the index recorded.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@pkg-pr-new

pkg-pr-new Bot commented May 5, 2026

Copy link
Copy Markdown
More templates

@tanstack/angular-db

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

@tanstack/browser-db-sqlite-persistence

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

@tanstack/capacitor-db-sqlite-persistence

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

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

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

@tanstack/db

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

@tanstack/db-ivm

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

@tanstack/db-sqlite-persistence-core

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

@tanstack/electric-db-collection

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

@tanstack/electron-db-sqlite-persistence

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

@tanstack/expo-db-sqlite-persistence

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

@tanstack/node-db-sqlite-persistence

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

@tanstack/offline-transactions

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

@tanstack/powersync-db-collection

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

@tanstack/query-db-collection

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

@tanstack/react-db

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

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

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

@tanstack/rxdb-db-collection

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

@tanstack/solid-db

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

@tanstack/svelte-db

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

@tanstack/tauri-db-sqlite-persistence

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

@tanstack/trailbase-db-collection

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

@tanstack/vue-db

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

commit: 72b31cc

@github-actions

github-actions Bot commented May 5, 2026

Copy link
Copy Markdown
Contributor

Size Change: +192 B (+0.17%)

Total Size: 114 kB

📦 View Changed
Filename Size Change
packages/db/dist/esm/indexes/basic-index.js 2.15 kB +102 B (+4.99%) 🔍
packages/db/dist/esm/indexes/btree-index.js 2.26 kB +90 B (+4.14%)
ℹ️ View Unchanged
Filename Size
packages/db/dist/esm/collection/change-events.js 1.39 kB
packages/db/dist/esm/collection/changes.js 1.38 kB
packages/db/dist/esm/collection/cleanup-queue.js 810 B
packages/db/dist/esm/collection/events.js 434 B
packages/db/dist/esm/collection/index.js 3.61 kB
packages/db/dist/esm/collection/indexes.js 1.99 kB
packages/db/dist/esm/collection/lifecycle.js 1.69 kB
packages/db/dist/esm/collection/mutations.js 2.47 kB
packages/db/dist/esm/collection/state.js 5.26 kB
packages/db/dist/esm/collection/subscription.js 3.74 kB
packages/db/dist/esm/collection/sync.js 2.88 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 4.92 kB
packages/db/dist/esm/event-emitter.js 748 B
packages/db/dist/esm/index.js 3 kB
packages/db/dist/esm/indexes/auto-index.js 830 B
packages/db/dist/esm/indexes/base-index.js 729 B
packages/db/dist/esm/indexes/index-registry.js 820 B
packages/db/dist/esm/indexes/reverse-index.js 538 B
packages/db/dist/esm/local-only.js 890 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 919 B
packages/db/dist/esm/query/builder/index.js 5.25 kB
packages/db/dist/esm/query/builder/ref-proxy.js 1.2 kB
packages/db/dist/esm/query/compiler/evaluators.js 1.62 kB
packages/db/dist/esm/query/compiler/expressions.js 430 B
packages/db/dist/esm/query/compiler/group-by.js 2.69 kB
packages/db/dist/esm/query/compiler/index.js 4.13 kB
packages/db/dist/esm/query/compiler/joins.js 2.34 kB
packages/db/dist/esm/query/compiler/order-by.js 1.72 kB
packages/db/dist/esm/query/compiler/select.js 1.11 kB
packages/db/dist/esm/query/effect.js 4.78 kB
packages/db/dist/esm/query/expression-helpers.js 1.43 kB
packages/db/dist/esm/query/ir.js 829 B
packages/db/dist/esm/query/live-query-collection.js 360 B
packages/db/dist/esm/query/live/collection-config-builder.js 7.88 kB
packages/db/dist/esm/query/live/collection-registry.js 264 B
packages/db/dist/esm/query/live/collection-subscriber.js 1.95 kB
packages/db/dist/esm/query/live/internal.js 145 B
packages/db/dist/esm/query/live/utils.js 1.64 kB
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/query/subset-dedupe.js 960 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 927 B
packages/db/dist/esm/utils/array-utils.js 273 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 1.05 kB
packages/db/dist/esm/utils/cursor.js 457 B
packages/db/dist/esm/utils/index-optimization.js 1.54 kB
packages/db/dist/esm/utils/type-guards.js 157 B
packages/db/dist/esm/virtual-props.js 360 B

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

@github-actions

github-actions Bot commented May 5, 2026

Copy link
Copy Markdown
Contributor

Size Change: 0 B

Total Size: 4.24 kB

ℹ️ View Unchanged
Filename Size
packages/react-db/dist/esm/index.js 249 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/useLiveQueryEffect.js 355 B
packages/react-db/dist/esm/useLiveSuspenseQuery.js 567 B
packages/react-db/dist/esm/usePacedMutations.js 401 B

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

Both BasicIndex and BTreeIndex now keep a key->indexedValue map and use
it on remove/update instead of evaluating the caller-supplied oldItem.
add() also drops the key from its current bucket first so a missed
remove (or a second add) can't leave the key in two buckets at once.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@kevin-dp kevin-dp changed the title test(db): a key in BasicIndex/BTreeIndex lives in at most one bucket fix(db): a key in BasicIndex/BTreeIndex lives in at most one bucket May 5, 2026
Use describe.each to run each test against both index implementations
instead of duplicating the BasicIndex and BTreeIndex variants by hand.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@KyleAMathews

Copy link
Copy Markdown
Collaborator

I reviewed this alongside #1691 and the stacked follow-up in K-Mistele#1. This PR has the right core invariant, but it can be made both faster and more thoroughly tested once that stack lands.

Suggested implementation after #1691

Treat the reverse keyToValue map as the authoritative old indexed value:

  1. Evaluate and normalize newItem before mutating any state.
  2. Read the current normalized value from keyToValue; do not trust or evaluate caller-supplied oldItem for a tracked key.
  3. Return early only when the recorded value matches the new value and the key is present in the expected bucket/key set.
  4. For a real move, remove the key directly from its recorded bucket, add it to the new bucket, update keyToValue, and update the timestamp once.
  5. Make repeated add(key, item) remove the key from its recorded bucket first.
  6. Make remove(key, item) use the recorded bucket when available.
  7. Maintain keyToValue in build() and clear().

This fits #1691's direct bucket helpers and avoids the old public remove() + add() update path. It should also remove one expression evaluation from every tracked update: only newItem needs evaluation. The evaluator cache and primitive normalization fast path in the stacked follow-up reduce the remaining work.

Evaluate newItem before removing the old bucket so an evaluation failure can leave the existing index entry intact. That is safer than partially removing the key and then throwing, but it changes the old fallback semantics and should be explicit in the tests.

The cost remains one extra map entry per indexed key, per index. That memory trade-off buys an authoritative one-key/one-bucket invariant and avoids scanning buckets.

Property-test expansion

The stacked follow-up adds model-based property tests for both BasicIndex and BTreeIndex. They currently generate valid add/update/delete sequences and compare, after every action:

  • keyCount and the indexed key set
  • exact value buckets and ordered entries
  • equality and range queries
  • rebuilt index state

For this PR, extend the action generator with:

  • updates whose supplied oldItem is stale
  • repeated add() calls for an existing key
  • removals whose supplied item is stale
  • expression-evaluation failures

After every action, also assert directly that each modeled key occurs in exactly one bucket and that its bucket agrees with the reference model. Keep the optimistic-to-synced collection regression in this PR as the end-to-end proof.

Interaction with the stacked follow-up

The follow-up currently has tests that pin the legacy evaluator-failure fallback and a narrow BasicIndex repair for bucket membership disagreeing with indexedKeys. Those tests will need revision once tracked removals no longer evaluate stale or throwing oldItem values. The extra no-op membership checks can remain as cheap defense-in-depth.

Landing

My plan is to pick this up after #1691 and K-Mistele#1 land, then adapt this branch with normal follow-up commits. That keeps the performance work and the one-key/one-bucket correctness change independently reviewable while letting this PR use the new property-test and update infrastructure.

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