diff --git a/.changeset/optimize-hot-paths.md b/.changeset/optimize-hot-paths.md new file mode 100644 index 0000000000..3fabc3ebf6 --- /dev/null +++ b/.changeset/optimize-hot-paths.md @@ -0,0 +1,7 @@ +--- +'@tanstack/db-sqlite-persistence-core': patch +'@tanstack/offline-transactions': patch +'@tanstack/react-db': patch +--- + +Optimize hot paths: Schwartzian transform for stable serialization sorting, splice-based queue flushing, Map-based transaction lookups, Set-based filtering, and lazy property access in useLiveQuery so status-only consumers skip full entry materialization. diff --git a/package.json b/package.json index 93eae3610d..ce623c013f 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "prepare": "husky", "test": "pnpm --filter \"./packages/**\" test", "test:docs": "node scripts/verify-links.ts", + "bench:perf": "tsx scripts/perf-bench.ts", "test:sherif": "sherif -i zod -p offline-transactions-react-native -p shopping-list-react-native", "generate-docs": "node scripts/generate-docs.ts" }, diff --git a/packages/db-sqlite-persistence-core/src/persisted.ts b/packages/db-sqlite-persistence-core/src/persisted.ts index 7c20e96883..bfd05dd797 100644 --- a/packages/db-sqlite-persistence-core/src/persisted.ts +++ b/packages/db-sqlite-persistence-core/src/persisted.ts @@ -649,15 +649,15 @@ function toStableSerializable(value: unknown): unknown { return Array.from(value) .map((entry) => toStableSerializable(entry)) .filter((entry) => entry !== undefined) - .sort((left, right) => { - const leftSerialized = JSON.stringify(left) - const rightSerialized = JSON.stringify(right) - return leftSerialized < rightSerialized + .map((entry) => ({ entry, serialized: JSON.stringify(entry) })) + .sort((left, right) => + left.serialized < right.serialized ? -1 - : leftSerialized > rightSerialized + : left.serialized > right.serialized ? 1 - : 0 - }) + : 0, + ) + .map(({ entry }) => entry) } if (value instanceof Map) { @@ -667,15 +667,18 @@ function toStableSerializable(value: unknown): unknown { value: toStableSerializable(mapValue), })) .filter((entry) => entry.key !== undefined && entry.value !== undefined) - .sort((left, right) => { - const leftSerialized = JSON.stringify(left.key) - const rightSerialized = JSON.stringify(right.key) - return leftSerialized < rightSerialized + .map((entry) => ({ + entry, + serializedKey: JSON.stringify(entry.key), + })) + .sort((left, right) => + left.serializedKey < right.serializedKey ? -1 - : leftSerialized > rightSerialized + : left.serializedKey > right.serializedKey ? 1 - : 0 - }) + : 0, + ) + .map(({ entry }) => entry) } const record = value as Record @@ -1298,11 +1301,9 @@ class PersistedCollectionRuntime< } private async flushQueuedHydrationTransactionsUnsafe(): Promise { - while (this.queuedHydrationTransactions.length > 0) { - const transaction = this.queuedHydrationTransactions.shift() - if (!transaction) { - continue - } + const queuedTransactions = this.queuedHydrationTransactions.splice(0) + + for (const transaction of queuedTransactions) { await this.applyBufferedSyncTransactionUnsafe(transaction) } } @@ -1867,11 +1868,9 @@ class PersistedCollectionRuntime< } private async flushQueuedTxCommittedUnsafe(): Promise { - while (this.queuedTxCommitted.length > 0) { - const queued = this.queuedTxCommitted.shift() - if (!queued) { - continue - } + const queuedTransactions = this.queuedTxCommitted.splice(0) + + for (const queued of queuedTransactions) { await this.processCommittedTxUnsafe(queued) } } diff --git a/packages/db-sqlite-persistence-core/tests/persisted.test.ts b/packages/db-sqlite-persistence-core/tests/persisted.test.ts index 57c11d8442..1db1f0fd6f 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted.test.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted.test.ts @@ -1266,6 +1266,78 @@ describe(`persistedCollectionOptions`, () => { }) }) + it(`processes tx:committed messages queued during a reload flush`, async () => { + const adapter = createRecordingAdapter([{ id: `1`, title: `Initial` }]) + const coordinator = createCoordinatorHarness() + + const collection = createCollection( + persistedCollectionOptions({ + id: `sync-present`, + getKey: (item) => item.id, + sync: { + sync: ({ markReady }) => { + markReady() + }, + }, + persistence: { + adapter, + coordinator, + }, + }), + ) + + await collection.preload() + await flushAsyncWork() + + const originalLoadSubset = adapter.loadSubset + let emittedDuringReload = false + adapter.loadSubset = async (collectionId, options, ctx) => { + const rows = await originalLoadSubset(collectionId, options, ctx) + + if (!emittedDuringReload) { + emittedDuringReload = true + coordinator.emit({ + type: `tx:committed`, + term: 1, + seq: 2, + txId: `tx-during-reload`, + latestRowVersion: 2, + requiresFullReload: false, + changedRows: [ + { key: `2`, value: { id: `2`, title: `Queued during reload` } }, + ], + deletedKeys: [], + }) + } + + return rows + } + + adapter.rows.set(`2`, { + id: `2`, + title: `Queued during reload`, + }) + + coordinator.emit({ + type: `tx:committed`, + term: 1, + seq: 1, + txId: `tx-reload`, + latestRowVersion: 1, + requiresFullReload: true, + }) + + await flushAsyncWork() + await flushAsyncWork() + await flushAsyncWork() + + expect(emittedDuringReload).toBe(true) + expect(stripVirtualProps(collection.get(`2`))).toEqual({ + id: `2`, + title: `Queued during reload`, + }) + }) + it(`removes deleted rows after tx:committed invalidation reload`, async () => { const adapter = createRecordingAdapter([ { id: `1`, title: `Keep` }, @@ -1513,6 +1585,60 @@ describe(`persistedCollectionOptions`, () => { expect(collection.get(`2`)).toBeUndefined() }) + it(`dedupes active subsets with stable Map and Set option serialization`, async () => { + const adapter = createRecordingAdapter([{ id: `1`, title: `Row 1` }]) + const coordinator = createCoordinatorHarness() + + const collection = createCollection( + persistedCollectionOptions({ + id: `sync-present`, + syncMode: `on-demand`, + getKey: (item) => item.id, + sync: { + sync: ({ markReady }) => { + markReady() + }, + }, + persistence: { + adapter, + coordinator, + }, + }), + ) + + collection.startSyncImmediate() + await flushAsyncWork() + + const firstCursor = new Map([ + [`set`, new Set([`b`, `a`])], + [`nested`, new Map([[`z`, 1]])], + ]) + const secondCursor = new Map([ + [`nested`, new Map([[`z`, 1]])], + [`set`, new Set([`a`, `b`])], + ]) + + await (collection as any)._sync.loadSubset({ cursor: firstCursor }) + await (collection as any)._sync.loadSubset({ cursor: secondCursor }) + await flushAsyncWork() + + const loadSubsetCallsBeforeReload = adapter.loadSubsetCalls.length + + coordinator.emit({ + type: `tx:committed`, + term: 1, + seq: 1, + txId: `tx-reload-stable-key`, + latestRowVersion: 1, + requiresFullReload: true, + }) + + await flushAsyncWork() + await flushAsyncWork() + + expect(adapter.loadSubsetCalls.length).toBe(loadSubsetCallsBeforeReload + 1) + }) + it(`paginated subset falls back to full reload`, async () => { const adapter = createRecordingAdapter([{ id: `1`, title: `Row 1` }]) const coordinator = createCoordinatorHarness() diff --git a/packages/offline-transactions/src/executor/KeyScheduler.ts b/packages/offline-transactions/src/executor/KeyScheduler.ts index da59b904b7..374d67b8ea 100644 --- a/packages/offline-transactions/src/executor/KeyScheduler.ts +++ b/packages/offline-transactions/src/executor/KeyScheduler.ts @@ -103,15 +103,37 @@ export class KeyScheduler { return [...this.pendingTransactions] } + getEarliestRetryTime(): number | null { + let earliestRetryTime: number | null = null + + for (const transaction of this.pendingTransactions) { + earliestRetryTime = + earliestRetryTime === null + ? transaction.nextAttemptAt + : Math.min(earliestRetryTime, transaction.nextAttemptAt) + } + + return earliestRetryTime + } + updateTransactions(updatedTransactions: Array): void { - for (const updatedTx of updatedTransactions) { - const index = this.pendingTransactions.findIndex( - (tx) => tx.id === updatedTx.id, + if (updatedTransactions.length === 0) { + return + } + + const updatedById = new Map( + updatedTransactions.map((transaction) => [transaction.id, transaction]), + ) + + for (let index = 0; index < this.pendingTransactions.length; index++) { + const updatedTransaction = updatedById.get( + this.pendingTransactions[index]!.id, ) - if (index >= 0) { - this.pendingTransactions[index] = updatedTx + if (updatedTransaction) { + this.pendingTransactions[index] = updatedTransaction } } + // Re-sort to maintain FIFO order after updates this.pendingTransactions.sort( (a, b) => a.createdAt.getTime() - b.createdAt.getTime(), diff --git a/packages/offline-transactions/src/executor/TransactionExecutor.ts b/packages/offline-transactions/src/executor/TransactionExecutor.ts index 71328443fa..39abb056db 100644 --- a/packages/offline-transactions/src/executor/TransactionExecutor.ts +++ b/packages/offline-transactions/src/executor/TransactionExecutor.ts @@ -247,8 +247,11 @@ export class TransactionExecutor { // Schedule retry timer for loaded transactions this.scheduleNextRetry() + const filteredTransactionIds = new Set( + filteredTransactions.map((transaction) => transaction.id), + ) const removedTransactions = transactions.filter( - (tx) => !filteredTransactions.some((filtered) => filtered.id === tx.id), + (transaction) => !filteredTransactionIds.has(transaction.id), ) if (removedTransactions.length > 0) { @@ -355,13 +358,7 @@ export class TransactionExecutor { } private getEarliestRetryTime(): number | null { - const allTransactions = this.scheduler.getAllPendingTransactions() - - if (allTransactions.length === 0) { - return null - } - - return Math.min(...allTransactions.map((tx) => tx.nextAttemptAt)) + return this.scheduler.getEarliestRetryTime() } private clearRetryTimer(): void { @@ -380,10 +377,11 @@ export class TransactionExecutor { } resetRetryDelays(): void { + const nextAttemptAt = Date.now() const allTransactions = this.scheduler.getAllPendingTransactions() const updatedTransactions = allTransactions.map((transaction) => ({ ...transaction, - nextAttemptAt: Date.now(), + nextAttemptAt, })) this.scheduler.updateTransactions(updatedTransactions) diff --git a/packages/offline-transactions/tests/KeyScheduler.test.ts b/packages/offline-transactions/tests/KeyScheduler.test.ts index d7226a0a01..655985ef4b 100644 --- a/packages/offline-transactions/tests/KeyScheduler.test.ts +++ b/packages/offline-transactions/tests/KeyScheduler.test.ts @@ -258,6 +258,155 @@ describe(`KeyScheduler`, () => { expect(scheduler.getNext()).toBeUndefined() }) + it(`updates many pending transactions in one call`, () => { + const now = Date.now() + const scheduler = new KeyScheduler() + const first = createTransaction({ + id: `first`, + createdAt: new Date(now), + nextAttemptAt: now, + }) + const second = createTransaction({ + id: `second`, + createdAt: new Date(now + 1), + nextAttemptAt: now, + }) + + scheduler.schedule(first) + scheduler.schedule(second) + + scheduler.updateTransactions([ + { ...first, retryCount: 1, nextAttemptAt: now + 1000 }, + { ...second, retryCount: 2, nextAttemptAt: now + 2000 }, + ]) + + const pending = scheduler.getAllPendingTransactions() + expect(pending.find((tx) => tx.id === `first`)).toMatchObject({ + retryCount: 1, + nextAttemptAt: now + 1000, + }) + expect(pending.find((tx) => tx.id === `second`)).toMatchObject({ + retryCount: 2, + nextAttemptAt: now + 2000, + }) + }) + + it(`ignores unknown transaction ids when updating many transactions`, () => { + const now = Date.now() + const scheduler = new KeyScheduler() + const existing = createTransaction({ + id: `existing`, + createdAt: new Date(now), + nextAttemptAt: now, + }) + const unknown = createTransaction({ + id: `unknown`, + createdAt: new Date(now + 1), + nextAttemptAt: now + 1000, + }) + + scheduler.schedule(existing) + scheduler.updateTransactions([unknown]) + + expect(scheduler.getAllPendingTransactions()).toEqual([existing]) + }) + + it(`preserves pending transactions not included in bulk updates`, () => { + const now = Date.now() + const scheduler = new KeyScheduler() + const updated = createTransaction({ + id: `updated`, + createdAt: new Date(now), + nextAttemptAt: now, + }) + const untouched = createTransaction({ + id: `untouched`, + createdAt: new Date(now + 1), + nextAttemptAt: now, + }) + + scheduler.schedule(updated) + scheduler.schedule(untouched) + scheduler.updateTransactions([{ ...updated, retryCount: 3 }]) + + const pending = scheduler.getAllPendingTransactions() + expect(pending.find((tx) => tx.id === `updated`)?.retryCount).toBe(3) + expect(pending.find((tx) => tx.id === `untouched`)).toBe(untouched) + }) + + it(`uses the last duplicate id when bulk updating transactions`, () => { + const now = Date.now() + const scheduler = new KeyScheduler() + const transaction = createTransaction({ + id: `duplicate`, + createdAt: new Date(now), + nextAttemptAt: now, + }) + + scheduler.schedule(transaction) + scheduler.updateTransactions([ + { ...transaction, retryCount: 1 }, + { ...transaction, retryCount: 2 }, + ]) + + expect(scheduler.getAllPendingTransactions()[0]?.retryCount).toBe(2) + }) + + it(`keeps FIFO order after bulk updates`, () => { + vi.useFakeTimers() + + const now = new Date(`2026-01-01T00:00:00.000Z`) + vi.setSystemTime(now) + + const scheduler = new KeyScheduler() + const first = createTransaction({ + id: `first`, + createdAt: new Date(now.getTime()), + nextAttemptAt: now.getTime(), + }) + const second = createTransaction({ + id: `second`, + createdAt: new Date(now.getTime() + 1), + nextAttemptAt: now.getTime(), + }) + + scheduler.schedule(first) + scheduler.schedule(second) + scheduler.updateTransactions([ + { ...second, retryCount: 1 }, + { ...first, retryCount: 1 }, + ]) + + expect(scheduler.getNext()?.id).toBe(`first`) + }) + + it(`returns null for earliest retry time when empty`, () => { + const scheduler = new KeyScheduler() + expect(scheduler.getEarliestRetryTime()).toBeNull() + }) + + it(`returns the minimum nextAttemptAt as the earliest retry time`, () => { + const now = Date.now() + const scheduler = new KeyScheduler() + + scheduler.schedule( + createTransaction({ + id: `later`, + createdAt: new Date(now), + nextAttemptAt: now + 5000, + }), + ) + scheduler.schedule( + createTransaction({ + id: `earlier`, + createdAt: new Date(now + 1), + nextAttemptAt: now - 1000, + }), + ) + + expect(scheduler.getEarliestRetryTime()).toBe(now - 1000) + }) + it(`processes transactions with identical createdAt in scheduling order`, () => { vi.useFakeTimers() diff --git a/packages/react-db/src/useLiveQuery.ts b/packages/react-db/src/useLiveQuery.ts index 331ff3a279..0e44b8140c 100644 --- a/packages/react-db/src/useLiveQuery.ts +++ b/packages/react-db/src/useLiveQuery.ts @@ -514,34 +514,47 @@ export function useLiveQuery( isEnabled: false, } } else { - // Capture a stable view of entries for this snapshot to avoid tearing - const entries = Array.from(snapshot.collection.entries()) + const collection = snapshot.collection const config: CollectionConfigSingleRowOption = - snapshot.collection.config + collection.config const singleResult = config.singleResult + let entriesCache: Array<[string | number, unknown]> | null = null let stateCache: Map | null = null let dataCache: Array | null = null + let singleResultCache: unknown = undefined + let hasSingleResultCache = false + + const getEntries = () => { + entriesCache ??= Array.from(collection.entries()) + return entriesCache + } returnedRef.current = { get state() { - if (!stateCache) { - stateCache = new Map(entries) - } + stateCache ??= new Map(getEntries()) return stateCache }, get data() { - if (!dataCache) { - dataCache = entries.map(([, value]) => value) + if (singleResult) { + if (!hasSingleResultCache) { + const entries = getEntries() + singleResultCache = + entries.length > 0 ? entries[0]![1] : undefined + hasSingleResultCache = true + } + return singleResultCache } - return singleResult ? dataCache[0] : dataCache + + dataCache ??= getEntries().map(([, value]) => value) + return dataCache }, - collection: snapshot.collection, - status: snapshot.collection.status, - isLoading: snapshot.collection.status === `loading`, - isReady: snapshot.collection.status === `ready`, - isIdle: snapshot.collection.status === `idle`, - isError: snapshot.collection.status === `error`, - isCleanedUp: snapshot.collection.status === `cleaned-up`, + collection, + status: collection.status, + isLoading: collection.status === `loading`, + isReady: collection.status === `ready`, + isIdle: collection.status === `idle`, + isError: collection.status === `error`, + isCleanedUp: collection.status === `cleaned-up`, isEnabled: true, } } diff --git a/packages/react-db/tests/useLiveQuery.test.tsx b/packages/react-db/tests/useLiveQuery.test.tsx index fbb48d882c..55eead9a9b 100644 --- a/packages/react-db/tests/useLiveQuery.test.tsx +++ b/packages/react-db/tests/useLiveQuery.test.tsx @@ -2357,6 +2357,198 @@ describe(`Query Collections`, () => { }) }) + describe(`performance-sensitive lazy access`, () => { + it(`does not materialize collection entries when only status flags are read`, async () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `lazy-status-only-test`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }), + ) + + let entriesCalls = 0 + let valuesCalls = 0 + const originalEntries = collection.entries.bind(collection) + const originalValues = collection.values.bind(collection) + collection.entries = function* () { + entriesCalls++ + yield* originalEntries() + } + collection.values = function* () { + valuesCalls++ + yield* originalValues() + } + + const { result } = renderHook(() => { + const query = useLiveQuery(collection) + return { + isReady: query.isReady, + status: query.status, + } + }) + + await waitFor(() => { + expect(result.current.isReady).toBe(true) + }) + + expect(result.current.status).toBe(`ready`) + expect(entriesCalls).toBe(0) + expect(valuesCalls).toBe(0) + }) + + it(`materializes entries lazily when data is read`, async () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `lazy-data-test`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }), + ) + + let entriesCalls = 0 + let valuesCalls = 0 + const originalEntries = collection.entries.bind(collection) + const originalValues = collection.values.bind(collection) + collection.entries = function* () { + entriesCalls++ + yield* originalEntries() + } + collection.values = function* () { + valuesCalls++ + yield* originalValues() + } + + const { result } = renderHook(() => { + const query = useLiveQuery(collection) + return { + data: query.data, + isReady: query.isReady, + } + }) + + await waitFor(() => { + expect(result.current.isReady).toBe(true) + expect(result.current.data).toHaveLength(initialPersons.length) + }) + + expect(entriesCalls).toBeGreaterThan(0) + expect(valuesCalls).toBe(0) + }) + + it(`materializes entries lazily when state is read`, async () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `lazy-state-test`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }), + ) + + let entriesCalls = 0 + let valuesCalls = 0 + const originalEntries = collection.entries.bind(collection) + const originalValues = collection.values.bind(collection) + collection.entries = function* () { + entriesCalls++ + yield* originalEntries() + } + collection.values = function* () { + valuesCalls++ + yield* originalValues() + } + + const { result } = renderHook(() => { + const query = useLiveQuery(collection) + return { + isReady: query.isReady, + state: query.state, + } + }) + + await waitFor(() => { + expect(result.current.isReady).toBe(true) + expect(result.current.state.size).toBe(initialPersons.length) + }) + + expect(entriesCalls).toBeGreaterThan(0) + expect(valuesCalls).toBe(0) + }) + + it(`returns singleResult data from the shared entries snapshot`, async () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `lazy-single-result-test`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }), + ) + const liveQueryCollection = createLiveQueryCollection({ + query: (q) => q.from({ collection }).findOne(), + startSync: true, + }) + + let entriesCalls = 0 + let valuesCalls = 0 + const originalEntries = + liveQueryCollection.entries.bind(liveQueryCollection) + const originalValues = + liveQueryCollection.values.bind(liveQueryCollection) + liveQueryCollection.entries = function* () { + entriesCalls++ + yield* originalEntries() + } + liveQueryCollection.values = function* () { + valuesCalls++ + yield* originalValues() + } + + const { result } = renderHook(() => { + const query = useLiveQuery(liveQueryCollection) + return { + data: query.data, + isReady: query.isReady, + } + }) + + await waitFor(() => { + expect(result.current.isReady).toBe(true) + expect(result.current.data).toMatchObject({ id: `1` }) + }) + + expect(entriesCalls).toBeGreaterThan(0) + expect(valuesCalls).toBe(0) + }) + + it(`returns undefined for empty singleResult data`, async () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `lazy-empty-single-result-test`, + getKey: (person: Person) => person.id, + initialData: [], + }), + ) + const liveQueryCollection = createLiveQueryCollection({ + query: (q) => q.from({ collection }).findOne(), + startSync: true, + }) + + const { result } = renderHook(() => { + const query = useLiveQuery(liveQueryCollection) + return { + data: query.data, + isReady: query.isReady, + } + }) + + await waitFor(() => { + expect(result.current.isReady).toBe(true) + }) + + expect(result.current.data).toBeUndefined() + }) + }) + describe(`aggregates nested inside expressions`, () => { it(`coalesce(count(...), 0) in groupBy select returns count per group`, async () => { const collection = createCollection(