Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 7 additions & 0 deletions .changeset/quick-trees-rest.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@tanstack/db': patch
---

Skip unchanged index writes while preserving index bookkeeping after failed
removals. Cache index evaluators and avoid object normalization work for
primitive values to reduce update overhead.
5 changes: 4 additions & 1 deletion packages/db/src/indexes/base-index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { compileSingleRowExpression } from '../query/compiler/evaluators.js'
import { comparisonFunctions } from '../query/builder/functions.js'
import { DEFAULT_COMPARE_OPTIONS, deepEquals } from '../utils.js'
import type { CompiledSingleRowExpression } from '../query/compiler/evaluators.js'
import type { RangeQueryOptions } from './btree-index.js'
import type { CompareOptions } from '../query/builder/types.js'
import type { BasicExpression, OrderByDirection } from '../query/ir.js'
Expand Down Expand Up @@ -99,6 +100,7 @@ export abstract class BaseIndex<
protected totalLookupTime = 0
protected lastUpdated = new Date()
protected compareOptions: CompareOptions
private compiledIndexEvaluator: CompiledSingleRowExpression | undefined
/**
* Set by subclasses when constructed with a user-supplied comparator, whose
* ordering may not match the WHERE evaluator's relational operators.
Expand Down Expand Up @@ -210,7 +212,8 @@ export abstract class BaseIndex<
protected abstract initialize(options?: any): void

protected evaluateIndexExpression(item: any): any {
const evaluator = compileSingleRowExpression(this.expression)
const evaluator = (this.compiledIndexEvaluator ??=
compileSingleRowExpression(this.expression))
return evaluator(item as Record<string, unknown>)
}

Expand Down
60 changes: 47 additions & 13 deletions packages/db/src/indexes/basic-index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { defaultComparator, normalizeValue } from '../utils/comparison.js'
import {
areSameValueZeroEqual,
defaultComparator,
normalizeValue,
} from '../utils/comparison.js'
import {
deleteInSortedArray,
findInsertPositionInArray,
Expand Down Expand Up @@ -89,9 +93,17 @@ export class BasicIndex<

const normalizedValue = normalizeValue(indexedValue)

if (this.valueMap.has(normalizedValue)) {
this.addToBucket(key, normalizedValue)

this.indexedKeys.add(key)
this.updateTimestamp()
}

private addToBucket(key: TKey, normalizedValue: unknown): void {
const keySet = this.valueMap.get(normalizedValue)
if (keySet) {
// Value already exists, just add the key to the set
this.valueMap.get(normalizedValue)!.add(key)
keySet.add(key)
} else {
// New value - add to map and insert into sorted array
this.valueMap.set(normalizedValue, new Set([key]))
Expand All @@ -104,9 +116,6 @@ export class BasicIndex<
)
this.sortedValues.splice(insertIdx, 0, normalizedValue)
}

this.indexedKeys.add(key)
this.updateTimestamp()
}

/**
Expand All @@ -128,8 +137,15 @@ export class BasicIndex<

const normalizedValue = normalizeValue(indexedValue)

if (this.valueMap.has(normalizedValue)) {
const keySet = this.valueMap.get(normalizedValue)!
this.removeFromBucket(key, normalizedValue)

this.indexedKeys.delete(key)
this.updateTimestamp()
}

private removeFromBucket(key: TKey, normalizedValue: unknown): void {
const keySet = this.valueMap.get(normalizedValue)
if (keySet) {
keySet.delete(key)

if (keySet.size === 0) {
Expand All @@ -138,17 +154,35 @@ export class BasicIndex<
deleteInSortedArray(this.sortedValues, normalizedValue, this.compareFn)
}
}

this.indexedKeys.delete(key)
this.updateTimestamp()
}

/**
* Updates a value in the index
*/
update(key: TKey, oldItem: any, newItem: any): void {
this.remove(key, oldItem)
this.add(key, newItem)
let oldValue: unknown
let newValue: unknown
try {
oldValue = normalizeValue(this.evaluateIndexExpression(oldItem))
newValue = normalizeValue(this.evaluateIndexExpression(newItem))
} catch {
this.remove(key, oldItem)
this.add(key, newItem)
return
}

if (
areSameValueZeroEqual(oldValue, newValue) &&
this.valueMap.get(newValue)?.has(key) &&
this.indexedKeys.has(key)
) {
return
}

this.removeFromBucket(key, oldValue)
this.addToBucket(key, newValue)
this.indexedKeys.add(key)
this.updateTimestamp()
}

