mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
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:
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user