Skip to content

Commit 8ee783d

Browse files
perf(db): skip unchanged index updates (#1691)
* perf(db): skip unchanged index updates * test(db): cover nullish index transitions * fix(db): avoid self-comparison in index equality * perf(db): harden unchanged index updates * docs(db): combine index update changeset --------- Co-authored-by: Kyle Mathews <mathews.kyle@gmail.com>
1 parent d70fb2a commit 8ee783d

7 files changed

Lines changed: 372 additions & 29 deletions

File tree

.changeset/quick-trees-rest.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@tanstack/db': patch
3+
---
4+
5+
Skip unchanged index writes while preserving index bookkeeping after failed
6+
removals. Cache index evaluators and avoid object normalization work for
7+
primitive values to reduce update overhead.

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { compileSingleRowExpression } from '../query/compiler/evaluators.js'
22
import { comparisonFunctions } from '../query/builder/functions.js'
33
import { DEFAULT_COMPARE_OPTIONS, deepEquals } from '../utils.js'
4+
import type { CompiledSingleRowExpression } from '../query/compiler/evaluators.js'
45
import type { RangeQueryOptions } from './btree-index.js'
56
import type { CompareOptions } from '../query/builder/types.js'
67
import type { BasicExpression, OrderByDirection } from '../query/ir.js'
@@ -99,6 +100,7 @@ export abstract class BaseIndex<
99100
protected totalLookupTime = 0
100101
protected lastUpdated = new Date()
101102
protected compareOptions: CompareOptions
103+
private compiledIndexEvaluator: CompiledSingleRowExpression | undefined
102104
/**
103105
* Set by subclasses when constructed with a user-supplied comparator, whose
104106
* ordering may not match the WHERE evaluator's relational operators.
@@ -210,7 +212,8 @@ export abstract class BaseIndex<
210212
protected abstract initialize(options?: any): void
211213

212214
protected evaluateIndexExpression(item: any): any {
213-
const evaluator = compileSingleRowExpression(this.expression)
215+
const evaluator = (this.compiledIndexEvaluator ??=
216+
compileSingleRowExpression(this.expression))
214217
return evaluator(item as Record<string, unknown>)
215218
}
216219

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

Lines changed: 47 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import { defaultComparator, normalizeValue } from '../utils/comparison.js'
1+
import {
2+
areSameValueZeroEqual,
3+
defaultComparator,
4+
normalizeValue,
5+
} from '../utils/comparison.js'
26
import {
37
deleteInSortedArray,
48
findInsertPositionInArray,
@@ -89,9 +93,17 @@ export class BasicIndex<
8993

9094
const normalizedValue = normalizeValue(indexedValue)
9195

92-
if (this.valueMap.has(normalizedValue)) {
96+
this.addToBucket(key, normalizedValue)
97+
98+
this.indexedKeys.add(key)
99+
this.updateTimestamp()
100+
}
101+
102+
private addToBucket(key: TKey, normalizedValue: unknown): void {
103+
const keySet = this.valueMap.get(normalizedValue)
104+
if (keySet) {
93105
// Value already exists, just add the key to the set
94-
this.valueMap.get(normalizedValue)!.add(key)
106+
keySet.add(key)
95107
} else {
96108
// New value - add to map and insert into sorted array
97109
this.valueMap.set(normalizedValue, new Set([key]))
@@ -104,9 +116,6 @@ export class BasicIndex<
104116
)
105117
this.sortedValues.splice(insertIdx, 0, normalizedValue)
106118
}
107-
108-
this.indexedKeys.add(key)
109-
this.updateTimestamp()
110119
}
111120

112121
/**
@@ -128,8 +137,15 @@ export class BasicIndex<
128137

129138
const normalizedValue = normalizeValue(indexedValue)
130139

131-
if (this.valueMap.has(normalizedValue)) {
132-
const keySet = this.valueMap.get(normalizedValue)!
140+
this.removeFromBucket(key, normalizedValue)
141+
142+
this.indexedKeys.delete(key)
143+
this.updateTimestamp()
144+
}
145+
146+
private removeFromBucket(key: TKey, normalizedValue: unknown): void {
147+
const keySet = this.valueMap.get(normalizedValue)
148+
if (keySet) {
133149
keySet.delete(key)
134150

135151
if (keySet.size === 0) {
@@ -138,17 +154,35 @@ export class BasicIndex<
138154
deleteInSortedArray(this.sortedValues, normalizedValue, this.compareFn)
139155
}
140156
}
141-
142-
this.indexedKeys.delete(key)
143-
this.updateTimestamp()
144157
}
145158

146159
/**
147160
* Updates a value in the index
148161
*/
149162
update(key: TKey, oldItem: any, newItem: any): void {
150-
this.remove(key, oldItem)
151-
this.add(key, newItem)
163+
let oldValue: unknown
164+
let newValue: unknown
165+
try {
166+
oldValue = normalizeValue(this.evaluateIndexExpression(oldItem))
167+
newValue = normalizeValue(this.evaluateIndexExpression(newItem))
168+
} catch {
169+
this.remove(key, oldItem)
170+
this.add(key, newItem)
171+
return
172+
}
173+
174+
if (
175+
areSameValueZeroEqual(oldValue, newValue) &&
176+
this.valueMap.get(newValue)?.has(key) &&
177+
this.indexedKeys.has(key)
178+
) {
179+
return
180+
}
181+
182+
this.removeFromBucket(key, oldValue)
183+
this.addToBucket(key, newValue)
184+
this.indexedKeys.add(key)
185+
this.updateTimestamp()
152186
}
153187

154188
/**

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

Lines changed: 44 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { compareKeys } from '@tanstack/db-ivm'
22
import { BTree } from '../utils/btree.js'
33
import {
4+
areSameValueZeroEqual,
45
defaultComparator,
56
denormalizeUndefined,
67
normalizeForBTree,
@@ -94,19 +95,23 @@ export class BTreeIndex<
9495
// Normalize the value for Map key usage
9596
const normalizedValue = normalizeForBTree(indexedValue)
9697

97-
// Check if this value already exists
98-
if (this.valueMap.has(normalizedValue)) {
98+
this.addToBucket(key, normalizedValue)
99+
100+
this.indexedKeys.add(key)
101+
this.updateTimestamp()
102+
}
103+
104+
private addToBucket(key: TKey, normalizedValue: unknown): void {
105+
const keySet = this.valueMap.get(normalizedValue)
106+
if (keySet) {
99107
// Add to existing set
100-
this.valueMap.get(normalizedValue)!.add(key)
108+
keySet.add(key)
101109
} else {
102110
// Create new set for this value
103-
const keySet = new Set<TKey>([key])
104-
this.valueMap.set(normalizedValue, keySet)
111+
const newKeySet = new Set<TKey>([key])
112+
this.valueMap.set(normalizedValue, newKeySet)
105113
this.orderedEntries.set(normalizedValue, undefined)
106114
}
107-
108-
this.indexedKeys.add(key)
109-
this.updateTimestamp()
110115
}
111116

112117
/**
@@ -127,8 +132,15 @@ export class BTreeIndex<
127132
// Normalize the value for Map key usage
128133
const normalizedValue = normalizeForBTree(indexedValue)
129134

130-
if (this.valueMap.has(normalizedValue)) {
131-
const keySet = this.valueMap.get(normalizedValue)!
135+
this.removeFromBucket(key, normalizedValue)
136+
137+
this.indexedKeys.delete(key)
138+
this.updateTimestamp()
139+
}
140+
141+
private removeFromBucket(key: TKey, normalizedValue: unknown): void {
142+
const keySet = this.valueMap.get(normalizedValue)
143+
if (keySet) {
132144
keySet.delete(key)
133145

134146
// If set is now empty, remove the entry entirely
@@ -139,17 +151,34 @@ export class BTreeIndex<
139151
this.orderedEntries.delete(normalizedValue)
140152
}
141153
}
142-
143-
this.indexedKeys.delete(key)
144-
this.updateTimestamp()
145154
}
146155

147156
/**
148157
* Updates a value in the index
149158
*/
150159
update(key: TKey, oldItem: any, newItem: any): void {
151-
this.remove(key, oldItem)
152-
this.add(key, newItem)
160+
let oldValue: unknown
161+
let newValue: unknown
162+
try {
163+
oldValue = normalizeForBTree(this.evaluateIndexExpression(oldItem))
164+
newValue = normalizeForBTree(this.evaluateIndexExpression(newItem))
165+
} catch {
166+
this.remove(key, oldItem)
167+
this.add(key, newItem)
168+
return
169+
}
170+
171+
if (
172+
areSameValueZeroEqual(oldValue, newValue) &&
173+
this.valueMap.get(newValue)?.has(key)
174+
) {
175+
return
176+
}
177+
178+
this.removeFromBucket(key, oldValue)
179+
this.addToBucket(key, newValue)
180+
this.indexedKeys.add(key)
181+
this.updateTimestamp()
153182
}
154183

155184
/**

packages/db/src/utils/comparison.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,10 @@ export const UNDEFINED_SENTINEL = `__TS_DB_BTREE_UNDEFINED_VALUE__`
185185
* for BTree index operations that need to distinguish undefined values.
186186
*/
187187
export function normalizeValue(value: any): any {
188+
if (typeof value !== `object` || value === null) {
189+
return value
190+
}
191+
188192
if (value instanceof Date) {
189193
return value.getTime()
190194
}
@@ -226,6 +230,13 @@ export function normalizeForBTree(value: any): any {
226230
return normalizeValue(value)
227231
}
228232

233+
/**
234+
* Compare values using the equality semantics used by Map keys.
235+
*/
236+
export function areSameValueZeroEqual(a: unknown, b: unknown): boolean {
237+
return a === b || (Number.isNaN(a) && Number.isNaN(b))
238+
}
239+
229240
/**
230241
* Converts the `UNDEFINED_SENTINEL` back to `undefined`.
231242
* Needed such that the sentinel is converted back to `undefined` before comparison.
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { describe, expect, it, vi } from 'vitest'
2+
import { BasicIndex } from '../src/indexes/basic-index.js'
3+
import { BTreeIndex } from '../src/indexes/btree-index.js'
4+
import { PropRef } from '../src/query/ir.js'
5+
import { normalizeValue } from '../src/utils/comparison.js'
6+
import type { BaseIndex } from '../src/indexes/base-index.js'
7+
8+
type IndexConstructor = new (
9+
id: number,
10+
expression: PropRef,
11+
name?: string,
12+
options?: unknown,
13+
) => BaseIndex<string> & {
14+
valueMapData: Map<unknown, Set<string>>
15+
}
16+
17+
const indexTypes: Array<[string, IndexConstructor]> = [
18+
[`BasicIndex`, BasicIndex as IndexConstructor],
19+
[`BTreeIndex`, BTreeIndex as IndexConstructor],
20+
]
21+
22+
describe.each(indexTypes)(`%s update`, (_indexName, IndexType) => {
23+
function createIndex(options?: unknown) {
24+
return new IndexType(1, new PropRef([`value`]), `test_index`, options)
25+
}
26+
27+
it(`keeps the existing bucket when the indexed value does not change`, () => {
28+
const index = createIndex()
29+
index.add(`a`, { value: 1, version: 1 })
30+
const bucket = index.valueMapData.get(1)
31+
const lastUpdated = index.getStats().lastUpdated
32+
33+
index.update(`a`, { value: 1, version: 1 }, { value: 1, version: 2 })
34+
35+
expect(index.valueMapData.get(1)).toBe(bucket)
36+
expect(index.getStats().lastUpdated).toBe(lastUpdated)
37+
expect(index.lookup(`eq`, 1)).toEqual(new Set([`a`]))
38+
})
39+
40+
it.each([
41+
[`undefined`, undefined, undefined],
42+
[`NaN`, Number.NaN, Number.NaN],
43+
[`signed zero`, 0, -0],
44+
[`equal dates`, new Date(1000), new Date(1000)],
45+
[`equal byte arrays`, new Uint8Array([1, 2]), new Uint8Array([1, 2])],
46+
])(`keeps the existing bucket for %s`, (_caseName, oldValue, newValue) => {
47+
const index = createIndex()
48+
index.add(`a`, { value: oldValue })
49+
const bucket = index.valueMapData.get(normalizeValue(oldValue))
50+
expect(bucket).toBeDefined()
51+
52+
index.update(`a`, { value: oldValue }, { value: newValue })
53+
54+
expect(index.valueMapData.get(normalizeValue(newValue))).toBe(bucket)
55+
})
56+
57+
it(`moves the key when the indexed value changes`, () => {
58+
const index = createIndex()
59+
index.add(`a`, { value: 1 })
60+
61+
index.update(`a`, { value: 1 }, { value: 2 })
62+
63+
expect(index.lookup(`eq`, 1)).toEqual(new Set())
64+
expect(index.lookup(`eq`, 2)).toEqual(new Set([`a`]))
65+
})
66+
67+
it(`does not conflate undefined and null`, () => {
68+
const index = createIndex()
69+
index.add(`a`, { value: undefined })
70+
71+
index.update(`a`, { value: undefined }, { value: null })
72+
73+
expect(index.lookup(`eq`, undefined)).toEqual(new Set())
74+
expect(index.lookup(`eq`, null)).toEqual(new Set([`a`]))
75+
})
76+
77+
it(`does not use comparator equality to skip an update`, () => {
78+
const index = createIndex({
79+
compareFn: (a: string, b: string) =>
80+
a.toLowerCase().localeCompare(b.toLowerCase()),
81+
})
82+
index.add(`a`, { value: `A` })
83+
84+
index.update(`a`, { value: `A` }, { value: `a` })
85+
86+
expect(index.lookup(`eq`, `A`)).toEqual(new Set())
87+
expect(index.lookup(`eq`, `a`)).toEqual(new Set([`a`]))
88+
})
89+
90+
it(`preserves the previous error behavior when evaluation fails`, () => {
91+
const index = createIndex()
92+
index.add(`a`, { value: 1 })
93+
const newItem = Object.defineProperty({}, `value`, {
94+
get() {
95+
throw new Error(`evaluation failed`)
96+
},
97+
})
98+
99+
expect(() => index.update(`a`, { value: 1 }, newItem)).toThrow(
100+
`evaluation failed`,
101+
)
102+
103+
expect(index.lookup(`eq`, 1)).toEqual(new Set())
104+
expect(index.keyCount).toBe(0)
105+
})
106+
})
107+
108+
describe(`BasicIndex update bookkeeping`, () => {
109+
it(`repairs indexed key membership after a failed removal`, () => {
110+
const index = new BasicIndex<string>(1, new PropRef([`value`]))
111+
index.add(`a`, { value: 1 })
112+
const itemWithThrowingValue = Object.defineProperty({}, `value`, {
113+
get() {
114+
throw new Error(`evaluation failed`)
115+
},
116+
})
117+
const warn = vi.spyOn(console, `warn`).mockImplementation(() => {})
118+
119+
try {
120+
index.remove(`a`, itemWithThrowingValue)
121+
} finally {
122+
warn.mockRestore()
123+
}
124+
125+
expect(index.lookup(`eq`, 1)).toEqual(new Set([`a`]))
126+
expect(index.keyCount).toBe(0)
127+
128+
index.update(`a`, { value: 1 }, { value: 1 })
129+
130+
expect(index.lookup(`eq`, 1)).toEqual(new Set([`a`]))
131+
expect(index.keyCount).toBe(1)
132+
})
133+
})

0 commit comments

Comments
 (0)