Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
d8f95da
Fix potential infinite loops in graph execution and data loading
claude Jan 22, 2026
2a796ff
Fix root cause of Electric infinite loop with ORDER BY/LIMIT queries
claude Jan 22, 2026
e698eb1
Add tests for infinite loop prevention with ORDER BY + LIMIT queries
claude Jan 22, 2026
9202d1c
Add test that reproduces Electric infinite loop bug
KyleAMathews Jan 22, 2026
d43a509
ci: apply automated fixes
autofix-ci[bot] Jan 22, 2026
e6a9fbc
Add changeset for infinite loop fix
KyleAMathews Jan 22, 2026
dc3d40c
Remove flawed test that doesn't reproduce real Electric bug
KyleAMathews Jan 22, 2026
cb07f2b
Fix localIndexExhausted resetting on updates and add verification test
KyleAMathews Jan 22, 2026
6e35ac5
Add detailed comment explaining why reset-only-on-inserts is correct
KyleAMathews Jan 22, 2026
3a5f05d
Update changeset with clearer ELI5 explanation
KyleAMathews Jan 22, 2026
9cc8215
Simplify infinite loop fix to band-aid with diagnostics
KyleAMathews Jan 26, 2026
79fb6cf
ci: apply automated fixes
autofix-ci[bot] Jan 26, 2026
68cdfda
Change circuit breakers to gracefully recover instead of erroring
KyleAMathews Jan 26, 2026
295b14d
Add iteration breakdown tracking to circuit breaker diagnostics
KyleAMathews Jan 26, 2026
8dd9714
Update changeset with iteration tracking details
KyleAMathews Jan 26, 2026
ef0e60c
ci: apply automated fixes
autofix-ci[bot] Jan 26, 2026
cf6efac
Refactor: Extract shared iteration tracking into utility
KyleAMathews Jan 26, 2026
b9a2cf7
ci: apply automated fixes
autofix-ci[bot] Jan 26, 2026
cebfb82
Add tests for iteration tracker utility
KyleAMathews Jan 26, 2026
44d89fc
ci: apply automated fixes
autofix-ci[bot] Jan 26, 2026
e45f334
Address PR review feedback
KyleAMathews Jan 26, 2026
35804da
Add state-change tracking to iteration limit checker
KyleAMathews Jan 26, 2026
0fb214c
ci: apply automated fixes
autofix-ci[bot] Jan 26, 2026
32bd8e8
Clarify requestLimitedSnapshot comments
KyleAMathews Jan 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .changeset/fix-infinite-loop-orderby-limit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@tanstack/db': patch
'@tanstack/db-ivm': patch
---

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 - the TopK keeps asking for more data that doesn't exist.

**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, 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.

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
31 changes: 31 additions & 0 deletions packages/db-ivm/src/d2.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { DifferenceStreamWriter } from './graph.js'
import { createIterationLimitChecker } from './iteration-tracker.js'
import type {
BinaryOperator,
DifferenceStreamReader,
Expand Down Expand Up @@ -57,7 +58,37 @@ 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 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)
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,
},
}
}, operatorsWithWorkCount)
) {
break
}

this.step()
}
}
Expand Down
1 change: 1 addition & 0 deletions packages/db-ivm/src/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
95 changes: 95 additions & 0 deletions packages/db-ivm/src/iteration-tracker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/**
* Creates an iteration counter with limit checks based on state changes.
*
* 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({
* 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()
* }
* ```
*/

export type LimitExceededInfo = {
context: string
diagnostics?: Record<string, unknown>
}

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 limits are exceeded.
*
* @param options - Configuration for iteration limits
* @returns A function that checks limits and returns true if exceeded
*/
export function createIterationLimitChecker(
options: IterationLimitOptions = {},
): (getInfo: () => LimitExceededInfo, stateKey?: string | number) => boolean {
const maxSameState = options.maxSameState ?? 10000
const maxTotal = options.maxTotal ?? 100000

let totalIterations = 0
let sameStateIterations = 0
let lastStateKey: string | number | undefined

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 iteration limit: ${reason}. ` +
`Continuing with available data.` +
diagnosticSection +
`Please report this issue at https://github.com/TanStack/db/issues`,
)
return true
}

return false
}
}
144 changes: 144 additions & 0 deletions packages/db-ivm/tests/iteration-tracker.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { describe, expect, it, vi } from 'vitest'
import { createIterationLimitChecker } from '../src/iteration-tracker.js'

describe(`createIterationLimitChecker`, () => {
it(`should not exceed limit on normal iteration counts`, () => {
const checkLimit = createIterationLimitChecker({ maxSameState: 100 })

for (let i = 0; i < 50; i++) {
expect(checkLimit(() => ({ context: `test` }))).toBe(false)
}
})

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)
}

// 11th iteration exceeds the limit
const consoleSpy = vi.spyOn(console, `warn`).mockImplementation(() => {})
expect(checkLimit(() => ({ context: `test` }))).toBe(true)
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({ maxSameState: 5 })
const getInfo = vi.fn(() => ({ context: `test` }))

// First 5 iterations should not call getInfo
for (let i = 0; i < 5; i++) {
checkLimit(getInfo)
}
expect(getInfo).not.toHaveBeenCalled()

// 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({ maxSameState: 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`)
expect(warning).toContain(`iterations without state change`)
expect(warning).toContain(`Continuing with available data`)
expect(warning).toContain(`"totalOperators": 8`)
expect(warning).toContain(`TopK`)
expect(warning).toContain(`https://github.com/TanStack/db/issues`)

consoleSpy.mockRestore()
})

it(`should log warning without diagnostics when not provided`, () => {
const checkLimit = createIterationLimitChecker({ maxSameState: 1 })
const consoleSpy = vi.spyOn(console, `warn`).mockImplementation(() => {})

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`)
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()
})
})
Loading
Loading