diff --git a/packages/db/tests/query/includes-work-counter-oracle.test.ts b/packages/db/tests/query/includes-work-counter-oracle.test.ts new file mode 100644 index 000000000..873ea1de0 --- /dev/null +++ b/packages/db/tests/query/includes-work-counter-oracle.test.ts @@ -0,0 +1,512 @@ +import { fc, test as fcTest } from '@fast-check/vitest' +import { beforeAll, describe, expect, it } from 'vitest' +import { createCollection } from '../../src/collection/index.js' +import { BTreeIndex } from '../../src/indexes/btree-index.js' +import { localOnlyCollectionOptions } from '../../src/local-only.js' +import { + createLiveQueryCollection, + eq, + materialize, +} from '../../src/query/index.js' +import { expectAssertionFailure } from '../expected-failure.js' +import { TraceAssertionError } from '../trace-runner.js' +import type { Collection } from '../../src/collection/index.js' + +let nextCollectionId = 0 + +type TermRow = { id: string; text: string } +type MeaningRow = { id: string; termId: string } +type GroupRow = { id: string; meaningId: string } +type LinkRow = { id: string; groupId: string; targetId: string } + +type SourceRows = { + terms: Array + meanings: Array + groups: Array + links: Array +} + +type FillerCounts = { + terms: number + meanings: number + groups: number + links: number +} + +type WorkScenario = { + filler: FillerCounts + joinTargets: boolean +} + +type WorkCount = { + delivered: number + examined: number +} + +type SourceWork = { + terms: WorkCount + meanings: WorkCount + groups: WorkCount + links: WorkCount +} + +type LinkObservation = + | { id: string; text: string } + | { id: string; targetId: string } + +type WorkObservation = { + result: Array<{ + id: string + meanings: Array<{ + id: string + groups: Array<{ + id: string + links: Array + }> + }> + }> + sourceWork: SourceWork +} + +async function runCleanups( + cleanups: ReadonlyArray<() => void | Promise>, +): Promise { + const results = await Promise.allSettled( + cleanups.map(async (cleanup) => cleanup()), + ) + const firstRejection = results.find( + (result): result is PromiseRejectedResult => result.status === `rejected`, + ) + if (firstRejection !== undefined) throw firstRejection.reason +} + +const noFillers: FillerCounts = { + terms: 0, + meanings: 0, + groups: 0, + links: 0, +} + +function createSourceCollection( + name: string, + initialData: Array, +) { + return createCollection( + localOnlyCollectionOptions({ + id: `${name}-${nextCollectionId++}`, + getKey: (row) => row.id, + initialData, + }), + ) +} + +// Count both sides of the source boundary named in #1709's profile. Delivered +// rows show what enters the dataflow graph. entries() visits capture scans and +// get() calls capture keyed reads, so examined work cannot hide behind a filter. +function countSourceWork(collection: Collection) { + let deliveredRows = 0 + let examinedRows = 0 + let readingEntry = false + const originalSubscribeChanges = collection.subscribeChanges.bind(collection) + const originalEntries = collection.entries.bind(collection) + const originalGet = collection.get.bind(collection) + + collection.subscribeChanges = (callback, options) => { + return originalSubscribeChanges((changes) => { + deliveredRows += changes.length + callback(changes) + }, options) + } + + collection.get = (key) => { + if (!readingEntry) examinedRows++ + return originalGet(key) + } + + collection.entries = function* () { + const entries = originalEntries() + const readNext = () => { + readingEntry = true + try { + return entries.next() + } finally { + readingEntry = false + } + } + + for (let next = readNext(); !next.done; next = readNext()) { + examinedRows++ + yield next.value + } + } + + return (): WorkCount => ({ delivered: deliveredRows, examined: examinedRows }) +} + +function createFixtureRows(filler: FillerCounts): SourceRows { + return { + terms: [ + { id: `term-0`, text: `selected term` }, + { id: `term-1`, text: `first target` }, + { id: `term-2`, text: `second target` }, + ...Array.from({ length: filler.terms }, (_, index) => ({ + id: `term-filler-${index}`, + text: `irrelevant target ${index}`, + })), + ], + meanings: [ + { id: `meaning-0`, termId: `term-0` }, + ...Array.from({ length: filler.meanings }, (_, index) => ({ + id: `meaning-filler-${index}`, + termId: `term-never-selected`, + })), + ], + groups: [ + { id: `group-0`, meaningId: `meaning-0` }, + ...Array.from({ length: filler.groups }, (_, index) => ({ + id: `group-filler-${index}`, + meaningId: `meaning-never-selected`, + })), + ], + links: [ + // Keep filler links on one existing target key. Only the left-side input + // grows; the term-filler control probes right-side input growth separately. + { id: `link-0`, groupId: `group-0`, targetId: `term-1` }, + { + id: `link-1`, + groupId: `group-0`, + targetId: `term-2`, + }, + ...Array.from({ length: filler.links }, (_, index) => ({ + id: `link-filler-${index}`, + groupId: `group-never-selected`, + targetId: `term-1`, + })), + ], + } +} + +function observeLink(link: LinkObservation): LinkObservation { + if (`text` in link) return { id: link.id, text: link.text } + if (`targetId` in link) return { id: link.id, targetId: link.targetId } + + const exhaustive: never = link + return exhaustive +} + +async function observeWork({ + filler, + joinTargets, +}: WorkScenario): Promise { + const rows = createFixtureRows(filler) + const sources = { + terms: createSourceCollection(`work-terms`, rows.terms), + meanings: createSourceCollection(`work-meanings`, rows.meanings), + groups: createSourceCollection(`work-groups`, rows.groups), + links: createSourceCollection(`work-links`, rows.links), + } + let cleanupLive: (() => Promise) | undefined + + try { + await Promise.all(Object.values(sources).map((source) => source.preload())) + + // Match #1709's reproduction: load first, then add a B-tree index on each + // correlation and join column before constructing the live query. + sources.terms.createIndex((row) => row.id, { indexType: BTreeIndex }) + sources.meanings.createIndex((row) => row.termId, { + indexType: BTreeIndex, + }) + sources.groups.createIndex((row) => row.meaningId, { + indexType: BTreeIndex, + }) + sources.links.createIndex((row) => row.groupId, { indexType: BTreeIndex }) + sources.links.createIndex((row) => row.targetId, { + indexType: BTreeIndex, + }) + + const counters = { + terms: countSourceWork(sources.terms), + meanings: countSourceWork(sources.meanings), + groups: countSourceWork(sources.groups), + links: countSourceWork(sources.links), + } + + const live = createLiveQueryCollection((q) => + q + .from({ term: sources.terms }) + .where(({ term }) => eq(term.id, `term-0`)) + .select(({ term }) => ({ + id: term.id, + meanings: materialize( + q + .from({ meaning: sources.meanings }) + .where(({ meaning }) => eq(meaning.termId, term.id)) + .select(({ meaning }) => ({ + id: meaning.id, + groups: materialize( + q + .from({ group: sources.groups }) + .where(({ group }) => eq(group.meaningId, meaning.id)) + .select(({ group }) => { + const selectedLinks = q + .from({ link: sources.links }) + .where(({ link }) => eq(link.groupId, group.id)) + + return { + id: group.id, + links: joinTargets + ? materialize( + selectedLinks + .innerJoin( + { target: sources.terms }, + ({ link, target }) => + eq(link.targetId, target.id), + ) + .select(({ link, target }) => ({ + id: link.id, + text: target.text, + })), + ) + : materialize( + selectedLinks.select(({ link }) => ({ + id: link.id, + targetId: link.targetId, + })), + ), + } + }), + ), + })), + ), + })), + ) + cleanupLive = () => live.cleanup() + + await live.preload() + const root = live.toArray[0]! + return { + result: [ + { + id: root.id, + meanings: root.meanings.map((meaning) => ({ + id: meaning.id, + groups: meaning.groups.map((group) => ({ + id: group.id, + links: group.links.map(observeLink), + })), + })), + }, + ], + sourceWork: { + terms: counters.terms(), + meanings: counters.meanings(), + groups: counters.groups(), + links: counters.links(), + }, + } + } finally { + await runCleanups([ + async () => cleanupLive?.(), + ...Object.values(sources).map((source) => async () => source.cleanup()), + ]) + } +} + +function expectedResult({ + joinTargets, +}: Pick): WorkObservation[`result`] { + return [ + { + id: `term-0`, + meanings: [ + { + id: `meaning-0`, + groups: [ + { + id: `group-0`, + links: [ + joinTargets + ? { id: `link-0`, text: `first target` } + : { id: `link-0`, targetId: `term-1` }, + joinTargets + ? { + id: `link-1`, + text: `second target`, + } + : { + id: `link-1`, + targetId: `term-2`, + }, + ], + }, + ], + }, + ], + }, + ] +} + +function assertEqualSourceWork( + actual: SourceWork, + expected: SourceWork, +): Promise { + try { + expect(actual).toEqual(expected) + return Promise.resolve() + } catch (error) { + return Promise.reject(new TraceAssertionError(1, error)) + } +} + +function isExactWorkCount(value: unknown, expected: WorkCount): boolean { + return ( + typeof value === `object` && + value !== null && + `delivered` in value && + value.delivered === expected.delivered && + `examined` in value && + value.examined === expected.examined + ) +} + +function isExactSourceWork(value: unknown, expected: SourceWork): boolean { + return ( + typeof value === `object` && + value !== null && + `terms` in value && + isExactWorkCount(value.terms, expected.terms) && + `meanings` in value && + isExactWorkCount(value.meanings, expected.meanings) && + `groups` in value && + isExactWorkCount(value.groups, expected.groups) && + `links` in value && + isExactWorkCount(value.links, expected.links) + ) +} + +const joinedBaselineWork: SourceWork = { + terms: { delivered: 3, examined: 3 }, + meanings: { delivered: 1, examined: 1 }, + groups: { delivered: 1, examined: 1 }, + links: { delivered: 2, examined: 2 }, +} + +const joinFreeBaselineWork: SourceWork = { + terms: { delivered: 1, examined: 1 }, + meanings: { delivered: 1, examined: 1 }, + groups: { delivered: 1, examined: 1 }, + links: { delivered: 2, examined: 2 }, +} + +let joinedBaselineObservation: WorkObservation +let joinFreeBaselineObservation: WorkObservation + +async function expectKnownCorrelatedJoinDefect( + fillerCount: number, +): Promise { + const baseline = joinedBaselineObservation + const scaled = await observeWork({ + filler: { + terms: 0, + meanings: 0, + groups: 0, + links: fillerCount, + }, + joinTargets: true, + }) + expect(baseline.result).toEqual(expectedResult({ joinTargets: true })) + expect(scaled.result).toEqual(baseline.result) + expect(baseline.sourceWork).toEqual(joinedBaselineWork) + + const knownLinkWork = { + delivered: baseline.sourceWork.links.delivered + fillerCount, + // Once irrelevant rows exist, the defective route scans the whole + // collection and then reads the two selected rows through the index. + examined: baseline.sourceWork.links.examined + fillerCount + 2, + } + const knownScaledWork: SourceWork = { + // The full left scan also activates one extra indexed target route. + terms: { delivered: 4, examined: 4 }, + meanings: baseline.sourceWork.meanings, + groups: baseline.sourceWork.groups, + links: knownLinkWork, + } + await expectAssertionFailure(assertEqualSourceWork, { + checkpoint: 1, + classify: ({ actual, expected }) => + isExactSourceWork(actual, knownScaledWork) && + isExactSourceWork(expected, baseline.sourceWork), + })(scaled.sourceWork, baseline.sourceWork) +} + +describe(`includes deterministic work-counter oracle`, () => { + beforeAll(async () => { + const [joinedBaseline, joinFreeBaseline] = await Promise.all([ + observeWork({ filler: noFillers, joinTargets: true }), + observeWork({ filler: noFillers, joinTargets: false }), + ]) + joinedBaselineObservation = joinedBaseline + joinFreeBaselineObservation = joinFreeBaseline + }) + + it.each([1, 2, 3])( + `pins the #1709 defect formula at the small filler boundary (%i)`, + expectKnownCorrelatedJoinDefect, + ) + + fcTest.prop([fc.integer({ min: 1, max: 24 })], { + numRuns: 6, + seed: 1709, + })( + `known work defect: a join defeats correlated source pushdown (#1709)`, + expectKnownCorrelatedJoinDefect, + ) + + fcTest.prop([fc.integer({ min: 1, max: 24 })], { + numRuns: 6, + seed: 170_900, + })( + `indexed join-target growth keeps source work flat (#1709 direction control)`, + async (fillerCount) => { + const baseline = joinedBaselineObservation + const scaled = await observeWork({ + filler: { + terms: fillerCount, + meanings: 0, + groups: 0, + links: 0, + }, + joinTargets: true, + }) + + expect(baseline.result).toEqual(expectedResult({ joinTargets: true })) + expect(scaled.result).toEqual(baseline.result) + expect(baseline.sourceWork).toEqual(joinedBaselineWork) + expect(scaled.sourceWork).toEqual(baseline.sourceWork) + }, + ) + + fcTest.prop([fc.integer({ min: 1, max: 24 })], { + numRuns: 6, + seed: 17_090, + })( + `join-free correlated includes keep source work flat (#1709 control)`, + async (fillerCount) => { + const baseline = joinFreeBaselineObservation + const scaled = await observeWork({ + filler: { + terms: fillerCount, + meanings: fillerCount, + groups: fillerCount, + links: fillerCount, + }, + joinTargets: false, + }) + + expect(baseline.result).toEqual(expectedResult({ joinTargets: false })) + expect(scaled.result).toEqual(baseline.result) + expect(baseline.sourceWork).toEqual(joinFreeBaselineWork) + expect(scaled.sourceWork).toEqual(baseline.sourceWork) + }, + ) +}) diff --git a/packages/query-db-collection/tests/includes-work-counter-oracle.test.ts b/packages/query-db-collection/tests/includes-work-counter-oracle.test.ts new file mode 100644 index 000000000..460f98549 --- /dev/null +++ b/packages/query-db-collection/tests/includes-work-counter-oracle.test.ts @@ -0,0 +1,345 @@ +import { fc, test as fcTest } from '@fast-check/vitest' +import { QueryClient } from '@tanstack/query-core' +import { + BasicIndex, + createCollection, + createLiveQueryCollection, + eq, +} from '@tanstack/db' +import { describe, expect, it } from 'vitest' +import { queryCollectionOptions } from '../src/query' +import type { Collection } from '@tanstack/db' + +let nextCollectionId = 0 + +type RootRow = { id: string; value: string } +type BranchRow = { id: string; parentId: string; value: string } +type TwigRow = { id: string; parentId: string; value: string } +type LeafRow = { id: string; parentId: string; value: string } + +type TreeCounts = { + roots: number + branches: number + twigs: number + leaves: number +} + +type ChildLevel = Exclude + +type NodeRow = { + id: string + value: string + children?: NodeCollection +} + +type NodeCollection = Pick< + Collection, + 'cleanup' | 'preload' | 'size' | 'toArray' +> + +type NestedTreeShape = { + reachableTreeRows: number + sourceRowsDeliveredAtPreload: TreeCounts + sourceRowsDeliveredAfterTraversal: TreeCounts + reachableChildCollections: Record + reachableChildRows: Record +} + +const branchesPerRoot = 2 +const twigsPerBranch = 5 +const leavesPerTwig = 10 + +function createNestedTreeRows(rootCount: number) { + const roots: Array = [] + const branches: Array = [] + const twigs: Array = [] + const leaves: Array = [] + + for (let rootIndex = 0; rootIndex < rootCount; rootIndex++) { + const rootId = `root-${rootIndex}` + roots.push({ id: rootId, value: rootId }) + + for (let branchIndex = 0; branchIndex < branchesPerRoot; branchIndex++) { + const branchId = `branch-${rootIndex}-${branchIndex}` + branches.push({ id: branchId, parentId: rootId, value: branchId }) + + for (let twigIndex = 0; twigIndex < twigsPerBranch; twigIndex++) { + const twigId = `twig-${rootIndex}-${branchIndex}-${twigIndex}` + twigs.push({ id: twigId, parentId: branchId, value: twigId }) + + for (let leafIndex = 0; leafIndex < leavesPerTwig; leafIndex++) { + const leafId = `leaf-${rootIndex}-${branchIndex}-${twigIndex}-${leafIndex}` + leaves.push({ id: leafId, parentId: twigId, value: leafId }) + } + } + } + } + + return { roots, branches, twigs, leaves } +} + +function createQuerySource( + name: string, + rows: Array, + queryClient: QueryClient, +) { + const id = `${name}-${nextCollectionId++}` + return createCollection( + queryCollectionOptions({ + id, + queryClient, + autoIndex: `eager`, + defaultIndexType: BasicIndex, + queryKey: [id], + queryFn: () => Promise.resolve(rows), + getKey: (row) => row.id, + }), + ) +} + +function countDeliveredRows(collection: Collection) { + let deliveredRows = 0 + const originalSubscribeChanges = collection.subscribeChanges.bind(collection) + + collection.subscribeChanges = (callback, options) => { + return originalSubscribeChanges((changes) => { + deliveredRows += changes.length + callback(changes) + }, options) + } + + return () => deliveredRows +} + +function expectedTreeCounts(rootCount: number): TreeCounts { + const branches = rootCount * branchesPerRoot + const twigs = branches * twigsPerBranch + + return { + roots: rootCount, + branches, + twigs, + leaves: twigs * leavesPerTwig, + } +} + +function requireChildren(row: NodeRow, level: string): NodeCollection { + if (row.children === undefined) { + throw new Error(`Expected ${level} row ${row.id} to have children`) + } + return row.children +} + +function observeReachableTreeShape(roots: NodeCollection) { + const reachableChildCollections = { branches: 0, twigs: 0, leaves: 0 } + const reachableChildRows = { branches: 0, twigs: 0, leaves: 0 } + let reachableTreeRows = roots.size + + const countChildren = ( + collection: NodeCollection, + level: ChildLevel, + ): void => { + const children = collection.toArray + reachableChildCollections[level]++ + reachableChildRows[level] += children.length + reachableTreeRows += children.length + + const nextLevel = + level === `branches` ? `twigs` : level === `twigs` ? `leaves` : undefined + if (nextLevel === undefined) return + + for (const child of children) { + countChildren(requireChildren(child, level), nextLevel) + } + } + + for (const root of roots.toArray) { + countChildren(requireChildren(root, `root`), `branches`) + } + + return { reachableTreeRows, reachableChildCollections, reachableChildRows } +} + +function snapshotSourceRowsDelivered( + sourceCounters: Record number>, +): TreeCounts { + return { + roots: sourceCounters.roots(), + branches: sourceCounters.branches(), + twigs: sourceCounters.twigs(), + leaves: sourceCounters.leaves(), + } +} + +async function runCleanups( + cleanups: ReadonlyArray<() => void | Promise>, +): Promise { + const results = await Promise.allSettled( + cleanups.map(async (cleanup) => cleanup()), + ) + const firstRejection = results.find( + (result): result is PromiseRejectedResult => result.status === `rejected`, + ) + if (firstRejection !== undefined) throw firstRejection.reason +} + +function rethrowFirstCleanupError( + results: ReadonlyArray<{ rejected: boolean; error: unknown }>, +): void { + const firstRejection = results.find((result) => result.rejected) + if (firstRejection !== undefined) throw firstRejection.error +} + +async function observeNestedTreeShape( + rootCount: number, +): Promise { + const rows = createNestedTreeRows(rootCount) + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + const sources = { + roots: createQuerySource(`tree-roots`, rows.roots, queryClient), + branches: createQuerySource(`tree-branches`, rows.branches, queryClient), + twigs: createQuerySource(`tree-twigs`, rows.twigs, queryClient), + leaves: createQuerySource(`tree-leaves`, rows.leaves, queryClient), + } + let rootCollection: NodeCollection | undefined + + try { + const sourceCounters = { + roots: countDeliveredRows(sources.roots), + branches: countDeliveredRows(sources.branches), + twigs: countDeliveredRows(sources.twigs), + leaves: countDeliveredRows(sources.leaves), + } + + // This matches the nested result shape in #1634. Each children property + // remains a live Collection. The public result API exposes the reachable + // tree, not internal allocation counts, so this oracle constrains reachable + // cardinality and source delivery rather than claiming to count allocations. + const roots: NodeCollection = createLiveQueryCollection({ + startSync: true, + query: (q) => + q.from({ root: sources.roots }).select(({ root }) => ({ + id: root.id, + value: root.value, + children: q + .from({ branch: sources.branches }) + .where(({ branch }) => eq(branch.parentId, root.id)) + .select(({ branch }) => ({ + id: branch.id, + value: branch.value, + children: q + .from({ twig: sources.twigs }) + .where(({ twig }) => eq(twig.parentId, branch.id)) + .select(({ twig }) => ({ + id: twig.id, + value: twig.value, + children: q + .from({ leaf: sources.leaves }) + .where(({ leaf }) => eq(leaf.parentId, twig.id)) + .select(({ leaf }) => ({ + id: leaf.id, + value: leaf.value, + })), + })), + })), + })), + }) + rootCollection = roots + await roots.preload() + + const sourceRowsDeliveredAtPreload = + snapshotSourceRowsDelivered(sourceCounters) + const { reachableTreeRows, reachableChildCollections, reachableChildRows } = + observeReachableTreeShape(roots) + + return { + reachableTreeRows, + sourceRowsDeliveredAtPreload, + sourceRowsDeliveredAfterTraversal: + snapshotSourceRowsDelivered(sourceCounters), + reachableChildCollections, + reachableChildRows, + } + } finally { + let cleanupRejected = false + let cleanupError: unknown + try { + await runCleanups([ + async () => rootCollection?.cleanup(), + ...Object.values(sources).map((source) => async () => source.cleanup()), + ]) + } catch (error) { + cleanupRejected = true + cleanupError = error + } + + let clearRejected = false + let clearError: unknown + try { + queryClient.clear() + } catch (error) { + clearRejected = true + clearError = error + } + + rethrowFirstCleanupError([ + { rejected: cleanupRejected, error: cleanupError }, + { rejected: clearRejected, error: clearError }, + ]) + } +} + +function expectNestedTreeShape( + observation: NestedTreeShape, + rootCount: number, +): void { + const expected = expectedTreeCounts(rootCount) + expect(observation.reachableTreeRows).toBe( + Object.values(expected).reduce((sum, count) => sum + count, 0), + ) + expect(observation.sourceRowsDeliveredAtPreload).toEqual(expected) + expect(observation.sourceRowsDeliveredAfterTraversal).toEqual( + observation.sourceRowsDeliveredAtPreload, + ) + expect(observation.reachableChildCollections).toEqual({ + branches: expected.roots, + twigs: expected.branches, + leaves: expected.twigs, + }) + expect(observation.reachableChildRows).toEqual({ + branches: expected.branches, + twigs: expected.twigs, + leaves: expected.leaves, + }) +} + +describe(`nested includes reachable-shape oracle`, () => { + fcTest.prop([fc.integer({ min: 0, max: 20 })], { + numRuns: 6, + seed: 1634, + })( + `preserves the complete reachable nested tree shape (#1634)`, + async (rootCount) => { + expectNestedTreeShape(await observeNestedTreeShape(rootCount), rootCount) + }, + ) + + it(`exposes no nested collections for an empty root query`, async () => { + expectNestedTreeShape(await observeNestedTreeShape(0), 0) + }) + + it(`pins #1634's reported 20-by-2-by-5-by-10 tree`, async () => { + // These semantic counters do not claim to measure elapsed time or internal + // allocations. They pin source delivery at preload and reachable shape. + expectNestedTreeShape(await observeNestedTreeShape(20), 20) + }) + + it(`does not deliver more source rows while traversing the result`, async () => { + const observation = await observeNestedTreeShape(20) + expect(observation.sourceRowsDeliveredAfterTraversal).toEqual( + observation.sourceRowsDeliveredAtPreload, + ) + }) +})