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.
This commit is contained in:
Jinwoo-H
2026-09-06 04:48:04 -04:00
parent feb5ad5af4
commit 5ffeb0ef78
2 changed files with 107 additions and 16 deletions
@@ -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<string> {
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' })
}
})
})
@@ -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')