Skip to content

Commit 43c7c9d

Browse files
kevin-dpclaudeautofix-ci[bot]
authored
Fix infinite loop in takeInternal with undefined values (#1198)
* test: Add failing test for BTreeIndex infinite loop with undefined values (issue #1186) This test demonstrates the infinite loop bug that occurs when calling take() on a BTreeIndex containing items with undefined indexed values. The bug is in takeInternal() where nextHigherPair(undefined) returns the minimum pair [undefined, undefined], then key is set to pair[0] (undefined), causing the same pair to be returned infinitely since the while condition (pair !== undefined) is always true for arrays. https://claude.ai/code/session_01RKBKXMoKVe1hSXGy3VVEFo * ci: apply automated fixes * Remove broken tests * Distinguish usage of undefined by introducing a sentinel for starting from the start/end. Also introduce a sentinel such that we never store undefined as a key in the btree. * Fix infinite loop reproduction test * Fix some tests * ci: apply automated fixes * Fix some tests * Introduce a normalizeValueForBTree that is separate from normalizeValue because normalizeValue is also used in other places. * ci: apply automated fixes * Changeset * ci: apply automated fixes --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 268552e commit 43c7c9d

9 files changed

Lines changed: 300 additions & 52 deletions
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@tanstack/db': patch
3+
---
4+
5+
Fixed infinite loop in `BTreeIndex.takeInternal` when indexed values are `undefined`.
6+
7+
The BTree uses `undefined` as a special parameter meaning "start from beginning/end", which caused an infinite loop when the actual indexed value was `undefined`.
8+
9+
Added `takeFromStart` and `takeReversedFromEnd` methods to explicitly start from the beginning/end, and introduced a sentinel value for storing `undefined` in the BTree.

packages/db/src/collection/change-events.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -347,7 +347,7 @@ function getOrderedKeys<T extends object, TKey extends string | number>(
347347
// Take the keys that match the filter and limit
348348
// if no limit is provided `index.keyCount` is used,
349349
// i.e. we will take all keys that match the filter
350-
return index.take(limit ?? index.keyCount, undefined, filterFn)
350+
return index.takeFromStart(limit ?? index.keyCount, filterFn)
351351
}
352352
}
353353
}

packages/db/src/collection/subscription.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,9 @@ export class CollectionSubscription
425425
)
426426
}
427427

428+
// Check if minValues has a first element (regardless of its value)
429+
// This distinguishes between "no min value provided" vs "min value is undefined"
430+
const hasMinValue = minValues !== undefined && minValues.length > 0
428431
// Derive first column value from minValues (used for local index operations)
429432
const minValue = minValues?.[0]
430433
// Cast for index operations (index expects string | number)
@@ -436,8 +439,8 @@ export class CollectionSubscription
436439
? createFilterFunctionFromExpression(where)
437440
: undefined
438441

