From 2ab72a9c988122635e9610f7d7353d91e96af31d Mon Sep 17 00:00:00 2001 From: xNet Test Date: Sun, 5 Jul 2026 16:44:11 -0700 Subject: [PATCH] perf(data): stop query-debug diagnostics convoying the SQLite worker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With xnet:query:debug enabled, plan diagnostics issued EXPLAIN QUERY PLAN + PRAGMA schema_version + one PRAGMA index_info per index (~29) as separate round-trips per query on the single serial SQLite worker. The #351 schema_version-keyed cache only populated after a build finished, so concurrent boot queries all missed it and each enqueued its own full build — hundreds of identical index_info round-trips convoying real query results by 18-20s (2026-07-05 capture: loadDoc readMs 19637 with worker execMs 0). - getIndexInfo shares one in-flight probe+build across concurrent callers, and fetches all index metadata in ONE batched pragma_index_info join (per-index fallback for runtimes without table-valued pragmas): a cold diagnostic run is 2 round-trips, warm 1. - The storage adapter memoizes plan diagnostics per unique compiled SQL shape per session (bound values are params, so keyset pages share an entry), cleared when adaptive indexes are created/dropped, capped at 512 entries. - Regression tests count worker round-trips per diagnostic run at the diagnostics layer, verify concurrent dedupe + fallback + a real sql.js engine pass, and assert one EXPLAIN per SQL shape at the adapter layer. Co-Authored-By: Claude Fable 5 Signed-off-by: xNet Test --- .changeset/query-debug-diagnostics-convoy.md | 15 ++ .../data/src/store/sqlite-adapter.test.ts | 49 +++++ packages/data/src/store/sqlite-adapter.ts | 62 +++++- packages/sqlite/src/diagnostics.test.ts | 190 +++++++++++++++--- packages/sqlite/src/diagnostics.ts | 116 ++++++++++- ...ry-debug-mode-no-longer-distorts-what.json | 8 + 6 files changed, 400 insertions(+), 40 deletions(-) create mode 100644 .changeset/query-debug-diagnostics-convoy.md create mode 100644 site/src/data/changelog/2026-07-05-query-debug-mode-no-longer-distorts-what.json diff --git a/.changeset/query-debug-diagnostics-convoy.md b/.changeset/query-debug-diagnostics-convoy.md new file mode 100644 index 000000000..357c20555 --- /dev/null +++ b/.changeset/query-debug-diagnostics-convoy.md @@ -0,0 +1,15 @@ +--- +'@xnetjs/sqlite': patch +'@xnetjs/data': patch +--- + +Query-plan debug diagnostics no longer convoy the SQLite worker. With +`xnet:query:debug` enabled, every query used to issue EXPLAIN QUERY PLAN + +PRAGMA schema_version + one PRAGMA index_info per index as separate serial +worker round-trips — hundreds per boot, delaying real query results by +18-20s. `getIndexInfo` now dedupes concurrent callers onto one in-flight +build and fetches all index metadata in a single batched +`pragma_index_info` join (with a per-index fallback for runtimes without +table-valued pragmas), and the storage adapter collects plan diagnostics +once per unique compiled SQL shape per session instead of per execution +(invalidated when adaptive indexes are created or dropped). diff --git a/packages/data/src/store/sqlite-adapter.test.ts b/packages/data/src/store/sqlite-adapter.test.ts index 575e999fd..a9e4df836 100644 --- a/packages/data/src/store/sqlite-adapter.test.ts +++ b/packages/data/src/store/sqlite-adapter.test.ts @@ -2052,6 +2052,55 @@ describe('SQLiteNodeStorageAdapter', () => { } }) + it('throttles plan diagnostics to one collection per compiled SQL shape (2026-07-05 convoy)', async () => { + // Pre-fix, EVERY debug-mode execution issued EXPLAIN QUERY PLAN + + // PRAGMA schema_version + the index inventory as separate round-trips + // on the single serial worker — hundreds per boot, delaying the very + // queries being measured by 18-20s. Diagnostics must be collected once + // per unique compiled SQL shape and served from the session memo after. + const throttledAdapter = new SQLiteNodeStorageAdapter(db, { queryDiagnostics: true }) + const querySpy = vi.spyOn(db, 'query') + const explainCount = () => + querySpy.mock.calls.filter(([sql]) => String(sql).startsWith('EXPLAIN QUERY PLAN')).length + + const descriptor = { + schemaId: taskSchemaId, + includeDeleted: false, + where: { status: 'open' } + } + + const first = await throttledAdapter.queryNodes(descriptor) + expect(first.plan.usedIndexNames).toBeDefined() + expect(first.plan.availableIndexCount).toBeGreaterThan(0) + expect(explainCount()).toBe(1) + + // Same shape again — and the same shape with different bound values + // (values are `?` params, so the compiled SQL is identical): both are + // served from the memo, with diagnostics still present on every plan. + const second = await throttledAdapter.queryNodes(descriptor) + const third = await throttledAdapter.queryNodes({ + ...descriptor, + where: { status: 'done' } + }) + expect(second.plan.usedIndexNames).toBeDefined() + expect(third.plan.usedIndexNames).toBeDefined() + expect(explainCount()).toBe(1) + + // Concurrent cold executions (the boot pattern) share one in-flight + // collection instead of each enqueueing their own. + const coldAdapter = new SQLiteNodeStorageAdapter(db, { queryDiagnostics: true }) + const before = explainCount() + const results = await Promise.all( + Array.from({ length: 8 }, () => coldAdapter.queryNodes(descriptor)) + ) + for (const result of results) { + expect(result.plan.usedIndexNames).toBeDefined() + } + expect(explainCount()).toBe(before + 1) + + querySpy.mockRestore() + }) + it('skips plan diagnostics by default', async () => { const defaultAdapter = new SQLiteNodeStorageAdapter(db) diff --git a/packages/data/src/store/sqlite-adapter.ts b/packages/data/src/store/sqlite-adapter.ts index 6bd7bf8b1..5fffba92b 100644 --- a/packages/data/src/store/sqlite-adapter.ts +++ b/packages/data/src/store/sqlite-adapter.ts @@ -151,9 +151,13 @@ export interface SQLiteNodeStorageAdapterOptions { adaptiveIndexing?: SQLiteAdaptiveIndexingOptions queryVerification?: SQLiteQueryVerificationOptions /** - * Collect EXPLAIN QUERY PLAN + index inventory per query. Costs extra - * round trips per query, so it is off unless explicitly enabled or the - * `xnet:query:debug` localStorage flag is set. + * Collect EXPLAIN QUERY PLAN + index inventory for queries. Costs extra + * round trips, so it is off unless explicitly enabled or the + * `xnet:query:debug` localStorage flag is set. Diagnostics are collected + * once per unique compiled SQL shape per session (invalidated when the + * adapter itself runs DDL) — per-execution collection convoyed the serial + * worker and delayed the very queries being measured by 18-20s at boot + * (2026-07-05 capture). */ queryDiagnostics?: boolean /** @@ -316,6 +320,12 @@ const DEFAULT_QUERY_VERIFICATION: QueryVerificationConfig = { const QUERY_TELEMETRY_FLUSH_THRESHOLD = 50 +// Distinct compiled SQL shapes are usually few (dozens), but IN-list binds +// mint one shape per list length, so a long debug session can keep growing. +// Evict oldest-first past this — recomputing a plan later is cheap; holding +// thousands of memo entries is not. +const COMPILED_QUERY_DIAGNOSTICS_MEMO_LIMIT = 512 + interface PendingQueryTelemetry { schemaId: string descriptorJson: string @@ -429,6 +439,15 @@ export class SQLiteNodeStorageAdapter implements NodeStorageAdapter { private storageCapabilitiesPromise?: Promise + /** + * Plan diagnostics memoized per compiled SQL shape (the string EXPLAINed), + * shared across executions AND concurrent callers. Cleared when this adapter + * creates/drops an adaptive index, since that changes plans. Debug mode must + * not distort what it measures: per-execution EXPLAIN + index-inventory + * round-trips convoyed the single serial worker at boot. + */ + private compiledQueryDiagnosticsMemo = new Map>() + private spatialTablesState: SpatialTablesState = 'unknown' private fullTextSearchTablesState: FullTextSearchTablesState = 'unknown' @@ -3282,7 +3301,38 @@ CREATE INDEX IF NOT EXISTS idx_node_spatial_ids_schema return this.queryDiagnostics || this.isQueryDebugEnabled() } - private async collectCompiledQueryDiagnostics( + /** + * Throttled: one collection per unique compiled SQL shape per session. + * EXPLAIN plans depend on the statement, not the bound values, so keyset + * pages of the same query share one entry. Concurrent callers share the + * in-flight promise; failed collections are not memoized. + */ + private collectCompiledQueryDiagnostics( + compiled: CompiledNodeQuery + ): Promise { + const memoKey = compiled.sql + const memoized = this.compiledQueryDiagnosticsMemo.get(memoKey) + if (memoized) { + return memoized + } + + const pending = this.computeCompiledQueryDiagnostics(compiled).then((diagnostics) => { + if (diagnostics.diagnosticsError) { + this.compiledQueryDiagnosticsMemo.delete(memoKey) + } + return diagnostics + }) + if (this.compiledQueryDiagnosticsMemo.size >= COMPILED_QUERY_DIAGNOSTICS_MEMO_LIMIT) { + const oldest = this.compiledQueryDiagnosticsMemo.keys().next().value + if (oldest !== undefined) { + this.compiledQueryDiagnosticsMemo.delete(oldest) + } + } + this.compiledQueryDiagnosticsMemo.set(memoKey, pending) + return pending + } + + private async computeCompiledQueryDiagnostics( compiled: CompiledNodeQuery ): Promise { try { @@ -3574,6 +3624,8 @@ CREATE INDEX IF NOT EXISTS idx_node_spatial_ids_schema if (createdIndex) { await runAnalyze(this.db, 'node_property_scalars') await this.db.exec('PRAGMA optimize') + // New index → plans may change; memoized diagnostics are stale. + this.compiledQueryDiagnosticsMemo.clear() } return touchedIndexNames @@ -3786,6 +3838,8 @@ CREATE INDEX IF NOT EXISTS idx_node_spatial_ids_schema ): Promise { await this.db.exec(`DROP INDEX IF EXISTS ${this.quoteSqlIdentifier(indexName)}`) await this.db.run('DELETE FROM query_index_candidates WHERE index_name = ?', [indexName]) + // Dropped index → plans may change; memoized diagnostics are stale. + this.compiledQueryDiagnosticsMemo.clear() this.debugAdaptiveIndex('drop', { indexName, reason }) } diff --git a/packages/sqlite/src/diagnostics.test.ts b/packages/sqlite/src/diagnostics.test.ts index 821c693e9..40cf45639 100644 --- a/packages/sqlite/src/diagnostics.test.ts +++ b/packages/sqlite/src/diagnostics.test.ts @@ -1,28 +1,57 @@ /** - * Tests for getIndexInfo's per-adapter, schema_version-keyed cache (0253). + * Tests for getIndexInfo's per-adapter, schema_version-keyed cache (0253) and + * its round-trip budget (2026-07-05 debug-convoy capture). * - * `collectCompiledQueryDiagnostics` called `getIndexInfo` on every cold query, - * each call re-running `sqlite_master` + one `PRAGMA index_info` per index — - * ~870 serial worker round-trips in the 0253 capture, which flooded the boot log - * and obscured the real stall. The index set is stable between DDL changes, so - * the cache must serve repeated calls off one build and only rebuild when - * `PRAGMA schema_version` bumps. + * `collectCompiledQueryDiagnostics` called `getIndexInfo` on every cold query. + * Two regressions live here: + * + * 1. 0253: each call re-ran `sqlite_master` + one `PRAGMA index_info` per index + * — ~870 serial worker round-trips flooding the boot log. Fixed by the + * schema_version-keyed cache (#351). + * 2. 2026-07-05: the #351 cache only populated AFTER a build finished, so + * concurrent boot queries all missed it and each enqueued its own full + * build on the single serial worker — hundreds of identical `index_info` + * round-trips convoying real query results by 18-20s. Fixed by sharing the + * in-flight probe+build across concurrent callers AND collapsing the build + * itself into ONE batched `pragma_index_info` join. + * + * The round-trip counters below are the regression guard: a cold diagnostic + * run must cost 2 worker round-trips (version probe + batched fetch), a warm + * one 1 (probe only), and N concurrent cold calls must still cost 2 total. */ import type { SQLiteAdapter } from './adapter' import { describe, it, expect } from 'vitest' +import { createMemorySQLiteAdapter } from './adapters/memory' import { getIndexInfo } from './diagnostics' /** Minimal adapter stub: getIndexInfo only ever calls `query`/`queryOne`. */ -function makeStub(initialVersion: number): { +function makeStub( + initialVersion: number, + options: { supportsBatchedPragma?: boolean } = {} +): { db: SQLiteAdapter setVersion: (v: number) => void - counts: { schemaVersion: number; sqliteMaster: number; indexInfo: number } + counts: { + total: number + schemaVersion: number + batchedIndexInfo: number + sqliteMaster: number + indexInfo: number + } } { + const supportsBatchedPragma = options.supportsBatchedPragma ?? true let version = initialVersion - const counts = { schemaVersion: 0, sqliteMaster: 0, indexInfo: 0 } + const counts = { + total: 0, + schemaVersion: 0, + batchedIndexInfo: 0, + sqliteMaster: 0, + indexInfo: 0 + } const db = { async queryOne(sql: string): Promise { + counts.total++ if (sql.includes('schema_version')) { counts.schemaVersion++ return { schema_version: version } @@ -30,15 +59,44 @@ function makeStub(initialVersion: number): { return null }, async query(sql: string): Promise { + counts.total++ + // The batched statement contains BOTH `sqlite_master` and + // `pragma_index_info(` — match it first. + if (sql.includes('pragma_index_info(')) { + if (!supportsBatchedPragma) { + throw new Error('no such table-valued function: pragma_index_info') + } + counts.batchedIndexInfo++ + return [ + { + index_name: 'idx_nodes_schema', + table_name: 'nodes', + index_sql: 'CREATE INDEX idx_nodes_schema ON nodes (schema_id, updated_at)', + seqno: 0, + column_name: 'schema_id' + }, + { + index_name: 'idx_nodes_schema', + table_name: 'nodes', + index_sql: 'CREATE INDEX idx_nodes_schema ON nodes (schema_id, updated_at)', + seqno: 1, + column_name: 'updated_at' + } + ] + } if (sql.includes('FROM sqlite_master')) { counts.sqliteMaster++ return [ - { name: 'idx_nodes_schema', tbl_name: 'nodes', sql: 'CREATE INDEX idx_nodes_schema ...' } + { + name: 'idx_nodes_schema', + tbl_name: 'nodes', + sql: 'CREATE INDEX idx_nodes_schema ON nodes (schema_id, updated_at)' + } ] } if (sql.includes('index_info')) { counts.indexInfo++ - return [{ name: 'schema_id' }] + return [{ name: 'schema_id' }, { name: 'updated_at' }] } return [] } @@ -47,36 +105,67 @@ function makeStub(initialVersion: number): { return { db, setVersion: (v: number) => (version = v), counts } } -describe('getIndexInfo cache (0253)', () => { - it('builds once, then serves repeat calls without re-reading sqlite_master', async () => { +describe('getIndexInfo cache (0253) + round-trip budget (2026-07-05)', () => { + it('cold build costs exactly 2 worker round-trips: version probe + one batched fetch', async () => { const { db, counts } = makeStub(7) const first = await getIndexInfo(db) expect(first).toHaveLength(1) - expect(first[0].columns).toEqual(['schema_id']) - expect(counts.sqliteMaster).toBe(1) - expect(counts.indexInfo).toBe(1) + expect(first[0]).toEqual({ + name: 'idx_nodes_schema', + tableName: 'nodes', + unique: false, + columns: ['schema_id', 'updated_at'], + partial: false + }) + expect(counts.schemaVersion).toBe(1) + expect(counts.batchedIndexInfo).toBe(1) + expect(counts.sqliteMaster).toBe(0) + expect(counts.indexInfo).toBe(0) + expect(counts.total).toBe(2) + }) + + it('serves repeat calls off the cache — 1 probe round-trip each, no rebuild', async () => { + const { db, counts } = makeStub(7) + + await getIndexInfo(db) + expect(counts.total).toBe(2) // Three more calls at the same schema_version: each pays only the cheap - // version probe; sqlite_master + index_info are NOT re-run. + // version probe (that's the invalidation check); no re-fetch. await getIndexInfo(db) await getIndexInfo(db) await getIndexInfo(db) - expect(counts.sqliteMaster).toBe(1) - expect(counts.indexInfo).toBe(1) - // The version probe still runs every call (that's the invalidation check). + expect(counts.batchedIndexInfo).toBe(1) expect(counts.schemaVersion).toBe(4) + expect(counts.total).toBe(5) + }) + + it('concurrent callers share ONE in-flight probe+build (the 2026-07-05 convoy)', async () => { + const { db, counts } = makeStub(3) + + // Boot fires dozens of debug-instrumented queries before the first build + // resolves. Pre-fix, each of these enqueued its own full build on the + // serial worker; now they must all piggyback on one. + const results = await Promise.all(Array.from({ length: 25 }, () => getIndexInfo(db))) + + expect(counts.schemaVersion).toBe(1) + expect(counts.batchedIndexInfo).toBe(1) + expect(counts.total).toBe(2) + for (const result of results) { + expect(result).toBe(results[0]) // the same cached array, not 25 copies + } }) it('rebuilds when schema_version bumps (a DDL change)', async () => { const { db, setVersion, counts } = makeStub(1) await getIndexInfo(db) - expect(counts.sqliteMaster).toBe(1) + expect(counts.batchedIndexInfo).toBe(1) setVersion(2) // e.g. a CREATE INDEX ran await getIndexInfo(db) - expect(counts.sqliteMaster).toBe(2) + expect(counts.batchedIndexInfo).toBe(2) }) it('keys the cache per adapter (no cross-adapter leakage)', async () => { @@ -84,7 +173,58 @@ describe('getIndexInfo cache (0253)', () => { const b = makeStub(5) await getIndexInfo(a.db) await getIndexInfo(b.db) - expect(a.counts.sqliteMaster).toBe(1) - expect(b.counts.sqliteMaster).toBe(1) + expect(a.counts.batchedIndexInfo).toBe(1) + expect(b.counts.batchedIndexInfo).toBe(1) + }) + + it('falls back to per-index PRAGMA index_info when table-valued pragmas are unavailable', async () => { + const { db, counts } = makeStub(9, { supportsBatchedPragma: false }) + + const result = await getIndexInfo(db) + expect(result).toHaveLength(1) + expect(result[0].columns).toEqual(['schema_id', 'updated_at']) + expect(counts.sqliteMaster).toBe(1) + expect(counts.indexInfo).toBe(1) + + // The fallback result is cached too — no repeat loop on the next call. + await getIndexInfo(db) + expect(counts.sqliteMaster).toBe(1) + expect(counts.indexInfo).toBe(1) + }) + + it('batched fetch works against a real SQLite engine (sql.js)', async () => { + const db = await createMemorySQLiteAdapter() + try { + await db.exec('CREATE TABLE diag_probe (a TEXT, b INTEGER)') + await db.exec('CREATE UNIQUE INDEX idx_diag_unique ON diag_probe (a)') + await db.exec('CREATE INDEX idx_diag_multi ON diag_probe (a, b)') + await db.exec('CREATE INDEX idx_diag_partial ON diag_probe (b) WHERE b IS NOT NULL') + + const indexes = await getIndexInfo(db) + const byName = new Map(indexes.map((index) => [index.name, index])) + + expect(byName.get('idx_diag_unique')).toMatchObject({ + tableName: 'diag_probe', + unique: true, + columns: ['a'], + partial: false + }) + expect(byName.get('idx_diag_multi')).toMatchObject({ + unique: false, + columns: ['a', 'b'], // seqno order preserved by the batched join + partial: false + }) + expect(byName.get('idx_diag_partial')).toMatchObject({ + columns: ['b'], + partial: true + }) + + // DDL bumps schema_version → the cache must rebuild and see the new index. + await db.exec('CREATE INDEX idx_diag_late ON diag_probe (b)') + const refreshed = await getIndexInfo(db) + expect(refreshed.some((index) => index.name === 'idx_diag_late')).toBe(true) + } finally { + await db.close() + } }) }) diff --git a/packages/sqlite/src/diagnostics.ts b/packages/sqlite/src/diagnostics.ts index 7d16a0bd8..5a9e6bd8c 100644 --- a/packages/sqlite/src/diagnostics.ts +++ b/packages/sqlite/src/diagnostics.ts @@ -54,8 +54,19 @@ export interface SQLiteRuntimeCapabilities { * index_info` *per index* on every cold query — ~870 serial worker round-trips in * the 0253 capture, flooding the boot log and obscuring the real stall. The index * set is stable between schema changes, so one build per `schema_version` suffices. + * + * `inFlight` dedupes CONCURRENT callers: the resolved cache is only populated + * once a build finishes, so without it every query issued while the first build + * was still queued on the serial worker started its own full build — hundreds of + * identical `index_info` round-trips per boot convoying real query results by + * 18-20s in the 2026-07-05 capture, despite the #351 cache. */ -const indexInfoCache = new WeakMap() +interface IndexInfoCacheEntry { + inFlight?: Promise + resolved?: { schemaVersion: number; indexes: IndexInfo[] } +} + +const indexInfoCache = new WeakMap() async function readSchemaVersion(db: SQLiteAdapter): Promise { try { @@ -67,18 +78,56 @@ async function readSchemaVersion(db: SQLiteAdapter): Promise { } /** - * Get information about all indexes in the database. Cached per adapter and keyed - * on `PRAGMA schema_version`, so repeated calls between DDL changes pay one cheap - * version probe instead of `sqlite_master` + N `PRAGMA index_info` round-trips - * (exploration 0253). + * Fetch every index and its columns in ONE statement via the `pragma_index_info` + * table-valued function (SQLite ≥ 3.16), instead of `sqlite_master` + one + * `PRAGMA index_info` round-trip per index. On the single serial worker each + * round-trip queues behind real queries, so the loop form is O(indexes) latency + * even when every statement executes in 0ms. */ -export async function getIndexInfo(db: SQLiteAdapter): Promise { - const schemaVersion = await readSchemaVersion(db) - const cached = indexInfoCache.get(db) - if (cached && cached.schemaVersion === schemaVersion) { - return cached.indexes +async function fetchIndexInfoBatched(db: SQLiteAdapter): Promise { + interface BatchedIndexRow { + index_name: string + table_name: string + index_sql: string | null + seqno: number | null + column_name: string | null + [key: string]: SQLValue } + const rows = await db.query( + `SELECT m.name AS index_name, m.tbl_name AS table_name, m.sql AS index_sql, + ii.seqno AS seqno, ii.name AS column_name + FROM sqlite_master AS m + LEFT JOIN pragma_index_info(m.name) AS ii + WHERE m.type = 'index' AND m.name NOT LIKE 'sqlite_%' + ORDER BY m.name, ii.seqno` + ) + + const byName = new Map() + for (const row of rows) { + let info = byName.get(row.index_name) + if (!info) { + info = { + name: row.index_name, + tableName: row.table_name, + unique: row.index_sql?.includes('UNIQUE') ?? false, + columns: [], + partial: row.index_sql?.includes('WHERE') ?? false + } + byName.set(row.index_name, info) + } + if (row.seqno !== null) { + // Expression-index columns have a NULL name — preserved as-is, matching + // what `PRAGMA index_info` reported on the per-index path. + info.columns.push(row.column_name as string) + } + } + + return [...byName.values()] +} + +/** Per-index fallback for runtimes without table-valued pragma functions. */ +async function fetchIndexInfoPerIndex(db: SQLiteAdapter): Promise { interface IndexRow { name: string tbl_name: string @@ -109,10 +158,55 @@ export async function getIndexInfo(db: SQLiteAdapter): Promise { }) } - indexInfoCache.set(db, { schemaVersion, indexes: result }) return result } +/** + * Get information about all indexes in the database. Cached per adapter and keyed + * on `PRAGMA schema_version`, so repeated calls between DDL changes pay one cheap + * version probe instead of a rebuild (exploration 0253). Concurrent callers share + * a single in-flight probe+build, and a cold build is a single batched statement + * — worst case one diagnostic run costs 2 worker round-trips, not 1 + 1 + N. + */ +export async function getIndexInfo(db: SQLiteAdapter): Promise { + let entry = indexInfoCache.get(db) + if (!entry) { + entry = {} + indexInfoCache.set(db, entry) + } + + // A probe/build is already queued on the worker — piggyback instead of + // enqueueing another. Callers racing a DDL change were unordered anyway. + if (entry.inFlight) { + return entry.inFlight + } + + const cacheEntry = entry + const inFlight = (async () => { + const schemaVersion = await readSchemaVersion(db) + if (cacheEntry.resolved && cacheEntry.resolved.schemaVersion === schemaVersion) { + return cacheEntry.resolved.indexes + } + + let indexes: IndexInfo[] + try { + indexes = await fetchIndexInfoBatched(db) + } catch { + indexes = await fetchIndexInfoPerIndex(db) + } + + cacheEntry.resolved = { schemaVersion, indexes } + return indexes + })() + + entry.inFlight = inFlight + try { + return await inFlight + } finally { + cacheEntry.inFlight = undefined + } +} + /** * Check which indexes are being used for a query. */ diff --git a/site/src/data/changelog/2026-07-05-query-debug-mode-no-longer-distorts-what.json b/site/src/data/changelog/2026-07-05-query-debug-mode-no-longer-distorts-what.json new file mode 100644 index 000000000..d5f35ea14 --- /dev/null +++ b/site/src/data/changelog/2026-07-05-query-debug-mode-no-longer-distorts-what.json @@ -0,0 +1,8 @@ +{ + "id": "2026-07-05-query-debug-mode-no-longer-distorts-what", + "date": "July 5, 2026", + "title": "Query debug mode no longer distorts what it measures", + "summary": "With xnet:query:debug enabled, plan diagnostics issued hundreds of serial SQLite worker round-trips per boot (EXPLAIN + per-index PRAGMAs per query), delaying real query results by 18-20s. Index metadata is now fetched in one batched statement, shared across concurrent callers, and plan diagnostics are collected once per unique query shape per session.", + "highlights": [], + "tags": ["performance", "devtools"] +}