Skip to content

Commit 09da081

Browse files
fix(db): ensure deterministic iteration order for collections and indexes (#958)
* fix(db): ensure deterministic iteration order for collections and indexes - SortedMap: add key-based tie-breaking for deterministic ordering - SortedMap: optimize to skip value comparison when no comparator provided - BTreeIndex: sort keys within same indexed value for deterministic order - BTreeIndex: add fast paths for empty/single-key sets - CollectionStateManager: always use SortedMap for deterministic iteration - Extract compareKeys utility to utils/comparison.ts - Add comprehensive tests for deterministic ordering behavior * ci: apply automated fixes --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 52c29fa commit 09da081

File tree

7 files changed

+504
-65
lines changed

7 files changed

+504
-65
lines changed
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@tanstack/db': patch
3+
---
4+
5+
Ensure deterministic iteration order for collections and indexes.
6+
7+
**SortedMap improvements:**
8+
9+
- Added key-based tie-breaking when values compare as equal, ensuring deterministic ordering
10+
- Optimized to skip value comparison entirely when no comparator is provided (key-only sorting)
11+
- Extracted `compareKeys` utility to `utils/comparison.ts` for reuse
12+
13+
**BTreeIndex improvements:**
14+
15+
- Keys within the same indexed value are now returned in deterministic sorted order
16+
- Optimized with fast paths for empty sets and single-key sets to avoid unnecessary allocations
17+
18+
**CollectionStateManager changes:**
19+
20+
- Collections now always use `SortedMap` for `syncedData`, ensuring deterministic iteration order
21+
- When no `compare` function is provided, entries are sorted by key only
22+
23+
This ensures that live queries with `orderBy` and `limit` produce stable, deterministic results even when multiple rows have equal sort values.

packages/db/src/SortedMap.ts

Lines changed: 49 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,62 +1,81 @@
1+
import { compareKeys } from '@tanstack/db-ivm'
2+
13
/**
24
* A Map implementation that keeps its entries sorted based on a comparator function
3-
* @template TKey - The type of keys in the map
5+
* @template TKey - The type of keys in the map (must be string | number)
46
* @template TValue - The type of values in the map
57
*/
6-
export class SortedMap<TKey, TValue> {
8+
export class SortedMap<TKey extends string | number, TValue> {
79
private map: Map<TKey, TValue>
810
private sortedKeys: Array<TKey>
9-
private comparator: (a: TValue, b: TValue) => number
11+
private comparator: ((a: TValue, b: TValue) => number) | undefined
1012

1113
/**
1214
* Creates a new SortedMap instance
1315
*
14-
* @param comparator - Optional function to compare values for sorting
16+
* @param comparator - Optional function to compare values for sorting.
17+
* If not provided, entries are sorted by key only.
1518
*/
1619
constructor(comparator?: (a: TValue, b: TValue) => number) {
1720
this.map = new Map<TKey, TValue>()
1821
this.sortedKeys = []
19-
this.comparator = comparator || this.defaultComparator
20-
}
21-
22-
/**
23-
* Default comparator function used when none is provided
24-
*
25-
* @param a - First value to compare
26-
* @param b - Second value to compare
27-
* @returns -1 if a < b, 1 if a > b, 0 if equal
28-
*/
29-
private defaultComparator(a: TValue, b: TValue): number {
30-
if (a < b) return -1
31-
if (a > b) return 1
32-
return 0
22+
this.comparator = comparator
3323
}
3424

3525
/**
3626
* Finds the index where a key-value pair should be inserted to maintain sort order.
37-
* Uses binary search to find the correct position based on the value.
38-
* Hence, it is in O(log n) time.
27+
* Uses binary search to find the correct position based on the value (if comparator provided),
28+
* with key-based tie-breaking for deterministic ordering when values compare as equal.
29+
* If no comparator is provided, sorts by key only.
30+
* Runs in O(log n) time.
3931
*
40-
* @param key - The key to find position for
41-
* @param value - The value to compare against
32+
* @param key - The key to find position for (used as tie-breaker or primary sort when no comparator)
33+
* @param value - The value to compare against (only used if comparator is provided)
4234
* @returns The index where the key should be inserted
4335
*/
44-
private indexOf(value: TValue): number {
36+
private indexOf(key: TKey, value: TValue): number {
4537
let left = 0
4638
let right = this.sortedKeys.length
4739

40+
// Fast path: no comparator means sort by key only
41+
if (!this.comparator) {
42+
while (left < right) {
43+
const mid = Math.floor((left + right) / 2)
44+
const midKey = this.sortedKeys[mid]!
45+
const keyComparison = compareKeys(key, midKey)
46+
if (keyComparison < 0) {
47+
right = mid
48+
} else if (keyComparison > 0) {
49+
left = mid + 1
50+
} else {
51+
return mid
52+
}
53+
}
54+
return left
55+
}
56+
57+
// With comparator: sort by value first, then key as tie-breaker
4858
while (left < right) {
4959
const mid = Math.floor((left + right) / 2)
5060
const midKey = this.sortedKeys[mid]!
5161
const midValue = this.map.get(midKey)!
52-
const comparison = this.comparator(value, midValue)
62+
const valueComparison = this.comparator(value, midValue)
5363

54-
if (comparison < 0) {
64+
if (valueComparison < 0) {
5565
right = mid
56-
} else if (comparison > 0) {
66+
} else if (valueComparison > 0) {
5767
left = mid + 1
5868
} else {
59-
return mid
69+
// Values are equal, use key as tie-breaker for deterministic ordering
70+
const keyComparison = compareKeys(key, midKey)
71+
if (keyComparison < 0) {
72+
right = mid
73+
} else if (keyComparison > 0) {
74+
left = mid + 1
75+
} else {
76+
// Same key (shouldn't happen during insert, but handle for lookups)
77+
return mid
78+
}
6079
}
6180
}
6281

@@ -74,12 +93,12 @@ export class SortedMap<TKey, TValue> {
7493
if (this.map.has(key)) {
7594
// Need to remove the old key from the sorted keys array
7695
const oldValue = this.map.get(key)!
77-
const oldIndex = this.indexOf(oldValue)
96+
const oldIndex = this.indexOf(key, oldValue)
7897
this.sortedKeys.splice(oldIndex, 1)
7998
}
8099

81100
// Insert the new key at the correct position
82-
const index = this.indexOf(value)
101+
const index = this.indexOf(key, value)
83102
this.sortedKeys.splice(index, 0, key)
84103

85104
this.map.set(key, value)
@@ -106,7 +125,7 @@ export class SortedMap<TKey, TValue> {
106125
delete(key: TKey): boolean {
107126
if (this.map.has(key)) {
108127
const oldValue = this.map.get(key)
109-
const index = this.indexOf(oldValue!)
128+
const index = this.indexOf(key, oldValue!)
110129
this.sortedKeys.splice(index, 1)
111130
return this.map.delete(key)
112131
}

packages/db/src/collection/state.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export class CollectionStateManager<
4343
public pendingSyncedTransactions: Array<
4444
PendingSyncedTransaction<TOutput, TKey>
4545
> = []
46-
public syncedData: Map<TKey, TOutput> | SortedMap<TKey, TOutput>
46+
public syncedData: SortedMap<TKey, TOutput>
4747
public syncedMetadata = new Map<TKey, unknown>()
4848

4949
// Optimistic state tracking - make public for testing
@@ -69,12 +69,9 @@ export class CollectionStateManager<
6969
a.compareCreatedAt(b),
7070
)
7171

72-
// Set up data storage with optional comparison function
73-
if (config.compare) {
74-
this.syncedData = new SortedMap<TKey, TOutput>(config.compare)
75-
} else {
76-
this.syncedData = new Map<TKey, TOutput>()
77-
}
72+
// Set up data storage - always use SortedMap for deterministic iteration.
73+
// If a custom compare function is provided, use it; otherwise entries are sorted by key only.
74+
this.syncedData = new SortedMap<TKey, TOutput>(config.compare)
7875
}
7976

8077
setDeps(deps: {

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

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { compareKeys } from '@tanstack/db-ivm'
12
import { BTree } from '../utils/btree.js'
23
import { defaultComparator, normalizeValue } from '../utils/comparison.js'
34
import { BaseIndex } from './base-index.js'
@@ -262,6 +263,7 @@ export class BTreeIndex<
262263
nextPair: (k?: any) => [any, any] | undefined,
263264
from?: any,
264265
filterFn?: (key: TKey) => boolean,
266+
reversed: boolean = false,
265267
): Array<TKey> {
266268
const keysInResult: Set<TKey> = new Set()
267269
const result: Array<TKey> = []
@@ -271,10 +273,12 @@ export class BTreeIndex<
271273
while ((pair = nextPair(key)) !== undefined && result.length < n) {
272274
key = pair[0]
273275
const keys = this.valueMap.get(key)
274-
if (keys) {
275-
const it = keys.values()
276-
let ks: TKey | undefined
277-
while (result.length < n && (ks = it.next().value)) {
276+
if (keys && keys.size > 0) {
277+
// Sort keys for deterministic order, reverse if needed
278+
const sorted = Array.from(keys).sort(compareKeys)
279+
if (reversed) sorted.reverse()
280+
for (const ks of sorted) {
281+
if (result.length >= n) break
278282
if (!keysInResult.has(ks) && (filterFn?.(ks) ?? true)) {
279283
result.push(ks)
280284
keysInResult.add(ks)
@@ -309,7 +313,7 @@ export class BTreeIndex<
309313
filterFn?: (key: TKey) => boolean,
310314
): Array<TKey> {
311315
const nextPair = (k?: any) => this.orderedEntries.nextLowerPair(k)
312-
return this.takeInternal(n, nextPair, from, filterFn)
316+
return this.takeInternal(n, nextPair, from, filterFn, true)
313317
}
314318

315319
/**

0 commit comments

Comments
 (0)