perf(db): relax frequent local writes, checkpoint WAL off Main, index startup recovery (schema 58)
This commit is contained in:
@@ -19,7 +19,8 @@ const directories: string[] = []
|
||||
const opened: AssistantDatabase[] = []
|
||||
afterEach(() => {
|
||||
for (const database of opened.splice(0)) database.close()
|
||||
for (const directory of directories.splice(0)) rmSync(directory, { recursive: true, force: true })
|
||||
// A readonly worker ends asynchronously after close and may still hold the file briefly.
|
||||
for (const directory of directories.splice(0)) rmSync(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
|
||||
})
|
||||
|
||||
function open(): { database: AssistantDatabase; raw: DatabaseSync; path: string; directory: string } {
|
||||
|
||||
@@ -14,7 +14,8 @@ import {
|
||||
} from '../../shared/assistant-contracts'
|
||||
import {
|
||||
AssistantDatabase,
|
||||
ASSISTANT_DATABASE_SCHEMA_VERSION
|
||||
ASSISTANT_DATABASE_SCHEMA_VERSION,
|
||||
MESSAGE_RECOVERY_CANDIDATE_SQL
|
||||
} from './assistant-database'
|
||||
import { agentRuntimeSelectionKey } from '../../shared/runtime-selection-contracts'
|
||||
import type { ImageOperation } from '../../shared/image-generation-contracts'
|
||||
@@ -530,6 +531,86 @@ function claimManualScheduleQueueItem(
|
||||
}
|
||||
|
||||
describe('AssistantDatabase', () => {
|
||||
it('writes frequent local saves with synchronous NORMAL and keeps FULL for everything else', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-relaxed-'))
|
||||
temporaryDirectories.push(directory)
|
||||
const path = join(directory, 'assistant.sqlite')
|
||||
const database = new AssistantDatabase(path)
|
||||
database.initialize(process.cwd())
|
||||
try {
|
||||
const raw = (database as unknown as { database: DatabaseSync }).database
|
||||
const levels: number[] = []
|
||||
const exec = raw.exec.bind(raw)
|
||||
raw.exec = (sql: string) => {
|
||||
exec(sql)
|
||||
if (/^\s*COMMIT/i.test(sql)) levels.push((raw.prepare('PRAGMA synchronous').get() as { synchronous: number }).synchronous)
|
||||
}
|
||||
const level = (): number => (raw.prepare('PRAGMA synchronous').get() as { synchronous: number }).synchronous
|
||||
expect(level()).toBe(2)
|
||||
const task = database.createTask({ id: randomUUID(), title: 't', instructions: 'i', workMode: 'ask' })
|
||||
database.appendTaskEvent(task.id, 'text', { type: 'text', requestId: task.id, delta: 'a' })
|
||||
expect(level()).toBe(2)
|
||||
const id = randomUUID()
|
||||
database.saveLocalConversations([{ header: { id, title: 'Relaxed', updatedAt: 1 },
|
||||
messages: [{ id: randomUUID(), role: 'user', state: 'complete', content: 'hi', createdAt: 1 }] }])
|
||||
expect(levels.at(-1)).toBe(1)
|
||||
expect(level()).toBe(2)
|
||||
// A failed relaxed write still restores FULL.
|
||||
expect(() => database.saveLocalConversations([{ header: { id, title: 'Relaxed', updatedAt: 2 },
|
||||
messages: [{ id: randomUUID(), role: 'user', state: 'complete', content: 'x', createdAt: 2 }, { id: 'dup', role: 'user', state: 'complete', content: 'y', createdAt: 2 }, { id: 'dup', role: 'assistant', state: 'complete', content: 'z', createdAt: 2 }] }]))
|
||||
.toThrow()
|
||||
expect(level()).toBe(2)
|
||||
// Lifecycle events and task status changes commit with FULL.
|
||||
levels.length = 0
|
||||
database.updateTaskStatus(task.id, 'completed')
|
||||
expect(level()).toBe(2)
|
||||
expect(levels.every((value) => value === 2)).toBe(true)
|
||||
expect(database.getConversation(id).messages.map((message) => message.content)).toEqual(['hi'])
|
||||
} finally { database.close() }
|
||||
})
|
||||
|
||||
it('startup recovery reads only candidate messages and ends the same ones as a full scan', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-recovery-index-'))
|
||||
temporaryDirectories.push(directory)
|
||||
const path = join(directory, 'assistant.sqlite')
|
||||
const database = new AssistantDatabase(path)
|
||||
database.initialize(process.cwd())
|
||||
const id = randomUUID()
|
||||
const message = (content: string, extra: Record<string, unknown> = {}) => ({
|
||||
id: randomUUID(), role: 'assistant' as const, state: 'complete' as const, content, createdAt: 1, ...extra })
|
||||
const messages = [
|
||||
message('streaming', { state: 'streaming' as const }),
|
||||
message('running tool', { tools: [{ name: 'a', state: 'running', summary: 's' }] }),
|
||||
message('pending block', { blocks: [{ type: 'tool', tool: { name: 'b', state: 'pending', summary: 's' } }] }),
|
||||
message('queued subagent', { subagents: [{ childTaskId: randomUUID(), expertId: randomUUID(), expertName: 'e', routingMode: 'native', state: 'queued' }] }),
|
||||
// Mentions "running" only inside text: a candidate, but nothing to end.
|
||||
message('"state":"running" in text'),
|
||||
message('done', { tools: [{ name: 'c', state: 'completed', summary: 's' }] })
|
||||
]
|
||||
database.saveLocalConversations([{ header: { id, title: 'Recovery', updatedAt: 1 }, messages }])
|
||||
const raw = (database as unknown as { database: DatabaseSync }).database
|
||||
const plan = (raw.prepare(`EXPLAIN QUERY PLAN SELECT id FROM messages WHERE ${MESSAGE_RECOVERY_CANDIDATE_SQL}`)
|
||||
.all() as Array<{ detail: string }>).map((row) => row.detail).join(' ')
|
||||
expect(plan).toContain('messages_recovery_idx')
|
||||
const imagePlan = (raw.prepare(`EXPLAIN QUERY PLAN SELECT id FROM messages
|
||||
WHERE ${MESSAGE_RECOVERY_CANDIDATE_SQL} AND json_type(metadata_json, '$.imageOperations') = 'array'`)
|
||||
.all() as Array<{ detail: string }>).map((row) => row.detail).join(' ')
|
||||
expect(imagePlan).toContain('messages_recovery_idx')
|
||||
database.close()
|
||||
|
||||
const recovered = new AssistantDatabase(path)
|
||||
recovered.initialize(process.cwd())
|
||||
try {
|
||||
const after = recovered.getConversation(id).messages
|
||||
expect(after.map((item) => item.state)).toEqual(['error', 'complete', 'complete', 'complete', 'complete', 'complete'])
|
||||
expect(after[1]?.tools?.[0]?.state).toBe('interrupted')
|
||||
expect((after[2]?.blocks?.[0] as { tool: { state: string } }).tool.state).toBe('interrupted')
|
||||
expect(after[3]?.subagents?.[0]?.state).toBe('cancelled')
|
||||
expect(after[4]?.content).toBe('"state":"running" in text')
|
||||
expect(after[5]?.tools?.[0]?.state).toBe('completed')
|
||||
} finally { recovered.close() }
|
||||
})
|
||||
|
||||
it('round trips long checklist metadata and explicit clears across reopen', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-checklist-'))
|
||||
temporaryDirectories.push(directory)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { magicNoteCanvasAnalysisText } from '../../shared/magic-note-canvas-text
|
||||
import { knowledgeReferenceKey } from '../../shared/knowledge-reference'
|
||||
import { appendConversationQuestionBlock } from '../../shared/conversation-question-blocks'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { Worker } from 'node:worker_threads'
|
||||
import { ExecutionStatsReader } from './execution-stats-reader'
|
||||
import { ReadonlyQueryReader, readWithFallback } from '../readonly-query-reader'
|
||||
import { ActivityHistoryRepository, migrateActivityHistoryOrder } from './activity-history-repository'
|
||||
@@ -138,7 +139,23 @@ import {
|
||||
SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION
|
||||
} from './subagent-progress-storage'
|
||||
|
||||
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 57
|
||||
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 58
|
||||
/**
|
||||
* Messages startup recovery may have to end (schema 58): still streaming, or
|
||||
* metadata naming an unfinished tool/subagent (pending, running, queued) or
|
||||
* image operation (running, saving, cancelling). Metadata is always minified
|
||||
* JSON.stringify output, so every such state appears literally; the exact
|
||||
* checks run on these rows only, through the partial index
|
||||
* messages_recovery_idx.
|
||||
*/
|
||||
export const MESSAGE_RECOVERY_CANDIDATE_SQL = `(state = 'streaming'
|
||||
OR instr(metadata_json, '"state":"running"') > 0
|
||||
OR instr(metadata_json, '"state":"pending"') > 0
|
||||
OR instr(metadata_json, '"state":"queued"') > 0
|
||||
OR instr(metadata_json, '"state":"saving"') > 0
|
||||
OR instr(metadata_json, '"state":"cancelling"') > 0)`
|
||||
/** Main's automatic checkpoint threshold while the checkpoint worker runs (16 MB of 4 KB pages). */
|
||||
const WAL_SAFETY_CHECKPOINT_PAGES = 4_000
|
||||
|
||||
export type RemoteTaskEventInput = {
|
||||
taskId: string
|
||||
@@ -1973,6 +1990,7 @@ export class AssistantDatabase {
|
||||
private activityHistoryRepository?: { database: DatabaseSync; repository: ActivityHistoryRepository }
|
||||
private executionStatsReader?: ExecutionStatsReader
|
||||
private readonlyReader?: ReadonlyQueryReader
|
||||
private checkpointWorker?: Worker
|
||||
private readonlyWorkerPath?: string
|
||||
private foldedSearchConnection?: DatabaseSync
|
||||
private readonly noteStorage: MagicNoteStorage
|
||||
@@ -2271,12 +2289,15 @@ export class AssistantDatabase {
|
||||
)`
|
||||
)
|
||||
.run()
|
||||
database.exec(`DELETE FROM messages WHERE json_extract(metadata_json, '$.queueItemId') IN (
|
||||
SELECT id FROM conversation_queue_items WHERE source = 'user'
|
||||
) AND EXISTS (
|
||||
SELECT 1 FROM json_each(messages.metadata_json, '$.attachments') attachment
|
||||
WHERE json_extract(attachment.value, '$.resourceId') IS NOT NULL
|
||||
)`)
|
||||
// This scans every message; with no user queue item it cannot match anything.
|
||||
if (database.prepare("SELECT 1 FROM conversation_queue_items WHERE source = 'user' LIMIT 1").get()) {
|
||||
database.exec(`DELETE FROM messages WHERE json_extract(metadata_json, '$.queueItemId') IN (
|
||||
SELECT id FROM conversation_queue_items WHERE source = 'user'
|
||||
) AND EXISTS (
|
||||
SELECT 1 FROM json_each(messages.metadata_json, '$.attachments') attachment
|
||||
WHERE json_extract(attachment.value, '$.resourceId') IS NOT NULL
|
||||
)`)
|
||||
}
|
||||
database.exec(`
|
||||
INSERT OR IGNORE INTO conversation_queue_items
|
||||
(id, conversation_id, source, label, payload_json,
|
||||
@@ -2318,9 +2339,13 @@ export class AssistantDatabase {
|
||||
|
||||
const recoverableMessages = database
|
||||
.prepare(
|
||||
// The partial index messages_recovery_idx holds exactly the rows
|
||||
// matching the first term (a superset of those updated below), so
|
||||
// startup no longer reads every message's metadata.
|
||||
`SELECT id, state, metadata_json
|
||||
FROM messages
|
||||
WHERE NOT EXISTS (
|
||||
WHERE ${MESSAGE_RECOVERY_CANDIDATE_SQL}
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM tasks
|
||||
WHERE tasks.id = messages.request_id
|
||||
AND tasks.remote_recoverable = 1
|
||||
@@ -2402,6 +2427,7 @@ export class AssistantDatabase {
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.stopWalCheckpointWorker()
|
||||
this.executionStatsReader?.close()
|
||||
this.executionStatsReader = undefined
|
||||
this.readonlyReader?.close()
|
||||
@@ -3337,6 +3363,11 @@ export class AssistantDatabase {
|
||||
}
|
||||
|
||||
saveLocalConversations(batch: LocalConversationSaveBatch): void {
|
||||
// The renderer saves every 500 ms and retries until acknowledged.
|
||||
this.relaxedWrite(() => this.saveLocalConversationsNow(batch))
|
||||
}
|
||||
|
||||
private saveLocalConversationsNow(batch: LocalConversationSaveBatch): void {
|
||||
const database = this.requireDatabase()
|
||||
const findConversation = database.prepare(
|
||||
'SELECT channel, context_state_json FROM conversations WHERE id = ?'
|
||||
@@ -5013,6 +5044,69 @@ export class AssistantDatabase {
|
||||
this.database = new DatabaseSync(this.databasePath, { readOnly: true, timeout: 5_000 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a connection used only to checkpoint the WAL from a worker thread
|
||||
* (PERF-15). It never writes rows; see enableWalCheckpointWorker.
|
||||
*/
|
||||
openCheckpointer(): void {
|
||||
if (this.database) throw new Error('Database already open')
|
||||
this.database = new DatabaseSync(this.databasePath, { timeout: 5_000 })
|
||||
this.database.exec('PRAGMA busy_timeout = 5000; PRAGMA wal_autocheckpoint = 0')
|
||||
}
|
||||
|
||||
/** One PASSIVE checkpoint: copies committed WAL frames without blocking writers. */
|
||||
checkpointWal(): { busy: number; log: number; checkpointed: number } {
|
||||
if (!this.database) throw new Error('Database is not open')
|
||||
return this.database.prepare('PRAGMA wal_checkpoint(PASSIVE)').get() as
|
||||
{ busy: number; log: number; checkpointed: number }
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves WAL checkpoints off Main (PERF-15). SQLite's automatic checkpoint
|
||||
* runs inside the commit that crosses 1,000 WAL pages, so on a large
|
||||
* database one ordinary write now and then blocked Main for 100-250 ms
|
||||
* while pages were copied and synced. A worker thread checkpoints on its
|
||||
* own connection instead. While it runs, Main's automatic checkpoint stays
|
||||
* as a safety net at a higher threshold: under sustained writes a passive
|
||||
* checkpoint never catches up exactly, so the WAL could not reset; Main's
|
||||
* checkpoint then finds almost every frame already copied and only
|
||||
* finishes the rest. The default threshold comes back if the worker stops.
|
||||
*/
|
||||
enableWalCheckpointWorker(workerPath: string, intervalMs = 250): void {
|
||||
const database = this.database
|
||||
if (!database || this.checkpointWorker || this.databasePath === ':memory:') return
|
||||
let worker: Worker
|
||||
try {
|
||||
worker = new Worker(workerPath, {
|
||||
workerData: { kind: 'checkpoint', databasePath: this.databasePath, intervalMs }
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
this.checkpointWorker = worker
|
||||
worker.unref()
|
||||
const restore = (): void => {
|
||||
if (this.checkpointWorker !== worker) return
|
||||
this.checkpointWorker = undefined
|
||||
if (this.database === database) database.exec('PRAGMA wal_autocheckpoint = 1000')
|
||||
}
|
||||
worker.on('message', (message: { ready?: boolean }) => {
|
||||
if (message.ready && this.checkpointWorker === worker && this.database === database) {
|
||||
database.exec(`PRAGMA wal_autocheckpoint = ${WAL_SAFETY_CHECKPOINT_PAGES}`)
|
||||
}
|
||||
})
|
||||
worker.once('error', restore)
|
||||
worker.once('exit', restore)
|
||||
}
|
||||
|
||||
private stopWalCheckpointWorker(): void {
|
||||
const worker = this.checkpointWorker
|
||||
if (!worker) return
|
||||
this.checkpointWorker = undefined
|
||||
this.database?.exec('PRAGMA wal_autocheckpoint = 1000')
|
||||
void worker.terminate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs multi-statement reads against one WAL snapshot. Used by the readonly
|
||||
* worker, where Main may commit between statements; on Main itself the
|
||||
@@ -5312,7 +5406,30 @@ export class AssistantDatabase {
|
||||
|
||||
/** Applies incremental changes in one transaction (PERF-15). */
|
||||
updateActivityHistory(input: unknown): ActivityHistoryUpdateResult {
|
||||
return this.activityHistory().update(input)
|
||||
return this.relaxedWrite(() => this.activityHistory().update(input))
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a high-frequency local write with `synchronous = NORMAL` instead of
|
||||
* FULL (PERF-15). In WAL mode NORMAL skips the fsync per commit; the commit
|
||||
* is still atomic and survives an application crash, and the next FULL
|
||||
* commit or checkpoint makes it durable. A power loss can only drop a
|
||||
* suffix of such local writes, never a FULL commit or anything before it,
|
||||
* because the WAL is synced as a whole. Writes another party may rely on
|
||||
* (remote acknowledgements, channel delivery) keep using FULL.
|
||||
*/
|
||||
private relaxedWrite<T>(body: () => T): T {
|
||||
const database = this.requireDatabase()
|
||||
// Inside a caller's transaction the caller's commit decides durability.
|
||||
if (database.isTransaction) return body()
|
||||
const previous = (database.prepare('PRAGMA synchronous').get() as { synchronous: number }).synchronous
|
||||
if (previous <= 1) return body()
|
||||
database.exec('PRAGMA synchronous = NORMAL')
|
||||
try {
|
||||
return body()
|
||||
} finally {
|
||||
database.exec(`PRAGMA synchronous = ${previous}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Deletes every activity record in one transaction. */
|
||||
@@ -6184,6 +6301,20 @@ export class AssistantDatabase {
|
||||
taskId: string,
|
||||
kind: string,
|
||||
payload: unknown
|
||||
): void {
|
||||
// Local run events stream many times per second. Lifecycle events stay
|
||||
// FULL; their commit also makes every earlier relaxed event durable.
|
||||
if (kind === 'status' || kind === 'done' || kind === 'error') {
|
||||
this.appendTaskEventNow(taskId, kind, payload)
|
||||
} else {
|
||||
this.relaxedWrite(() => this.appendTaskEventNow(taskId, kind, payload))
|
||||
}
|
||||
}
|
||||
|
||||
private appendTaskEventNow(
|
||||
taskId: string,
|
||||
kind: string,
|
||||
payload: unknown
|
||||
): void {
|
||||
const database = this.requireDatabase()
|
||||
const payloadJson = this.subagentProgress!.serialize(taskId, kind, payload)
|
||||
@@ -7109,7 +7240,9 @@ export class AssistantDatabase {
|
||||
const database = this.requireDatabase()
|
||||
database.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const rows = database.prepare("SELECT id, metadata_json FROM messages WHERE json_type(metadata_json, '$.imageOperations') = 'array'")
|
||||
// Candidates come from messages_recovery_idx instead of a scan of every message.
|
||||
const rows = database.prepare(`SELECT id, metadata_json FROM messages
|
||||
WHERE ${MESSAGE_RECOVERY_CANDIDATE_SQL} AND json_type(metadata_json, '$.imageOperations') = 'array'`)
|
||||
.all() as { id: string; metadata_json: string }[]
|
||||
for (const row of rows) {
|
||||
const metadata = JSON.parse(row.metadata_json) as MessageMetadata
|
||||
@@ -12257,6 +12390,16 @@ export class AssistantDatabase {
|
||||
database.exec('PRAGMA user_version = 57; COMMIT;')
|
||||
} catch (error) { database.exec('ROLLBACK'); throw error }
|
||||
}
|
||||
if (version.user_version < 58) {
|
||||
// Startup recovery reads candidate messages by index instead of scanning all (PERF-15).
|
||||
database.exec(`
|
||||
BEGIN IMMEDIATE;
|
||||
CREATE INDEX IF NOT EXISTS messages_recovery_idx ON messages(id)
|
||||
WHERE ${MESSAGE_RECOVERY_CANDIDATE_SQL};
|
||||
PRAGMA user_version = 58;
|
||||
COMMIT;
|
||||
`)
|
||||
}
|
||||
}
|
||||
|
||||
supervisionExperiences(): SupervisionExperienceStore {
|
||||
|
||||
@@ -1275,6 +1275,7 @@ if (hasSingleInstanceLock) {
|
||||
startupMark('main:assistant-db-init:start')
|
||||
startupAssistantDatabase.initialize(defaultWorkspace)
|
||||
startupAssistantDatabase.enableReadonlyWorker(readonlyQueryWorkerPath)
|
||||
startupAssistantDatabase.enableWalCheckpointWorker(readonlyQueryWorkerPath)
|
||||
imageGenerationService!.initialize()
|
||||
startupAssistantDatabase.ensureChannelProjects(
|
||||
defaultWorkspace,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// @vitest-environment node
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { statSync } from 'node:fs'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
@@ -292,3 +293,37 @@ describe('assistant readonly worker', () => {
|
||||
} finally { database.close() }
|
||||
}, 60_000)
|
||||
})
|
||||
|
||||
describe('WAL checkpoint worker', () => {
|
||||
it('checkpoints off Main, keeps a raised safety threshold and restores the default when stopped', async () => {
|
||||
const path = join(directory, `checkpoint-${randomUUID()}.sqlite`)
|
||||
const database = new AssistantDatabase(path)
|
||||
database.initialize(directory)
|
||||
const raw = (database as unknown as { database: import('node:sqlite').DatabaseSync }).database
|
||||
const autocheckpoint = (): number =>
|
||||
(raw.prepare('PRAGMA wal_autocheckpoint').get() as { wal_autocheckpoint: number }).wal_autocheckpoint
|
||||
try {
|
||||
expect(autocheckpoint()).toBe(1000)
|
||||
database.enableWalCheckpointWorker(workerPath, 20)
|
||||
await expect.poll(autocheckpoint, { timeout: 10_000 }).toBe(4000)
|
||||
const task = database.createTask({ id: randomUUID(), title: 't', instructions: 'i', workMode: 'ask' })
|
||||
const sizeBefore = statSync(path).size
|
||||
for (let index = 0; index < 300; index += 1) {
|
||||
database.appendTaskEvent(task.id, 'text', { type: 'text', requestId: task.id, delta: 'x'.repeat(4_000) })
|
||||
}
|
||||
// ~300 WAL pages stay below Main's 4,000-page threshold, so only the
|
||||
// worker's checkpoint can copy them into the database file.
|
||||
await expect.poll(() => statSync(path).size, { timeout: 10_000 }).toBeGreaterThan(sizeBefore + 1_000_000)
|
||||
expect((raw.prepare("SELECT COUNT(*) AS n FROM task_events WHERE task_id = ? AND kind = 'text'").get(task.id) as { n: number }).n).toBe(300)
|
||||
} finally {
|
||||
database.close()
|
||||
}
|
||||
// A reopened database starts with SQLite's default threshold again.
|
||||
const reopened = new AssistantDatabase(path)
|
||||
reopened.initialize(directory)
|
||||
try {
|
||||
const again = (reopened as unknown as { database: import('node:sqlite').DatabaseSync }).database
|
||||
expect((again.prepare('PRAGMA wal_autocheckpoint').get() as { wal_autocheckpoint: number }).wal_autocheckpoint).toBe(1000)
|
||||
} finally { reopened.close() }
|
||||
}, 60_000)
|
||||
})
|
||||
|
||||
@@ -8,13 +8,35 @@ import type { ReadonlyQueryKind, ReadonlyQueryRequest } from './readonly-query-r
|
||||
// It reuses the production classes on a read-only connection, so results are
|
||||
// produced by exactly the same code as the synchronous path.
|
||||
|
||||
const { kind, databasePath } = workerData as { kind: ReadonlyQueryKind; databasePath: string }
|
||||
const { kind, databasePath, intervalMs } = workerData as {
|
||||
kind: ReadonlyQueryKind | 'checkpoint'
|
||||
databasePath: string
|
||||
intervalMs?: number
|
||||
}
|
||||
|
||||
type Handler = (args: unknown[], signal: AbortSignal) => unknown
|
||||
let handlers: Record<string, Handler>
|
||||
let close: () => void
|
||||
|
||||
if (kind === 'knowledge') {
|
||||
if (kind === 'checkpoint') {
|
||||
// WAL checkpointer for AssistantDatabase.enableWalCheckpointWorker (PERF-15):
|
||||
// PASSIVE checkpoints never wait for, or block, Main's readers and writers.
|
||||
const database = new AssistantDatabase(databasePath)
|
||||
database.openCheckpointer()
|
||||
const timer = setInterval(() => {
|
||||
try {
|
||||
database.checkpointWal()
|
||||
} catch {
|
||||
// SQLITE_BUSY and similar: try again on the next tick.
|
||||
}
|
||||
}, intervalMs ?? 1_000)
|
||||
close = () => {
|
||||
clearInterval(timer)
|
||||
database.close()
|
||||
}
|
||||
handlers = {}
|
||||
parentPort!.postMessage({ ready: true })
|
||||
} else if (kind === 'knowledge') {
|
||||
const database = new KnowledgeDatabase(databasePath)
|
||||
database.openReadOnly()
|
||||
close = () => database.close()
|
||||
|
||||
Reference in New Issue
Block a user