/**
Expand Down
59 changes: 44 additions & 15 deletions packages/db/src/indexes/btree-index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { compareKeys } from '@tanstack/db-ivm'
import { BTree } from '../utils/btree.js'
import {
areSameValueZeroEqual,
defaultComparator,
denormalizeUndefined,
normalizeForBTree,
Expand Down Expand Up @@ -94,19 +95,23 @@ export class BTreeIndex<
// Normalize the value for Map key usage
const normalizedValue = normalizeForBTree(indexedValue)

// Check if this value already exists
if (this.valueMap.has(normalizedValue)) {
this.addToBucket(key, normalizedValue)

this.indexedKeys.add(key)
this.updateTimestamp()
}

private addToBucket(key: TKey, normalizedValue: unknown): void {
const keySet = this.valueMap.get(normalizedValue)
if (keySet) {
// Add to existing set
this.valueMap.get(normalizedValue)!.add(key)
keySet.add(key)
} else {
// Create new set for this value
const keySet = new Set<TKey>([key])
this.valueMap.set(normalizedValue, keySet)
const newKeySet = new Set<TKey>([key])
this.valueMap.set(normalizedValue, newKeySet)
this.orderedEntries.set(normalizedValue, undefined)
}

this.indexedKeys.add(key)
this.updateTimestamp()
}

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

if (this.valueMap.has(normalizedValue)) {
const keySet = this.valueMap.get(normalizedValue)!
this.removeFromBucket(key, normalizedValue)

this.indexedKeys.delete(key)
this.updateTimestamp()
}

private removeFromBucket(key: TKey, normalizedValue: unknown): void {
const keySet = this.valueMap.get(normalizedValue)
if (keySet) {
keySet.delete(key)

// If set is now empty, remove the entry entirely
Expand All @@ -139,17 +151,34 @@ export class BTreeIndex<
this.orderedEntries.delete(normalizedValue)
}
}

this.indexedKeys.delete(key)
this.updateTimestamp()
}

/**
* Updates a value in the index
*/
update(key: TKey, oldItem: any, newItem: any): void {
this.remove(key, oldItem)
this.add(key, newItem)
let oldValue: unknown
let newValue: unknown
try {
oldValue = normalizeForBTree(this.evaluateIndexExpression(oldItem))
newValue = normalizeForBTree(this.evaluateIndexExpression(newItem))
} catch {
this.remove(key, oldItem)
this.add(key, newItem)
return
}

if (
areSameValueZeroEqual(oldValue, newValue) &&
this.valueMap.get(newValue)?.has(key)
) {
return
}

this.removeFromBucket(key, oldValue)
this.addToBucket(key, newValue)
this.indexedKeys.add(key)
this.updateTimestamp()
}

