/** * Performance benchmarks for the query engine. * * Run with: npx vitest run src/query/query.perf.test.ts * * Purpose: compare performance between MobX-based (main) and push-based (ciao-mobx) * implementations. Each benchmark uses performance.now() and reports median/p95 * across multiple iterations. */ import { describe, it, expect } from 'vitest' import { sortApplogsByTs } from '../../applog/applog-utils.ts' import type { Applog, ApplogForInsert } from '../../applog/datom-types.ts' import { ThreadInMemory } from '../../thread/writeable.ts' import { rollingFilter } from '../../thread/filters.ts' import { liveQuery, query, lastWriteWins, withoutDeleted } from '../../query/basic.ts' // ─── Benchmark Helpers ────────────────────────────────────────── function benchmarkSync(name: string, fn: () => void, iterations = 100): { median: number; p95: number; min: number } { // Warmup for (let i = 0; i < 3; i++) fn() const times: number[] = [] for (let i = 0; i < iterations; i++) { const start = performance.now() fn() times.push(performance.now() - start) } times.sort((a, b) => a - b) const median = times[Math.floor(times.length / 2)] const p95 = times[Math.floor(times.length * 0.95)] const min = times[0] console.log(` [PERF] ${name}: median=${median.toFixed(3)}ms p95=${p95.toFixed(3)}ms min=${min.toFixed(3)}ms (${iterations} runs)`) return { median, p95, min } } // ─── Data Generation ──────────────────────────────────────────── const ENTITY_COUNT = 500 const TYPES = ['block', 'page', 'image', 'link', 'heading', 'list', 'table'] const STATUSES = ['draft', 'published', 'archived', 'deleted'] const AGENT = 'perf-agent' function generateDataset(entityCount: number): Applog[] { const inputs: ApplogForInsert[] = [] let tsCounter = Date.now() - entityCount * 10 // stagger timestamps for (let i = 0; i < entityCount; i++) { const en = `e${i}` const ts = new Date(tsCounter).toISOString() tsCounter += 5 // entity/name inputs.push({ en, at: 'entity/name', vl: `Entity ${i}`, ag: AGENT }) // entity/type inputs.push({ en, at: 'entity/type', vl: TYPES[i % TYPES.length], ag: AGENT }) // entity/status inputs.push({ en, at: 'entity/status', vl: STATUSES[i % STATUSES.length], ag: AGENT }) // relation/parent — ~80% of entities have a parent if (i > 0 && i % 5 !== 0) { const parentIdx = Math.floor(i / 5) * 5 // parent is the "section head" inputs.push({ en, at: 'relation/parent', vl: `e${parentIdx}`, ag: AGENT }) } // Some entities get extra attributes to increase log count if (i % 3 === 0) { inputs.push({ en, at: 'entity/content', vl: `Content for entity ${i} with some text to simulate real data`, ag: AGENT }) } if (i % 7 === 0) { inputs.push({ en, at: 'entity/created', vl: new Date(tsCounter - 1000).toISOString(), ag: AGENT }) } } // Add some deletions (~5%) for (let i = 0; i < entityCount; i++) { if (i % 20 === 0) { inputs.push({ en: `e${i}`, at: 'isDeleted', vl: true, ag: AGENT }) } } // Add duplicate-attribute writes (for lastWriteWins testing) — ~10% of entities get a second status for (let i = 0; i < entityCount; i++) { if (i % 10 === 0) { inputs.push({ en: `e${i}`, at: 'entity/status', vl: 'updated', ag: AGENT }) } } const logs = inputs.map(input => ({ ts: new Date(tsCounter++).toISOString(), pv: null, ag: input.ag || AGENT, ...input, })) as Applog[] sortApplogsByTs(logs) return logs } // ─── Datasets ─────────────────────────────────────────────────── const dataset500 = generateDataset(500) const dataset2000 = generateDataset(2000) console.log(`\n[PERF] Dataset sizes: 500-entity=${dataset500.length} applogs, 2000-entity=${dataset2000.length} applogs\n`) // ═════════════════════════════════════════════════════════════════ // BENCHMARKS // ═════════════════════════════════════════════════════════════════ describe('query() performance', () => { it('single-step: find all entities by type', () => { const db = ThreadInMemory.fromArray([...dataset2000], 'perf-single-step') const result = benchmarkSync('query single-step (2K entities)', () => { query(db, [{ at: 'entity/type', vl: 'block' }]) }, 200) // Sanity: should find ~1/7 of entities const check = query(db, [{ at: 'entity/type', vl: 'block' }]) expect(check.nodes.length).toBeGreaterThan(100) expect(result.median).toBeLessThan(500) // generous upper bound }) it('multi-step: two-step variable binding', () => { const db = ThreadInMemory.fromArray([...dataset2000], 'perf-multi-step') const result = benchmarkSync('query multi-step (2K entities)', () => { query(db, [ { en: '?id', at: 'entity/type', vl: 'block' }, { en: '?id', at: 'entity/status', vl: '?status' }, ]) }, 100) const check = query(db, [ { en: '?id', at: 'entity/type', vl: 'block' }, { en: '?id', at: 'entity/status', vl: '?status' }, ]) expect(check.records.length).toBeGreaterThan(100) expect(result.median).toBeLessThan(1000) }) it('three-step: entity -> parent -> parent name', () => { const db = ThreadInMemory.fromArray([...dataset500], 'perf-three-step') const result = benchmarkSync('query three-step (500 entities)', () => { query(db, [ { en: '?childId', at: 'entity/type', vl: 'block' }, { en: '?childId', at: 'relation/parent', vl: '?parentId' }, { en: '?parentId', at: 'entity/name', vl: '?parentName' }, ]) }, 50) expect(result.median).toBeLessThan(2000) }) it('memoization: same query 1000x', () => { const db = ThreadInMemory.fromArray([...dataset2000], 'perf-memo') // First call populates cache query(db, [{ at: 'entity/type', vl: 'block' }]) const result = benchmarkSync('query memoized cache hit (2K entities)', () => { query(db, [{ at: 'entity/type', vl: 'block' }]) }, 1000) // Cache hits should be sub-millisecond expect(result.median).toBeLessThan(1) }) }) describe('liveQuery() performance', () => { it('setup cost: create live query and get initial results', () => { const result = benchmarkSync('liveQuery setup (2K entities)', () => { const db = ThreadInMemory.fromArray([...dataset2000], 'perf-live-setup') const lq = liveQuery(db, [{ en: '?id', at: 'entity/type', vl: '?type' }]) expect(lq.nodes.length).toBeGreaterThan(0) lq.dispose() }, 50) expect(result.median).toBeLessThan(2000) }) it('incremental update: insert 1 applog into live query', () => { const db = ThreadInMemory.fromArray([...dataset2000], 'perf-live-incr') const lq = liveQuery(db, [{ at: 'entity/type', vl: 'block' }]) const initialCount = lq.nodes.length let insertCounter = 0 const result = benchmarkSync('liveQuery incremental insert (2K entities)', () => { db.insert([{ en: `new-entity-${insertCounter++}`, at: 'entity/type', vl: 'block', ag: AGENT, }]) }, 200) expect(lq.nodes.length).toBeGreaterThan(initialCount) lq.dispose() // Incremental should be fast expect(result.median).toBeLessThan(50) }) it('incremental update: insert into multi-step live query', () => { const db = ThreadInMemory.fromArray([...dataset500], 'perf-live-multi-incr') const lq = liveQuery(db, [ { en: '?id', at: 'entity/type', vl: 'block' }, { en: '?id', at: 'entity/status', vl: '?status' }, ]) const initialCount = lq.nodes.length let insertCounter = 0 const result = benchmarkSync('liveQuery multi-step incremental (500 entities)', () => { const en = `new-multi-${insertCounter++}` db.insert([ { en, at: 'entity/type', vl: 'block', ag: AGENT }, { en, at: 'entity/status', vl: 'draft', ag: AGENT }, ]) }, 100) expect(lq.nodes.length).toBeGreaterThan(initialCount) lq.dispose() expect(result.median).toBeLessThan(100) }) it('subscribe event delivery latency', () => { const db = ThreadInMemory.fromArray([...dataset2000], 'perf-live-subscribe') const lq = liveQuery(db, [{ at: 'entity/type', vl: 'block' }]) const times: number[] = [] lq.subscribe(() => { times.push(performance.now()) }) let insertCounter = 0 for (let i = 0; i < 100; i++) { const start = performance.now() db.insert([{ en: `sub-entity-${insertCounter++}`, at: 'entity/type', vl: 'block', ag: AGENT, }]) // times array is populated synchronously by the subscribe callback } expect(times).toHaveLength(100) lq.dispose() console.log(` [PERF] liveQuery subscribe: 100 events delivered synchronously`) }) }) describe('withoutDeleted() performance', () => { it('initial filtering on large thread', () => { const result = benchmarkSync('withoutDeleted initial (2K entities)', () => { const db = ThreadInMemory.fromArray([...dataset2000], 'perf-wod') const filtered = withoutDeleted(db) expect(filtered.applogs.length).toBeLessThan(dataset2000.length) }, 50) expect(result.median).toBeLessThan(1000) }) it('incremental: insert deletion into large thread', () => { const db = ThreadInMemory.fromArray([...dataset2000], 'perf-wod-incr') const filtered = withoutDeleted(db) const initialCount = filtered.applogs.length let deleteCounter = 0 const result = benchmarkSync('withoutDeleted incremental delete (2K entities)', () => { db.insert([{ en: `e${100 + deleteCounter++}`, // delete existing entities at: 'isDeleted', vl: true, ag: AGENT, }]) }, 100) expect(filtered.applogs.length).toBeLessThanOrEqual(initialCount) expect(result.median).toBeLessThan(50) }) }) describe('lastWriteWins() performance', () => { it('initial deduplication on large thread', () => { const result = benchmarkSync('lastWriteWins initial (2K entities)', () => { const db = ThreadInMemory.fromArray([...dataset2000], 'perf-lww') const lww = lastWriteWins(db) expect(lww.applogs.length).toBeLessThan(dataset2000.length) }, 50) expect(result.median).toBeLessThan(1000) }) it('incremental: insert overwriting attribute', () => { const db = ThreadInMemory.fromArray([...dataset2000], 'perf-lww-incr') const lww = lastWriteWins(db) const initialCount = lww.applogs.length let updateCounter = 0 const result = benchmarkSync('lastWriteWins incremental update (2K entities)', () => { db.insert([{ en: `e${updateCounter++ % 500}`, at: 'entity/status', vl: `updated-${updateCounter}`, ag: AGENT, }]) }, 200) // Count should stay roughly the same (overwrites, not additions) expect(lww.applogs.length).toBeLessThanOrEqual(initialCount + 200) // some might be new en+at combos expect(result.median).toBeLessThan(50) }) }) describe('rollingFilter() performance', () => { it('initial filter on large thread', () => { const result = benchmarkSync('rollingFilter initial (2K entities)', () => { const db = ThreadInMemory.fromArray([...dataset2000], 'perf-rf') const filtered = rollingFilter(db, { at: 'entity/type', vl: 'block' }) expect(filtered.applogs.length).toBeGreaterThan(0) }, 50) expect(result.median).toBeLessThan(500) }) it('incremental: insert matching applog', () => { const db = ThreadInMemory.fromArray([...dataset2000], 'perf-rf-incr') const filtered = rollingFilter(db, { at: 'entity/type', vl: 'block' }) const initialCount = filtered.applogs.length let insertCounter = 0 const result = benchmarkSync('rollingFilter incremental insert (2K entities)', () => { db.insert([{ en: `rf-entity-${insertCounter++}`, at: 'entity/type', vl: 'block', ag: AGENT, }]) }, 200) expect(filtered.applogs.length).toBeGreaterThan(initialCount) expect(result.median).toBeLessThan(50) }) it('incremental: insert non-matching applog (should be fast no-op)', () => { const db = ThreadInMemory.fromArray([...dataset2000], 'perf-rf-nomatch') const filtered = rollingFilter(db, { at: 'entity/type', vl: 'block' }) const initialCount = filtered.applogs.length let insertCounter = 0 const result = benchmarkSync('rollingFilter non-matching insert (2K entities)', () => { db.insert([{ en: `rf-nomatch-${insertCounter++}`, at: 'entity/type', vl: 'image', ag: AGENT, }]) }, 200) expect(filtered.applogs.length).toBe(initialCount) expect(result.median).toBeLessThan(50) }) }) describe('combined pipeline performance', () => { it('full pipeline: withoutDeleted -> lastWriteWins -> query', () => { const result = benchmarkSync('full pipeline (500 entities)', () => { const db = ThreadInMemory.fromArray([...dataset500], 'perf-pipeline') const noDeleted = withoutDeleted(db) const lww = lastWriteWins(noDeleted) const qr = query(lww, [ { en: '?id', at: 'entity/type', vl: 'block' }, { en: '?id', at: 'entity/name', vl: '?name' }, ]) expect(qr.records.length).toBeGreaterThan(0) }, 30) expect(result.median).toBeLessThan(3000) }) it('full pipeline incremental: insert into active pipeline', () => { const db = ThreadInMemory.fromArray([...dataset500], 'perf-pipeline-incr') const noDeleted = withoutDeleted(db) const lww = lastWriteWins(noDeleted) const lq = liveQuery(lww, [{ en: '?id', at: 'entity/type', vl: 'block' }]) const initialCount = lq.nodes.length let insertCounter = 0 const result = benchmarkSync('full pipeline incremental (500 entities)', () => { db.insert([ { en: `pipe-${insertCounter}`, at: 'entity/type', vl: 'block', ag: AGENT }, { en: `pipe-${insertCounter}`, at: 'entity/name', vl: `New ${insertCounter}`, ag: AGENT }, ]) insertCounter++ }, 100) expect(lq.nodes.length).toBeGreaterThan(initialCount) lq.dispose() expect(result.median).toBeLessThan(100) }) })