439-
const filterFn = (key: string | number): boolean => {
440-
if (this.sentKeys.has(key)) {
442+
const filterFn = (key: string | number | undefined): boolean => {
443+
if (key !== undefined && this.sentKeys.has(key)) {
441444
return false
442445
}
443446

@@ -462,7 +465,7 @@ export class CollectionSubscription
462465
// For multi-column orderBy, we use the first column value for index operations (wide bounds)
463466
// This may load some duplicates but ensures we never miss any rows.
464467
let keys: Array<string | number> = []
465-
if (minValueForIndex !== undefined) {
468+
if (hasMinValue) {
466469
// First, get all items with the same FIRST COLUMN value as minValue
467470
// This provides wide bounds for the local index
468471
const { expression } = orderBy[0]!
@@ -481,15 +484,16 @@ export class CollectionSubscription
481484
// Then get items greater than minValue
482485
const keysGreaterThanMin = index.take(
483486
limit - keys.length,
484-
minValueForIndex,
487+
minValueForIndex!,
485488
filterFn,
486489
)
487490
keys.push(...keysGreaterThanMin)
488491
} else {
489-
keys = index.take(limit, minValueForIndex, filterFn)
492+
keys = index.take(limit, minValueForIndex!, filterFn)
490493
}
491494
} else {
492-
keys = index.take(limit, minValueForIndex, filterFn)
495+
// No min value provided, start from the beginning
496+
keys = index.takeFromStart(limit, filterFn)
493497
}
494498

495499
const valuesNeeded = () => Math.max(limit - changes.length, 0)
@@ -518,7 +522,7 @@ export class CollectionSubscription
518522
insertedKeys.add(key) // Track this key
519523
}
520524

521-
keys = index.take(valuesNeeded(), biggestObservedValue, filterFn)
525+
keys = index.take(valuesNeeded(), biggestObservedValue!, filterFn)
522526
}
523527

524528
// Track row count for offset-based pagination (before sending to callback)

packages/db/src/indexes/base-index.ts

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export interface IndexStats {
2626
}
2727

2828
export interface IndexInterface<
29-
TKey extends string | number = string | number,
29+
TKey extends string | number | undefined = string | number | undefined,
3030
> {
3131
add: (key: TKey, item: any) => void
3232
remove: (key: TKey, item: any) => void
@@ -45,12 +45,17 @@ export interface IndexInterface<
4545

4646
take: (
4747
n: number,
48-
from?: TKey,
48+
from: TKey,
4949
filterFn?: (key: TKey) => boolean,
5050
) => Array<TKey>
51+
takeFromStart: (n: number, filterFn?: (key: TKey) => boolean) => Array<TKey>
5152
takeReversed: (
5253
n: number,
53-
from?: TKey,
54+
from: TKey,
55+
filterFn?: (key: TKey) => boolean,
56+
) => Array<TKey>
57+
takeReversedFromEnd: (
58+
n: number,
5459
filterFn?: (key: TKey) => boolean,
5560
) => Array<TKey>
5661

@@ -74,7 +79,7 @@ export interface IndexInterface<
7479
* Base abstract class that all index types extend
7580
*/
7681
export abstract class BaseIndex<
77-
TKey extends string | number = string | number,
82+
TKey extends string | number | undefined = string | number | undefined,
7883
> implements IndexInterface<TKey> {
7984
public readonly id: number
8085
public readonly name?: string
@@ -108,12 +113,20 @@ export abstract class BaseIndex<
108113
abstract lookup(operation: IndexOperation, value: any): Set<TKey>
109114
abstract take(
110115
n: number,
111-
from?: TKey,
116+
from: TKey,
117+
filterFn?: (key: TKey) => boolean,
118+
): Array<TKey>
119+
abstract takeFromStart(
120+
n: number,
112121
filterFn?: (key: TKey) => boolean,
113122
): Array<TKey>
114123
abstract takeReversed(
115124
n: number,
116-
from?: TKey,
125+
from: TKey,
126+
filterFn?: (key: TKey) => boolean,
127+
): Array<TKey>
128+
abstract takeReversedFromEnd(
129+
n: number,
117130
filterFn?: (key: TKey) => boolean,
118131
): Array<TKey>
119132
abstract get keyCount(): number

packages/db/src/indexes/btree-index.ts

Lines changed: 101 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { compareKeys } from '@tanstack/db-ivm'
22
import { BTree } from '../utils/btree.js'
3-
import { defaultComparator, normalizeValue } from '../utils/comparison.js'
3+
import {
4+
defaultComparator,
5+
denormalizeUndefined,
6+
normalizeForBTree,
7+
} from '../utils/comparison.js'
48
import { BaseIndex } from './base-index.js'
59
import type { CompareOptions } from '../query/builder/types.js'
610
import type { BasicExpression } from '../query/ir.js'
@@ -29,7 +33,7 @@ export interface RangeQueryOptions {
2933
* This maintains items in sorted order and provides efficient range operations
3034
*/
3135
export class BTreeIndex<
32-
TKey extends string | number = string | number,
36+
TKey extends string | number | undefined = string | number | undefined,
3337
> extends BaseIndex<TKey> {
3438
public readonly supportedOperations = new Set<IndexOperation>([
3539
`eq`,
@@ -55,7 +59,16 @@ export class BTreeIndex<
5559
options?: any,
5660
) {
5761
super(id, expression, name, options)
58-
this.compareFn = options?.compareFn ?? defaultComparator
62+
63+
// Get the base compare function
64+
const baseCompareFn = options?.compareFn ?? defaultComparator
65+
66+
// Wrap it to denormalize sentinels before comparison
67+
// This ensures UNDEFINED_SENTINEL is converted back to undefined
68+
// before being passed to the baseCompareFn (which can be user-provided and is unaware of the UNDEFINED_SENTINEL)
69+
this.compareFn = (a: any, b: any) =>
70+
baseCompareFn(denormalizeUndefined(a), denormalizeUndefined(b))
71+
5972
if (options?.compareOptions) {
6073
this.compareOptions = options!.compareOptions
6174
}
@@ -78,7 +91,7 @@ export class BTreeIndex<
7891
}
7992

8093
// Normalize the value for Map key usage
81-
const normalizedValue = normalizeValue(indexedValue)
94+
const normalizedValue = normalizeForBTree(indexedValue)
8295

8396
// Check if this value already exists
8497
if (this.valueMap.has(normalizedValue)) {
@@ -111,7 +124,7 @@ export class BTreeIndex<
111124
}
112125

113126
// Normalize the value for Map key usage
114-
const normalizedValue = normalizeValue(indexedValue)
127+
const normalizedValue = normalizeForBTree(indexedValue)
115128

116129
if (this.valueMap.has(normalizedValue)) {
117130
const keySet = this.valueMap.get(normalizedValue)!
@@ -207,7 +220,7 @@ export class BTreeIndex<
207220
* Performs an equality lookup
208221
*/
209222
equalityLookup(value: any): Set<TKey> {
210-
const normalizedValue = normalizeValue(value)
223+
const normalizedValue = normalizeForBTree(value)
211224
return new Set(this.valueMap.get(normalizedValue) ?? [])
212225
}
213226

@@ -219,10 +232,15 @@ export class BTreeIndex<
219232
const { from, to, fromInclusive = true, toInclusive = true } = options
220233
const result = new Set<TKey>()
221234

222-
const normalizedFrom = normalizeValue(from)
223-
const normalizedTo = normalizeValue(to)
224-
const fromKey = normalizedFrom ?? this.orderedEntries.minKey()
225-
const toKey = normalizedTo ?? this.orderedEntries.maxKey()
235+
// Check if from/to were explicitly provided (even if undefined)
236+
// vs not provided at all (should use min/max key)
237+
const hasFrom = `from` in options
238+
const hasTo = `to` in options
239+
240+
const fromKey = hasFrom
241+
? normalizeForBTree(from)
242+
: this.orderedEntries.minKey()
243+
const toKey = hasTo ? normalizeForBTree(to) : this.orderedEntries.maxKey()
226244

227245
this.orderedEntries.forRange(
228246
fromKey,
@@ -250,29 +268,43 @@ export class BTreeIndex<
250268
*/
251269
rangeQueryReversed(options: RangeQueryOptions = {}): Set<TKey> {
252270
const { from, to, fromInclusive = true, toInclusive = true } = options
271+
const hasFrom = `from` in options
272+
const hasTo = `to` in options
273+
274+
// Swap from/to for reversed query, respecting explicit undefined values
253275
return this.rangeQuery({
254-
from: to ?? this.orderedEntries.maxKey(),
255-
to: from ?? this.orderedEntries.minKey(),
276+
from: hasTo ? to : this.orderedEntries.maxKey(),
277+
to: hasFrom ? from : this.orderedEntries.minKey(),
256278
fromInclusive: toInclusive,
257279
toInclusive: fromInclusive,
258280
})
259281
}
260282

283+
/**
284+
* Internal method for taking items from the index.
285+
* @param n - The number of items to return
286+
* @param nextPair - Function to get the next pair from the BTree
287+
* @param from - Already normalized! undefined means "start from beginning/end", sentinel means "start from the key undefined"
288+
* @param filterFn - Optional filter function
289+
* @param reversed - Whether to reverse the order of keys within each value
290+
*/
261291
private takeInternal(
262292
n: number,
263293
nextPair: (k?: any) => [any, any] | undefined,
264-
from?: any,
294+
from: any,
265295
filterFn?: (key: TKey) => boolean,
266296
reversed: boolean = false,
267297
): Array<TKey> {
268298
const keysInResult: Set<TKey> = new Set()
269299
const result: Array<TKey> = []
270300
let pair: [any, any] | undefined
271-
let key = normalizeValue(from)
301+
let key = from // Use as-is - it's already normalized by the caller
272302

273303
while ((pair = nextPair(key)) !== undefined && result.length < n) {
274304
key = pair[0]
275-
const keys = this.valueMap.get(key)
305+
const keys = this.valueMap.get(key) as
306+
| Set<Exclude<TKey, undefined>>
307+
| undefined
276308
if (keys && keys.size > 0) {
277309
// Sort keys for deterministic order, reverse if needed
278310
const sorted = Array.from(keys).sort(compareKeys)
@@ -291,29 +323,60 @@ export class BTreeIndex<
291323
}
292324

293325
/**
294-
* Returns the next n items after the provided item or the first n items if no from item is provided.
326+
* Returns the next n items after the provided item.
295327
* @param n - The number of items to return
296-
* @param from - The item to start from (exclusive). Starts from the smallest item (inclusive) if not provided.
297-
* @returns The next n items after the provided key. Returns the first n items if no from item is provided.
328+
* @param from - The item to start from (exclusive).
329+
* @returns The next n items after the provided key.
298330
*/
299-
take(n: number, from?: any, filterFn?: (key: TKey) => boolean): Array<TKey> {
331+
take(n: number, from: any, filterFn?: (key: TKey) => boolean): Array<TKey> {
300332
const nextPair = (k?: any) => this.orderedEntries.nextHigherPair(k)
301-
return this.takeInternal(n, nextPair, from, filterFn)
333+
// Normalize the from value
334+
const normalizedFrom = normalizeForBTree(from)
335+
return this.takeInternal(n, nextPair, normalizedFrom, filterFn)
302336
}
303337

304338
/**
305-
* Returns the next n items **before** the provided item (in descending order) or the last n items if no from item is provided.
339+
* Returns the first n items from the beginning.
306340
* @param n - The number of items to return
307-
* @param from - The item to start from (exclusive). Starts from the largest item (inclusive) if not provided.
308-
* @returns The next n items **before** the provided key. Returns the last n items if no from item is provided.
341+
* @param filterFn - Optional filter function
342+
* @returns The first n items
343+
*/
344+
takeFromStart(n: number, filterFn?: (key: TKey) => boolean): Array<TKey> {
345+
const nextPair = (k?: any) => this.orderedEntries.nextHigherPair(k)
346+
// Pass undefined to mean "start from beginning" (BTree's native behavior)
347+
return this.takeInternal(n, nextPair, undefined, filterFn)
348+
}
349+
350+
/**
351+
* Returns the next n items **before** the provided item (in descending order).
352+
* @param n - The number of items to return
353+
* @param from - The item to start from (exclusive). Required.
354+
* @returns The next n items **before** the provided key.
309355
*/
310356
takeReversed(
311357
n: number,
312-
from?: any,
358+
from: any,
359+
filterFn?: (key: TKey) => boolean,
360+
): Array<TKey> {
361+
const nextPair = (k?: any) => this.orderedEntries.nextLowerPair(k)
362+
// Normalize the from value
363+
const normalizedFrom = normalizeForBTree(from)
364+
return this.takeInternal(n, nextPair, normalizedFrom, filterFn, true)
365+
}
366+
367+
/**
368+
* Returns the last n items from the end.
369+
* @param n - The number of items to return
370+
* @param filterFn - Optional filter function
371+
* @returns The last n items
372+
*/
373+
takeReversedFromEnd(
374+
n: number,
313375
filterFn?: (key: TKey) => boolean,
314376
): Array<TKey> {
315377
const nextPair = (k?: any) => this.orderedEntries.nextLowerPair(k)
316-
return this.takeInternal(n, nextPair, from, filterFn, true)
378+
// Pass undefined to mean "start from end" (BTree's native behavior)
379+
return this.takeInternal(n, nextPair, undefined, filterFn, true)
317380
}
318381

319382
/**
@@ -323,7 +386,7 @@ export class BTreeIndex<
323386
const result = new Set<TKey>()
324387

325388
for (const value of values) {
326-
const normalizedValue = normalizeValue(value)
389+
const normalizedValue = normalizeForBTree(value)
327390
const keys = this.valueMap.get(normalizedValue)
328391
if (keys) {
329392
keys.forEach((key) => result.add(key))
@@ -341,17 +404,25 @@ export class BTreeIndex<
341404
get orderedEntriesArray(): Array<[any, Set<TKey>]> {
342405
return this.orderedEntries
343406
.keysArray()
344-
.map((key) => [key, this.valueMap.get(key) ?? new Set()])
407+
.map((key) => [
408+
denormalizeUndefined(key),
409+
this.valueMap.get(key) ?? new Set(),
410+
])
345411
}
346412

347413
get orderedEntriesArrayReversed(): Array<[any, Set<TKey>]> {
348-
return this.takeReversed(this.orderedEntries.size).map((key) => [
349-
key,
414+
return this.takeReversedFromEnd(this.orderedEntries.size).map((key) => [
415+
denormalizeUndefined(key),
350416
this.valueMap.get(key) ?? new Set(),
351417
])
352418
}
353419

354420
get valueMapData(): Map<any, Set<TKey>> {
355-
return this.valueMap
421+
// Return a new Map with denormalized keys
422+
const result = new Map<any, Set<TKey>>()
423+
for (const [key, value] of this.valueMap) {
424+
result.set(denormalizeUndefined(key), value)
425+
}
426+
return result
356427
}
357428
}

0 commit comments

Comments
 (0)