/**
Expand Down
11 changes: 11 additions & 0 deletions packages/db/src/utils/comparison.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,10 @@ export const UNDEFINED_SENTINEL = `__TS_DB_BTREE_UNDEFINED_VALUE__`
* for BTree index operations that need to distinguish undefined values.
*/
export function normalizeValue(value: any): any {
if (typeof value !== `object` || value === null) {
return value
}

if (value instanceof Date) {
return value.getTime()
}
Expand Down Expand Up @@ -226,6 +230,13 @@ export function normalizeForBTree(value: any): any {
return normalizeValue(value)
}

/**
* Compare values using the equality semantics used by Map keys.
*/
export function areSameValueZeroEqual(a: unknown, b: unknown): boolean {
return a === b || (Number.isNaN(a) && Number.isNaN(b))
}

/**
* Converts the `UNDEFINED_SENTINEL` back to `undefined`.
* Needed such that the sentinel is converted back to `undefined` before comparison.
Expand Down
133 changes: 133 additions & 0 deletions packages/db/tests/index-update-short-circuit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { describe, expect, it, vi } from 'vitest'
import { BasicIndex } from '../src/indexes/basic-index.js'
import { BTreeIndex } from '../src/indexes/btree-index.js'
import { PropRef } from '../src/query/ir.js'
import { normalizeValue } from '../src/utils/comparison.js'
import type { BaseIndex } from '../src/indexes/base-index.js'

type IndexConstructor = new (
id: number,
expression: PropRef,
name?: string,
options?: unknown,
) => BaseIndex<string> & {
valueMapData: Map<unknown, Set<string>>
}

const indexTypes: Array<[string, IndexConstructor]> = [
[`BasicIndex`, BasicIndex as IndexConstructor],
[`BTreeIndex`, BTreeIndex as IndexConstructor],
]

describe.each(indexTypes)(`%s update`, (_indexName, IndexType) => {
function createIndex(options?: unknown) {
return new IndexType(1, new PropRef([`value`]), `test_index`, options)
}

it(`keeps the existing bucket when the indexed value does not change`, () => {
const index = createIndex()
index.add(`a`, { value: 1, version: 1 })
const bucket = index.valueMapData.get(1)
const lastUpdated = index.getStats().lastUpdated

index.update(`a`, { value: 1, version: 1 }, { value: 1, version: 2 })

expect(index.valueMapData.get(1)).toBe(bucket)
expect(index.getStats().lastUpdated).toBe(lastUpdated)
expect(index.lookup(`eq`, 1)).toEqual(new Set([`a`]))
})

it.each([
[`undefined`, undefined, undefined],
[`NaN`, Number.NaN, Number.NaN],
[`signed zero`, 0, -0],
[`equal dates`, new Date(1000), new Date(1000)],
[`equal byte arrays`, new Uint8Array([1, 2]), new Uint8Array([1, 2])],
])(`keeps the existing bucket for %s`, (_caseName, oldValue, newValue) => {
const index = createIndex()
index.add(`a`, { value: oldValue })
const bucket = index.valueMapData.get(normalizeValue(oldValue))
expect(bucket).toBeDefined()

index.update(`a`, { value: oldValue }, { value: newValue })

expect(index.valueMapData.get(normalizeValue(newValue))).toBe(bucket)
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it(`moves the key when the indexed value changes`, () => {
const index = createIndex()
index.add(`a`, { value: 1 })

index.update(`a`, { value: 1 }, { value: 2 })

expect(index.lookup(`eq`, 1)).toEqual(new Set())
expect(index.lookup(`eq`, 2)).toEqual(new Set([`a`]))
})

it(`does not conflate undefined and null`, () => {
const index = createIndex()
index.add(`a`, { value: undefined })

index.update(`a`, { value: undefined }, { value: null })

expect(index.lookup(`eq`, undefined)).toEqual(new Set())
expect(index.lookup(`eq`, null)).toEqual(new Set([`a`]))
})

it(`does not use comparator equality to skip an update`, () => {
const index = createIndex({
compareFn: (a: string, b: string) =>
a.toLowerCase().localeCompare(b.toLowerCase()),
})
index.add(`a`, { value: `A` })

index.update(`a`, { value: `A` }, { value: `a` })

expect(index.lookup(`eq`, `A`)).toEqual(new Set())
expect(index.lookup(`eq`, `a`)).toEqual(new Set([`a`]))
})

it(`preserves the previous error behavior when evaluation fails`, () => {
const index = createIndex()
index.add(`a`, { value: 1 })
const newItem = Object.defineProperty({}, `value`, {
get() {
throw new Error(`evaluation failed`)
},
})

expect(() => index.update(`a`, { value: 1 }, newItem)).toThrow(
`evaluation failed`,
)

expect(index.lookup(`eq`, 1)).toEqual(new Set())
expect(index.keyCount).toBe(0)
})
})

describe(`BasicIndex update bookkeeping`, () => {
it(`repairs indexed key membership after a failed removal`, () => {
const index = new BasicIndex<string>(1, new PropRef([`value`]))
index.add(`a`, { value: 1 })
const itemWithThrowingValue = Object.defineProperty({}, `value`, {
get() {
throw new Error(`evaluation failed`)
},
})
const warn = vi.spyOn(console, `warn`).mockImplementation(() => {})

try {
index.remove(`a`, itemWithThrowingValue)
} finally {
warn.mockRestore()
}

expect(index.lookup(`eq`, 1)).toEqual(new Set([`a`]))
expect(index.keyCount).toBe(0)

index.update(`a`, { value: 1 }, { value: 1 })

expect(index.lookup(`eq`, 1)).toEqual(new Set([`a`]))
expect(index.keyCount).toBe(1)
})
})
Loading
Loading