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

Skip index writes when an update leaves the indexed value unchanged.
59 changes: 46 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,34 @@ 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)
) {
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
7 changes: 7 additions & 0 deletions packages/db/src/utils/comparison.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,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
88 changes: 88 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,88 @@
import { describe, expect, it } 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))

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`]))
})
})