From 5ffeb0ef787be136e2253b418f936544491cdf6e Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 2 Sep 2026 03:30:29 -0400 Subject: [PATCH] fix(ai-vault-search): replace the index file on a schema bump instead of dropping tables DROP TABLE on the 3.3 GB messages_fts took 18 s on this machine, inside the scanner service's init, past its 5 s ready timeout; every search then failed with 'did not become ready' until the restart circuit opened. Unlinking the file and its sidecars is instant and the index is a cache, never a source. --- .../session-search-schema.test.ts | 83 +++++++++++++++++++ .../ai-vault-search/session-search-schema.ts | 40 +++++---- 2 files changed, 107 insertions(+), 16 deletions(-) create mode 100644 src/main/ai-vault-search/session-search-schema.test.ts diff --git a/src/main/ai-vault-search/session-search-schema.test.ts b/src/main/ai-vault-search/session-search-schema.test.ts new file mode 100644 index 00000000000..d922e1010d6 --- /dev/null +++ b/src/main/ai-vault-search/session-search-schema.test.ts @@ -0,0 +1,83 @@ +import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import type SyncDatabase from '../sqlite/sync-database' +import { + SESSION_SEARCH_SCHEMA_VERSION, + openSessionSearchDatabase, + removeSessionSearchDatabase +} from './session-search-schema' + +let roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true }))) + roots = [] +}) + +async function tempDatabasePath(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-session-search-schema-')) + roots.push(root) + return join(root, 'index.sqlite') +} + +function schemaVersion(db: SyncDatabase): string | undefined { + return ( + db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get() as + | { value: string } + | undefined + )?.value +} + +describe('openSessionSearchDatabase', () => { + it('keeps a current-version index and its rows', async () => { + const path = await tempDatabasePath() + const first = openSessionSearchDatabase(path) + first + .prepare( + "INSERT INTO search_log(ts, query, route, hits, duration_ms) VALUES ('t', 'q', 'or', 0, 1)" + ) + .run() + first.close() + + const second = openSessionSearchDatabase(path) + expect(schemaVersion(second)).toBe(String(SESSION_SEARCH_SCHEMA_VERSION)) + expect(second.prepare('SELECT COUNT(*) AS c FROM search_log').get()).toEqual({ c: 1 }) + second.close() + }) + + it('replaces the file on a version mismatch instead of dropping tables in place', async () => { + const path = await tempDatabasePath() + const stale = openSessionSearchDatabase(path) + stale + .prepare( + "INSERT INTO search_log(ts, query, route, hits, duration_ms) VALUES ('t', 'q', 'or', 0, 1)" + ) + .run() + stale + .prepare("UPDATE meta SET value = ? WHERE key = 'schema_version'") + .run(String(SESSION_SEARCH_SCHEMA_VERSION - 1)) + stale.close() + // Why: a stale sidecar must go with the main file, or SQLite replays it into the new one. + await writeFile(`${path}-wal`, 'stale wal bytes') + const before = await stat(path) + + const fresh = openSessionSearchDatabase(path) + expect(schemaVersion(fresh)).toBe(String(SESSION_SEARCH_SCHEMA_VERSION)) + expect(fresh.prepare('SELECT COUNT(*) AS c FROM search_log').get()).toEqual({ c: 0 }) + fresh.close() + const after = await stat(path) + expect(after.ino).not.toBe(before.ino) + }) + + it('removes the database with every sidecar', async () => { + const path = await tempDatabasePath() + openSessionSearchDatabase(path).close() + await writeFile(`${path}-shm`, '') + removeSessionSearchDatabase(path) + for (const suffix of ['', '-wal', '-shm']) { + await expect(stat(`${path}${suffix}`)).rejects.toMatchObject({ code: 'ENOENT' }) + } + }) +}) diff --git a/src/main/ai-vault-search/session-search-schema.ts b/src/main/ai-vault-search/session-search-schema.ts index 3e6fd41e04b..3b1d8d17272 100644 --- a/src/main/ai-vault-search/session-search-schema.ts +++ b/src/main/ai-vault-search/session-search-schema.ts @@ -1,3 +1,4 @@ +import { rmSync } from 'node:fs' import SyncDatabase from '../sqlite/sync-database' // Bump to drop and rebuild: the index is a cache over the transcripts, never a source. @@ -63,25 +64,15 @@ CREATE TABLE IF NOT EXISTS search_log( ); ` -const DROP_SQL = ` -DROP TABLE IF EXISTS messages_vocab; -DROP TABLE IF EXISTS conversation_fts; -DROP TABLE IF EXISTS messages_fts; -DROP TABLE IF EXISTS messages; -DROP TABLE IF EXISTS files; -DROP TABLE IF EXISTS sessions; -DROP TABLE IF EXISTS search_log; -DROP TABLE IF EXISTS meta; -` - export function openSessionSearchDatabase(path: string): SyncDatabase { - const db = new SyncDatabase(path) - db.pragma('journal_mode = WAL') - db.pragma('synchronous = NORMAL') - db.pragma('busy_timeout = 5000') + let db = openWithPragmas(path) const version = readSchemaVersion(db) if (version !== null && version !== SESSION_SEARCH_SCHEMA_VERSION) { - db.exec(DROP_SQL) + // Why: DROP TABLE on a multi-GB FTS index takes minutes and runs inside the + // scanner service's init, past its ready timeout; unlinking is instant. + db.close() + removeSessionSearchDatabase(path) + db = openWithPragmas(path) } db.exec(SCHEMA_SQL) db.prepare('INSERT OR REPLACE INTO meta(key, value) VALUES (?, ?)').run( @@ -91,6 +82,23 @@ export function openSessionSearchDatabase(path: string): SyncDatabase { return db } +function openWithPragmas(path: string): SyncDatabase { + const db = new SyncDatabase(path) + db.pragma('journal_mode = WAL') + db.pragma('synchronous = NORMAL') + db.pragma('busy_timeout = 5000') + return db +} + +export function removeSessionSearchDatabase(path: string): void { + if (path === ':memory:') { + return + } + for (const suffix of ['', '-wal', '-shm', '-journal']) { + rmSync(`${path}${suffix}`, { force: true }) + } +} + export function openSessionSearchDatabaseReadOnly(path: string): SyncDatabase { const db = new SyncDatabase(path, { readonly: true, fileMustExist: true }) db.pragma('busy_timeout = 1500')