From d8f95da82efe74a17e9fd9849888ef5afa3c6237 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Jan 2026 16:46:40 +0000 Subject: [PATCH 01/24] Fix potential infinite loops in graph execution and data loading Add iteration safeguards to prevent infinite loops that can occur when using Electric with large datasets and ORDER BY/LIMIT queries: 1. `maybeRunGraph` while loop (collection-config-builder.ts): - Can loop infinitely when data loading triggers graph updates - Happens when WHERE filters out most data, causing dataNeeded() > 0 - Loading more data triggers updates that get filtered out - Added 10,000 iteration limit with error logging 2. `requestLimitedSnapshot` while loop (subscription.ts): - Can loop if index iteration has issues - Added 10,000 iteration limit with error logging - Removed unused `insertedKeys` tracking 3. `D2.run()` while loop (d2.ts): - Can loop infinitely on circular data flow bugs - Added 100,000 iteration limit with error logging The safeguards log errors to help debug the root cause while preventing the app from freezing. --- packages/db-ivm/src/d2.ts | 15 ++++++++++++++ packages/db/src/collection/subscription.ts | 18 +++++++++++++++-- .../query/live/collection-config-builder.ts | 20 +++++++++++++++++++ 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/packages/db-ivm/src/d2.ts b/packages/db-ivm/src/d2.ts index 8451b2aff..7fe069ae5 100644 --- a/packages/db-ivm/src/d2.ts +++ b/packages/db-ivm/src/d2.ts @@ -57,7 +57,22 @@ export class D2 implements ID2 { } run(): void { + // Safety limit to prevent infinite loops in case of circular data flow + // or other bugs that cause operators to perpetually produce output. + // For legitimate pipelines, data should flow through in finite steps. + const MAX_RUN_ITERATIONS = 100000 + let iterations = 0 + while (this.pendingWork()) { + iterations++ + if (iterations > MAX_RUN_ITERATIONS) { + console.error( + `[D2 Graph] Execution exceeded ${MAX_RUN_ITERATIONS} iterations. ` + + `This may indicate an infinite loop in the dataflow graph. ` + + `Breaking out to prevent app freeze.`, + ) + break + } this.step() } } diff --git a/packages/db/src/collection/subscription.ts b/packages/db/src/collection/subscription.ts index 44c9af49f..ac88009af 100644 --- a/packages/db/src/collection/subscription.ts +++ b/packages/db/src/collection/subscription.ts @@ -502,8 +502,23 @@ export class CollectionSubscription ? compileExpression(new PropRef(orderByExpression.path), true) : null + // Safety limit to prevent infinite loops if the index iteration or filtering + // logic has issues. The loop should naturally terminate when the index is + // exhausted, but this provides a backstop. 10000 iterations is generous + // for any legitimate use case. + const MAX_SNAPSHOT_ITERATIONS = 10000 + let snapshotIterations = 0 + while (valuesNeeded() > 0 && !collectionExhausted()) { - const insertedKeys = new Set() // Track keys we add to `changes` in this iteration + snapshotIterations++ + if (snapshotIterations > MAX_SNAPSHOT_ITERATIONS) { + console.error( + `[TanStack DB] requestLimitedSnapshot exceeded ${MAX_SNAPSHOT_ITERATIONS} iterations. ` + + `This may indicate an infinite loop in index iteration or filtering. ` + + `Breaking out to prevent app freeze. Collection: ${this.collection.id}`, + ) + break + } for (const key of keys) { const value = this.collection.get(key)! @@ -515,7 +530,6 @@ export class CollectionSubscription // Extract the indexed value (e.g., salary) from the row, not the full row // This is needed for index.take() to work correctly with the BTree comparator biggestObservedValue = valueExtractor ? valueExtractor(value) : value - insertedKeys.add(key) // Track this key } keys = index.take(valuesNeeded(), biggestObservedValue, filterFn) diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index 21cd04d1d..50c533cf6 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -336,7 +336,27 @@ export class CollectionConfigBuilder< // Always run the graph if subscribed (eager execution) if (syncState.subscribedToAllCollections) { + // Safety limit to prevent infinite loops when data loading and graph processing + // create a feedback cycle. This can happen when: + // 1. OrderBy/limit queries filter out most data, causing dataNeeded() > 0 + // 2. Loading more data triggers updates that get filtered out + // 3. The cycle continues indefinitely + // 10000 iterations is generous for legitimate use cases but prevents hangs. + const MAX_GRAPH_ITERATIONS = 10000 + let iterations = 0 + while (syncState.graph.pendingWork()) { + iterations++ + if (iterations > MAX_GRAPH_ITERATIONS) { + console.error( + `[TanStack DB] Graph execution exceeded ${MAX_GRAPH_ITERATIONS} iterations. ` + + `This may indicate an infinite loop caused by data loading triggering ` + + `continuous graph updates. Breaking out of the loop to prevent app freeze. ` + + `Query ID: ${this.id}`, + ) + break + } + syncState.graph.run() // Flush accumulated changes after each graph step to commit them as one transaction. // This ensures intermediate join states (like null on one side) don't cause From 2a796ff87f1fb254c1057d97d54bd8626af95aa5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Jan 2026 16:57:17 +0000 Subject: [PATCH 02/24] Fix root cause of Electric infinite loop with ORDER BY/LIMIT queries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The infinite loop occurred because `loadMoreIfNeeded` kept trying to load data even when the local index was exhausted. This happened when: 1. TopK had fewer items than limit (WHERE filtered out data) 2. loadMoreIfNeeded tried to load more → no local data found 3. Loop continued indefinitely since TopK still needed data Root cause fix: - Add `localIndexExhausted` flag to CollectionSubscriber - Track when local index has no more data for current cursor - Stop calling loadMoreIfNeeded when exhausted - Reset flag when new data arrives from sync layer (inserts) - requestLimitedSnapshot now returns boolean indicating if data was found Error handling improvements (per review feedback): - D2.run() now throws Error when iteration limit exceeded - Caller catches and calls transitionToError() for proper error state - requestLimitedSnapshot returns false when iteration limit hit - Live query properly shows error state if safeguard limits are hit This fixes the issue for both eager and progressive syncMode. --- packages/db-ivm/src/d2.ts | 11 ++--- packages/db/src/collection/subscription.ts | 17 +++++-- .../query/live/collection-config-builder.ts | 25 ++++++---- .../src/query/live/collection-subscriber.ts | 47 +++++++++++++++++-- 4 files changed, 76 insertions(+), 24 deletions(-) diff --git a/packages/db-ivm/src/d2.ts b/packages/db-ivm/src/d2.ts index 7fe069ae5..f8c5233d8 100644 --- a/packages/db-ivm/src/d2.ts +++ b/packages/db-ivm/src/d2.ts @@ -64,14 +64,11 @@ export class D2 implements ID2 { let iterations = 0 while (this.pendingWork()) { - iterations++ - if (iterations > MAX_RUN_ITERATIONS) { - console.error( - `[D2 Graph] Execution exceeded ${MAX_RUN_ITERATIONS} iterations. ` + - `This may indicate an infinite loop in the dataflow graph. ` + - `Breaking out to prevent app freeze.`, + if (++iterations > MAX_RUN_ITERATIONS) { + throw new Error( + `D2 graph execution exceeded ${MAX_RUN_ITERATIONS} iterations. ` + + `This may indicate an infinite loop in the dataflow graph.`, ) - break } this.step() } diff --git a/packages/db/src/collection/subscription.ts b/packages/db/src/collection/subscription.ts index ac88009af..785f9e846 100644 --- a/packages/db/src/collection/subscription.ts +++ b/packages/db/src/collection/subscription.ts @@ -410,13 +410,15 @@ export class CollectionSubscription * Note 1: it may load more rows than the provided LIMIT because it loads all values equal to the first cursor value + limit values greater. * This is needed to ensure that it does not accidentally skip duplicate values when the limit falls in the middle of some duplicated values. * Note 2: it does not send keys that have already been sent before. + * + * @returns true if local data was found and sent, false if the local index was exhausted */ requestLimitedSnapshot({ orderBy, limit, minValues, offset, - }: RequestLimitedSnapshotOptions) { + }: RequestLimitedSnapshotOptions): boolean { if (!limit) throw new Error(`limit is required`) if (!this.orderByIndex) { @@ -508,15 +510,16 @@ export class CollectionSubscription // for any legitimate use case. const MAX_SNAPSHOT_ITERATIONS = 10000 let snapshotIterations = 0 + let hitIterationLimit = false while (valuesNeeded() > 0 && !collectionExhausted()) { - snapshotIterations++ - if (snapshotIterations > MAX_SNAPSHOT_ITERATIONS) { + if (++snapshotIterations > MAX_SNAPSHOT_ITERATIONS) { console.error( `[TanStack DB] requestLimitedSnapshot exceeded ${MAX_SNAPSHOT_ITERATIONS} iterations. ` + `This may indicate an infinite loop in index iteration or filtering. ` + `Breaking out to prevent app freeze. Collection: ${this.collection.id}`, ) + hitIterationLimit = true break } @@ -611,6 +614,14 @@ export class CollectionSubscription // Track this loadSubset call this.loadedSubsets.push(loadOptions) this.trackLoadSubsetPromise(syncResult) + + // Return whether local data was found and iteration completed normally. + // Return false if: + // - No local data was found (index exhausted) + // - Iteration limit was hit (abnormal exit) + // Either case signals that the caller should stop trying to load more. + // The async loadSubset may still return data later. + return changes.length > 0 && !hitIterationLimit } // TODO: also add similar test but that checks that it can also load it from the collection's loadSubset function diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index 50c533cf6..9be18da77 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -346,18 +346,25 @@ export class CollectionConfigBuilder< let iterations = 0 while (syncState.graph.pendingWork()) { - iterations++ - if (iterations > MAX_GRAPH_ITERATIONS) { - console.error( - `[TanStack DB] Graph execution exceeded ${MAX_GRAPH_ITERATIONS} iterations. ` + - `This may indicate an infinite loop caused by data loading triggering ` + - `continuous graph updates. Breaking out of the loop to prevent app freeze. ` + - `Query ID: ${this.id}`, + if (++iterations > MAX_GRAPH_ITERATIONS) { + this.transitionToError( + `Graph execution exceeded ${MAX_GRAPH_ITERATIONS} iterations. ` + + `This likely indicates an infinite loop caused by data loading ` + + `triggering continuous graph updates.`, ) - break + return } - syncState.graph.run() + try { + syncState.graph.run() + } catch (error) { + // D2 graph throws when it exceeds its internal iteration limit + // Transition to error state so callers can detect incomplete data + this.transitionToError( + error instanceof Error ? error.message : String(error), + ) + return + } // Flush accumulated changes after each graph step to commit them as one transaction. // This ensures intermediate join states (like null on one side) don't cause // duplicate key errors when the full join result arrives in the same step. diff --git a/packages/db/src/query/live/collection-subscriber.ts b/packages/db/src/query/live/collection-subscriber.ts index ec4876b74..91af3858b 100644 --- a/packages/db/src/query/live/collection-subscriber.ts +++ b/packages/db/src/query/live/collection-subscriber.ts @@ -37,6 +37,12 @@ export class CollectionSubscriber< // can potentially send the same item to D2 multiple times. private sentToD2Keys = new Set() + // Track when the local index has been exhausted for the current cursor position. + // When true, loadMoreIfNeeded will not try to load more data until new data arrives. + // This prevents infinite loops when the TopK can't be filled because WHERE filters + // out all available data. + private localIndexExhausted = false + constructor( private alias: string, private collectionId: string, @@ -301,11 +307,25 @@ export class CollectionSubscriber< return true } + // If we've already exhausted the local index, don't try to load more. + // This prevents infinite loops when the TopK can't be filled because + // the WHERE clause filters out all available local data. + // The flag is reset when new data arrives from the sync layer. + if (this.localIndexExhausted) { + return true + } + // `dataNeeded` probes the orderBy operator to see if it needs more data // if it needs more data, it returns the number of items it needs const n = dataNeeded() if (n > 0) { - this.loadNextItems(n, subscription) + const foundLocalData = this.loadNextItems(n, subscription) + if (!foundLocalData) { + // No local data found - mark the index as exhausted so we don't + // keep trying in subsequent graph iterations. The sync layer's + // loadSubset has been called and may return data asynchronously. + this.localIndexExhausted = true + } } return true } @@ -320,7 +340,19 @@ export class CollectionSubscriber< return } - const trackedChanges = this.trackSentValues(changes, orderByInfo.comparator) + // Reset localIndexExhausted when new data arrives from the sync layer. + // This allows loadMoreIfNeeded to try loading again since there's new data. + // We only reset on inserts since updates/deletes don't add new data to load. + const changesArray = Array.isArray(changes) ? changes : [...changes] + const hasInserts = changesArray.some((c) => c.type === `insert`) + if (hasInserts) { + this.localIndexExhausted = false + } + + const trackedChanges = this.trackSentValues( + changesArray, + orderByInfo.comparator, + ) // Cache the loadMoreIfNeeded callback on the subscription using a symbol property. // This ensures we pass the same function instance to the scheduler each time, @@ -342,10 +374,14 @@ export class CollectionSubscriber< // Loads the next `n` items from the collection // starting from the biggest item it has sent - private loadNextItems(n: number, subscription: CollectionSubscription) { + // Returns true if local data was found, false if the local index is exhausted + private loadNextItems( + n: number, + subscription: CollectionSubscription, + ): boolean { const orderByInfo = this.getOrderByInfo() if (!orderByInfo) { - return + return false } const { orderBy, valueExtractorForRawRow, offset } = orderByInfo const biggestSentRow = this.biggest @@ -369,7 +405,8 @@ export class CollectionSubscriber< // Take the `n` items after the biggest sent value // Pass the current window offset to ensure proper deduplication - subscription.requestLimitedSnapshot({ + // Returns true if local data was found + return subscription.requestLimitedSnapshot({ orderBy: normalizedOrderBy, limit: n, minValues, From e698eb185ec5a043dd9a1a76577e1bd5155e0283 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Jan 2026 17:12:54 +0000 Subject: [PATCH 03/24] Add tests for infinite loop prevention with ORDER BY + LIMIT queries These tests verify the localIndexExhausted fix works correctly: 1. Does not infinite loop when WHERE filters out most data - Query wants 10 items, only 2 match - Verifies status !== 'error' (fix works, not just safeguard) 2. Resumes loading when new matching data arrives - Starts with 0 matching items - Insert new matching items - localIndexExhausted resets, loads new data 3. Handles updates that move items out of WHERE clause - Updates change values to no longer match WHERE - TopK correctly refills from remaining matching data --- .../db/tests/infinite-loop-prevention.test.ts | 190 ++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 packages/db/tests/infinite-loop-prevention.test.ts diff --git a/packages/db/tests/infinite-loop-prevention.test.ts b/packages/db/tests/infinite-loop-prevention.test.ts new file mode 100644 index 000000000..403bd46de --- /dev/null +++ b/packages/db/tests/infinite-loop-prevention.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it } from 'vitest' +import { createCollection } from '../src/collection/index.js' +import { createLiveQueryCollection, gt } from '../src/query/index.js' +import { mockSyncCollectionOptions } from './utils.js' + +/** + * Tests for infinite loop prevention in ORDER BY + LIMIT queries. + * + * The issue: When a live query has ORDER BY + LIMIT, the TopK operator + * requests data until it has `limit` items. If the WHERE clause filters + * out most data, the TopK may never be filled, causing loadMoreIfNeeded + * to be called repeatedly in an infinite loop. + * + * The fix: CollectionSubscriber tracks when the local index is exhausted + * via `localIndexExhausted` flag, preventing repeated load attempts. + */ + +type TestItem = { + id: number + value: number + category: string +} + +describe(`Infinite loop prevention`, () => { + it(`should not infinite loop when WHERE filters out most data for ORDER BY + LIMIT query`, async () => { + // This test verifies that the localIndexExhausted optimization prevents + // unnecessary load attempts when the TopK can't be filled. + // + // The scenario: + // 1. Query wants 10 items with value > 90 + // 2. Only 2 items match (values 95 and 100) + // 3. Without the fix, loadMoreIfNeeded would keep trying to load more + // 4. With the fix, localIndexExhausted stops unnecessary attempts + + const initialData: Array = [] + for (let i = 1; i <= 20; i++) { + initialData.push({ + id: i, + value: i * 5, // values: 5, 10, 15, ... 95, 100 + category: i <= 10 ? `A` : `B`, + }) + } + + const sourceCollection = createCollection( + mockSyncCollectionOptions({ + id: `infinite-loop-test`, + getKey: (item: TestItem) => item.id, + initialData, + }), + ) + + await sourceCollection.preload() + + const liveQueryCollection = createLiveQueryCollection((q) => + q + .from({ items: sourceCollection }) + .where(({ items }) => gt(items.value, 90)) + .orderBy(({ items }) => items.value, `desc`) + .limit(10) + .select(({ items }) => ({ + id: items.id, + value: items.value, + })), + ) + + // Should complete without hanging or hitting safeguard + await liveQueryCollection.preload() + + // Verify results + const results = Array.from(liveQueryCollection.values()) + expect(results).toHaveLength(2) + expect(results.map((r) => r.value)).toEqual([100, 95]) + + // Verify not in error state (didn't hit safeguard) + expect( + liveQueryCollection.status, + `Query should not be in error state`, + ).not.toBe(`error`) + }) + + it(`should resume loading when new matching data arrives`, async () => { + // Start with data that doesn't match WHERE clause + const initialData: Array = [ + { id: 1, value: 10, category: `A` }, + { id: 2, value: 20, category: `A` }, + { id: 3, value: 30, category: `A` }, + ] + + const { utils, ...options } = mockSyncCollectionOptions({ + id: `resume-loading-test`, + getKey: (item: TestItem) => item.id, + initialData, + }) + + const sourceCollection = createCollection(options) + await sourceCollection.preload() + + // Query wants items with value > 50, ordered by value, limit 5 + // Initially 0 items match + const liveQueryCollection = createLiveQueryCollection((q) => + q + .from({ items: sourceCollection }) + .where(({ items }) => gt(items.value, 50)) + .orderBy(({ items }) => items.value, `desc`) + .limit(5) + .select(({ items }) => ({ + id: items.id, + value: items.value, + })), + ) + + await liveQueryCollection.preload() + + // Should have 0 items initially + let results = Array.from(liveQueryCollection.values()) + expect(results).toHaveLength(0) + + // Now add items that match the WHERE clause + utils.begin() + utils.write({ type: `insert`, value: { id: 4, value: 60, category: `B` } }) + utils.write({ type: `insert`, value: { id: 5, value: 70, category: `B` } }) + utils.commit() + + // Wait for changes to propagate + await new Promise((resolve) => setTimeout(resolve, 50)) + + // Should now have 2 items (localIndexExhausted was reset by new inserts) + results = Array.from(liveQueryCollection.values()) + expect(results).toHaveLength(2) + expect(results.map((r) => r.value)).toEqual([70, 60]) + }) + + it(`should handle updates that move items out of WHERE clause`, async () => { + // All items initially match WHERE clause + const initialData: Array = [ + { id: 1, value: 100, category: `A` }, + { id: 2, value: 90, category: `A` }, + { id: 3, value: 80, category: `A` }, + { id: 4, value: 70, category: `A` }, + { id: 5, value: 60, category: `A` }, + ] + + const sourceCollection = createCollection( + mockSyncCollectionOptions({ + id: `update-out-of-where-test`, + getKey: (item: TestItem) => item.id, + initialData, + }), + ) + + await sourceCollection.preload() + + // Query: WHERE value > 50, ORDER BY value DESC, LIMIT 3 + const liveQueryCollection = createLiveQueryCollection((q) => + q + .from({ items: sourceCollection }) + .where(({ items }) => gt(items.value, 50)) + .orderBy(({ items }) => items.value, `desc`) + .limit(3) + .select(({ items }) => ({ + id: items.id, + value: items.value, + })), + ) + + await liveQueryCollection.preload() + + // Should have top 3: 100, 90, 80 + let results = Array.from(liveQueryCollection.values()) + expect(results).toHaveLength(3) + expect(results.map((r) => r.value)).toEqual([100, 90, 80]) + + // Update items to move them OUT of WHERE clause + // This could trigger the infinite loop if not handled properly + sourceCollection.update(1, (draft) => { + draft.value = 40 // Now < 50, filtered out + }) + sourceCollection.update(2, (draft) => { + draft.value = 30 // Now < 50, filtered out + }) + + // Wait for changes to propagate + await new Promise((resolve) => setTimeout(resolve, 50)) + + // Should now have: 80, 70, 60 (items 3, 4, 5) + results = Array.from(liveQueryCollection.values()) + expect(results).toHaveLength(3) + expect(results.map((r) => r.value)).toEqual([80, 70, 60]) + }) +}) From 9202d1c50e42e2e33be6ecff53b9d425fb0f61c2 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Thu, 22 Jan 2026 10:51:42 -0700 Subject: [PATCH 04/24] Add test that reproduces Electric infinite loop bug Adds a new test that directly reproduces the infinite loop bug by creating a collection with a custom loadSubset that synchronously injects updates matching the WHERE clause. This simulates Electric's behavior of sending continuous updates during graph execution. The test verifies: - Without the localIndexExhausted fix, loadSubset is called 100+ times (infinite loop) - With the fix, loadSubset is called < 10 times (loop terminates correctly) Also adds additional tests for edge cases around the localIndexExhausted flag. Co-Authored-By: Claude Opus 4.5 --- .../db/tests/infinite-loop-prevention.test.ts | 316 ++++++++++++++++++ 1 file changed, 316 insertions(+) diff --git a/packages/db/tests/infinite-loop-prevention.test.ts b/packages/db/tests/infinite-loop-prevention.test.ts index 403bd46de..a04bde961 100644 --- a/packages/db/tests/infinite-loop-prevention.test.ts +++ b/packages/db/tests/infinite-loop-prevention.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { createCollection } from '../src/collection/index.js' import { createLiveQueryCollection, gt } from '../src/query/index.js' import { mockSyncCollectionOptions } from './utils.js' +import type { SyncConfig } from '../src/index.js' /** * Tests for infinite loop prevention in ORDER BY + LIMIT queries. @@ -11,8 +12,17 @@ import { mockSyncCollectionOptions } from './utils.js' * out most data, the TopK may never be filled, causing loadMoreIfNeeded * to be called repeatedly in an infinite loop. * + * The infinite loop specifically occurs when: + * 1. Initial load exhausts the local index (TopK still needs more items) + * 2. Updates arrive (e.g., from Electric sync layer converting duplicate inserts to updates) + * 3. maybeRunGraph processes the update and calls loadMoreIfNeeded + * 4. loadMoreIfNeeded sees dataNeeded() > 0, calls loadNextItems + * 5. loadNextItems finds nothing (index exhausted), but without tracking this, + * the next iteration repeats steps 3-5 indefinitely + * * The fix: CollectionSubscriber tracks when the local index is exhausted * via `localIndexExhausted` flag, preventing repeated load attempts. + * The flag resets when new inserts arrive, allowing the system to try again. */ type TestItem = { @@ -22,6 +32,17 @@ type TestItem = { } describe(`Infinite loop prevention`, () => { + // The infinite loop bug occurs when: + // 1. Query has ORDER BY + LIMIT + WHERE that filters most data + // 2. Sync layer (like Electric) continuously sends updates + // 3. These updates trigger pendingWork() to remain true during maybeRunGraph + // 4. Without the localIndexExhausted fix, loadMoreIfNeeded keeps trying to load + // from the exhausted local index + // + // The last test ("should not infinite loop when loadSubset synchronously injects updates") + // directly reproduces the bug by creating a custom loadSubset that synchronously + // injects updates matching the WHERE clause. Without the fix, this causes an infinite loop. + it(`should not infinite loop when WHERE filters out most data for ORDER BY + LIMIT query`, async () => { // This test verifies that the localIndexExhausted optimization prevents // unnecessary load attempts when the TopK can't be filled. @@ -187,4 +208,299 @@ describe(`Infinite loop prevention`, () => { expect(results).toHaveLength(3) expect(results.map((r) => r.value)).toEqual([80, 70, 60]) }) + + it(`should not infinite loop when updates arrive after local index is exhausted`, async () => { + // This test simulates the Electric scenario where: + // 1. Initial data loads, but TopK can't be filled (WHERE filters too much) + // 2. Updates arrive from sync layer (like Electric converting duplicate inserts to updates) + // 3. Without the fix, each update would trigger loadMoreIfNeeded which tries + // to load from the exhausted local index, causing an infinite loop + // + // The fix: localIndexExhausted flag prevents repeated load attempts. + // The flag only resets when NEW INSERTS arrive (not updates/deletes). + + const initialData: Array = [] + for (let i = 1; i <= 10; i++) { + initialData.push({ + id: i, + value: i * 10, // values: 10, 20, 30, ... 100 + category: `A`, + }) + } + + const { utils, ...options } = mockSyncCollectionOptions({ + id: `electric-update-loop-test`, + getKey: (item: TestItem) => item.id, + initialData, + }) + + const sourceCollection = createCollection(options) + await sourceCollection.preload() + + // Query: WHERE value > 95, ORDER BY value DESC, LIMIT 5 + // Only item with value=100 matches, but we want 5 items + // This exhausts the local index after the first item + const liveQueryCollection = createLiveQueryCollection((q) => + q + .from({ items: sourceCollection }) + .where(({ items }) => gt(items.value, 95)) + .orderBy(({ items }) => items.value, `desc`) + .limit(5) + .select(({ items }) => ({ + id: items.id, + value: items.value, + })), + ) + + // Preload should complete without hanging + const preloadPromise = liveQueryCollection.preload() + const timeoutPromise = new Promise((_, reject) => + setTimeout( + () => reject(new Error(`Timeout during preload - possible infinite loop`)), + 5000, + ), + ) + + await expect( + Promise.race([preloadPromise, timeoutPromise]), + ).resolves.toBeUndefined() + + // Should have 1 item (value=100) + let results = Array.from(liveQueryCollection.values()) + expect(results).toHaveLength(1) + expect(results[0]!.value).toBe(100) + + // Now simulate Electric sending updates (like duplicate insert → update conversion) + // Without the fix, this would trigger infinite loop because: + // 1. Update arrives, triggers maybeRunGraph + // 2. loadMoreIfNeeded sees dataNeeded() > 0 (TopK still needs 4 more) + // 3. loadNextItems finds nothing (index exhausted) + // 4. Without localIndexExhausted flag, loop would repeat indefinitely + const updatePromise = (async () => { + // Send several updates that don't change the result set + // These simulate Electric's duplicate handling + for (let i = 0; i < 5; i++) { + utils.begin() + // Update an item that doesn't match WHERE - this shouldn't affect results + // but could trigger the infinite loop bug + utils.write({ + type: `update`, + value: { id: 5, value: 50 + i, category: `A` }, // Still doesn't match WHERE + }) + utils.commit() + + // Small delay between updates to simulate real Electric behavior + await new Promise((resolve) => setTimeout(resolve, 10)) + } + })() + + const updateTimeoutPromise = new Promise((_, reject) => + setTimeout( + () => reject(new Error(`Timeout during updates - possible infinite loop`)), + 5000, + ), + ) + + await expect( + Promise.race([updatePromise, updateTimeoutPromise]), + ).resolves.toBeUndefined() + + // Results should still be the same (updates didn't add matching items) + results = Array.from(liveQueryCollection.values()) + expect(results).toHaveLength(1) + expect(results[0]!.value).toBe(100) + }) + + it(`should reset localIndexExhausted when new inserts arrive`, async () => { + // This test verifies that the localIndexExhausted flag properly resets + // when new inserts arrive, allowing the system to load more data + + const { utils, ...options } = mockSyncCollectionOptions({ + id: `reset-exhausted-flag-test`, + getKey: (item: TestItem) => item.id, + initialData: [ + { id: 1, value: 100, category: `A` }, + ], + }) + + const sourceCollection = createCollection(options) + await sourceCollection.preload() + + // Query: WHERE value > 50, ORDER BY value DESC, LIMIT 5 + // Initially only 1 item matches, but we want 5 + const liveQueryCollection = createLiveQueryCollection((q) => + q + .from({ items: sourceCollection }) + .where(({ items }) => gt(items.value, 50)) + .orderBy(({ items }) => items.value, `desc`) + .limit(5) + .select(({ items }) => ({ + id: items.id, + value: items.value, + })), + ) + + await liveQueryCollection.preload() + + // Should have 1 item initially + let results = Array.from(liveQueryCollection.values()) + expect(results).toHaveLength(1) + + // Send updates (should NOT reset the flag, should NOT trigger more loads) + utils.begin() + utils.write({ type: `update`, value: { id: 1, value: 101, category: `A` } }) + utils.commit() + + await new Promise((resolve) => setTimeout(resolve, 50)) + + // Still 1 item (updated value) + results = Array.from(liveQueryCollection.values()) + expect(results).toHaveLength(1) + expect(results[0]!.value).toBe(101) + + // Now send NEW INSERTS - this SHOULD reset the flag and load more + utils.begin() + utils.write({ type: `insert`, value: { id: 2, value: 90, category: `B` } }) + utils.write({ type: `insert`, value: { id: 3, value: 80, category: `B` } }) + utils.commit() + + await new Promise((resolve) => setTimeout(resolve, 50)) + + // Now should have 3 items (new inserts reset the flag, allowing more to load) + results = Array.from(liveQueryCollection.values()) + expect(results).toHaveLength(3) + expect(results.map((r) => r.value)).toEqual([101, 90, 80]) + }) + + it(`should not infinite loop when loadSubset synchronously injects updates (simulates Electric)`, async () => { + // This test reproduces the actual infinite loop bug by simulating Electric's behavior: + // - Electric's sync layer can call loadSubset which synchronously injects updates + // - These updates add data to D2, making pendingWork() return true + // - Without localIndexExhausted, loadMoreIfNeeded keeps trying to load from exhausted index + // - The synchronous update injection keeps pendingWork() true → infinite loop + // + // The fix: localIndexExhausted flag prevents repeated load attempts after index is exhausted + + const initialData: Array = [ + { id: 1, value: 100, category: `A` }, // Only this matches WHERE > 95 + { id: 2, value: 50, category: `A` }, + { id: 3, value: 40, category: `A` }, + ] + + // Track how many times loadSubset is called to detect infinite loop + let loadSubsetCallCount = 0 + const MAX_LOADSUBSET_CALLS = 100 // Safety limit + + // Store sync params for injecting updates in loadSubset + let syncBegin: () => void + let syncWrite: (msg: { type: string; value: TestItem }) => void + let syncCommit: () => void + let updateCounter = 0 + + const sync: SyncConfig = { + sync: (params) => { + syncBegin = params.begin + syncWrite = params.write as typeof syncWrite + syncCommit = params.commit + const markReady = params.markReady + + // Load initial data + syncBegin() + initialData.forEach((item) => { + syncWrite({ type: `insert`, value: item }) + }) + syncCommit() + markReady() + + return { + // This loadSubset function simulates Electric's behavior: + // When the subscription asks for more data (because TopK isn't full), + // Electric might synchronously send an update for existing data + loadSubset: () => { + loadSubsetCallCount++ + + if (loadSubsetCallCount > MAX_LOADSUBSET_CALLS) { + throw new Error( + `loadSubset called ${loadSubsetCallCount} times - infinite loop detected!`, + ) + } + + // Simulate Electric sending an update for a matching item + // This is key: the update must match WHERE clause to pass the subscription filter + // and actually add work to D2, keeping pendingWork() true + syncBegin() + syncWrite({ + type: `update`, + value: { id: 1, value: 100 + updateCounter++, category: `A` }, // Matches WHERE > 95 + }) + syncCommit() + + return true // Synchronous completion + }, + } + }, + } + + const sourceCollection = createCollection({ + id: `loadsubset-infinite-loop-test`, + getKey: (item: TestItem) => item.id, + sync, + syncMode: `on-demand`, + }) + + await sourceCollection.preload() + + // Query: WHERE value > 95, ORDER BY value DESC, LIMIT 5 + // Only 1 item matches (value=100), but we want 5 + // This will exhaust the local index and trigger loadSubset calls + const liveQueryCollection = createLiveQueryCollection((q) => + q + .from({ items: sourceCollection }) + .where(({ items }) => gt(items.value, 95)) + .orderBy(({ items }) => items.value, `desc`) + .limit(5) + .select(({ items }) => ({ + id: items.id, + value: items.value, + })), + ) + + // Without the fix, this would infinite loop because: + // 1. Initial load finds 1 item, TopK needs 4 more + // 2. loadSubset is called, synchronously injects an update + // 3. Update adds D2 work, pendingWork() returns true + // 4. maybeRunGraph continues, loadMoreIfNeeded is called + // 5. TopK still needs 4 more, loadNextItems finds nothing (index exhausted) + // 6. But wait! We're back to step 2 because pendingWork() is still true from the update + // 7. GOTO step 2 → infinite loop! + // + // With the fix: localIndexExhausted flag is set in step 5, preventing step 6 + + const preloadPromise = liveQueryCollection.preload() + const timeoutPromise = new Promise((_, reject) => + setTimeout( + () => + reject( + new Error( + `Timeout - possible infinite loop. loadSubset was called ${loadSubsetCallCount} times.`, + ), + ), + 5000, + ), + ) + + await expect( + Promise.race([preloadPromise, timeoutPromise]), + ).resolves.toBeUndefined() + + // Verify we got the expected result + const results = Array.from(liveQueryCollection.values()) + expect(results).toHaveLength(1) + // Value will be 100 + number of loadSubset calls (minus 1 since counter starts at 0) + expect(results[0]!.value).toBeGreaterThanOrEqual(100) + + // loadSubset should be called a small number of times, not infinitely + // The exact count depends on implementation details, but should be < 10 + expect(loadSubsetCallCount).toBeLessThan(10) + }) }) From d43a509241d96c15a64b72bbb54ac775bc12b126 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 22 Jan 2026 17:52:54 +0000 Subject: [PATCH 05/24] ci: apply automated fixes --- packages/db/tests/infinite-loop-prevention.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/db/tests/infinite-loop-prevention.test.ts b/packages/db/tests/infinite-loop-prevention.test.ts index a04bde961..969c6c2ad 100644 --- a/packages/db/tests/infinite-loop-prevention.test.ts +++ b/packages/db/tests/infinite-loop-prevention.test.ts @@ -256,7 +256,8 @@ describe(`Infinite loop prevention`, () => { const preloadPromise = liveQueryCollection.preload() const timeoutPromise = new Promise((_, reject) => setTimeout( - () => reject(new Error(`Timeout during preload - possible infinite loop`)), + () => + reject(new Error(`Timeout during preload - possible infinite loop`)), 5000, ), ) @@ -296,7 +297,8 @@ describe(`Infinite loop prevention`, () => { const updateTimeoutPromise = new Promise((_, reject) => setTimeout( - () => reject(new Error(`Timeout during updates - possible infinite loop`)), + () => + reject(new Error(`Timeout during updates - possible infinite loop`)), 5000, ), ) @@ -318,9 +320,7 @@ describe(`Infinite loop prevention`, () => { const { utils, ...options } = mockSyncCollectionOptions({ id: `reset-exhausted-flag-test`, getKey: (item: TestItem) => item.id, - initialData: [ - { id: 1, value: 100, category: `A` }, - ], + initialData: [{ id: 1, value: 100, category: `A` }], }) const sourceCollection = createCollection(options) From e6a9fbc56ceb539004949c207e46d3cc8ef3454e Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Thu, 22 Jan 2026 11:01:07 -0700 Subject: [PATCH 06/24] Add changeset for infinite loop fix Co-Authored-By: Claude Opus 4.5 --- .changeset/fix-infinite-loop-orderby-limit.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/fix-infinite-loop-orderby-limit.md diff --git a/.changeset/fix-infinite-loop-orderby-limit.md b/.changeset/fix-infinite-loop-orderby-limit.md new file mode 100644 index 000000000..5d0740499 --- /dev/null +++ b/.changeset/fix-infinite-loop-orderby-limit.md @@ -0,0 +1,6 @@ +--- +'@tanstack/db': patch +'@tanstack/db-ivm': patch +--- + +Fix infinite loop in ORDER BY + LIMIT queries when WHERE clause filters out most data. Add `localIndexExhausted` flag to prevent repeated load attempts when the local index is exhausted. Also add safety iteration limits to D2 graph execution, maybeRunGraph, and requestLimitedSnapshot as backstops. From dc3d40c63268d77df0f0424fccab49f413f73162 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Thu, 22 Jan 2026 11:30:27 -0700 Subject: [PATCH 07/24] Remove flawed test that doesn't reproduce real Electric bug The removed test synchronously injected data in loadSubset, which artificially creates an infinite loop. Electric's loadSubset is async (uses await stream.fetchSnapshot), so it can't synchronously inject data during the maybeRunGraph loop. The remaining tests verify the localIndexExhausted flag's behavior correctly: - Prevents repeated load attempts when exhausted - Resets when new inserts arrive Co-Authored-By: Claude Opus 4.5 --- .../db/tests/infinite-loop-prevention.test.ts | 147 ++---------------- 1 file changed, 12 insertions(+), 135 deletions(-) diff --git a/packages/db/tests/infinite-loop-prevention.test.ts b/packages/db/tests/infinite-loop-prevention.test.ts index 969c6c2ad..c8d8b0f7f 100644 --- a/packages/db/tests/infinite-loop-prevention.test.ts +++ b/packages/db/tests/infinite-loop-prevention.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from 'vitest' import { createCollection } from '../src/collection/index.js' import { createLiveQueryCollection, gt } from '../src/query/index.js' import { mockSyncCollectionOptions } from './utils.js' -import type { SyncConfig } from '../src/index.js' /** * Tests for infinite loop prevention in ORDER BY + LIMIT queries. @@ -39,9 +38,9 @@ describe(`Infinite loop prevention`, () => { // 4. Without the localIndexExhausted fix, loadMoreIfNeeded keeps trying to load // from the exhausted local index // - // The last test ("should not infinite loop when loadSubset synchronously injects updates") - // directly reproduces the bug by creating a custom loadSubset that synchronously - // injects updates matching the WHERE clause. Without the fix, this causes an infinite loop. + // These tests verify the localIndexExhausted flag works correctly: + // - Prevents repeated load attempts when the local index is exhausted + // - Resets when new inserts arrive, allowing the system to try again it(`should not infinite loop when WHERE filters out most data for ORDER BY + LIMIT query`, async () => { // This test verifies that the localIndexExhausted optimization prevents @@ -372,135 +371,13 @@ describe(`Infinite loop prevention`, () => { expect(results.map((r) => r.value)).toEqual([101, 90, 80]) }) - it(`should not infinite loop when loadSubset synchronously injects updates (simulates Electric)`, async () => { - // This test reproduces the actual infinite loop bug by simulating Electric's behavior: - // - Electric's sync layer can call loadSubset which synchronously injects updates - // - These updates add data to D2, making pendingWork() return true - // - Without localIndexExhausted, loadMoreIfNeeded keeps trying to load from exhausted index - // - The synchronous update injection keeps pendingWork() true → infinite loop - // - // The fix: localIndexExhausted flag prevents repeated load attempts after index is exhausted - - const initialData: Array = [ - { id: 1, value: 100, category: `A` }, // Only this matches WHERE > 95 - { id: 2, value: 50, category: `A` }, - { id: 3, value: 40, category: `A` }, - ] - - // Track how many times loadSubset is called to detect infinite loop - let loadSubsetCallCount = 0 - const MAX_LOADSUBSET_CALLS = 100 // Safety limit - - // Store sync params for injecting updates in loadSubset - let syncBegin: () => void - let syncWrite: (msg: { type: string; value: TestItem }) => void - let syncCommit: () => void - let updateCounter = 0 - - const sync: SyncConfig = { - sync: (params) => { - syncBegin = params.begin - syncWrite = params.write as typeof syncWrite - syncCommit = params.commit - const markReady = params.markReady - - // Load initial data - syncBegin() - initialData.forEach((item) => { - syncWrite({ type: `insert`, value: item }) - }) - syncCommit() - markReady() - - return { - // This loadSubset function simulates Electric's behavior: - // When the subscription asks for more data (because TopK isn't full), - // Electric might synchronously send an update for existing data - loadSubset: () => { - loadSubsetCallCount++ - - if (loadSubsetCallCount > MAX_LOADSUBSET_CALLS) { - throw new Error( - `loadSubset called ${loadSubsetCallCount} times - infinite loop detected!`, - ) - } - - // Simulate Electric sending an update for a matching item - // This is key: the update must match WHERE clause to pass the subscription filter - // and actually add work to D2, keeping pendingWork() true - syncBegin() - syncWrite({ - type: `update`, - value: { id: 1, value: 100 + updateCounter++, category: `A` }, // Matches WHERE > 95 - }) - syncCommit() - - return true // Synchronous completion - }, - } - }, - } - - const sourceCollection = createCollection({ - id: `loadsubset-infinite-loop-test`, - getKey: (item: TestItem) => item.id, - sync, - syncMode: `on-demand`, - }) - - await sourceCollection.preload() - - // Query: WHERE value > 95, ORDER BY value DESC, LIMIT 5 - // Only 1 item matches (value=100), but we want 5 - // This will exhaust the local index and trigger loadSubset calls - const liveQueryCollection = createLiveQueryCollection((q) => - q - .from({ items: sourceCollection }) - .where(({ items }) => gt(items.value, 95)) - .orderBy(({ items }) => items.value, `desc`) - .limit(5) - .select(({ items }) => ({ - id: items.id, - value: items.value, - })), - ) - - // Without the fix, this would infinite loop because: - // 1. Initial load finds 1 item, TopK needs 4 more - // 2. loadSubset is called, synchronously injects an update - // 3. Update adds D2 work, pendingWork() returns true - // 4. maybeRunGraph continues, loadMoreIfNeeded is called - // 5. TopK still needs 4 more, loadNextItems finds nothing (index exhausted) - // 6. But wait! We're back to step 2 because pendingWork() is still true from the update - // 7. GOTO step 2 → infinite loop! - // - // With the fix: localIndexExhausted flag is set in step 5, preventing step 6 - - const preloadPromise = liveQueryCollection.preload() - const timeoutPromise = new Promise((_, reject) => - setTimeout( - () => - reject( - new Error( - `Timeout - possible infinite loop. loadSubset was called ${loadSubsetCallCount} times.`, - ), - ), - 5000, - ), - ) - - await expect( - Promise.race([preloadPromise, timeoutPromise]), - ).resolves.toBeUndefined() - - // Verify we got the expected result - const results = Array.from(liveQueryCollection.values()) - expect(results).toHaveLength(1) - // Value will be 100 + number of loadSubset calls (minus 1 since counter starts at 0) - expect(results[0]!.value).toBeGreaterThanOrEqual(100) - - // loadSubset should be called a small number of times, not infinitely - // The exact count depends on implementation details, but should be < 10 - expect(loadSubsetCallCount).toBeLessThan(10) - }) + // NOTE: The actual Electric infinite loop is difficult to reproduce in unit tests because + // Electric's loadSubset is async (uses `await stream.fetchSnapshot`), so it can't + // synchronously inject data during the maybeRunGraph loop. The exact conditions that + // cause the infinite loop in production involve timing-dependent interactions between + // async data arrival and graph execution that are hard to simulate deterministically. + // + // The localIndexExhausted fix prevents unnecessary repeated load attempts regardless + // of whether the trigger is sync or async. The tests above verify the flag's behavior + // correctly: it prevents repeated loads when exhausted, and resets when new inserts arrive. }) From cb07f2b732dc90696c00e3d479c288dcf6699915 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Thu, 22 Jan 2026 11:34:23 -0700 Subject: [PATCH 08/24] Fix localIndexExhausted resetting on updates and add verification test The localIndexExhausted flag was incorrectly resetting when updates arrived because splitUpdates converts updates to delete+insert pairs for D2, and the hasInserts check was seeing those fake inserts. Fix: Check for ORIGINAL inserts before calling splitUpdates, and pass that information to sendChangesToPipelineWithTracking. Now the flag only resets for genuine new inserts from the sync layer. Also adds a test that verifies requestLimitedSnapshot calls are limited after the index is exhausted - with the fix, only 0-4 calls happen after initial load even when 20 updates arrive. Without the fix, it would be ~19 calls. Co-Authored-By: Claude Opus 4.5 --- .../src/query/live/collection-subscriber.ts | 20 +++- .../db/tests/infinite-loop-prevention.test.ts | 109 ++++++++++++++++-- 2 files changed, 115 insertions(+), 14 deletions(-) diff --git a/packages/db/src/query/live/collection-subscriber.ts b/packages/db/src/query/live/collection-subscriber.ts index 91af3858b..d08b837b4 100644 --- a/packages/db/src/query/live/collection-subscriber.ts +++ b/packages/db/src/query/live/collection-subscriber.ts @@ -228,11 +228,19 @@ export class CollectionSubscriber< const sendChangesInRange = ( changes: Iterable>, ) => { + // Check for ORIGINAL inserts before splitting updates. + // splitUpdates converts updates to delete+insert pairs for D2, but those + // fake inserts shouldn't reset localIndexExhausted. Only genuine new inserts + // (new data from the sync layer) should reset it. + const changesArray = Array.isArray(changes) ? changes : [...changes] + const hasOriginalInserts = changesArray.some((c) => c.type === `insert`) + // Split live updates into a delete of the old value and an insert of the new value - const splittedChanges = splitUpdates(changes) + const splittedChanges = splitUpdates(changesArray) this.sendChangesToPipelineWithTracking( splittedChanges, subscriptionHolder.current!, + hasOriginalInserts, ) } @@ -333,6 +341,7 @@ export class CollectionSubscriber< private sendChangesToPipelineWithTracking( changes: Iterable>, subscription: CollectionSubscription, + hasOriginalInserts?: boolean, ) { const orderByInfo = this.getOrderByInfo() if (!orderByInfo) { @@ -340,12 +349,13 @@ export class CollectionSubscriber< return } - // Reset localIndexExhausted when new data arrives from the sync layer. + // Reset localIndexExhausted when genuinely new data arrives from the sync layer. // This allows loadMoreIfNeeded to try loading again since there's new data. - // We only reset on inserts since updates/deletes don't add new data to load. + // We only reset on ORIGINAL inserts - not fake inserts from splitUpdates. + // splitUpdates converts updates to delete+insert for D2, but those shouldn't + // reset the flag since they don't represent new data that could fill the TopK. const changesArray = Array.isArray(changes) ? changes : [...changes] - const hasInserts = changesArray.some((c) => c.type === `insert`) - if (hasInserts) { + if (hasOriginalInserts) { this.localIndexExhausted = false } diff --git a/packages/db/tests/infinite-loop-prevention.test.ts b/packages/db/tests/infinite-loop-prevention.test.ts index c8d8b0f7f..f1a9ef0eb 100644 --- a/packages/db/tests/infinite-loop-prevention.test.ts +++ b/packages/db/tests/infinite-loop-prevention.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { createCollection } from '../src/collection/index.js' import { createLiveQueryCollection, gt } from '../src/query/index.js' +import { CollectionSubscription } from '../src/collection/subscription.js' import { mockSyncCollectionOptions } from './utils.js' /** @@ -371,13 +372,103 @@ describe(`Infinite loop prevention`, () => { expect(results.map((r) => r.value)).toEqual([101, 90, 80]) }) - // NOTE: The actual Electric infinite loop is difficult to reproduce in unit tests because - // Electric's loadSubset is async (uses `await stream.fetchSnapshot`), so it can't - // synchronously inject data during the maybeRunGraph loop. The exact conditions that - // cause the infinite loop in production involve timing-dependent interactions between - // async data arrival and graph execution that are hard to simulate deterministically. - // - // The localIndexExhausted fix prevents unnecessary repeated load attempts regardless - // of whether the trigger is sync or async. The tests above verify the flag's behavior - // correctly: it prevents repeated loads when exhausted, and resets when new inserts arrive. + it(`should limit requestLimitedSnapshot calls when index is exhausted`, async () => { + // This test verifies that the localIndexExhausted optimization actually limits + // how many times we try to load from an exhausted index. + // + // We patch CollectionSubscription.prototype.requestLimitedSnapshot to count calls, + // then send multiple updates and verify the call count stays low (not unbounded). + + // Patch prototype before creating anything + let requestLimitedSnapshotCallCount = 0 + const originalRequestLimitedSnapshot = + CollectionSubscription.prototype.requestLimitedSnapshot + + CollectionSubscription.prototype.requestLimitedSnapshot = function ( + ...args: Array + ) { + requestLimitedSnapshotCallCount++ + return originalRequestLimitedSnapshot.apply(this, args as any) + } + + try { + const initialData: Array = [ + { id: 1, value: 100, category: `A` }, // Only this matches WHERE > 95 + { id: 2, value: 50, category: `A` }, + { id: 3, value: 40, category: `A` }, + ] + + const { utils, ...options } = mockSyncCollectionOptions({ + id: `limited-snapshot-calls-test`, + getKey: (item: TestItem) => item.id, + initialData, + }) + + const sourceCollection = createCollection(options) + await sourceCollection.preload() + + // Query: WHERE value > 95, ORDER BY value DESC, LIMIT 5 + // Only 1 item matches (value=100), but we want 5 + const liveQueryCollection = createLiveQueryCollection((q) => + q + .from({ items: sourceCollection }) + .where(({ items }) => gt(items.value, 95)) + .orderBy(({ items }) => items.value, `desc`) + .limit(5) + .select(({ items }) => ({ + id: items.id, + value: items.value, + })), + ) + + await liveQueryCollection.preload() + + // Record how many calls happened during initial load + const initialLoadCalls = requestLimitedSnapshotCallCount + + // Should have 1 item initially + let results = Array.from(liveQueryCollection.values()) + expect(results).toHaveLength(1) + expect(results[0]!.value).toBe(100) + + // Send 20 updates that match the WHERE clause + // Without the fix, each update would trigger loadMoreIfNeeded which would + // call requestLimitedSnapshot. With the fix, localIndexExhausted prevents + // repeated calls. + for (let i = 0; i < 20; i++) { + utils.begin() + utils.write({ + type: `update`, + value: { id: 1, value: 100 + i, category: `A` }, + }) + utils.commit() + await new Promise((resolve) => setTimeout(resolve, 5)) + } + + // Wait for all processing to complete + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Calculate calls after the updates + const callsAfterUpdates = + requestLimitedSnapshotCallCount - initialLoadCalls + + // With the fix, requestLimitedSnapshot should be called very few times + // after the initial load (ideally 0 since index was already exhausted) + // Without the fix, it would be called ~20 times (once per update) + expect(callsAfterUpdates).toBeLessThan(5) + + // Results should show the latest value + results = Array.from(liveQueryCollection.values()) + expect(results).toHaveLength(1) + expect(results[0]!.value).toBeGreaterThanOrEqual(100) + } finally { + // Restore original method + CollectionSubscription.prototype.requestLimitedSnapshot = + originalRequestLimitedSnapshot + } + }) + + // NOTE: The actual Electric infinite loop involves async timing that's hard to reproduce + // in unit tests. The test above verifies the optimization limits repeated calls, + // which is the core behavior the localIndexExhausted flag provides. }) From 6e35ac5f44151500bacd678ec41fc4c37b2b6807 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Thu, 22 Jan 2026 11:38:53 -0700 Subject: [PATCH 09/24] Add detailed comment explaining why reset-only-on-inserts is correct Documents the reasoning for external reviewers: 1. splitUpdates fake inserts don't reset the flag 2. Updates to existing rows don't add new rows to scan 3. Edge case "update makes row match WHERE" is handled by filterAndFlipChanges converting unseen key updates to inserts Co-Authored-By: Claude Opus 4.5 --- .../src/query/live/collection-subscriber.ts | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/db/src/query/live/collection-subscriber.ts b/packages/db/src/query/live/collection-subscriber.ts index d08b837b4..428a562a2 100644 --- a/packages/db/src/query/live/collection-subscriber.ts +++ b/packages/db/src/query/live/collection-subscriber.ts @@ -228,10 +228,22 @@ export class CollectionSubscriber< const sendChangesInRange = ( changes: Iterable>, ) => { - // Check for ORIGINAL inserts before splitting updates. - // splitUpdates converts updates to delete+insert pairs for D2, but those - // fake inserts shouldn't reset localIndexExhausted. Only genuine new inserts - // (new data from the sync layer) should reset it. + // Check for inserts before splitting updates, to determine if we should + // reset localIndexExhausted. We reset on inserts because: + // + // 1. splitUpdates (below) converts updates to delete+insert pairs for D2, + // but those "fake" inserts shouldn't reset the flag - they don't represent + // new rows that could fill the TopK. + // + // 2. "Reset only on inserts" is correct because updates to existing rows + // don't add new rows to scan in the local index. The updated row is + // already being processed in the current graph run. + // + // 3. Edge case: "update makes row match WHERE" is handled correctly because + // the subscription's filterAndFlipChanges converts "update for unseen key" + // to "insert" before we receive it here. So if a row that was previously + // filtered out by WHERE now matches after an update, it arrives as an + // insert and correctly resets the flag. const changesArray = Array.isArray(changes) ? changes : [...changes] const hasOriginalInserts = changesArray.some((c) => c.type === `insert`) From 3a5f05d4238b11110ebfbf5d2bbae416135e471f Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Thu, 22 Jan 2026 12:10:26 -0700 Subject: [PATCH 10/24] Update changeset with clearer ELI5 explanation Co-Authored-By: Claude Opus 4.5 --- .changeset/fix-infinite-loop-orderby-limit.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.changeset/fix-infinite-loop-orderby-limit.md b/.changeset/fix-infinite-loop-orderby-limit.md index 5d0740499..37d80a22d 100644 --- a/.changeset/fix-infinite-loop-orderby-limit.md +++ b/.changeset/fix-infinite-loop-orderby-limit.md @@ -3,4 +3,10 @@ '@tanstack/db-ivm': patch --- -Fix infinite loop in ORDER BY + LIMIT queries when WHERE clause filters out most data. Add `localIndexExhausted` flag to prevent repeated load attempts when the local index is exhausted. Also add safety iteration limits to D2 graph execution, maybeRunGraph, and requestLimitedSnapshot as backstops. +Fix infinite loop in ORDER BY + LIMIT queries when WHERE filters out most data. + +**The problem**: Query asks for "top 10 where category='rare'" but only 3 rare items exist locally. System keeps asking "give me more!" but local index has nothing else. Loop forever. + +**The fix**: Added `localIndexExhausted` flag. When local index says "nothing left," we remember and stop asking. Flag resets when genuinely new data arrives from sync layer. + +Also adds safety iteration limits as backstops (D2: 100k, maybeRunGraph: 10k, requestLimitedSnapshot: 10k). From 9cc821548e9bf240e4ca34d25e28b883ff935ed9 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 26 Jan 2026 09:32:38 -0700 Subject: [PATCH 11/24] Simplify infinite loop fix to band-aid with diagnostics Remove the localIndexExhausted optimization and keep only the safety limits with enhanced diagnostic error messages. This approach: 1. Prevents app freezes by capping iterations 2. Provides detailed diagnostic info when limits are hit: - Collection IDs and query structure - TopK size vs data needed - Cursor position and iteration counts - Which D2 operators have pending work The diagnostic info will help identify the actual root cause when these errors occur in production. Co-Authored-By: Claude Opus 4.5 --- .changeset/fix-infinite-loop-orderby-limit.md | 18 +- packages/db-ivm/src/d2.ts | 13 +- packages/db/src/collection/subscription.ts | 29 +- .../query/live/collection-config-builder.ts | 19 +- .../src/query/live/collection-subscriber.ts | 51 +-- .../db/tests/infinite-loop-prevention.test.ts | 369 ++---------------- .../nan-comparator-infinite-loop.test.ts | 362 +++++++++++++++++ 7 files changed, 470 insertions(+), 391 deletions(-) create mode 100644 packages/db/tests/nan-comparator-infinite-loop.test.ts diff --git a/.changeset/fix-infinite-loop-orderby-limit.md b/.changeset/fix-infinite-loop-orderby-limit.md index 37d80a22d..b691e2da6 100644 --- a/.changeset/fix-infinite-loop-orderby-limit.md +++ b/.changeset/fix-infinite-loop-orderby-limit.md @@ -3,10 +3,20 @@ '@tanstack/db-ivm': patch --- -Fix infinite loop in ORDER BY + LIMIT queries when WHERE filters out most data. +Add safety limits and diagnostic error messages to prevent app freezes from infinite loops. -**The problem**: Query asks for "top 10 where category='rare'" but only 3 rare items exist locally. System keeps asking "give me more!" but local index has nothing else. Loop forever. +**The problem**: ORDER BY + LIMIT queries can cause excessive iterations when WHERE filters out most data, leading to app freezes. -**The fix**: Added `localIndexExhausted` flag. When local index says "nothing left," we remember and stop asking. Flag resets when genuinely new data arrives from sync layer. +**The fix**: Added iteration safety limits as backstops that prevent hangs and provide detailed diagnostic info when triggered: -Also adds safety iteration limits as backstops (D2: 100k, maybeRunGraph: 10k, requestLimitedSnapshot: 10k). +- D2 graph: 100,000 iterations +- maybeRunGraph: 10,000 iterations +- requestLimitedSnapshot: 10,000 iterations + +When limits are hit, detailed error messages include: +- Collection IDs and query info +- TopK size vs data needed +- Cursor position and iteration counts +- Which D2 operators have pending work + +This diagnostic info will help identify the root cause of production freezes. Please report any errors with the diagnostic output to https://github.com/TanStack/db/issues diff --git a/packages/db-ivm/src/d2.ts b/packages/db-ivm/src/d2.ts index f8c5233d8..8659dc23e 100644 --- a/packages/db-ivm/src/d2.ts +++ b/packages/db-ivm/src/d2.ts @@ -65,9 +65,20 @@ export class D2 implements ID2 { while (this.pendingWork()) { if (++iterations > MAX_RUN_ITERATIONS) { + // Gather diagnostic info about which operators have pending work + const operatorsWithWork = this.#operators + .filter((op) => op.hasPendingWork()) + .map((op) => ({ + id: op.id, + type: op.constructor.name, + })) + throw new Error( `D2 graph execution exceeded ${MAX_RUN_ITERATIONS} iterations. ` + - `This may indicate an infinite loop in the dataflow graph.`, + `This may indicate an infinite loop in the dataflow graph.\n` + + `Operators with pending work: ${JSON.stringify(operatorsWithWork)}\n` + + `Total operators: ${this.#operators.length}\n` + + `Please report this issue at https://github.com/TanStack/db/issues`, ) } this.step() diff --git a/packages/db/src/collection/subscription.ts b/packages/db/src/collection/subscription.ts index 785f9e846..31f0c429e 100644 --- a/packages/db/src/collection/subscription.ts +++ b/packages/db/src/collection/subscription.ts @@ -514,10 +514,27 @@ export class CollectionSubscription while (valuesNeeded() > 0 && !collectionExhausted()) { if (++snapshotIterations > MAX_SNAPSHOT_ITERATIONS) { + // Gather diagnostic info to help debug the root cause + const diagnosticInfo = { + collectionId: this.collection.id, + collectionSize: this.collection.size, + limit, + offset, + valuesNeeded: valuesNeeded(), + changesCollected: changes.length, + sentKeysCount: this.sentKeys.size, + cursorValue: biggestObservedValue, + minValueForIndex, + keysInCurrentBatch: keys.length, + orderByDirection: orderBy[0]!.compareOptions.direction, + } + console.error( `[TanStack DB] requestLimitedSnapshot exceeded ${MAX_SNAPSHOT_ITERATIONS} iterations. ` + `This may indicate an infinite loop in index iteration or filtering. ` + - `Breaking out to prevent app freeze. Collection: ${this.collection.id}`, + `Breaking out to prevent app freeze.\n` + + `Diagnostic info: ${JSON.stringify(diagnosticInfo, null, 2)}\n` + + `Please report this issue at https://github.com/TanStack/db/issues`, ) hitIterationLimit = true break @@ -573,20 +590,20 @@ export class CollectionSubscription if (whereFromCursor) { const { expression } = orderBy[0]! - const minValue = minValues[0] + const cursorMinValue = minValues[0] // Build the whereCurrent expression for the first orderBy column // For Date values, we need to handle precision differences between JS (ms) and backends (μs) // A JS Date represents a 1ms range, so we query for all values within that range let whereCurrentCursor: BasicExpression - if (minValue instanceof Date) { - const minValuePlus1ms = new Date(minValue.getTime() + 1) + if (cursorMinValue instanceof Date) { + const minValuePlus1ms = new Date(cursorMinValue.getTime() + 1) whereCurrentCursor = and( - gte(expression, new Value(minValue)), + gte(expression, new Value(cursorMinValue)), lt(expression, new Value(minValuePlus1ms)), ) } else { - whereCurrentCursor = eq(expression, new Value(minValue)) + whereCurrentCursor = eq(expression, new Value(cursorMinValue)) } cursorExpressions = { diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index 9be18da77..33b8dd802 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -347,10 +347,27 @@ export class CollectionConfigBuilder< while (syncState.graph.pendingWork()) { if (++iterations > MAX_GRAPH_ITERATIONS) { + // Gather diagnostic info to help debug the root cause + const collectionIds = Object.keys(this.collections) + const orderByInfo = Object.entries( + this.optimizableOrderByCollections, + ).map(([id, info]) => ({ + collectionId: id, + limit: info.limit, + offset: info.offset, + dataNeeded: info.dataNeeded?.() ?? `unknown`, + })) + this.transitionToError( `Graph execution exceeded ${MAX_GRAPH_ITERATIONS} iterations. ` + `This likely indicates an infinite loop caused by data loading ` + - `triggering continuous graph updates.`, + `triggering continuous graph updates.\n` + + `Diagnostic info:\n` + + ` - Live query ID: ${this.id}\n` + + ` - Source collections: ${collectionIds.join(`, `)}\n` + + ` - Run count: ${this.runCount}\n` + + ` - OrderBy optimization info: ${JSON.stringify(orderByInfo)}\n` + + `Please report this issue with the above info at https://github.com/TanStack/db/issues`, ) return } diff --git a/packages/db/src/query/live/collection-subscriber.ts b/packages/db/src/query/live/collection-subscriber.ts index 428a562a2..62cb24560 100644 --- a/packages/db/src/query/live/collection-subscriber.ts +++ b/packages/db/src/query/live/collection-subscriber.ts @@ -37,12 +37,6 @@ export class CollectionSubscriber< // can potentially send the same item to D2 multiple times. private sentToD2Keys = new Set() - // Track when the local index has been exhausted for the current cursor position. - // When true, loadMoreIfNeeded will not try to load more data until new data arrives. - // This prevents infinite loops when the TopK can't be filled because WHERE filters - // out all available data. - private localIndexExhausted = false - constructor( private alias: string, private collectionId: string, @@ -228,31 +222,12 @@ export class CollectionSubscriber< const sendChangesInRange = ( changes: Iterable>, ) => { - // Check for inserts before splitting updates, to determine if we should - // reset localIndexExhausted. We reset on inserts because: - // - // 1. splitUpdates (below) converts updates to delete+insert pairs for D2, - // but those "fake" inserts shouldn't reset the flag - they don't represent - // new rows that could fill the TopK. - // - // 2. "Reset only on inserts" is correct because updates to existing rows - // don't add new rows to scan in the local index. The updated row is - // already being processed in the current graph run. - // - // 3. Edge case: "update makes row match WHERE" is handled correctly because - // the subscription's filterAndFlipChanges converts "update for unseen key" - // to "insert" before we receive it here. So if a row that was previously - // filtered out by WHERE now matches after an update, it arrives as an - // insert and correctly resets the flag. const changesArray = Array.isArray(changes) ? changes : [...changes] - const hasOriginalInserts = changesArray.some((c) => c.type === `insert`) - // Split live updates into a delete of the old value and an insert of the new value const splittedChanges = splitUpdates(changesArray) this.sendChangesToPipelineWithTracking( splittedChanges, subscriptionHolder.current!, - hasOriginalInserts, ) } @@ -327,25 +302,11 @@ export class CollectionSubscriber< return true } - // If we've already exhausted the local index, don't try to load more. - // This prevents infinite loops when the TopK can't be filled because - // the WHERE clause filters out all available local data. - // The flag is reset when new data arrives from the sync layer. - if (this.localIndexExhausted) { - return true - } - // `dataNeeded` probes the orderBy operator to see if it needs more data // if it needs more data, it returns the number of items it needs const n = dataNeeded() if (n > 0) { - const foundLocalData = this.loadNextItems(n, subscription) - if (!foundLocalData) { - // No local data found - mark the index as exhausted so we don't - // keep trying in subsequent graph iterations. The sync layer's - // loadSubset has been called and may return data asynchronously. - this.localIndexExhausted = true - } + this.loadNextItems(n, subscription) } return true } @@ -353,7 +314,6 @@ export class CollectionSubscriber< private sendChangesToPipelineWithTracking( changes: Iterable>, subscription: CollectionSubscription, - hasOriginalInserts?: boolean, ) { const orderByInfo = this.getOrderByInfo() if (!orderByInfo) { @@ -361,16 +321,7 @@ export class CollectionSubscriber< return } - // Reset localIndexExhausted when genuinely new data arrives from the sync layer. - // This allows loadMoreIfNeeded to try loading again since there's new data. - // We only reset on ORIGINAL inserts - not fake inserts from splitUpdates. - // splitUpdates converts updates to delete+insert for D2, but those shouldn't - // reset the flag since they don't represent new data that could fill the TopK. const changesArray = Array.isArray(changes) ? changes : [...changes] - if (hasOriginalInserts) { - this.localIndexExhausted = false - } - const trackedChanges = this.trackSentValues( changesArray, orderByInfo.comparator, diff --git a/packages/db/tests/infinite-loop-prevention.test.ts b/packages/db/tests/infinite-loop-prevention.test.ts index f1a9ef0eb..f87da5c09 100644 --- a/packages/db/tests/infinite-loop-prevention.test.ts +++ b/packages/db/tests/infinite-loop-prevention.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from 'vitest' import { createCollection } from '../src/collection/index.js' import { createLiveQueryCollection, gt } from '../src/query/index.js' -import { CollectionSubscription } from '../src/collection/subscription.js' import { mockSyncCollectionOptions } from './utils.js' /** @@ -9,20 +8,15 @@ import { mockSyncCollectionOptions } from './utils.js' * * The issue: When a live query has ORDER BY + LIMIT, the TopK operator * requests data until it has `limit` items. If the WHERE clause filters - * out most data, the TopK may never be filled, causing loadMoreIfNeeded - * to be called repeatedly in an infinite loop. + * out most data, the TopK may never be filled, causing excessive iterations. * - * The infinite loop specifically occurs when: - * 1. Initial load exhausts the local index (TopK still needs more items) - * 2. Updates arrive (e.g., from Electric sync layer converting duplicate inserts to updates) - * 3. maybeRunGraph processes the update and calls loadMoreIfNeeded - * 4. loadMoreIfNeeded sees dataNeeded() > 0, calls loadNextItems - * 5. loadNextItems finds nothing (index exhausted), but without tracking this, - * the next iteration repeats steps 3-5 indefinitely + * The band-aid fix: Safety limits that cap iterations and throw detailed + * error messages with diagnostic info when exceeded: + * - D2 graph: 100,000 iterations + * - maybeRunGraph: 10,000 iterations + * - requestLimitedSnapshot: 10,000 iterations * - * The fix: CollectionSubscriber tracks when the local index is exhausted - * via `localIndexExhausted` flag, preventing repeated load attempts. - * The flag resets when new inserts arrive, allowing the system to try again. + * These tests verify that queries complete without hitting safety limits. */ type TestItem = { @@ -32,27 +26,9 @@ type TestItem = { } describe(`Infinite loop prevention`, () => { - // The infinite loop bug occurs when: - // 1. Query has ORDER BY + LIMIT + WHERE that filters most data - // 2. Sync layer (like Electric) continuously sends updates - // 3. These updates trigger pendingWork() to remain true during maybeRunGraph - // 4. Without the localIndexExhausted fix, loadMoreIfNeeded keeps trying to load - // from the exhausted local index - // - // These tests verify the localIndexExhausted flag works correctly: - // - Prevents repeated load attempts when the local index is exhausted - // - Resets when new inserts arrive, allowing the system to try again - - it(`should not infinite loop when WHERE filters out most data for ORDER BY + LIMIT query`, async () => { - // This test verifies that the localIndexExhausted optimization prevents - // unnecessary load attempts when the TopK can't be filled. - // - // The scenario: - // 1. Query wants 10 items with value > 90 - // 2. Only 2 items match (values 95 and 100) - // 3. Without the fix, loadMoreIfNeeded would keep trying to load more - // 4. With the fix, localIndexExhausted stops unnecessary attempts - + it(`should complete ORDER BY + LIMIT query when WHERE filters out most data`, async () => { + // Scenario: Query wants 10 items with value > 90, but only 2 exist + // Should complete without hitting safety limits const initialData: Array = [] for (let i = 1; i <= 20; i++) { initialData.push({ @@ -84,7 +60,7 @@ describe(`Infinite loop prevention`, () => { })), ) - // Should complete without hanging or hitting safeguard + // Should complete without hanging or hitting safety limits await liveQueryCollection.preload() // Verify results @@ -92,14 +68,14 @@ describe(`Infinite loop prevention`, () => { expect(results).toHaveLength(2) expect(results.map((r) => r.value)).toEqual([100, 95]) - // Verify not in error state (didn't hit safeguard) + // Verify not in error state (didn't hit safety limit) expect( liveQueryCollection.status, `Query should not be in error state`, ).not.toBe(`error`) }) - it(`should resume loading when new matching data arrives`, async () => { + it(`should load new data when it arrives after initial load`, async () => { // Start with data that doesn't match WHERE clause const initialData: Array = [ { id: 1, value: 10, category: `A` }, @@ -132,104 +108,36 @@ describe(`Infinite loop prevention`, () => { await liveQueryCollection.preload() - // Should have 0 items initially + // Initially 0 results let results = Array.from(liveQueryCollection.values()) expect(results).toHaveLength(0) - // Now add items that match the WHERE clause + // Add new data that matches WHERE clause utils.begin() - utils.write({ type: `insert`, value: { id: 4, value: 60, category: `B` } }) - utils.write({ type: `insert`, value: { id: 5, value: 70, category: `B` } }) + utils.write({ type: `insert`, value: { id: 4, value: 60, category: `A` } }) + utils.write({ type: `insert`, value: { id: 5, value: 70, category: `A` } }) utils.commit() - // Wait for changes to propagate await new Promise((resolve) => setTimeout(resolve, 50)) - // Should now have 2 items (localIndexExhausted was reset by new inserts) + // Should now have 2 items results = Array.from(liveQueryCollection.values()) expect(results).toHaveLength(2) expect(results.map((r) => r.value)).toEqual([70, 60]) + + // Verify not in error state + expect(liveQueryCollection.status).not.toBe(`error`) }) - it(`should handle updates that move items out of WHERE clause`, async () => { - // All items initially match WHERE clause + it(`should handle updates without entering error state`, async () => { const initialData: Array = [ - { id: 1, value: 100, category: `A` }, - { id: 2, value: 90, category: `A` }, - { id: 3, value: 80, category: `A` }, - { id: 4, value: 70, category: `A` }, - { id: 5, value: 60, category: `A` }, + { id: 1, value: 100, category: `A` }, // Only this matches WHERE > 95 + { id: 2, value: 50, category: `A` }, + { id: 3, value: 40, category: `A` }, ] - const sourceCollection = createCollection( - mockSyncCollectionOptions({ - id: `update-out-of-where-test`, - getKey: (item: TestItem) => item.id, - initialData, - }), - ) - - await sourceCollection.preload() - - // Query: WHERE value > 50, ORDER BY value DESC, LIMIT 3 - const liveQueryCollection = createLiveQueryCollection((q) => - q - .from({ items: sourceCollection }) - .where(({ items }) => gt(items.value, 50)) - .orderBy(({ items }) => items.value, `desc`) - .limit(3) - .select(({ items }) => ({ - id: items.id, - value: items.value, - })), - ) - - await liveQueryCollection.preload() - - // Should have top 3: 100, 90, 80 - let results = Array.from(liveQueryCollection.values()) - expect(results).toHaveLength(3) - expect(results.map((r) => r.value)).toEqual([100, 90, 80]) - - // Update items to move them OUT of WHERE clause - // This could trigger the infinite loop if not handled properly - sourceCollection.update(1, (draft) => { - draft.value = 40 // Now < 50, filtered out - }) - sourceCollection.update(2, (draft) => { - draft.value = 30 // Now < 50, filtered out - }) - - // Wait for changes to propagate - await new Promise((resolve) => setTimeout(resolve, 50)) - - // Should now have: 80, 70, 60 (items 3, 4, 5) - results = Array.from(liveQueryCollection.values()) - expect(results).toHaveLength(3) - expect(results.map((r) => r.value)).toEqual([80, 70, 60]) - }) - - it(`should not infinite loop when updates arrive after local index is exhausted`, async () => { - // This test simulates the Electric scenario where: - // 1. Initial data loads, but TopK can't be filled (WHERE filters too much) - // 2. Updates arrive from sync layer (like Electric converting duplicate inserts to updates) - // 3. Without the fix, each update would trigger loadMoreIfNeeded which tries - // to load from the exhausted local index, causing an infinite loop - // - // The fix: localIndexExhausted flag prevents repeated load attempts. - // The flag only resets when NEW INSERTS arrive (not updates/deletes). - - const initialData: Array = [] - for (let i = 1; i <= 10; i++) { - initialData.push({ - id: i, - value: i * 10, // values: 10, 20, 30, ... 100 - category: `A`, - }) - } - const { utils, ...options } = mockSyncCollectionOptions({ - id: `electric-update-loop-test`, + id: `updates-test`, getKey: (item: TestItem) => item.id, initialData, }) @@ -237,9 +145,6 @@ describe(`Infinite loop prevention`, () => { const sourceCollection = createCollection(options) await sourceCollection.preload() - // Query: WHERE value > 95, ORDER BY value DESC, LIMIT 5 - // Only item with value=100 matches, but we want 5 items - // This exhausts the local index after the first item const liveQueryCollection = createLiveQueryCollection((q) => q .from({ items: sourceCollection }) @@ -252,223 +157,29 @@ describe(`Infinite loop prevention`, () => { })), ) - // Preload should complete without hanging - const preloadPromise = liveQueryCollection.preload() - const timeoutPromise = new Promise((_, reject) => - setTimeout( - () => - reject(new Error(`Timeout during preload - possible infinite loop`)), - 5000, - ), - ) - - await expect( - Promise.race([preloadPromise, timeoutPromise]), - ).resolves.toBeUndefined() - - // Should have 1 item (value=100) - let results = Array.from(liveQueryCollection.values()) - expect(results).toHaveLength(1) - expect(results[0]!.value).toBe(100) - - // Now simulate Electric sending updates (like duplicate insert → update conversion) - // Without the fix, this would trigger infinite loop because: - // 1. Update arrives, triggers maybeRunGraph - // 2. loadMoreIfNeeded sees dataNeeded() > 0 (TopK still needs 4 more) - // 3. loadNextItems finds nothing (index exhausted) - // 4. Without localIndexExhausted flag, loop would repeat indefinitely - const updatePromise = (async () => { - // Send several updates that don't change the result set - // These simulate Electric's duplicate handling - for (let i = 0; i < 5; i++) { - utils.begin() - // Update an item that doesn't match WHERE - this shouldn't affect results - // but could trigger the infinite loop bug - utils.write({ - type: `update`, - value: { id: 5, value: 50 + i, category: `A` }, // Still doesn't match WHERE - }) - utils.commit() - - // Small delay between updates to simulate real Electric behavior - await new Promise((resolve) => setTimeout(resolve, 10)) - } - })() - - const updateTimeoutPromise = new Promise((_, reject) => - setTimeout( - () => - reject(new Error(`Timeout during updates - possible infinite loop`)), - 5000, - ), - ) - - await expect( - Promise.race([updatePromise, updateTimeoutPromise]), - ).resolves.toBeUndefined() - - // Results should still be the same (updates didn't add matching items) - results = Array.from(liveQueryCollection.values()) - expect(results).toHaveLength(1) - expect(results[0]!.value).toBe(100) - }) - - it(`should reset localIndexExhausted when new inserts arrive`, async () => { - // This test verifies that the localIndexExhausted flag properly resets - // when new inserts arrive, allowing the system to load more data - - const { utils, ...options } = mockSyncCollectionOptions({ - id: `reset-exhausted-flag-test`, - getKey: (item: TestItem) => item.id, - initialData: [{ id: 1, value: 100, category: `A` }], - }) - - const sourceCollection = createCollection(options) - await sourceCollection.preload() - - // Query: WHERE value > 50, ORDER BY value DESC, LIMIT 5 - // Initially only 1 item matches, but we want 5 - const liveQueryCollection = createLiveQueryCollection((q) => - q - .from({ items: sourceCollection }) - .where(({ items }) => gt(items.value, 50)) - .orderBy(({ items }) => items.value, `desc`) - .limit(5) - .select(({ items }) => ({ - id: items.id, - value: items.value, - })), - ) - await liveQueryCollection.preload() // Should have 1 item initially let results = Array.from(liveQueryCollection.values()) expect(results).toHaveLength(1) - // Send updates (should NOT reset the flag, should NOT trigger more loads) - utils.begin() - utils.write({ type: `update`, value: { id: 1, value: 101, category: `A` } }) - utils.commit() + // Send several updates + for (let i = 0; i < 10; i++) { + utils.begin() + utils.write({ + type: `update`, + value: { id: 1, value: 100 + i, category: `A` }, + }) + utils.commit() + await new Promise((resolve) => setTimeout(resolve, 10)) + } - await new Promise((resolve) => setTimeout(resolve, 50)) + await new Promise((resolve) => setTimeout(resolve, 100)) - // Still 1 item (updated value) + // Should still have results and not be in error state results = Array.from(liveQueryCollection.values()) expect(results).toHaveLength(1) - expect(results[0]!.value).toBe(101) - - // Now send NEW INSERTS - this SHOULD reset the flag and load more - utils.begin() - utils.write({ type: `insert`, value: { id: 2, value: 90, category: `B` } }) - utils.write({ type: `insert`, value: { id: 3, value: 80, category: `B` } }) - utils.commit() - - await new Promise((resolve) => setTimeout(resolve, 50)) - - // Now should have 3 items (new inserts reset the flag, allowing more to load) - results = Array.from(liveQueryCollection.values()) - expect(results).toHaveLength(3) - expect(results.map((r) => r.value)).toEqual([101, 90, 80]) + expect(results[0]!.value).toBeGreaterThanOrEqual(100) + expect(liveQueryCollection.status).not.toBe(`error`) }) - - it(`should limit requestLimitedSnapshot calls when index is exhausted`, async () => { - // This test verifies that the localIndexExhausted optimization actually limits - // how many times we try to load from an exhausted index. - // - // We patch CollectionSubscription.prototype.requestLimitedSnapshot to count calls, - // then send multiple updates and verify the call count stays low (not unbounded). - - // Patch prototype before creating anything - let requestLimitedSnapshotCallCount = 0 - const originalRequestLimitedSnapshot = - CollectionSubscription.prototype.requestLimitedSnapshot - - CollectionSubscription.prototype.requestLimitedSnapshot = function ( - ...args: Array - ) { - requestLimitedSnapshotCallCount++ - return originalRequestLimitedSnapshot.apply(this, args as any) - } - - try { - const initialData: Array = [ - { id: 1, value: 100, category: `A` }, // Only this matches WHERE > 95 - { id: 2, value: 50, category: `A` }, - { id: 3, value: 40, category: `A` }, - ] - - const { utils, ...options } = mockSyncCollectionOptions({ - id: `limited-snapshot-calls-test`, - getKey: (item: TestItem) => item.id, - initialData, - }) - - const sourceCollection = createCollection(options) - await sourceCollection.preload() - - // Query: WHERE value > 95, ORDER BY value DESC, LIMIT 5 - // Only 1 item matches (value=100), but we want 5 - const liveQueryCollection = createLiveQueryCollection((q) => - q - .from({ items: sourceCollection }) - .where(({ items }) => gt(items.value, 95)) - .orderBy(({ items }) => items.value, `desc`) - .limit(5) - .select(({ items }) => ({ - id: items.id, - value: items.value, - })), - ) - - await liveQueryCollection.preload() - - // Record how many calls happened during initial load - const initialLoadCalls = requestLimitedSnapshotCallCount - - // Should have 1 item initially - let results = Array.from(liveQueryCollection.values()) - expect(results).toHaveLength(1) - expect(results[0]!.value).toBe(100) - - // Send 20 updates that match the WHERE clause - // Without the fix, each update would trigger loadMoreIfNeeded which would - // call requestLimitedSnapshot. With the fix, localIndexExhausted prevents - // repeated calls. - for (let i = 0; i < 20; i++) { - utils.begin() - utils.write({ - type: `update`, - value: { id: 1, value: 100 + i, category: `A` }, - }) - utils.commit() - await new Promise((resolve) => setTimeout(resolve, 5)) - } - - // Wait for all processing to complete - await new Promise((resolve) => setTimeout(resolve, 100)) - - // Calculate calls after the updates - const callsAfterUpdates = - requestLimitedSnapshotCallCount - initialLoadCalls - - // With the fix, requestLimitedSnapshot should be called very few times - // after the initial load (ideally 0 since index was already exhausted) - // Without the fix, it would be called ~20 times (once per update) - expect(callsAfterUpdates).toBeLessThan(5) - - // Results should show the latest value - results = Array.from(liveQueryCollection.values()) - expect(results).toHaveLength(1) - expect(results[0]!.value).toBeGreaterThanOrEqual(100) - } finally { - // Restore original method - CollectionSubscription.prototype.requestLimitedSnapshot = - originalRequestLimitedSnapshot - } - }) - - // NOTE: The actual Electric infinite loop involves async timing that's hard to reproduce - // in unit tests. The test above verifies the optimization limits repeated calls, - // which is the core behavior the localIndexExhausted flag provides. }) diff --git a/packages/db/tests/nan-comparator-infinite-loop.test.ts b/packages/db/tests/nan-comparator-infinite-loop.test.ts new file mode 100644 index 000000000..c636eab2b --- /dev/null +++ b/packages/db/tests/nan-comparator-infinite-loop.test.ts @@ -0,0 +1,362 @@ +import { describe, expect, it } from 'vitest' +import { createCollection } from '../src/collection/index.js' +import { createLiveQueryCollection } from '../src/query/index.js' +import { eq } from '../src/query/builder/functions.js' +import { mockSyncCollectionOptions } from './utils.js' + +/** + * Test that reproduces the infinite loop bug caused by NaN values in comparisons. + * + * The bug occurs when: + * 1. Data contains invalid Date objects (where getTime() returns NaN) + * 2. An ORDER BY + LIMIT query runs on the date field + * 3. The comparator returns NaN instead of -1, 0, or 1 + * 4. Binary search in TopK can't find a stable position + * 5. The comparison function is called infinitely + * + * This matches the production bug where: + * - Debugger paused in `gte` comparison code + * - App completely froze (not just slow) + * - Clearing Electric cache fixed it (fresh data had valid dates) + */ + +type TestItem = { + id: number + name: string + createdAt: Date +} + +describe(`NaN comparator infinite loop`, () => { + it(`should handle invalid dates in ORDER BY without infinite loop`, async () => { + // Create data with a mix of valid and INVALID dates + // Invalid dates have getTime() = NaN, which breaks comparisons + const initialData: Array = [ + { id: 1, name: `Valid 1`, createdAt: new Date(`2024-01-01`) }, + { id: 2, name: `Valid 2`, createdAt: new Date(`2024-01-02`) }, + { id: 3, name: `Invalid`, createdAt: new Date(`not a valid date`) }, // NaN! + { id: 4, name: `Valid 3`, createdAt: new Date(`2024-01-03`) }, + { id: 5, name: `Valid 4`, createdAt: new Date(`2024-01-04`) }, + ] + + // Verify we actually have an invalid date + expect(Number.isNaN(initialData[2]!.createdAt.getTime())).toBe(true) + + const sourceCollection = createCollection( + mockSyncCollectionOptions({ + id: `nan-date-test`, + getKey: (item: TestItem) => item.id, + initialData, + }), + ) + + await sourceCollection.preload() + + // This query should NOT hang - it should either: + // 1. Handle the NaN gracefully (ideal) + // 2. Throw an error (acceptable) + // 3. NOT infinite loop (critical) + const liveQueryCollection = createLiveQueryCollection((q) => + q + .from({ items: sourceCollection }) + .orderBy(({ items }) => items.createdAt, `desc`) + .limit(3) + .select(({ items }) => ({ + id: items.id, + name: items.name, + createdAt: items.createdAt, + })), + ) + + // Set up a timeout to detect infinite loop + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + reject(new Error(`Test timed out - infinite loop detected in ORDER BY with NaN date`)) + }, 5000) + }) + + // Race the preload against the timeout + const result = await Promise.race([ + liveQueryCollection.preload().then(() => `completed`), + timeoutPromise, + ]) + + expect(result).toBe(`completed`) + + // If we get here, the query completed without hanging + const results = Array.from(liveQueryCollection.values()) + // We should have some results (exact behavior depends on how NaN is handled) + expect(results.length).toBeGreaterThanOrEqual(0) + }) + + it(`should handle NaN in numeric ORDER BY without infinite loop`, async () => { + // Test with explicit NaN numeric values (not just invalid dates) + type NumericItem = { + id: number + value: number + } + + const initialData: Array = [ + { id: 1, value: 10 }, + { id: 2, value: 20 }, + { id: 3, value: NaN }, // Explicit NaN + { id: 4, value: 30 }, + { id: 5, value: 40 }, + ] + + const sourceCollection = createCollection( + mockSyncCollectionOptions({ + id: `nan-numeric-test`, + getKey: (item: NumericItem) => item.id, + initialData, + }), + ) + + await sourceCollection.preload() + + const liveQueryCollection = createLiveQueryCollection((q) => + q + .from({ items: sourceCollection }) + .orderBy(({ items }) => items.value, `desc`) + .limit(3) + .select(({ items }) => ({ + id: items.id, + value: items.value, + })), + ) + + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + reject(new Error(`Test timed out - infinite loop detected in ORDER BY with NaN value`)) + }, 5000) + }) + + const result = await Promise.race([ + liveQueryCollection.preload().then(() => `completed`), + timeoutPromise, + ]) + + expect(result).toBe(`completed`) + }) + + it(`should handle mixed valid/invalid dates during updates without infinite loop`, async () => { + // This simulates the Electric scenario where updates arrive with potentially invalid data + const initialData: Array = [ + { id: 1, name: `Item 1`, createdAt: new Date(`2024-01-01`) }, + { id: 2, name: `Item 2`, createdAt: new Date(`2024-01-02`) }, + { id: 3, name: `Item 3`, createdAt: new Date(`2024-01-03`) }, + ] + + const { utils, ...options } = mockSyncCollectionOptions({ + id: `nan-update-test`, + getKey: (item: TestItem) => item.id, + initialData, + }) + + const sourceCollection = createCollection(options) + await sourceCollection.preload() + + const liveQueryCollection = createLiveQueryCollection((q) => + q + .from({ items: sourceCollection }) + .orderBy(({ items }) => items.createdAt, `desc`) + .limit(5) + .select(({ items }) => ({ + id: items.id, + name: items.name, + })), + ) + + await liveQueryCollection.preload() + + // Initial results should work + let results = Array.from(liveQueryCollection.values()) + expect(results).toHaveLength(3) + + // Now send an UPDATE that introduces an invalid date + // This simulates Electric sending corrupted data + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + reject(new Error(`Test timed out - infinite loop detected after update with NaN date`)) + }, 5000) + }) + + const updatePromise = (async () => { + utils.begin() + utils.write({ + type: `update`, + value: { id: 2, name: `Updated Item 2`, createdAt: new Date(`invalid date string`) }, + }) + utils.commit() + + // Wait for processing + await new Promise((resolve) => setTimeout(resolve, 100)) + + return `completed` + })() + + const result = await Promise.race([updatePromise, timeoutPromise]) + expect(result).toBe(`completed`) + + // Query should still return results (behavior with NaN may vary) + results = Array.from(liveQueryCollection.values()) + expect(results.length).toBeGreaterThanOrEqual(0) + }) + + it(`should handle JOIN + ORDER BY + LIMIT with rapid updates (Electric scenario)`, async () => { + // This simulates the production scenario: + // - Two collections JOINed together + // - ORDER BY + LIMIT on a date field + // - Rapid updates arriving during processing (like Electric initial sync) + + type User = { id: number; name: string } + type Post = { + id: number + userId: number + title: string + createdAt: Date + } + + const usersData: Array = [ + { id: 1, name: `Alice` }, + { id: 2, name: `Bob` }, + { id: 3, name: `Charlie` }, + ] + + const postsData: Array = [] + for (let i = 1; i <= 20; i++) { + postsData.push({ + id: i, + userId: (i % 3) + 1, + title: `Post ${i}`, + createdAt: new Date(`2024-01-${String(i).padStart(2, `0`)}`), + }) + } + + const usersCollection = createCollection( + mockSyncCollectionOptions({ + id: `join-users`, + getKey: (item: User) => item.id, + initialData: usersData, + }), + ) + + const { utils: postsUtils, ...postsOptions } = mockSyncCollectionOptions({ + id: `join-posts`, + getKey: (item: Post) => item.id, + initialData: postsData, + }) + + const postsCollection = createCollection(postsOptions) + + await usersCollection.preload() + await postsCollection.preload() + + // JOIN + ORDER BY createdAt DESC + LIMIT 5 + const liveQueryCollection = createLiveQueryCollection((q) => + q + .from({ posts: postsCollection }) + .join({ users: usersCollection }, ({ posts, users }) => + eq(posts.userId, users.id), + ) + .orderBy(({ posts }) => posts.createdAt, `desc`) + .limit(5) + .select(({ posts, users }) => ({ + postId: posts.id, + title: posts.title, + author: users!.name, + createdAt: posts.createdAt, + })), + ) + + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + reject(new Error(`Test timed out - infinite loop in JOIN + ORDER BY + LIMIT`)) + }, 5000) + }) + + // Start preload and send rapid updates simultaneously (like Electric sync) + const testPromise = (async () => { + const preloadPromise = liveQueryCollection.preload() + + // Simulate rapid Electric updates during initial sync + for (let i = 21; i <= 40; i++) { + postsUtils.begin() + postsUtils.write({ + type: `insert`, + value: { + id: i, + userId: (i % 3) + 1, + title: `Post ${i}`, + createdAt: new Date(`2024-02-${String(i - 20).padStart(2, `0`)}`), + }, + }) + postsUtils.commit() + // Small delay between updates + await new Promise((resolve) => setTimeout(resolve, 1)) + } + + await preloadPromise + return `completed` + })() + + const result = await Promise.race([testPromise, timeoutPromise]) + expect(result).toBe(`completed`) + + const results = Array.from(liveQueryCollection.values()) + expect(results.length).toBeLessThanOrEqual(5) + }) + + it(`should handle empty string dates (common data issue) without infinite loop`, async () => { + // Empty strings are a common data issue - they create invalid dates + // Simulate data that might come from a database with empty date fields + const rawData = [ + { id: 1, name: `Has date`, createdAt: `2024-01-01` }, + { id: 2, name: `Empty string`, createdAt: `` }, // Common issue! + { id: 3, name: `Has date`, createdAt: `2024-01-03` }, + ] + + // Convert to Date objects (as user code might do) + const initialData: Array = rawData.map((item) => ({ + id: item.id, + name: item.name, + createdAt: new Date(item.createdAt), // Empty string -> Invalid Date + })) + + // Verify empty string creates invalid date + expect(Number.isNaN(initialData[1]!.createdAt.getTime())).toBe(true) + + const sourceCollection = createCollection( + mockSyncCollectionOptions({ + id: `empty-string-date-test`, + getKey: (item: TestItem) => item.id, + initialData, + }), + ) + + await sourceCollection.preload() + + const liveQueryCollection = createLiveQueryCollection((q) => + q + .from({ items: sourceCollection }) + .orderBy(({ items }) => items.createdAt, `desc`) + .limit(3) + .select(({ items }) => ({ + id: items.id, + name: items.name, + })), + ) + + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + reject(new Error(`Test timed out - infinite loop with empty string date`)) + }, 5000) + }) + + const result = await Promise.race([ + liveQueryCollection.preload().then(() => `completed`), + timeoutPromise, + ]) + + expect(result).toBe(`completed`) + }) +}) From 79fb6cf5bf7a5bd2ada8c24bc763f19cd295ddf0 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 26 Jan 2026 16:33:43 +0000 Subject: [PATCH 12/24] ci: apply automated fixes --- .changeset/fix-infinite-loop-orderby-limit.md | 1 + .../nan-comparator-infinite-loop.test.ts | 34 +++++++++++++++---- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/.changeset/fix-infinite-loop-orderby-limit.md b/.changeset/fix-infinite-loop-orderby-limit.md index b691e2da6..8354c2919 100644 --- a/.changeset/fix-infinite-loop-orderby-limit.md +++ b/.changeset/fix-infinite-loop-orderby-limit.md @@ -14,6 +14,7 @@ Add safety limits and diagnostic error messages to prevent app freezes from infi - requestLimitedSnapshot: 10,000 iterations When limits are hit, detailed error messages include: + - Collection IDs and query info - TopK size vs data needed - Cursor position and iteration counts diff --git a/packages/db/tests/nan-comparator-infinite-loop.test.ts b/packages/db/tests/nan-comparator-infinite-loop.test.ts index c636eab2b..08da832f5 100644 --- a/packages/db/tests/nan-comparator-infinite-loop.test.ts +++ b/packages/db/tests/nan-comparator-infinite-loop.test.ts @@ -70,7 +70,11 @@ describe(`NaN comparator infinite loop`, () => { // Set up a timeout to detect infinite loop const timeoutPromise = new Promise((_, reject) => { setTimeout(() => { - reject(new Error(`Test timed out - infinite loop detected in ORDER BY with NaN date`)) + reject( + new Error( + `Test timed out - infinite loop detected in ORDER BY with NaN date`, + ), + ) }, 5000) }) @@ -126,7 +130,11 @@ describe(`NaN comparator infinite loop`, () => { const timeoutPromise = new Promise((_, reject) => { setTimeout(() => { - reject(new Error(`Test timed out - infinite loop detected in ORDER BY with NaN value`)) + reject( + new Error( + `Test timed out - infinite loop detected in ORDER BY with NaN value`, + ), + ) }, 5000) }) @@ -176,7 +184,11 @@ describe(`NaN comparator infinite loop`, () => { // This simulates Electric sending corrupted data const timeoutPromise = new Promise((_, reject) => { setTimeout(() => { - reject(new Error(`Test timed out - infinite loop detected after update with NaN date`)) + reject( + new Error( + `Test timed out - infinite loop detected after update with NaN date`, + ), + ) }, 5000) }) @@ -184,7 +196,11 @@ describe(`NaN comparator infinite loop`, () => { utils.begin() utils.write({ type: `update`, - value: { id: 2, name: `Updated Item 2`, createdAt: new Date(`invalid date string`) }, + value: { + id: 2, + name: `Updated Item 2`, + createdAt: new Date(`invalid date string`), + }, }) utils.commit() @@ -270,7 +286,11 @@ describe(`NaN comparator infinite loop`, () => { const timeoutPromise = new Promise((_, reject) => { setTimeout(() => { - reject(new Error(`Test timed out - infinite loop in JOIN + ORDER BY + LIMIT`)) + reject( + new Error( + `Test timed out - infinite loop in JOIN + ORDER BY + LIMIT`, + ), + ) }, 5000) }) @@ -348,7 +368,9 @@ describe(`NaN comparator infinite loop`, () => { const timeoutPromise = new Promise((_, reject) => { setTimeout(() => { - reject(new Error(`Test timed out - infinite loop with empty string date`)) + reject( + new Error(`Test timed out - infinite loop with empty string date`), + ) }, 5000) }) From 68cdfdaf37562a75d760797ba7cca95c77a23f0c Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 26 Jan 2026 09:47:51 -0700 Subject: [PATCH 13/24] Change circuit breakers to gracefully recover instead of erroring Instead of transitioning to error state when iteration limits are hit, now we: 1. Log a warning with diagnostic info 2. Break out of the loop 3. Continue with the data we have This makes sense because by the time we hit the limit, we likely have all available data - we just couldn't fill the TopK completely because WHERE filtered out most items. Co-Authored-By: Claude Opus 4.5 --- .changeset/fix-infinite-loop-orderby-limit.md | 15 ++++-------- packages/db-ivm/src/d2.ts | 9 ++++--- packages/db/src/collection/subscription.ts | 5 ++-- .../query/live/collection-config-builder.ts | 24 +++++++------------ 4 files changed, 21 insertions(+), 32 deletions(-) diff --git a/.changeset/fix-infinite-loop-orderby-limit.md b/.changeset/fix-infinite-loop-orderby-limit.md index 8354c2919..09d471c80 100644 --- a/.changeset/fix-infinite-loop-orderby-limit.md +++ b/.changeset/fix-infinite-loop-orderby-limit.md @@ -3,21 +3,16 @@ '@tanstack/db-ivm': patch --- -Add safety limits and diagnostic error messages to prevent app freezes from infinite loops. +Add safety limits to prevent app freezes from excessive iterations in ORDER BY + LIMIT queries. -**The problem**: ORDER BY + LIMIT queries can cause excessive iterations when WHERE filters out most data, leading to app freezes. +**The problem**: ORDER BY + LIMIT queries can cause excessive iterations when WHERE filters out most data - the TopK keeps asking for more data that doesn't exist. -**The fix**: Added iteration safety limits as backstops that prevent hangs and provide detailed diagnostic info when triggered: +**The fix**: Added iteration safety limits that gracefully break out of loops and continue with available data: - D2 graph: 100,000 iterations - maybeRunGraph: 10,000 iterations - requestLimitedSnapshot: 10,000 iterations -When limits are hit, detailed error messages include: +When limits are hit, a warning is logged with diagnostic info (collection IDs, query structure, cursor position, etc.) but the query **continues normally** with the data it has - no error state, no app breakage. -- Collection IDs and query info -- TopK size vs data needed -- Cursor position and iteration counts -- Which D2 operators have pending work - -This diagnostic info will help identify the root cause of production freezes. Please report any errors with the diagnostic output to https://github.com/TanStack/db/issues +This diagnostic info will help identify the root cause if the warnings occur in production. Please report any warnings to https://github.com/TanStack/db/issues diff --git a/packages/db-ivm/src/d2.ts b/packages/db-ivm/src/d2.ts index 8659dc23e..673b1a83d 100644 --- a/packages/db-ivm/src/d2.ts +++ b/packages/db-ivm/src/d2.ts @@ -73,13 +73,16 @@ export class D2 implements ID2 { type: op.constructor.name, })) - throw new Error( - `D2 graph execution exceeded ${MAX_RUN_ITERATIONS} iterations. ` + - `This may indicate an infinite loop in the dataflow graph.\n` + + // Log warning but continue gracefully - we likely have all available data, + // just couldn't fill the TopK completely due to WHERE filtering + console.warn( + `[TanStack DB] D2 graph execution exceeded ${MAX_RUN_ITERATIONS} iterations. ` + + `Continuing with available data.\n` + `Operators with pending work: ${JSON.stringify(operatorsWithWork)}\n` + `Total operators: ${this.#operators.length}\n` + `Please report this issue at https://github.com/TanStack/db/issues`, ) + break } this.step() } diff --git a/packages/db/src/collection/subscription.ts b/packages/db/src/collection/subscription.ts index 31f0c429e..54e3f9339 100644 --- a/packages/db/src/collection/subscription.ts +++ b/packages/db/src/collection/subscription.ts @@ -529,10 +529,9 @@ export class CollectionSubscription orderByDirection: orderBy[0]!.compareOptions.direction, } - console.error( + console.warn( `[TanStack DB] requestLimitedSnapshot exceeded ${MAX_SNAPSHOT_ITERATIONS} iterations. ` + - `This may indicate an infinite loop in index iteration or filtering. ` + - `Breaking out to prevent app freeze.\n` + + `Continuing with available data.\n` + `Diagnostic info: ${JSON.stringify(diagnosticInfo, null, 2)}\n` + `Please report this issue at https://github.com/TanStack/db/issues`, ) diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index 33b8dd802..de0f1d557 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -358,30 +358,22 @@ export class CollectionConfigBuilder< dataNeeded: info.dataNeeded?.() ?? `unknown`, })) - this.transitionToError( - `Graph execution exceeded ${MAX_GRAPH_ITERATIONS} iterations. ` + - `This likely indicates an infinite loop caused by data loading ` + - `triggering continuous graph updates.\n` + + // Log warning but continue gracefully - we likely have all available data, + // just couldn't fill the TopK completely due to WHERE filtering + console.warn( + `[TanStack DB] Graph execution exceeded ${MAX_GRAPH_ITERATIONS} iterations. ` + + `Continuing with available data.\n` + `Diagnostic info:\n` + ` - Live query ID: ${this.id}\n` + ` - Source collections: ${collectionIds.join(`, `)}\n` + ` - Run count: ${this.runCount}\n` + ` - OrderBy optimization info: ${JSON.stringify(orderByInfo)}\n` + - `Please report this issue with the above info at https://github.com/TanStack/db/issues`, + `Please report this issue at https://github.com/TanStack/db/issues`, ) - return + break } - try { - syncState.graph.run() - } catch (error) { - // D2 graph throws when it exceeds its internal iteration limit - // Transition to error state so callers can detect incomplete data - this.transitionToError( - error instanceof Error ? error.message : String(error), - ) - return - } + syncState.graph.run() // Flush accumulated changes after each graph step to commit them as one transaction. // This ensures intermediate join states (like null on one side) don't cause // duplicate key errors when the full join result arrives in the same step. From 295b14d51110ae93d7d978670637b039b548414d Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 26 Jan 2026 10:08:01 -0700 Subject: [PATCH 14/24] Add iteration breakdown tracking to circuit breaker diagnostics Shows where the loop spent its time (e.g., "iterations 1-3: X, 4-10000: Y") to help identify stuck patterns when the safety limits are hit. This makes it much clearer where the infinite loop got stuck in the state machine. Co-Authored-By: Claude Opus 4.5 --- packages/db-ivm/src/d2.ts | 53 ++++++++++++++--- packages/db/src/collection/subscription.ts | 55 ++++++++++++++++- .../query/live/collection-config-builder.ts | 59 ++++++++++++++++++- 3 files changed, 153 insertions(+), 14 deletions(-) diff --git a/packages/db-ivm/src/d2.ts b/packages/db-ivm/src/d2.ts index 673b1a83d..45f2df70e 100644 --- a/packages/db-ivm/src/d2.ts +++ b/packages/db-ivm/src/d2.ts @@ -63,22 +63,59 @@ export class D2 implements ID2 { const MAX_RUN_ITERATIONS = 100000 let iterations = 0 + // Track state transitions to show where iterations were spent + // Each entry: { state: string (operators with work), startIter, endIter } + const stateHistory: Array<{ + state: string + startIter: number + endIter: number + }> = [] + let currentStateKey: string | null = null + let stateStartIter = 1 + while (this.pendingWork()) { + // Capture which operators have pending work + const operatorsWithWork = this.#operators + .filter((op) => op.hasPendingWork()) + .map((op) => op.constructor.name) + .sort() + const stateKey = operatorsWithWork.join(`,`) + + // Track state transitions + if (stateKey !== currentStateKey) { + if (currentStateKey !== null) { + stateHistory.push({ + state: currentStateKey, + startIter: stateStartIter, + endIter: iterations, + }) + } + currentStateKey = stateKey + stateStartIter = iterations + 1 + } + if (++iterations > MAX_RUN_ITERATIONS) { - // Gather diagnostic info about which operators have pending work - const operatorsWithWork = this.#operators - .filter((op) => op.hasPendingWork()) - .map((op) => ({ - id: op.id, - type: op.constructor.name, - })) + // Record final state period (currentStateKey is always set by now since we've iterated) + stateHistory.push({ + state: currentStateKey, + startIter: stateStartIter, + endIter: iterations, + }) + + // Format iteration breakdown + const iterationBreakdown = stateHistory + .map( + (h) => + ` ${h.startIter}-${h.endIter}: operators with work = [${h.state}]`, + ) + .join(`\n`) // Log warning but continue gracefully - we likely have all available data, // just couldn't fill the TopK completely due to WHERE filtering console.warn( `[TanStack DB] D2 graph execution exceeded ${MAX_RUN_ITERATIONS} iterations. ` + `Continuing with available data.\n` + - `Operators with pending work: ${JSON.stringify(operatorsWithWork)}\n` + + `Iteration breakdown (where the loop spent time):\n${iterationBreakdown}\n` + `Total operators: ${this.#operators.length}\n` + `Please report this issue at https://github.com/TanStack/db/issues`, ) diff --git a/packages/db/src/collection/subscription.ts b/packages/db/src/collection/subscription.ts index 54e3f9339..bd5fa9624 100644 --- a/packages/db/src/collection/subscription.ts +++ b/packages/db/src/collection/subscription.ts @@ -512,26 +512,75 @@ export class CollectionSubscription let snapshotIterations = 0 let hitIterationLimit = false + // Track state transitions to show where iterations were spent + type StateEntry = { + valuesNeeded: number + keysInBatch: number + startIter: number + endIter: number + } + const stateHistory: Array = [] + let currentStateKey: string | null = null + let currentValuesNeeded = 0 + let currentKeysInBatch = 0 + let stateStartIter = 1 + while (valuesNeeded() > 0 && !collectionExhausted()) { + // Capture current state for tracking + const vn = valuesNeeded() + const kb = keys.length + const stateKey = `${vn}-${kb}` + + // Track state transitions + if (stateKey !== currentStateKey) { + if (currentStateKey !== null) { + stateHistory.push({ + valuesNeeded: currentValuesNeeded, + keysInBatch: currentKeysInBatch, + startIter: stateStartIter, + endIter: snapshotIterations, + }) + } + currentStateKey = stateKey + currentValuesNeeded = vn + currentKeysInBatch = kb + stateStartIter = snapshotIterations + 1 + } + if (++snapshotIterations > MAX_SNAPSHOT_ITERATIONS) { - // Gather diagnostic info to help debug the root cause + // Record final state period (currentStateKey is always set by now since we've iterated) + stateHistory.push({ + valuesNeeded: currentValuesNeeded, + keysInBatch: currentKeysInBatch, + startIter: stateStartIter, + endIter: snapshotIterations, + }) + + // Format iteration breakdown + const iterationBreakdown = stateHistory + .map( + (h) => + ` ${h.startIter}-${h.endIter}: valuesNeeded=${h.valuesNeeded}, keysInBatch=${h.keysInBatch}`, + ) + .join(`\n`) + + // Gather additional diagnostic info const diagnosticInfo = { collectionId: this.collection.id, collectionSize: this.collection.size, limit, offset, - valuesNeeded: valuesNeeded(), changesCollected: changes.length, sentKeysCount: this.sentKeys.size, cursorValue: biggestObservedValue, minValueForIndex, - keysInCurrentBatch: keys.length, orderByDirection: orderBy[0]!.compareOptions.direction, } console.warn( `[TanStack DB] requestLimitedSnapshot exceeded ${MAX_SNAPSHOT_ITERATIONS} iterations. ` + `Continuing with available data.\n` + + `Iteration breakdown (where the loop spent time):\n${iterationBreakdown}\n` + `Diagnostic info: ${JSON.stringify(diagnosticInfo, null, 2)}\n` + `Please report this issue at https://github.com/TanStack/db/issues`, ) diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index de0f1d557..276343900 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -345,9 +345,62 @@ export class CollectionConfigBuilder< const MAX_GRAPH_ITERATIONS = 10000 let iterations = 0 + // Track state transitions to show where iterations were spent + // Each entry: { state (dataNeeded values), startIter, endIter } + type StateHistoryEntry = { + dataNeeded: Record + pendingWork: boolean + startIter: number + endIter: number + } + const stateHistory: Array = [] + let currentStateKey: string | null = null + let currentDataNeeded: Record = {} + let stateStartIter = 1 + while (syncState.graph.pendingWork()) { + // Capture current state: dataNeeded values for each orderBy collection + const dataNeeded: Record = {} + for (const [id, info] of Object.entries( + this.optimizableOrderByCollections, + )) { + dataNeeded[id] = info.dataNeeded?.() ?? `unknown` + } + const stateKey = JSON.stringify(dataNeeded) + + // Track state transitions + if (stateKey !== currentStateKey) { + if (currentStateKey !== null) { + stateHistory.push({ + dataNeeded: currentDataNeeded, + pendingWork: true, + startIter: stateStartIter, + endIter: iterations, + }) + } + currentStateKey = stateKey + currentDataNeeded = dataNeeded + stateStartIter = iterations + 1 + } + if (++iterations > MAX_GRAPH_ITERATIONS) { - // Gather diagnostic info to help debug the root cause + // Record final state period (currentStateKey is always set by now since we've iterated) + stateHistory.push({ + dataNeeded: currentDataNeeded, + pendingWork: syncState.graph.pendingWork(), + startIter: stateStartIter, + endIter: iterations, + }) + + // Format iteration breakdown + const iterationBreakdown = stateHistory + .map( + (h) => + ` ${h.startIter}-${h.endIter}: dataNeeded=${JSON.stringify(h.dataNeeded)}`, + ) + .join(`\n`) + + // Gather additional diagnostic info const collectionIds = Object.keys(this.collections) const orderByInfo = Object.entries( this.optimizableOrderByCollections, @@ -355,7 +408,6 @@ export class CollectionConfigBuilder< collectionId: id, limit: info.limit, offset: info.offset, - dataNeeded: info.dataNeeded?.() ?? `unknown`, })) // Log warning but continue gracefully - we likely have all available data, @@ -363,11 +415,12 @@ export class CollectionConfigBuilder< console.warn( `[TanStack DB] Graph execution exceeded ${MAX_GRAPH_ITERATIONS} iterations. ` + `Continuing with available data.\n` + + `Iteration breakdown (where the loop spent time):\n${iterationBreakdown}\n` + `Diagnostic info:\n` + ` - Live query ID: ${this.id}\n` + ` - Source collections: ${collectionIds.join(`, `)}\n` + ` - Run count: ${this.runCount}\n` + - ` - OrderBy optimization info: ${JSON.stringify(orderByInfo)}\n` + + ` - OrderBy config: ${JSON.stringify(orderByInfo)}\n` + `Please report this issue at https://github.com/TanStack/db/issues`, ) break From 8dd97148bf50c06878f40a9f272ebfaa2e1b6fe6 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 26 Jan 2026 10:08:29 -0700 Subject: [PATCH 15/24] Update changeset with iteration tracking details Co-Authored-By: Claude Opus 4.5 --- .changeset/fix-infinite-loop-orderby-limit.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.changeset/fix-infinite-loop-orderby-limit.md b/.changeset/fix-infinite-loop-orderby-limit.md index 09d471c80..0acb69c41 100644 --- a/.changeset/fix-infinite-loop-orderby-limit.md +++ b/.changeset/fix-infinite-loop-orderby-limit.md @@ -13,6 +13,10 @@ Add safety limits to prevent app freezes from excessive iterations in ORDER BY + - maybeRunGraph: 10,000 iterations - requestLimitedSnapshot: 10,000 iterations -When limits are hit, a warning is logged with diagnostic info (collection IDs, query structure, cursor position, etc.) but the query **continues normally** with the data it has - no error state, no app breakage. +When limits are hit, a warning is logged with: +- **Iteration breakdown**: Shows where the loop spent time (e.g., "iterations 1-5: [TopK, Filter], 6-10000: [TopK]") +- Diagnostic info: collection IDs, query structure, cursor position, etc. -This diagnostic info will help identify the root cause if the warnings occur in production. Please report any warnings to https://github.com/TanStack/db/issues +The query **continues normally** with the data it has - no error state, no app breakage. + +The iteration breakdown makes it easy to see the stuck pattern in the state machine. Please report any warnings to https://github.com/TanStack/db/issues From ef0e60ccd863acb306ae1cced6321eaf78ecb99f Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 26 Jan 2026 17:09:29 +0000 Subject: [PATCH 16/24] ci: apply automated fixes --- .changeset/fix-infinite-loop-orderby-limit.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.changeset/fix-infinite-loop-orderby-limit.md b/.changeset/fix-infinite-loop-orderby-limit.md index 0acb69c41..5d9f07afe 100644 --- a/.changeset/fix-infinite-loop-orderby-limit.md +++ b/.changeset/fix-infinite-loop-orderby-limit.md @@ -14,6 +14,7 @@ Add safety limits to prevent app freezes from excessive iterations in ORDER BY + - requestLimitedSnapshot: 10,000 iterations When limits are hit, a warning is logged with: + - **Iteration breakdown**: Shows where the loop spent time (e.g., "iterations 1-5: [TopK, Filter], 6-10000: [TopK]") - Diagnostic info: collection IDs, query structure, cursor position, etc. From cf6efac2baa751a7e2191a8de5f7a92ccf6630a3 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 26 Jan 2026 10:19:51 -0700 Subject: [PATCH 17/24] Refactor: Extract shared iteration tracking into utility Reduces code duplication across three circuit breaker implementations by creating a shared createIterationTracker utility. This makes the tracking logic easier to maintain and the circuit breaker implementations cleaner. - New: packages/db-ivm/src/iteration-tracker.ts - Simplified: D2.run(), maybeRunGraph(), requestLimitedSnapshot() - Net reduction: ~140 lines Co-Authored-By: Claude Opus 4.5 --- packages/db-ivm/src/d2.ts | 61 ++------ packages/db-ivm/src/index.ts | 1 + packages/db-ivm/src/iteration-tracker.ts | 141 ++++++++++++++++++ packages/db/src/collection/subscription.ts | 91 +++-------- .../query/live/collection-config-builder.ts | 82 ++-------- 5 files changed, 188 insertions(+), 188 deletions(-) create mode 100644 packages/db-ivm/src/iteration-tracker.ts diff --git a/packages/db-ivm/src/d2.ts b/packages/db-ivm/src/d2.ts index 45f2df70e..bb45e6b0d 100644 --- a/packages/db-ivm/src/d2.ts +++ b/packages/db-ivm/src/d2.ts @@ -1,4 +1,5 @@ import { DifferenceStreamWriter } from './graph.js' +import { createIterationTracker } from './iteration-tracker.js' import type { BinaryOperator, DifferenceStreamReader, @@ -59,68 +60,28 @@ export class D2 implements ID2 { run(): void { // Safety limit to prevent infinite loops in case of circular data flow // or other bugs that cause operators to perpetually produce output. - // For legitimate pipelines, data should flow through in finite steps. const MAX_RUN_ITERATIONS = 100000 - let iterations = 0 - - // Track state transitions to show where iterations were spent - // Each entry: { state: string (operators with work), startIter, endIter } - const stateHistory: Array<{ - state: string - startIter: number - endIter: number - }> = [] - let currentStateKey: string | null = null - let stateStartIter = 1 + const tracker = createIterationTracker( + MAX_RUN_ITERATIONS, + (state) => `operators with work = [${state}]` + ) while (this.pendingWork()) { - // Capture which operators have pending work const operatorsWithWork = this.#operators .filter((op) => op.hasPendingWork()) .map((op) => op.constructor.name) .sort() - const stateKey = operatorsWithWork.join(`,`) - - // Track state transitions - if (stateKey !== currentStateKey) { - if (currentStateKey !== null) { - stateHistory.push({ - state: currentStateKey, - startIter: stateStartIter, - endIter: iterations, - }) - } - currentStateKey = stateKey - stateStartIter = iterations + 1 - } + .join(`,`) - if (++iterations > MAX_RUN_ITERATIONS) { - // Record final state period (currentStateKey is always set by now since we've iterated) - stateHistory.push({ - state: currentStateKey, - startIter: stateStartIter, - endIter: iterations, - }) - - // Format iteration breakdown - const iterationBreakdown = stateHistory - .map( - (h) => - ` ${h.startIter}-${h.endIter}: operators with work = [${h.state}]`, - ) - .join(`\n`) - - // Log warning but continue gracefully - we likely have all available data, - // just couldn't fill the TopK completely due to WHERE filtering + if (tracker.trackAndCheckLimit(operatorsWithWork)) { console.warn( - `[TanStack DB] D2 graph execution exceeded ${MAX_RUN_ITERATIONS} iterations. ` + - `Continuing with available data.\n` + - `Iteration breakdown (where the loop spent time):\n${iterationBreakdown}\n` + - `Total operators: ${this.#operators.length}\n` + - `Please report this issue at https://github.com/TanStack/db/issues`, + tracker.formatWarning(`D2 graph execution`, { + totalOperators: this.#operators.length, + }) ) break } + this.step() } } diff --git a/packages/db-ivm/src/index.ts b/packages/db-ivm/src/index.ts index cae148b46..e4a21408c 100644 --- a/packages/db-ivm/src/index.ts +++ b/packages/db-ivm/src/index.ts @@ -1,4 +1,5 @@ export * from './d2.js' +export * from './iteration-tracker.js' export * from './multiset.js' export * from './operators/index.js' export * from './types.js' diff --git a/packages/db-ivm/src/iteration-tracker.ts b/packages/db-ivm/src/iteration-tracker.ts new file mode 100644 index 000000000..d9267df72 --- /dev/null +++ b/packages/db-ivm/src/iteration-tracker.ts @@ -0,0 +1,141 @@ +/** + * Tracks state transitions during iteration loops for diagnostic purposes. + * Used by circuit breakers to report where iterations were spent when limits are exceeded. + * + * The tracker collects a history of state transitions, where each entry records + * a period of time (iteration range) spent in a particular state. When the iteration + * limit is exceeded, this history helps diagnose infinite loop causes. + * + * @example + * ```ts + * const tracker = createIterationTracker<{ operators: string }>(100000) + * + * while (pendingWork()) { + * const state = { operators: getOperatorsWithWork().join(',') } + * if (tracker.trackAndCheckLimit(state)) { + * console.warn(tracker.formatWarning('D2 graph execution', { + * totalOperators: operators.length, + * })) + * break + * } + * step() + * } + * ``` + */ + +export type StateHistoryEntry = { + state: TState + startIter: number + endIter: number +} + +export type IterationTracker = { + /** + * Records the current state and increments the iteration counter. + * Returns true if the iteration limit has been exceeded. + */ + trackAndCheckLimit: (state: TState) => boolean + + /** + * Formats a warning message with iteration breakdown and diagnostic info. + * Call this after trackAndCheckLimit returns true. + */ + formatWarning: ( + context: string, + diagnosticInfo?: Record + ) => string + + /** + * Returns the current iteration count. + */ + getIterations: () => number + + /** + * Returns the state history for inspection. + */ + getHistory: () => Array> +} + +/** + * Creates an iteration tracker that monitors loop iterations and records state transitions. + * + * @param maxIterations - The maximum number of iterations before the limit is exceeded + * @param stateToKey - Optional function to convert state to a string key for comparison. + * Defaults to JSON.stringify. + */ +export function createIterationTracker( + maxIterations: number, + stateToKey: (state: TState) => string = (state) => JSON.stringify(state) +): IterationTracker { + const history: Array> = [] + let currentStateKey: string | null = null + let currentState: TState | null = null + let stateStartIter = 1 + let iterations = 0 + + function recordCurrentState(): void { + if (currentStateKey !== null && currentState !== null) { + history.push({ + state: currentState, + startIter: stateStartIter, + endIter: iterations, + }) + } + } + + function trackAndCheckLimit(state: TState): boolean { + const stateKey = stateToKey(state) + + if (stateKey !== currentStateKey) { + recordCurrentState() + currentStateKey = stateKey + currentState = state + stateStartIter = iterations + 1 + } + + iterations++ + + if (iterations > maxIterations) { + recordCurrentState() + return true + } + + return false + } + + function formatWarning( + context: string, + diagnosticInfo?: Record + ): string { + const iterationBreakdown = history + .map((h) => ` ${h.startIter}-${h.endIter}: ${stateToKey(h.state)}`) + .join(`\n`) + + const diagnosticSection = diagnosticInfo + ? `\nDiagnostic info: ${JSON.stringify(diagnosticInfo, null, 2)}\n` + : `\n` + + return ( + `[TanStack DB] ${context} exceeded ${maxIterations} iterations. ` + + `Continuing with available data.\n` + + `Iteration breakdown (where the loop spent time):\n${iterationBreakdown}` + + diagnosticSection + + `Please report this issue at https://github.com/TanStack/db/issues` + ) + } + + function getIterations(): number { + return iterations + } + + function getHistory(): Array> { + return [...history] + } + + return { + trackAndCheckLimit, + formatWarning, + getIterations, + getHistory, + } +} diff --git a/packages/db/src/collection/subscription.ts b/packages/db/src/collection/subscription.ts index bd5fa9624..ae98950cd 100644 --- a/packages/db/src/collection/subscription.ts +++ b/packages/db/src/collection/subscription.ts @@ -1,3 +1,4 @@ +import { createIterationTracker } from '@tanstack/db-ivm' import { ensureIndexForExpression } from '../indexes/auto-index.js' import { and, eq, gte, lt } from '../query/builder/functions.js' import { PropRef, Value } from '../query/ir.js' @@ -506,83 +507,31 @@ export class CollectionSubscription // Safety limit to prevent infinite loops if the index iteration or filtering // logic has issues. The loop should naturally terminate when the index is - // exhausted, but this provides a backstop. 10000 iterations is generous - // for any legitimate use case. + // exhausted, but this provides a backstop. const MAX_SNAPSHOT_ITERATIONS = 10000 - let snapshotIterations = 0 + type SnapshotState = { valuesNeeded: number; keysInBatch: number } + const tracker = createIterationTracker( + MAX_SNAPSHOT_ITERATIONS, + (state) => `valuesNeeded=${state.valuesNeeded}, keysInBatch=${state.keysInBatch}` + ) let hitIterationLimit = false - // Track state transitions to show where iterations were spent - type StateEntry = { - valuesNeeded: number - keysInBatch: number - startIter: number - endIter: number - } - const stateHistory: Array = [] - let currentStateKey: string | null = null - let currentValuesNeeded = 0 - let currentKeysInBatch = 0 - let stateStartIter = 1 - while (valuesNeeded() > 0 && !collectionExhausted()) { - // Capture current state for tracking - const vn = valuesNeeded() - const kb = keys.length - const stateKey = `${vn}-${kb}` - - // Track state transitions - if (stateKey !== currentStateKey) { - if (currentStateKey !== null) { - stateHistory.push({ - valuesNeeded: currentValuesNeeded, - keysInBatch: currentKeysInBatch, - startIter: stateStartIter, - endIter: snapshotIterations, - }) - } - currentStateKey = stateKey - currentValuesNeeded = vn - currentKeysInBatch = kb - stateStartIter = snapshotIterations + 1 - } - - if (++snapshotIterations > MAX_SNAPSHOT_ITERATIONS) { - // Record final state period (currentStateKey is always set by now since we've iterated) - stateHistory.push({ - valuesNeeded: currentValuesNeeded, - keysInBatch: currentKeysInBatch, - startIter: stateStartIter, - endIter: snapshotIterations, - }) - - // Format iteration breakdown - const iterationBreakdown = stateHistory - .map( - (h) => - ` ${h.startIter}-${h.endIter}: valuesNeeded=${h.valuesNeeded}, keysInBatch=${h.keysInBatch}`, - ) - .join(`\n`) - - // Gather additional diagnostic info - const diagnosticInfo = { - collectionId: this.collection.id, - collectionSize: this.collection.size, - limit, - offset, - changesCollected: changes.length, - sentKeysCount: this.sentKeys.size, - cursorValue: biggestObservedValue, - minValueForIndex, - orderByDirection: orderBy[0]!.compareOptions.direction, - } + const state = { valuesNeeded: valuesNeeded(), keysInBatch: keys.length } + if (tracker.trackAndCheckLimit(state)) { console.warn( - `[TanStack DB] requestLimitedSnapshot exceeded ${MAX_SNAPSHOT_ITERATIONS} iterations. ` + - `Continuing with available data.\n` + - `Iteration breakdown (where the loop spent time):\n${iterationBreakdown}\n` + - `Diagnostic info: ${JSON.stringify(diagnosticInfo, null, 2)}\n` + - `Please report this issue at https://github.com/TanStack/db/issues`, + tracker.formatWarning(`requestLimitedSnapshot`, { + collectionId: this.collection.id, + collectionSize: this.collection.size, + limit, + offset, + changesCollected: changes.length, + sentKeysCount: this.sentKeys.size, + cursorValue: biggestObservedValue, + minValueForIndex, + orderByDirection: orderBy[0]!.compareOptions.direction, + }) ) hitIterationLimit = true break diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index 276343900..e9cae1eb2 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -1,4 +1,4 @@ -import { D2, output } from '@tanstack/db-ivm' +import { D2, createIterationTracker, output } from '@tanstack/db-ivm' import { compileQuery } from '../compiler/index.js' import { buildQuery, getQueryIR } from '../builder/index.js' import { @@ -337,70 +337,23 @@ export class CollectionConfigBuilder< // Always run the graph if subscribed (eager execution) if (syncState.subscribedToAllCollections) { // Safety limit to prevent infinite loops when data loading and graph processing - // create a feedback cycle. This can happen when: - // 1. OrderBy/limit queries filter out most data, causing dataNeeded() > 0 - // 2. Loading more data triggers updates that get filtered out - // 3. The cycle continues indefinitely - // 10000 iterations is generous for legitimate use cases but prevents hangs. + // create a feedback cycle. const MAX_GRAPH_ITERATIONS = 10000 - let iterations = 0 - - // Track state transitions to show where iterations were spent - // Each entry: { state (dataNeeded values), startIter, endIter } - type StateHistoryEntry = { - dataNeeded: Record - pendingWork: boolean - startIter: number - endIter: number - } - const stateHistory: Array = [] - let currentStateKey: string | null = null - let currentDataNeeded: Record = {} - let stateStartIter = 1 + type GraphState = Record + const tracker = createIterationTracker( + MAX_GRAPH_ITERATIONS, + (state) => `dataNeeded=${JSON.stringify(state)}` + ) while (syncState.graph.pendingWork()) { - // Capture current state: dataNeeded values for each orderBy collection - const dataNeeded: Record = {} + const dataNeeded: GraphState = {} for (const [id, info] of Object.entries( this.optimizableOrderByCollections, )) { dataNeeded[id] = info.dataNeeded?.() ?? `unknown` } - const stateKey = JSON.stringify(dataNeeded) - - // Track state transitions - if (stateKey !== currentStateKey) { - if (currentStateKey !== null) { - stateHistory.push({ - dataNeeded: currentDataNeeded, - pendingWork: true, - startIter: stateStartIter, - endIter: iterations, - }) - } - currentStateKey = stateKey - currentDataNeeded = dataNeeded - stateStartIter = iterations + 1 - } - if (++iterations > MAX_GRAPH_ITERATIONS) { - // Record final state period (currentStateKey is always set by now since we've iterated) - stateHistory.push({ - dataNeeded: currentDataNeeded, - pendingWork: syncState.graph.pendingWork(), - startIter: stateStartIter, - endIter: iterations, - }) - - // Format iteration breakdown - const iterationBreakdown = stateHistory - .map( - (h) => - ` ${h.startIter}-${h.endIter}: dataNeeded=${JSON.stringify(h.dataNeeded)}`, - ) - .join(`\n`) - - // Gather additional diagnostic info + if (tracker.trackAndCheckLimit(dataNeeded)) { const collectionIds = Object.keys(this.collections) const orderByInfo = Object.entries( this.optimizableOrderByCollections, @@ -410,18 +363,13 @@ export class CollectionConfigBuilder< offset: info.offset, })) - // Log warning but continue gracefully - we likely have all available data, - // just couldn't fill the TopK completely due to WHERE filtering console.warn( - `[TanStack DB] Graph execution exceeded ${MAX_GRAPH_ITERATIONS} iterations. ` + - `Continuing with available data.\n` + - `Iteration breakdown (where the loop spent time):\n${iterationBreakdown}\n` + - `Diagnostic info:\n` + - ` - Live query ID: ${this.id}\n` + - ` - Source collections: ${collectionIds.join(`, `)}\n` + - ` - Run count: ${this.runCount}\n` + - ` - OrderBy config: ${JSON.stringify(orderByInfo)}\n` + - `Please report this issue at https://github.com/TanStack/db/issues`, + tracker.formatWarning(`Graph execution`, { + liveQueryId: this.id, + sourceCollections: collectionIds, + runCount: this.runCount, + orderByConfig: orderByInfo, + }) ) break } From b9a2cf74ec9d9d308370d616060cb707c374b88f Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 26 Jan 2026 17:21:32 +0000 Subject: [PATCH 18/24] ci: apply automated fixes --- packages/db-ivm/src/d2.ts | 4 ++-- packages/db-ivm/src/iteration-tracker.ts | 6 +++--- packages/db/src/collection/subscription.ts | 5 +++-- packages/db/src/query/live/collection-config-builder.ts | 4 ++-- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/db-ivm/src/d2.ts b/packages/db-ivm/src/d2.ts index bb45e6b0d..8f3167e0b 100644 --- a/packages/db-ivm/src/d2.ts +++ b/packages/db-ivm/src/d2.ts @@ -63,7 +63,7 @@ export class D2 implements ID2 { const MAX_RUN_ITERATIONS = 100000 const tracker = createIterationTracker( MAX_RUN_ITERATIONS, - (state) => `operators with work = [${state}]` + (state) => `operators with work = [${state}]`, ) while (this.pendingWork()) { @@ -77,7 +77,7 @@ export class D2 implements ID2 { console.warn( tracker.formatWarning(`D2 graph execution`, { totalOperators: this.#operators.length, - }) + }), ) break } diff --git a/packages/db-ivm/src/iteration-tracker.ts b/packages/db-ivm/src/iteration-tracker.ts index d9267df72..60c48da2f 100644 --- a/packages/db-ivm/src/iteration-tracker.ts +++ b/packages/db-ivm/src/iteration-tracker.ts @@ -42,7 +42,7 @@ export type IterationTracker = { */ formatWarning: ( context: string, - diagnosticInfo?: Record + diagnosticInfo?: Record, ) => string /** @@ -65,7 +65,7 @@ export type IterationTracker = { */ export function createIterationTracker( maxIterations: number, - stateToKey: (state: TState) => string = (state) => JSON.stringify(state) + stateToKey: (state: TState) => string = (state) => JSON.stringify(state), ): IterationTracker { const history: Array> = [] let currentStateKey: string | null = null @@ -105,7 +105,7 @@ export function createIterationTracker( function formatWarning( context: string, - diagnosticInfo?: Record + diagnosticInfo?: Record, ): string { const iterationBreakdown = history .map((h) => ` ${h.startIter}-${h.endIter}: ${stateToKey(h.state)}`) diff --git a/packages/db/src/collection/subscription.ts b/packages/db/src/collection/subscription.ts index ae98950cd..648419cb4 100644 --- a/packages/db/src/collection/subscription.ts +++ b/packages/db/src/collection/subscription.ts @@ -512,7 +512,8 @@ export class CollectionSubscription type SnapshotState = { valuesNeeded: number; keysInBatch: number } const tracker = createIterationTracker( MAX_SNAPSHOT_ITERATIONS, - (state) => `valuesNeeded=${state.valuesNeeded}, keysInBatch=${state.keysInBatch}` + (state) => + `valuesNeeded=${state.valuesNeeded}, keysInBatch=${state.keysInBatch}`, ) let hitIterationLimit = false @@ -531,7 +532,7 @@ export class CollectionSubscription cursorValue: biggestObservedValue, minValueForIndex, orderByDirection: orderBy[0]!.compareOptions.direction, - }) + }), ) hitIterationLimit = true break diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index e9cae1eb2..ec6ce4c59 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -342,7 +342,7 @@ export class CollectionConfigBuilder< type GraphState = Record const tracker = createIterationTracker( MAX_GRAPH_ITERATIONS, - (state) => `dataNeeded=${JSON.stringify(state)}` + (state) => `dataNeeded=${JSON.stringify(state)}`, ) while (syncState.graph.pendingWork()) { @@ -369,7 +369,7 @@ export class CollectionConfigBuilder< sourceCollections: collectionIds, runCount: this.runCount, orderByConfig: orderByInfo, - }) + }), ) break } From cebfb82b77115803ed7dffe1dbc7f03faf314ea0 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 26 Jan 2026 10:24:14 -0700 Subject: [PATCH 19/24] Add tests for iteration tracker utility Verifies the iteration breakdown output format and state tracking logic used by circuit breaker diagnostics. Co-Authored-By: Claude Opus 4.5 --- .../db-ivm/tests/iteration-tracker.test.ts | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 packages/db-ivm/tests/iteration-tracker.test.ts diff --git a/packages/db-ivm/tests/iteration-tracker.test.ts b/packages/db-ivm/tests/iteration-tracker.test.ts new file mode 100644 index 000000000..2900ba93c --- /dev/null +++ b/packages/db-ivm/tests/iteration-tracker.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from 'vitest' +import { createIterationTracker } from '../src/iteration-tracker.js' + +describe(`createIterationTracker`, () => { + it(`should not exceed limit on normal iteration counts`, () => { + const tracker = createIterationTracker(100) + + for (let i = 0; i < 50; i++) { + expect(tracker.trackAndCheckLimit(`state-a`)).toBe(false) + } + + expect(tracker.getIterations()).toBe(50) + }) + + it(`should return true when limit is exceeded`, () => { + const tracker = createIterationTracker(10) + + for (let i = 0; i < 10; i++) { + expect(tracker.trackAndCheckLimit(`state`)).toBe(false) + } + + // 11th iteration exceeds the limit + expect(tracker.trackAndCheckLimit(`state`)).toBe(true) + expect(tracker.getIterations()).toBe(11) + }) + + it(`should track state transitions correctly`, () => { + const tracker = createIterationTracker(100) + + // 3 iterations in state-a + tracker.trackAndCheckLimit(`state-a`) + tracker.trackAndCheckLimit(`state-a`) + tracker.trackAndCheckLimit(`state-a`) + + // 2 iterations in state-b + tracker.trackAndCheckLimit(`state-b`) + tracker.trackAndCheckLimit(`state-b`) + + // 1 iteration in state-c (forces recording of state-b) + tracker.trackAndCheckLimit(`state-c`) + + const history = tracker.getHistory() + + expect(history).toHaveLength(2) + expect(history[0]).toEqual({ + state: `state-a`, + startIter: 1, + endIter: 3, + }) + expect(history[1]).toEqual({ + state: `state-b`, + startIter: 4, + endIter: 5, + }) + }) + + it(`should record final state when limit is exceeded`, () => { + const tracker = createIterationTracker(5) + + // 2 iterations in state-a + tracker.trackAndCheckLimit(`state-a`) + tracker.trackAndCheckLimit(`state-a`) + + // 4 iterations in state-b (exceeds limit at iteration 6) + tracker.trackAndCheckLimit(`state-b`) + tracker.trackAndCheckLimit(`state-b`) + tracker.trackAndCheckLimit(`state-b`) + const exceeded = tracker.trackAndCheckLimit(`state-b`) + + expect(exceeded).toBe(true) + + const history = tracker.getHistory() + expect(history).toHaveLength(2) + expect(history[0]).toEqual({ + state: `state-a`, + startIter: 1, + endIter: 2, + }) + expect(history[1]).toEqual({ + state: `state-b`, + startIter: 3, + endIter: 6, + }) + }) + + it(`should format warning with iteration breakdown`, () => { + const tracker = createIterationTracker(5, (state) => `ops=[${state}]`) + + // Create a pattern: 2 in state-a, then exceed in state-b + tracker.trackAndCheckLimit(`TopK,Filter`) + tracker.trackAndCheckLimit(`TopK,Filter`) + tracker.trackAndCheckLimit(`TopK`) + tracker.trackAndCheckLimit(`TopK`) + tracker.trackAndCheckLimit(`TopK`) + tracker.trackAndCheckLimit(`TopK`) // exceeds + + const warning = tracker.formatWarning(`D2 graph execution`, { + totalOperators: 8, + }) + + expect(warning).toContain(`[TanStack DB] D2 graph execution exceeded 5 iterations`) + expect(warning).toContain(`Continuing with available data`) + expect(warning).toContain(`Iteration breakdown (where the loop spent time):`) + expect(warning).toContain(`1-2: ops=[TopK,Filter]`) + expect(warning).toContain(`3-6: ops=[TopK]`) + expect(warning).toContain(`"totalOperators": 8`) + expect(warning).toContain(`https://github.com/TanStack/db/issues`) + }) + + it(`should work with object states using default JSON serialization`, () => { + type State = { valuesNeeded: number; keysInBatch: number } + const tracker = createIterationTracker(10) + + tracker.trackAndCheckLimit({ valuesNeeded: 10, keysInBatch: 5 }) + tracker.trackAndCheckLimit({ valuesNeeded: 10, keysInBatch: 5 }) + tracker.trackAndCheckLimit({ valuesNeeded: 8, keysInBatch: 3 }) + + const history = tracker.getHistory() + expect(history).toHaveLength(1) + expect(history[0]!.state).toEqual({ valuesNeeded: 10, keysInBatch: 5 }) + }) + + it(`should use custom stateToKey function for display`, () => { + type State = { valuesNeeded: number; keysInBatch: number } + const tracker = createIterationTracker( + 5, + (state) => `valuesNeeded=${state.valuesNeeded}, keysInBatch=${state.keysInBatch}` + ) + + tracker.trackAndCheckLimit({ valuesNeeded: 10, keysInBatch: 5 }) + tracker.trackAndCheckLimit({ valuesNeeded: 10, keysInBatch: 5 }) + tracker.trackAndCheckLimit({ valuesNeeded: 10, keysInBatch: 5 }) + tracker.trackAndCheckLimit({ valuesNeeded: 10, keysInBatch: 5 }) + tracker.trackAndCheckLimit({ valuesNeeded: 10, keysInBatch: 5 }) + tracker.trackAndCheckLimit({ valuesNeeded: 10, keysInBatch: 5 }) // exceeds + + const warning = tracker.formatWarning(`requestLimitedSnapshot`) + + expect(warning).toContain(`1-6: valuesNeeded=10, keysInBatch=5`) + }) + + it(`should handle single state that exceeds limit`, () => { + const tracker = createIterationTracker(3) + + tracker.trackAndCheckLimit(`stuck`) + tracker.trackAndCheckLimit(`stuck`) + tracker.trackAndCheckLimit(`stuck`) + const exceeded = tracker.trackAndCheckLimit(`stuck`) + + expect(exceeded).toBe(true) + + const history = tracker.getHistory() + expect(history).toHaveLength(1) + expect(history[0]).toEqual({ + state: `stuck`, + startIter: 1, + endIter: 4, + }) + }) + + it(`should format warning without diagnostic info`, () => { + const tracker = createIterationTracker(2) + + tracker.trackAndCheckLimit(`state`) + tracker.trackAndCheckLimit(`state`) + tracker.trackAndCheckLimit(`state`) // exceeds + + const warning = tracker.formatWarning(`Graph execution`) + + expect(warning).toContain(`[TanStack DB] Graph execution exceeded 2 iterations`) + expect(warning).not.toContain(`Diagnostic info:`) + }) +}) From 44d89fc3067da09405169a9e8e321631dda5b8d6 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 26 Jan 2026 17:25:32 +0000 Subject: [PATCH 20/24] ci: apply automated fixes --- .../db-ivm/tests/iteration-tracker.test.ts | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/db-ivm/tests/iteration-tracker.test.ts b/packages/db-ivm/tests/iteration-tracker.test.ts index 2900ba93c..a3bcf6e36 100644 --- a/packages/db-ivm/tests/iteration-tracker.test.ts +++ b/packages/db-ivm/tests/iteration-tracker.test.ts @@ -84,7 +84,10 @@ describe(`createIterationTracker`, () => { }) it(`should format warning with iteration breakdown`, () => { - const tracker = createIterationTracker(5, (state) => `ops=[${state}]`) + const tracker = createIterationTracker( + 5, + (state) => `ops=[${state}]`, + ) // Create a pattern: 2 in state-a, then exceed in state-b tracker.trackAndCheckLimit(`TopK,Filter`) @@ -98,9 +101,13 @@ describe(`createIterationTracker`, () => { totalOperators: 8, }) - expect(warning).toContain(`[TanStack DB] D2 graph execution exceeded 5 iterations`) + expect(warning).toContain( + `[TanStack DB] D2 graph execution exceeded 5 iterations`, + ) expect(warning).toContain(`Continuing with available data`) - expect(warning).toContain(`Iteration breakdown (where the loop spent time):`) + expect(warning).toContain( + `Iteration breakdown (where the loop spent time):`, + ) expect(warning).toContain(`1-2: ops=[TopK,Filter]`) expect(warning).toContain(`3-6: ops=[TopK]`) expect(warning).toContain(`"totalOperators": 8`) @@ -124,7 +131,8 @@ describe(`createIterationTracker`, () => { type State = { valuesNeeded: number; keysInBatch: number } const tracker = createIterationTracker( 5, - (state) => `valuesNeeded=${state.valuesNeeded}, keysInBatch=${state.keysInBatch}` + (state) => + `valuesNeeded=${state.valuesNeeded}, keysInBatch=${state.keysInBatch}`, ) tracker.trackAndCheckLimit({ valuesNeeded: 10, keysInBatch: 5 }) @@ -167,7 +175,9 @@ describe(`createIterationTracker`, () => { const warning = tracker.formatWarning(`Graph execution`) - expect(warning).toContain(`[TanStack DB] Graph execution exceeded 2 iterations`) + expect(warning).toContain( + `[TanStack DB] Graph execution exceeded 2 iterations`, + ) expect(warning).not.toContain(`Diagnostic info:`) }) }) From e45f3345fc05dffdbf37d6183d6eb6a225ae2893 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 26 Jan 2026 11:34:15 -0700 Subject: [PATCH 21/24] Address PR review feedback - Simplify iteration tracker to lazy evaluation (only compute diagnostics when limit is actually exceeded, not on every iteration) - Revert unnecessary array conversions in collection-subscriber.ts - Revert unused boolean return from loadNextItems - Keep cursorMinValue rename (fixes ESLint shadowing warning) - Confirmed insertedKeys was dead code (never used after being set) Co-Authored-By: Claude Opus 4.5 --- packages/db-ivm/src/d2.ts | 34 +-- packages/db-ivm/src/iteration-tracker.ts | 138 +++--------- .../db-ivm/tests/iteration-tracker.test.ts | 203 +++++------------- packages/db/src/collection/subscription.ts | 25 +-- .../query/live/collection-config-builder.ts | 56 +++-- .../src/query/live/collection-subscriber.ts | 23 +- 6 files changed, 141 insertions(+), 338 deletions(-) diff --git a/packages/db-ivm/src/d2.ts b/packages/db-ivm/src/d2.ts index 8f3167e0b..20bd1ca01 100644 --- a/packages/db-ivm/src/d2.ts +++ b/packages/db-ivm/src/d2.ts @@ -1,5 +1,5 @@ import { DifferenceStreamWriter } from './graph.js' -import { createIterationTracker } from './iteration-tracker.js' +import { createIterationLimitChecker } from './iteration-tracker.js' import type { BinaryOperator, DifferenceStreamReader, @@ -61,24 +61,24 @@ export class D2 implements ID2 { // Safety limit to prevent infinite loops in case of circular data flow // or other bugs that cause operators to perpetually produce output. const MAX_RUN_ITERATIONS = 100000 - const tracker = createIterationTracker( - MAX_RUN_ITERATIONS, - (state) => `operators with work = [${state}]`, - ) + const checkLimit = createIterationLimitChecker(MAX_RUN_ITERATIONS) while (this.pendingWork()) { - const operatorsWithWork = this.#operators - .filter((op) => op.hasPendingWork()) - .map((op) => op.constructor.name) - .sort() - .join(`,`) - - if (tracker.trackAndCheckLimit(operatorsWithWork)) { - console.warn( - tracker.formatWarning(`D2 graph execution`, { - totalOperators: this.#operators.length, - }), - ) + if ( + checkLimit(() => { + // Only compute diagnostics when limit is exceeded (lazy) + const operatorsWithWork = this.#operators + .filter((op) => op.hasPendingWork()) + .map((op) => ({ id: op.id, type: op.constructor.name })) + return { + context: `D2 graph execution`, + diagnostics: { + operatorsWithPendingWork: operatorsWithWork, + totalOperators: this.#operators.length, + }, + } + }) + ) { break } diff --git a/packages/db-ivm/src/iteration-tracker.ts b/packages/db-ivm/src/iteration-tracker.ts index 60c48da2f..351872631 100644 --- a/packages/db-ivm/src/iteration-tracker.ts +++ b/packages/db-ivm/src/iteration-tracker.ts @@ -1,21 +1,18 @@ /** - * Tracks state transitions during iteration loops for diagnostic purposes. - * Used by circuit breakers to report where iterations were spent when limits are exceeded. + * Creates a simple iteration counter with a limit check. + * When the limit is exceeded, calls the provided diagnostic function to capture state. * - * The tracker collects a history of state transitions, where each entry records - * a period of time (iteration range) spent in a particular state. When the iteration - * limit is exceeded, this history helps diagnose infinite loop causes. + * This design avoids per-iteration overhead - state capture only happens when needed. * * @example * ```ts - * const tracker = createIterationTracker<{ operators: string }>(100000) + * const checkLimit = createIterationLimitChecker(100000) * * while (pendingWork()) { - * const state = { operators: getOperatorsWithWork().join(',') } - * if (tracker.trackAndCheckLimit(state)) { - * console.warn(tracker.formatWarning('D2 graph execution', { - * totalOperators: operators.length, - * })) + * if (checkLimit(() => ({ + * context: 'D2 graph execution', + * diagnostics: { totalOperators: operators.length } + * }))) { * break * } * step() @@ -23,119 +20,42 @@ * ``` */ -export type StateHistoryEntry = { - state: TState - startIter: number - endIter: number -} - -export type IterationTracker = { - /** - * Records the current state and increments the iteration counter. - * Returns true if the iteration limit has been exceeded. - */ - trackAndCheckLimit: (state: TState) => boolean - - /** - * Formats a warning message with iteration breakdown and diagnostic info. - * Call this after trackAndCheckLimit returns true. - */ - formatWarning: ( - context: string, - diagnosticInfo?: Record, - ) => string - - /** - * Returns the current iteration count. - */ - getIterations: () => number - - /** - * Returns the state history for inspection. - */ - getHistory: () => Array> +export type LimitExceededInfo = { + context: string + diagnostics?: Record } /** - * Creates an iteration tracker that monitors loop iterations and records state transitions. + * Creates an iteration limit checker that logs a warning when the limit is exceeded. * * @param maxIterations - The maximum number of iterations before the limit is exceeded - * @param stateToKey - Optional function to convert state to a string key for comparison. - * Defaults to JSON.stringify. + * @returns A function that increments the counter and returns true if limit exceeded */ -export function createIterationTracker( +export function createIterationLimitChecker( maxIterations: number, - stateToKey: (state: TState) => string = (state) => JSON.stringify(state), -): IterationTracker { - const history: Array> = [] - let currentStateKey: string | null = null - let currentState: TState | null = null - let stateStartIter = 1 +): (getInfo: () => LimitExceededInfo) => boolean { let iterations = 0 - function recordCurrentState(): void { - if (currentStateKey !== null && currentState !== null) { - history.push({ - state: currentState, - startIter: stateStartIter, - endIter: iterations, - }) - } - } - - function trackAndCheckLimit(state: TState): boolean { - const stateKey = stateToKey(state) - - if (stateKey !== currentStateKey) { - recordCurrentState() - currentStateKey = stateKey - currentState = state - stateStartIter = iterations + 1 - } - + return function checkLimit(getInfo: () => LimitExceededInfo): boolean { iterations++ if (iterations > maxIterations) { - recordCurrentState() + // Only capture diagnostic info when we actually exceed the limit + const { context, diagnostics } = getInfo() + + const diagnosticSection = diagnostics + ? `\nDiagnostic info: ${JSON.stringify(diagnostics, null, 2)}\n` + : `\n` + + console.warn( + `[TanStack DB] ${context} exceeded ${maxIterations} iterations. ` + + `Continuing with available data.` + + diagnosticSection + + `Please report this issue at https://github.com/TanStack/db/issues`, + ) return true } return false } - - function formatWarning( - context: string, - diagnosticInfo?: Record, - ): string { - const iterationBreakdown = history - .map((h) => ` ${h.startIter}-${h.endIter}: ${stateToKey(h.state)}`) - .join(`\n`) - - const diagnosticSection = diagnosticInfo - ? `\nDiagnostic info: ${JSON.stringify(diagnosticInfo, null, 2)}\n` - : `\n` - - return ( - `[TanStack DB] ${context} exceeded ${maxIterations} iterations. ` + - `Continuing with available data.\n` + - `Iteration breakdown (where the loop spent time):\n${iterationBreakdown}` + - diagnosticSection + - `Please report this issue at https://github.com/TanStack/db/issues` - ) - } - - function getIterations(): number { - return iterations - } - - function getHistory(): Array> { - return [...history] - } - - return { - trackAndCheckLimit, - formatWarning, - getIterations, - getHistory, - } } diff --git a/packages/db-ivm/tests/iteration-tracker.test.ts b/packages/db-ivm/tests/iteration-tracker.test.ts index a3bcf6e36..155ff8f58 100644 --- a/packages/db-ivm/tests/iteration-tracker.test.ts +++ b/packages/db-ivm/tests/iteration-tracker.test.ts @@ -1,183 +1,86 @@ -import { describe, expect, it } from 'vitest' -import { createIterationTracker } from '../src/iteration-tracker.js' +import { describe, expect, it, vi } from 'vitest' +import { createIterationLimitChecker } from '../src/iteration-tracker.js' -describe(`createIterationTracker`, () => { +describe(`createIterationLimitChecker`, () => { it(`should not exceed limit on normal iteration counts`, () => { - const tracker = createIterationTracker(100) + const checkLimit = createIterationLimitChecker(100) for (let i = 0; i < 50; i++) { - expect(tracker.trackAndCheckLimit(`state-a`)).toBe(false) + expect(checkLimit(() => ({ context: `test` }))).toBe(false) } - - expect(tracker.getIterations()).toBe(50) }) it(`should return true when limit is exceeded`, () => { - const tracker = createIterationTracker(10) + const checkLimit = createIterationLimitChecker(10) for (let i = 0; i < 10; i++) { - expect(tracker.trackAndCheckLimit(`state`)).toBe(false) + expect(checkLimit(() => ({ context: `test` }))).toBe(false) } // 11th iteration exceeds the limit - expect(tracker.trackAndCheckLimit(`state`)).toBe(true) - expect(tracker.getIterations()).toBe(11) - }) - - it(`should track state transitions correctly`, () => { - const tracker = createIterationTracker(100) - - // 3 iterations in state-a - tracker.trackAndCheckLimit(`state-a`) - tracker.trackAndCheckLimit(`state-a`) - tracker.trackAndCheckLimit(`state-a`) - - // 2 iterations in state-b - tracker.trackAndCheckLimit(`state-b`) - tracker.trackAndCheckLimit(`state-b`) - - // 1 iteration in state-c (forces recording of state-b) - tracker.trackAndCheckLimit(`state-c`) - - const history = tracker.getHistory() - - expect(history).toHaveLength(2) - expect(history[0]).toEqual({ - state: `state-a`, - startIter: 1, - endIter: 3, - }) - expect(history[1]).toEqual({ - state: `state-b`, - startIter: 4, - endIter: 5, - }) + const consoleSpy = vi.spyOn(console, `warn`).mockImplementation(() => {}) + expect(checkLimit(() => ({ context: `test` }))).toBe(true) + consoleSpy.mockRestore() }) - it(`should record final state when limit is exceeded`, () => { - const tracker = createIterationTracker(5) - - // 2 iterations in state-a - tracker.trackAndCheckLimit(`state-a`) - tracker.trackAndCheckLimit(`state-a`) - - // 4 iterations in state-b (exceeds limit at iteration 6) - tracker.trackAndCheckLimit(`state-b`) - tracker.trackAndCheckLimit(`state-b`) - tracker.trackAndCheckLimit(`state-b`) - const exceeded = tracker.trackAndCheckLimit(`state-b`) - - expect(exceeded).toBe(true) - - const history = tracker.getHistory() - expect(history).toHaveLength(2) - expect(history[0]).toEqual({ - state: `state-a`, - startIter: 1, - endIter: 2, - }) - expect(history[1]).toEqual({ - state: `state-b`, - startIter: 3, - endIter: 6, - }) - }) + it(`should only call getInfo when limit is exceeded (lazy evaluation)`, () => { + const checkLimit = createIterationLimitChecker(5) + const getInfo = vi.fn(() => ({ context: `test` })) - it(`should format warning with iteration breakdown`, () => { - const tracker = createIterationTracker( - 5, - (state) => `ops=[${state}]`, - ) - - // Create a pattern: 2 in state-a, then exceed in state-b - tracker.trackAndCheckLimit(`TopK,Filter`) - tracker.trackAndCheckLimit(`TopK,Filter`) - tracker.trackAndCheckLimit(`TopK`) - tracker.trackAndCheckLimit(`TopK`) - tracker.trackAndCheckLimit(`TopK`) - tracker.trackAndCheckLimit(`TopK`) // exceeds + // First 5 iterations should not call getInfo + for (let i = 0; i < 5; i++) { + checkLimit(getInfo) + } + expect(getInfo).not.toHaveBeenCalled() - const warning = tracker.formatWarning(`D2 graph execution`, { - totalOperators: 8, - }) + // 6th iteration exceeds limit and should call getInfo + const consoleSpy = vi.spyOn(console, `warn`).mockImplementation(() => {}) + checkLimit(getInfo) + expect(getInfo).toHaveBeenCalledTimes(1) + consoleSpy.mockRestore() + }) + it(`should log warning with context and diagnostics`, () => { + const checkLimit = createIterationLimitChecker(2) + const consoleSpy = vi.spyOn(console, `warn`).mockImplementation(() => {}) + + checkLimit(() => ({ context: `test` })) + checkLimit(() => ({ context: `test` })) + checkLimit(() => ({ + context: `D2 graph execution`, + diagnostics: { + totalOperators: 8, + operatorsWithWork: [`TopK`, `Filter`], + }, + })) + + expect(consoleSpy).toHaveBeenCalledTimes(1) + const warning = consoleSpy.mock.calls[0]![0] expect(warning).toContain( - `[TanStack DB] D2 graph execution exceeded 5 iterations`, + `[TanStack DB] D2 graph execution exceeded 2 iterations`, ) expect(warning).toContain(`Continuing with available data`) - expect(warning).toContain( - `Iteration breakdown (where the loop spent time):`, - ) - expect(warning).toContain(`1-2: ops=[TopK,Filter]`) - expect(warning).toContain(`3-6: ops=[TopK]`) expect(warning).toContain(`"totalOperators": 8`) + expect(warning).toContain(`TopK`) expect(warning).toContain(`https://github.com/TanStack/db/issues`) - }) - - it(`should work with object states using default JSON serialization`, () => { - type State = { valuesNeeded: number; keysInBatch: number } - const tracker = createIterationTracker(10) - - tracker.trackAndCheckLimit({ valuesNeeded: 10, keysInBatch: 5 }) - tracker.trackAndCheckLimit({ valuesNeeded: 10, keysInBatch: 5 }) - tracker.trackAndCheckLimit({ valuesNeeded: 8, keysInBatch: 3 }) - const history = tracker.getHistory() - expect(history).toHaveLength(1) - expect(history[0]!.state).toEqual({ valuesNeeded: 10, keysInBatch: 5 }) + consoleSpy.mockRestore() }) - it(`should use custom stateToKey function for display`, () => { - type State = { valuesNeeded: number; keysInBatch: number } - const tracker = createIterationTracker( - 5, - (state) => - `valuesNeeded=${state.valuesNeeded}, keysInBatch=${state.keysInBatch}`, - ) - - tracker.trackAndCheckLimit({ valuesNeeded: 10, keysInBatch: 5 }) - tracker.trackAndCheckLimit({ valuesNeeded: 10, keysInBatch: 5 }) - tracker.trackAndCheckLimit({ valuesNeeded: 10, keysInBatch: 5 }) - tracker.trackAndCheckLimit({ valuesNeeded: 10, keysInBatch: 5 }) - tracker.trackAndCheckLimit({ valuesNeeded: 10, keysInBatch: 5 }) - tracker.trackAndCheckLimit({ valuesNeeded: 10, keysInBatch: 5 }) // exceeds - - const warning = tracker.formatWarning(`requestLimitedSnapshot`) - - expect(warning).toContain(`1-6: valuesNeeded=10, keysInBatch=5`) - }) - - it(`should handle single state that exceeds limit`, () => { - const tracker = createIterationTracker(3) - - tracker.trackAndCheckLimit(`stuck`) - tracker.trackAndCheckLimit(`stuck`) - tracker.trackAndCheckLimit(`stuck`) - const exceeded = tracker.trackAndCheckLimit(`stuck`) + it(`should log warning without diagnostics when not provided`, () => { + const checkLimit = createIterationLimitChecker(1) + const consoleSpy = vi.spyOn(console, `warn`).mockImplementation(() => {}) - expect(exceeded).toBe(true) - - const history = tracker.getHistory() - expect(history).toHaveLength(1) - expect(history[0]).toEqual({ - state: `stuck`, - startIter: 1, - endIter: 4, - }) - }) - - it(`should format warning without diagnostic info`, () => { - const tracker = createIterationTracker(2) - - tracker.trackAndCheckLimit(`state`) - tracker.trackAndCheckLimit(`state`) - tracker.trackAndCheckLimit(`state`) // exceeds - - const warning = tracker.formatWarning(`Graph execution`) + checkLimit(() => ({ context: `test` })) + checkLimit(() => ({ context: `Graph execution` })) + expect(consoleSpy).toHaveBeenCalledTimes(1) + const warning = consoleSpy.mock.calls[0]![0] expect(warning).toContain( - `[TanStack DB] Graph execution exceeded 2 iterations`, + `[TanStack DB] Graph execution exceeded 1 iterations`, ) expect(warning).not.toContain(`Diagnostic info:`) + + consoleSpy.mockRestore() }) }) diff --git a/packages/db/src/collection/subscription.ts b/packages/db/src/collection/subscription.ts index 648419cb4..10e232ea2 100644 --- a/packages/db/src/collection/subscription.ts +++ b/packages/db/src/collection/subscription.ts @@ -1,4 +1,4 @@ -import { createIterationTracker } from '@tanstack/db-ivm' +import { createIterationLimitChecker } from '@tanstack/db-ivm' import { ensureIndexForExpression } from '../indexes/auto-index.js' import { and, eq, gte, lt } from '../query/builder/functions.js' import { PropRef, Value } from '../query/ir.js' @@ -509,31 +509,28 @@ export class CollectionSubscription // logic has issues. The loop should naturally terminate when the index is // exhausted, but this provides a backstop. const MAX_SNAPSHOT_ITERATIONS = 10000 - type SnapshotState = { valuesNeeded: number; keysInBatch: number } - const tracker = createIterationTracker( - MAX_SNAPSHOT_ITERATIONS, - (state) => - `valuesNeeded=${state.valuesNeeded}, keysInBatch=${state.keysInBatch}`, - ) + const checkLimit = createIterationLimitChecker(MAX_SNAPSHOT_ITERATIONS) let hitIterationLimit = false while (valuesNeeded() > 0 && !collectionExhausted()) { - const state = { valuesNeeded: valuesNeeded(), keysInBatch: keys.length } - - if (tracker.trackAndCheckLimit(state)) { - console.warn( - tracker.formatWarning(`requestLimitedSnapshot`, { + if ( + checkLimit(() => ({ + context: `requestLimitedSnapshot`, + diagnostics: { collectionId: this.collection.id, collectionSize: this.collection.size, limit, offset, + valuesNeeded: valuesNeeded(), + keysInBatch: keys.length, changesCollected: changes.length, sentKeysCount: this.sentKeys.size, cursorValue: biggestObservedValue, minValueForIndex, orderByDirection: orderBy[0]!.compareOptions.direction, - }), - ) + }, + })) + ) { hitIterationLimit = true break } diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index ec6ce4c59..f5481afaf 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -1,4 +1,4 @@ -import { D2, createIterationTracker, output } from '@tanstack/db-ivm' +import { D2, createIterationLimitChecker, output } from '@tanstack/db-ivm' import { compileQuery } from '../compiler/index.js' import { buildQuery, getQueryIR } from '../builder/index.js' import { @@ -339,38 +339,32 @@ export class CollectionConfigBuilder< // Safety limit to prevent infinite loops when data loading and graph processing // create a feedback cycle. const MAX_GRAPH_ITERATIONS = 10000 - type GraphState = Record - const tracker = createIterationTracker( - MAX_GRAPH_ITERATIONS, - (state) => `dataNeeded=${JSON.stringify(state)}`, - ) + const checkLimit = createIterationLimitChecker(MAX_GRAPH_ITERATIONS) while (syncState.graph.pendingWork()) { - const dataNeeded: GraphState = {} - for (const [id, info] of Object.entries( - this.optimizableOrderByCollections, - )) { - dataNeeded[id] = info.dataNeeded?.() ?? `unknown` - } - - if (tracker.trackAndCheckLimit(dataNeeded)) { - const collectionIds = Object.keys(this.collections) - const orderByInfo = Object.entries( - this.optimizableOrderByCollections, - ).map(([id, info]) => ({ - collectionId: id, - limit: info.limit, - offset: info.offset, - })) - - console.warn( - tracker.formatWarning(`Graph execution`, { - liveQueryId: this.id, - sourceCollections: collectionIds, - runCount: this.runCount, - orderByConfig: orderByInfo, - }), - ) + if ( + checkLimit(() => { + // Only compute diagnostics when limit is exceeded (lazy) + const collectionIds = Object.keys(this.collections) + const orderByInfo = Object.entries( + this.optimizableOrderByCollections, + ).map(([id, info]) => ({ + collectionId: id, + limit: info.limit, + offset: info.offset, + dataNeeded: info.dataNeeded?.() ?? `unknown`, + })) + return { + context: `Graph execution`, + diagnostics: { + liveQueryId: this.id, + sourceCollections: collectionIds, + runCount: this.runCount, + orderByConfig: orderByInfo, + }, + } + }) + ) { break } diff --git a/packages/db/src/query/live/collection-subscriber.ts b/packages/db/src/query/live/collection-subscriber.ts index 62cb24560..30a4f3efa 100644 --- a/packages/db/src/query/live/collection-subscriber.ts +++ b/packages/db/src/query/live/collection-subscriber.ts @@ -146,9 +146,8 @@ export class CollectionSubscriber< // Filter changes to prevent duplicate inserts to D2 pipeline. // This ensures D2 multiplicity stays at 1 for visible items, so deletes // properly reduce multiplicity to 0 (triggering DELETE output). - const changesArray = Array.isArray(changes) ? changes : [...changes] const filteredChanges: Array> = [] - for (const change of changesArray) { + for (const change of changes) { if (change.type === `insert`) { if (this.sentToD2Keys.has(change.key)) { // Skip duplicate insert - already sent to D2 @@ -222,9 +221,8 @@ export class CollectionSubscriber< const sendChangesInRange = ( changes: Iterable>, ) => { - const changesArray = Array.isArray(changes) ? changes : [...changes] // Split live updates into a delete of the old value and an insert of the new value - const splittedChanges = splitUpdates(changesArray) + const splittedChanges = splitUpdates(changes) this.sendChangesToPipelineWithTracking( splittedChanges, subscriptionHolder.current!, @@ -321,11 +319,7 @@ export class CollectionSubscriber< return } - const changesArray = Array.isArray(changes) ? changes : [...changes] - const trackedChanges = this.trackSentValues( - changesArray, - orderByInfo.comparator, - ) + const trackedChanges = this.trackSentValues(changes, orderByInfo.comparator) // Cache the loadMoreIfNeeded callback on the subscription using a symbol property. // This ensures we pass the same function instance to the scheduler each time, @@ -347,14 +341,10 @@ export class CollectionSubscriber< // Loads the next `n` items from the collection // starting from the biggest item it has sent - // Returns true if local data was found, false if the local index is exhausted - private loadNextItems( - n: number, - subscription: CollectionSubscription, - ): boolean { + private loadNextItems(n: number, subscription: CollectionSubscription) { const orderByInfo = this.getOrderByInfo() if (!orderByInfo) { - return false + return } const { orderBy, valueExtractorForRawRow, offset } = orderByInfo const biggestSentRow = this.biggest @@ -378,8 +368,7 @@ export class CollectionSubscriber< // Take the `n` items after the biggest sent value // Pass the current window offset to ensure proper deduplication - // Returns true if local data was found - return subscription.requestLimitedSnapshot({ + subscription.requestLimitedSnapshot({ orderBy: normalizedOrderBy, limit: n, minValues, From 35804da46598a727100ba8043c59d4c0cf548f39 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 26 Jan 2026 11:57:17 -0700 Subject: [PATCH 22/24] Add state-change tracking to iteration limit checker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The iteration checker now tracks both total iterations AND iterations without state change. This catches: - True infinite loops (same state repeating) - Oscillation patterns (A→B→A→B) - Slow progress that exceeds total limit Callers pass a lightweight state key (e.g., count of operators with work, number of changes collected) that resets the same-state counter when it changes. If making progress, allows more total iterations; if stuck on same state, triggers sooner. Runtime cost: one primitive comparison per iteration (~free). Co-Authored-By: Claude Opus 4.5 --- packages/db-ivm/src/d2.ts | 13 ++- packages/db-ivm/src/iteration-tracker.ts | 66 +++++++++++---- .../db-ivm/tests/iteration-tracker.test.ts | 82 ++++++++++++++++--- packages/db/src/collection/subscription.ts | 11 ++- .../query/live/collection-config-builder.ts | 11 ++- 5 files changed, 146 insertions(+), 37 deletions(-) diff --git a/packages/db-ivm/src/d2.ts b/packages/db-ivm/src/d2.ts index 20bd1ca01..506e39128 100644 --- a/packages/db-ivm/src/d2.ts +++ b/packages/db-ivm/src/d2.ts @@ -60,10 +60,17 @@ export class D2 implements ID2 { run(): void { // Safety limit to prevent infinite loops in case of circular data flow // or other bugs that cause operators to perpetually produce output. - const MAX_RUN_ITERATIONS = 100000 - const checkLimit = createIterationLimitChecker(MAX_RUN_ITERATIONS) + const checkLimit = createIterationLimitChecker({ + maxSameState: 10000, + maxTotal: 100000, + }) while (this.pendingWork()) { + // Use count of operators with pending work as state key + const operatorsWithWorkCount = this.#operators.filter((op) => + op.hasPendingWork(), + ).length + if ( checkLimit(() => { // Only compute diagnostics when limit is exceeded (lazy) @@ -77,7 +84,7 @@ export class D2 implements ID2 { totalOperators: this.#operators.length, }, } - }) + }, operatorsWithWorkCount) ) { break } diff --git a/packages/db-ivm/src/iteration-tracker.ts b/packages/db-ivm/src/iteration-tracker.ts index 351872631..9d7cdbcce 100644 --- a/packages/db-ivm/src/iteration-tracker.ts +++ b/packages/db-ivm/src/iteration-tracker.ts @@ -1,18 +1,23 @@ /** - * Creates a simple iteration counter with a limit check. - * When the limit is exceeded, calls the provided diagnostic function to capture state. + * Creates an iteration counter with limit checks based on state changes. * - * This design avoids per-iteration overhead - state capture only happens when needed. + * Tracks both total iterations AND iterations without state change. This catches: + * - True infinite loops (same state repeating) + * - Slow progress that exceeds total limit * * @example * ```ts - * const checkLimit = createIterationLimitChecker(100000) + * const checkLimit = createIterationLimitChecker({ + * maxSameState: 10000, // Max iterations without state change + * maxTotal: 100000, // Hard cap regardless of state changes + * }) * * while (pendingWork()) { + * const stateKey = operators.filter(op => op.hasPendingWork()).length * if (checkLimit(() => ({ * context: 'D2 graph execution', * diagnostics: { totalOperators: operators.length } - * }))) { + * }), stateKey)) { * break * } * step() @@ -25,30 +30,59 @@ export type LimitExceededInfo = { diagnostics?: Record } +export type IterationLimitOptions = { + /** Max iterations without state change before triggering (default: 10000) */ + maxSameState?: number + /** Hard cap on total iterations regardless of state changes (default: 100000) */ + maxTotal?: number +} + /** - * Creates an iteration limit checker that logs a warning when the limit is exceeded. + * Creates an iteration limit checker that logs a warning when limits are exceeded. * - * @param maxIterations - The maximum number of iterations before the limit is exceeded - * @returns A function that increments the counter and returns true if limit exceeded + * @param options - Configuration for iteration limits + * @returns A function that checks limits and returns true if exceeded */ export function createIterationLimitChecker( - maxIterations: number, -): (getInfo: () => LimitExceededInfo) => boolean { - let iterations = 0 + options: IterationLimitOptions = {}, +): (getInfo: () => LimitExceededInfo, stateKey?: string | number) => boolean { + const maxSameState = options.maxSameState ?? 10000 + const maxTotal = options.maxTotal ?? 100000 - return function checkLimit(getInfo: () => LimitExceededInfo): boolean { - iterations++ + let totalIterations = 0 + let sameStateIterations = 0 + let lastStateKey: string | number | undefined - if (iterations > maxIterations) { - // Only capture diagnostic info when we actually exceed the limit + return function checkLimit( + getInfo: () => LimitExceededInfo, + stateKey?: string | number, + ): boolean { + totalIterations++ + + // Track same-state iterations + if (stateKey !== undefined && stateKey !== lastStateKey) { + // State changed - reset same-state counter + sameStateIterations = 0 + lastStateKey = stateKey + } + sameStateIterations++ + + const sameStateExceeded = sameStateIterations > maxSameState + const totalExceeded = totalIterations > maxTotal + + if (sameStateExceeded || totalExceeded) { const { context, diagnostics } = getInfo() + const reason = sameStateExceeded + ? `${sameStateIterations} iterations without state change (limit: ${maxSameState})` + : `${totalIterations} total iterations (limit: ${maxTotal})` + const diagnosticSection = diagnostics ? `\nDiagnostic info: ${JSON.stringify(diagnostics, null, 2)}\n` : `\n` console.warn( - `[TanStack DB] ${context} exceeded ${maxIterations} iterations. ` + + `[TanStack DB] ${context} exceeded iteration limit: ${reason}. ` + `Continuing with available data.` + diagnosticSection + `Please report this issue at https://github.com/TanStack/db/issues`, diff --git a/packages/db-ivm/tests/iteration-tracker.test.ts b/packages/db-ivm/tests/iteration-tracker.test.ts index 155ff8f58..aac852836 100644 --- a/packages/db-ivm/tests/iteration-tracker.test.ts +++ b/packages/db-ivm/tests/iteration-tracker.test.ts @@ -3,15 +3,15 @@ import { createIterationLimitChecker } from '../src/iteration-tracker.js' describe(`createIterationLimitChecker`, () => { it(`should not exceed limit on normal iteration counts`, () => { - const checkLimit = createIterationLimitChecker(100) + const checkLimit = createIterationLimitChecker({ maxSameState: 100 }) for (let i = 0; i < 50; i++) { expect(checkLimit(() => ({ context: `test` }))).toBe(false) } }) - it(`should return true when limit is exceeded`, () => { - const checkLimit = createIterationLimitChecker(10) + it(`should return true when same-state limit is exceeded`, () => { + const checkLimit = createIterationLimitChecker({ maxSameState: 10 }) for (let i = 0; i < 10; i++) { expect(checkLimit(() => ({ context: `test` }))).toBe(false) @@ -23,8 +23,56 @@ describe(`createIterationLimitChecker`, () => { consoleSpy.mockRestore() }) + it(`should reset same-state counter when state key changes`, () => { + const checkLimit = createIterationLimitChecker({ + maxSameState: 5, + maxTotal: 100, + }) + const consoleSpy = vi.spyOn(console, `warn`).mockImplementation(() => {}) + + // 5 iterations with state key 1 - should not exceed + for (let i = 0; i < 5; i++) { + expect(checkLimit(() => ({ context: `test` }), 1)).toBe(false) + } + + // Change state key to 2 - counter resets + // 5 more iterations should not exceed + for (let i = 0; i < 5; i++) { + expect(checkLimit(() => ({ context: `test` }), 2)).toBe(false) + } + + // Change state key to 3 - counter resets again + // 5 more iterations should not exceed + for (let i = 0; i < 5; i++) { + expect(checkLimit(() => ({ context: `test` }), 3)).toBe(false) + } + + // Total is 15, but no same-state limit exceeded + expect(consoleSpy).not.toHaveBeenCalled() + consoleSpy.mockRestore() + }) + + it(`should trigger on total limit even with state changes`, () => { + const checkLimit = createIterationLimitChecker({ + maxSameState: 10, + maxTotal: 20, + }) + const consoleSpy = vi.spyOn(console, `warn`).mockImplementation(() => {}) + + // Alternate state keys to avoid same-state limit + for (let i = 0; i < 20; i++) { + expect(checkLimit(() => ({ context: `test` }), i % 2)).toBe(false) + } + + // 21st iteration exceeds total limit + expect(checkLimit(() => ({ context: `test` }), 0)).toBe(true) + expect(consoleSpy).toHaveBeenCalledTimes(1) + expect(consoleSpy.mock.calls[0]![0]).toContain(`total iterations`) + consoleSpy.mockRestore() + }) + it(`should only call getInfo when limit is exceeded (lazy evaluation)`, () => { - const checkLimit = createIterationLimitChecker(5) + const checkLimit = createIterationLimitChecker({ maxSameState: 5 }) const getInfo = vi.fn(() => ({ context: `test` })) // First 5 iterations should not call getInfo @@ -41,7 +89,7 @@ describe(`createIterationLimitChecker`, () => { }) it(`should log warning with context and diagnostics`, () => { - const checkLimit = createIterationLimitChecker(2) + const checkLimit = createIterationLimitChecker({ maxSameState: 2 }) const consoleSpy = vi.spyOn(console, `warn`).mockImplementation(() => {}) checkLimit(() => ({ context: `test` })) @@ -56,9 +104,8 @@ describe(`createIterationLimitChecker`, () => { expect(consoleSpy).toHaveBeenCalledTimes(1) const warning = consoleSpy.mock.calls[0]![0] - expect(warning).toContain( - `[TanStack DB] D2 graph execution exceeded 2 iterations`, - ) + expect(warning).toContain(`[TanStack DB] D2 graph execution`) + expect(warning).toContain(`iterations without state change`) expect(warning).toContain(`Continuing with available data`) expect(warning).toContain(`"totalOperators": 8`) expect(warning).toContain(`TopK`) @@ -68,7 +115,7 @@ describe(`createIterationLimitChecker`, () => { }) it(`should log warning without diagnostics when not provided`, () => { - const checkLimit = createIterationLimitChecker(1) + const checkLimit = createIterationLimitChecker({ maxSameState: 1 }) const consoleSpy = vi.spyOn(console, `warn`).mockImplementation(() => {}) checkLimit(() => ({ context: `test` })) @@ -76,11 +123,22 @@ describe(`createIterationLimitChecker`, () => { expect(consoleSpy).toHaveBeenCalledTimes(1) const warning = consoleSpy.mock.calls[0]![0] - expect(warning).toContain( - `[TanStack DB] Graph execution exceeded 1 iterations`, - ) + expect(warning).toContain(`[TanStack DB] Graph execution`) expect(warning).not.toContain(`Diagnostic info:`) consoleSpy.mockRestore() }) + + it(`should use default limits when not specified`, () => { + const checkLimit = createIterationLimitChecker({}) + const consoleSpy = vi.spyOn(console, `warn`).mockImplementation(() => {}) + + // Default maxSameState is 10000 - should not trigger + for (let i = 0; i < 1000; i++) { + expect(checkLimit(() => ({ context: `test` }))).toBe(false) + } + + expect(consoleSpy).not.toHaveBeenCalled() + consoleSpy.mockRestore() + }) }) diff --git a/packages/db/src/collection/subscription.ts b/packages/db/src/collection/subscription.ts index 10e232ea2..e0c582864 100644 --- a/packages/db/src/collection/subscription.ts +++ b/packages/db/src/collection/subscription.ts @@ -508,11 +508,16 @@ export class CollectionSubscription // Safety limit to prevent infinite loops if the index iteration or filtering // logic has issues. The loop should naturally terminate when the index is // exhausted, but this provides a backstop. - const MAX_SNAPSHOT_ITERATIONS = 10000 - const checkLimit = createIterationLimitChecker(MAX_SNAPSHOT_ITERATIONS) + const checkLimit = createIterationLimitChecker({ + maxSameState: 1000, + maxTotal: 10000, + }) let hitIterationLimit = false while (valuesNeeded() > 0 && !collectionExhausted()) { + // Use changes.length as state key - if we're making progress, this should increase + const stateKey = changes.length + if ( checkLimit(() => ({ context: `requestLimitedSnapshot`, @@ -529,7 +534,7 @@ export class CollectionSubscription minValueForIndex, orderByDirection: orderBy[0]!.compareOptions.direction, }, - })) + }), stateKey) ) { hitIterationLimit = true break diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index f5481afaf..840e962e2 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -338,10 +338,15 @@ export class CollectionConfigBuilder< if (syncState.subscribedToAllCollections) { // Safety limit to prevent infinite loops when data loading and graph processing // create a feedback cycle. - const MAX_GRAPH_ITERATIONS = 10000 - const checkLimit = createIterationLimitChecker(MAX_GRAPH_ITERATIONS) + const checkLimit = createIterationLimitChecker({ + maxSameState: 1000, + maxTotal: 10000, + }) while (syncState.graph.pendingWork()) { + // Use messagesCount as state key - if we're processing messages, we're making progress + const stateKey = syncState.messagesCount + if ( checkLimit(() => { // Only compute diagnostics when limit is exceeded (lazy) @@ -363,7 +368,7 @@ export class CollectionConfigBuilder< orderByConfig: orderByInfo, }, } - }) + }, stateKey) ) { break } From 0fb214c620863067ed0673fb4ee4da63bee6d737 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 26 Jan 2026 18:58:12 +0000 Subject: [PATCH 23/24] ci: apply automated fixes --- packages/db/src/collection/subscription.ts | 35 ++++++++++++---------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/packages/db/src/collection/subscription.ts b/packages/db/src/collection/subscription.ts index e0c582864..0c188eda6 100644 --- a/packages/db/src/collection/subscription.ts +++ b/packages/db/src/collection/subscription.ts @@ -519,22 +519,25 @@ export class CollectionSubscription const stateKey = changes.length if ( - checkLimit(() => ({ - context: `requestLimitedSnapshot`, - diagnostics: { - collectionId: this.collection.id, - collectionSize: this.collection.size, - limit, - offset, - valuesNeeded: valuesNeeded(), - keysInBatch: keys.length, - changesCollected: changes.length, - sentKeysCount: this.sentKeys.size, - cursorValue: biggestObservedValue, - minValueForIndex, - orderByDirection: orderBy[0]!.compareOptions.direction, - }, - }), stateKey) + checkLimit( + () => ({ + context: `requestLimitedSnapshot`, + diagnostics: { + collectionId: this.collection.id, + collectionSize: this.collection.size, + limit, + offset, + valuesNeeded: valuesNeeded(), + keysInBatch: keys.length, + changesCollected: changes.length, + sentKeysCount: this.sentKeys.size, + cursorValue: biggestObservedValue, + minValueForIndex, + orderByDirection: orderBy[0]!.compareOptions.direction, + }, + }), + stateKey, + ) ) { hitIterationLimit = true break From 32bd8e84ba9ef0048582fc1cd9e66b80182c6ed9 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 26 Jan 2026 12:15:27 -0700 Subject: [PATCH 24/24] Clarify requestLimitedSnapshot comments Remove confusing "index exhausted" terminology and clarify that this function loads from local collection via BTree index and also triggers async backend fetch. Co-Authored-By: Claude Opus 4.5 --- packages/db/src/collection/subscription.ts | 29 +++++++++++----------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/packages/db/src/collection/subscription.ts b/packages/db/src/collection/subscription.ts index 0c188eda6..35322c971 100644 --- a/packages/db/src/collection/subscription.ts +++ b/packages/db/src/collection/subscription.ts @@ -400,19 +400,22 @@ export class CollectionSubscription } /** - * Sends a snapshot that fulfills the `where` clause and all rows are bigger or equal to the cursor. - * Requires a range index to be set with `setOrderByIndex` prior to calling this method. - * It uses that range index to load the items in the order of the index. + * Loads rows from the local collection in sorted order using the BTree index. + * + * Uses the BTree index (set via `setOrderByIndex`) to iterate through the local + * collection's data in sorted order, starting from the cursor position (`minValues`). + * Also triggers an async `loadSubset` call to fetch more data from the sync layer + * (e.g., Electric backend) if needed. * * For multi-column orderBy: - * - Uses first value from `minValues` for LOCAL index operations (wide bounds, ensures no missed rows) - * - Uses all `minValues` to build a precise composite cursor for SYNC layer loadSubset + * - Uses first value from `minValues` for BTree index operations (wide bounds, ensures no missed rows) + * - Uses all `minValues` to build a precise composite cursor for sync layer loadSubset * - * Note 1: it may load more rows than the provided LIMIT because it loads all values equal to the first cursor value + limit values greater. - * This is needed to ensure that it does not accidentally skip duplicate values when the limit falls in the middle of some duplicated values. - * Note 2: it does not send keys that have already been sent before. + * Note 1: May load more rows than `limit` because it includes all rows equal to the + * cursor value. This prevents skipping duplicates when limit falls mid-duplicates. + * Note 2: Skips keys that have already been sent to prevent duplicates. * - * @returns true if local data was found and sent, false if the local index was exhausted + * @returns true if local data was found, false if no more matching rows exist locally */ requestLimitedSnapshot({ orderBy, @@ -635,12 +638,8 @@ export class CollectionSubscription this.loadedSubsets.push(loadOptions) this.trackLoadSubsetPromise(syncResult) - // Return whether local data was found and iteration completed normally. - // Return false if: - // - No local data was found (index exhausted) - // - Iteration limit was hit (abnormal exit) - // Either case signals that the caller should stop trying to load more. - // The async loadSubset may still return data later. + // Return true if we found and sent local data, false otherwise. + // The async loadSubset call may still fetch data from the backend. return changes.length > 0 && !hitIterationLimit }