Skip to content

Commit 52c29fa

Browse files
fix(db-ivm): use row keys for stable ORDER BY tie-breaking (#957)
* fix(db-ivm): use row keys for stable ORDER BY tie-breaking Replace hash-based object ID tie-breaking with direct key comparison for deterministic ordering when ORDER BY values are equal. - Use row key directly as tie-breaker (always string | number, unique per row) - Remove globalObjectIdGenerator dependency - Simplify TaggedValue from [K, V, Tag] to [K, T] tuple - Clean up helper functions (tagValue, getKey, getVal, getTag) This ensures stable, deterministic ordering across page reloads and eliminates potential hash collisions. * ci: apply automated fixes --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 101422e commit 52c29fa

File tree

5 files changed

+70
-67
lines changed

5 files changed

+70
-67
lines changed
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@tanstack/db-ivm': patch
3+
---
4+
5+
Use row keys for stable tie-breaking in ORDER BY operations instead of hash-based object IDs.
6+
7+
Previously, when multiple rows had equal ORDER BY values, tie-breaking used `globalObjectIdGenerator.getId(key)` which could produce hash collisions and wasn't stable across page reloads for object references. Now, the row key (which is always `string | number` and unique per row) is used directly for tie-breaking, ensuring deterministic and stable ordering.
8+
9+
This also simplifies the internal `TaggedValue` type from a 3-tuple `[K, V, Tag]` to a 2-tuple `[K, V]`, removing unnecessary complexity.

packages/db-ivm/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ export * from './d2.js'
22
export * from './multiset.js'
33
export * from './operators/index.js'
44
export * from './types.js'
5+
export { compareKeys } from './utils.js'

packages/db-ivm/src/operators/topKWithFractionalIndex.ts

Lines changed: 41 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,7 @@ import { generateKeyBetween } from 'fractional-indexing'
22
import { DifferenceStreamWriter, UnaryOperator } from '../graph.js'
33
import { StreamBuilder } from '../d2.js'
44
import { MultiSet } from '../multiset.js'
5-
import {
6-
binarySearch,
7-
diffHalfOpen,
8-
globalObjectIdGenerator,
9-
} from '../utils.js'
5+
import { binarySearch, compareKeys, diffHalfOpen } from '../utils.js'
106
import type { HRange } from '../utils.js'
117
import type { DifferenceStreamReader } from '../graph.js'
128
import type { IStreamBuilder, PipedOperator } from '../types.js'
@@ -239,17 +235,18 @@ class TopKArray<V> implements TopK<V> {
239235
* This operator maintains fractional indices for sorted elements
240236
* and only updates indices when elements move position
241237
*/
242-
export class TopKWithFractionalIndexOperator<K, T> extends UnaryOperator<
243-
[K, T],
244-
[K, IndexedValue<T>]
245-
> {
238+
export class TopKWithFractionalIndexOperator<
239+
K extends string | number,
240+
T,
241+
> extends UnaryOperator<[K, T], [K, IndexedValue<T>]> {
246242
#index: Map<K, number> = new Map() // maps keys to their multiplicity
247243

248244
/**
249245
* topK data structure that supports insertions and deletions
250246
* and returns changes to the topK.
247+
* Elements are stored as [key, value] tuples for stable tie-breaking.
251248
*/
252-
#topK: TopK<TaggedValue<K, T>>
249+
#topK: TopK<[K, T]>
253250

254251
constructor(
255252
id: number,
@@ -261,30 +258,20 @@ export class TopKWithFractionalIndexOperator<K, T> extends UnaryOperator<
261258
super(id, inputA, output)
262259
const limit = options.limit ?? Infinity
263260
const offset = options.offset ?? 0
264-
const compareTaggedValues = (
265-
a: TaggedValue<K, T>,
266-
b: TaggedValue<K, T>,
267-
) => {
268-
// First compare on the value
269-
const valueComparison = comparator(getVal(a), getVal(b))
270-
if (valueComparison !== 0) {
271-
return valueComparison
272-
}
273-
// If the values are equal, compare on the tag (object identity)
274-
const tieBreakerA = getTag(a)
275-
const tieBreakerB = getTag(b)
276-
return tieBreakerA - tieBreakerB
277-
}
278-
this.#topK = this.createTopK(offset, limit, compareTaggedValues)
261+
this.#topK = this.createTopK(
262+
offset,
263+
limit,
264+
createKeyedComparator(comparator),
265+
)
279266
options.setSizeCallback?.(() => this.#topK.size)
280267
options.setWindowFn?.(this.moveTopK.bind(this))
281268
}
282269

283270
protected createTopK(
284271
offset: number,
285272
limit: number,
286-
comparator: (a: TaggedValue<K, T>, b: TaggedValue<K, T>) => number,
287-
): TopK<TaggedValue<K, T>> {
273+
comparator: (a: [K, T], b: [K, T]) => number,
274+
): TopK<[K, T]> {
288275
return new TopKArray(offset, limit, comparator)
289276
}
290277

@@ -336,20 +323,18 @@ export class TopKWithFractionalIndexOperator<K, T> extends UnaryOperator<
336323
): void {
337324
const { oldMultiplicity, newMultiplicity } = this.addKey(key, multiplicity)
338325

339-
let res: TopKChanges<TaggedValue<K, T>> = {
326+
let res: TopKChanges<[K, T]> = {
340327
moveIn: null,
341328
moveOut: null,
342329
}
343330
if (oldMultiplicity <= 0 && newMultiplicity > 0) {
344331
// The value was invisible but should now be visible
345332
// Need to insert it into the array of sorted values
346-
const taggedValue = tagValue(key, value)
347-
res = this.#topK.insert(taggedValue)
333+
res = this.#topK.insert([key, value])
348334
} else if (oldMultiplicity > 0 && newMultiplicity <= 0) {
349335
// The value was visible but should now be invisible
350336
// Need to remove it from the array of sorted values
351-
const taggedValue = tagValue(key, value)
352-
res = this.#topK.delete(taggedValue)
337+
res = this.#topK.delete([key, value])
353338
} else {
354339
// The value was invisible and it remains invisible
355340
// or it was visible and remains visible
@@ -363,28 +348,22 @@ export class TopKWithFractionalIndexOperator<K, T> extends UnaryOperator<
363348
}
364349

365350
private handleMoveIn(
366-
moveIn: IndexedValue<TaggedValue<K, T>> | null,
351+
moveIn: IndexedValue<[K, T]> | null,
367352
result: Array<[[K, IndexedValue<T>], number]>,
368353
) {
369354
if (moveIn) {
370-
const index = getIndex(moveIn)
371-
const taggedValue = getValue(moveIn)
372-
const k = getKey(taggedValue)
373-
const val = getVal(taggedValue)
374-
result.push([[k, [val, index]], 1])
355+
const [[key, value], index] = moveIn
356+
result.push([[key, [value, index]], 1])
375357
}
376358
}
377359

378360
private handleMoveOut(
379-
moveOut: IndexedValue<TaggedValue<K, T>> | null,
361+
moveOut: IndexedValue<[K, T]> | null,
380362
result: Array<[[K, IndexedValue<T>], number]>,
381363
) {
382364
if (moveOut) {
383-
const index = getIndex(moveOut)
384-
const taggedValue = getValue(moveOut)
385-
const k = getKey(taggedValue)
386-
const val = getVal(taggedValue)
387-
result.push([[k, [val, index]], -1])
365+
const [[key, value], index] = moveOut
366+
result.push([[key, [value, index]], -1])
388367
}
389368
}
390369

@@ -417,7 +396,7 @@ export class TopKWithFractionalIndexOperator<K, T> extends UnaryOperator<
417396
* @param options - An optional object containing limit and offset properties
418397
* @returns A piped operator that orders the elements and limits the number of results
419398
*/
420-
export function topKWithFractionalIndex<KType, T>(
399+
export function topKWithFractionalIndex<KType extends string | number, T>(
421400
comparator: (a: T, b: T) => number,
422401
options?: TopKWithFractionalIndexOptions,
423402
): PipedOperator<[KType, T], [KType, IndexedValue<T>]> {
@@ -461,21 +440,21 @@ export function getIndex<V>(indexedVal: IndexedValue<V>): FractionalIndex {
461440
return indexedVal[1]
462441
}
463442

464-
export type Tag = number
465-
export type TaggedValue<K, V> = [K, V, Tag]
466-
467-
function tagValue<K, V>(key: K, value: V): TaggedValue<K, V> {
468-
return [key, value, globalObjectIdGenerator.getId(key)]
469-
}
470-
471-
function getKey<K, V>(tieBreakerTaggedValue: TaggedValue<K, V>): K {
472-
return tieBreakerTaggedValue[0]
473-
}
474-
475-
function getVal<K, V>(tieBreakerTaggedValue: TaggedValue<K, V>): V {
476-
return tieBreakerTaggedValue[1]
477-
}
478-
479-
function getTag<K, V>(tieBreakerTaggedValue: TaggedValue<K, V>): Tag {
480-
return tieBreakerTaggedValue[2]
443+
/**
444+
* Creates a comparator for [key, value] tuples that first compares values,
445+
* then uses the row key as a stable tie-breaker.
446+
*/
447+
function createKeyedComparator<K extends string | number, T>(
448+
comparator: (a: T, b: T) => number,
449+
): (a: [K, T], b: [K, T]) => number {
450+
return ([aKey, aVal], [bKey, bVal]) => {
451+
// First compare on the value
452+
const valueComparison = comparator(aVal, bVal)
453+
if (valueComparison !== 0) {
454+
return valueComparison
455+
}
456+
// If the values are equal, use the row key as tie-breaker
457+
// This provides stable, deterministic ordering since keys are string | number
458+
return compareKeys(aKey, bKey)
459+
}
481460
}

packages/db-ivm/src/operators/topKWithFractionalIndexBTree.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import {
1010
import type { IStreamBuilder, PipedOperator } from '../types.js'
1111
import type {
1212
IndexedValue,
13-
TaggedValue,
1413
TopK,
1514
TopKChanges,
1615
TopKWithFractionalIndexOptions,
@@ -243,14 +242,14 @@ class TopKTree<V> implements TopK<V> {
243242
* and only updates indices when elements move position
244243
*/
245244
export class TopKWithFractionalIndexBTreeOperator<
246-
K,
245+
K extends string | number,
247246
T,
248247
> extends TopKWithFractionalIndexOperator<K, T> {
249248
protected override createTopK(
250249
offset: number,
251250
limit: number,
252-
comparator: (a: TaggedValue<K, T>, b: TaggedValue<K, T>) => number,
253-
): TopK<TaggedValue<K, T>> {
251+
comparator: (a: [K, T], b: [K, T]) => number,
252+
): TopK<[K, T]> {
254253
if (BTree === undefined) {
255254
throw new Error(
256255
`B+ tree not loaded. You need to call loadBTree() before using TopKWithFractionalIndexBTreeOperator.`,
@@ -275,7 +274,7 @@ export class TopKWithFractionalIndexBTreeOperator<
275274
* @param options - An optional object containing limit and offset properties
276275
* @returns A piped operator that orders the elements and limits the number of results
277276
*/
278-
export function topKWithFractionalIndexBTree<KType, T>(
277+
export function topKWithFractionalIndexBTree<KType extends string | number, T>(
279278
comparator: (a: T, b: T) => number,
280279
options?: TopKWithFractionalIndexOptions,
281280
): PipedOperator<[KType, T], [KType, IndexedValue<T>]> {

packages/db-ivm/src/utils.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,3 +177,18 @@ function range(start: number, end: number): Array<number> {
177177
for (let i = start; i < end; i++) out.push(i)
178178
return out
179179
}
180+
181+
/**
182+
* Compares two keys (string | number) in a consistent, deterministic way.
183+
* Handles mixed types by ordering strings before numbers.
184+
*/
185+
export function compareKeys(a: string | number, b: string | number): number {
186+
// Same type: compare directly
187+
if (typeof a === typeof b) {
188+
if (a < b) return -1
189+
if (a > b) return 1
190+
return 0
191+
}
192+
// Different types: strings come before numbers
193+
return typeof a === `string` ? -1 : 1
194+
}

0 commit comments

Comments
 (0)