perf: run knowledge, story graph and conversation search reads in a read-only worker
This commit is contained in:
@@ -5,6 +5,7 @@ import { knowledgeReferenceKey } from '../../shared/knowledge-reference'
|
||||
import { appendConversationQuestionBlock } from '../../shared/conversation-question-blocks'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { ExecutionStatsReader } from './execution-stats-reader'
|
||||
import { ReadonlyQueryReader, readWithFallback } from '../readonly-query-reader'
|
||||
import { statSync } from 'node:fs'
|
||||
import { MagicNoteStorage } from '../magic-notes/magic-note-storage'
|
||||
import type { AssistantStorageProgress } from '../../shared/assistant-storage-contracts'
|
||||
@@ -1964,6 +1965,9 @@ export class AssistantDatabase {
|
||||
private database?: DatabaseSync
|
||||
private activityHistoryCache?: { records: Map<string, string>; dataVersion: number }
|
||||
private executionStatsReader?: ExecutionStatsReader
|
||||
private readonlyReader?: ReadonlyQueryReader
|
||||
private readonlyWorkerPath?: string
|
||||
private foldedSearchConnection?: DatabaseSync
|
||||
private readonly noteStorage: MagicNoteStorage
|
||||
private readonly dirtyMagicNotes = new Set<string>()
|
||||
private readonly pendingMagicNoteCleanup = new Set<string | undefined>()
|
||||
@@ -2393,6 +2397,9 @@ export class AssistantDatabase {
|
||||
close(): void {
|
||||
this.executionStatsReader?.close()
|
||||
this.executionStatsReader = undefined
|
||||
this.readonlyReader?.close()
|
||||
this.readonlyReader = undefined
|
||||
this.foldedSearchConnection = undefined
|
||||
this.executionStatsCache.clear()
|
||||
this.activityHistoryCache = undefined
|
||||
this.database?.close()
|
||||
@@ -3021,61 +3028,99 @@ export class AssistantDatabase {
|
||||
private readConversationList(detailIds?: ReadonlySet<string>): import('../../shared/assistant-contracts').ConversationListSnapshot[] {
|
||||
const database = this.requireDatabase()
|
||||
const conversations = this.listConversationRows()
|
||||
const messageStatement = database.prepare(
|
||||
`SELECT id, conversation_id, role, content, state, metadata_json,
|
||||
created_at
|
||||
FROM (
|
||||
SELECT id, conversation_id, role, content, state, metadata_json,
|
||||
created_at, sequence
|
||||
FROM messages
|
||||
WHERE conversation_id = ?
|
||||
ORDER BY sequence DESC
|
||||
)
|
||||
ORDER BY sequence ASC`
|
||||
)
|
||||
const summaryStatement = database.prepare(
|
||||
`SELECT count(*) AS count,
|
||||
max(state = 'streaming') AS streaming,
|
||||
max(created_at) AS latestMessageAt,
|
||||
(SELECT role FROM messages WHERE conversation_id = ? ORDER BY sequence LIMIT 1) AS firstRole
|
||||
FROM messages WHERE conversation_id = ?`
|
||||
)
|
||||
type MessageSummaryRow = {
|
||||
conversation_id: string; count: number; streaming: number | null
|
||||
firstRole: ConversationMessage['role'] | null; latestMessageAt: string | null
|
||||
}
|
||||
const activeIds = new Set(
|
||||
(database.prepare(`SELECT conversation_id FROM (${activeVisibleTaskSelect})`).all() as
|
||||
{ conversation_id: string }[]).map(row => row.conversation_id)
|
||||
)
|
||||
// Batched reads (PERF-15): one aggregate for every summary and one ordered
|
||||
// scan for every detailed conversation, instead of 1-2 statements each.
|
||||
const summaries = new Map<string, MessageSummaryRow>()
|
||||
if (detailIds) {
|
||||
for (const row of database.prepare(
|
||||
`SELECT m.conversation_id AS conversation_id, count(*) AS count,
|
||||
max(m.state = 'streaming') AS streaming,
|
||||
max(m.created_at) AS latestMessageAt,
|
||||
(SELECT f.role FROM messages f WHERE f.conversation_id = m.conversation_id
|
||||
ORDER BY f.sequence LIMIT 1) AS firstRole
|
||||
FROM messages m JOIN conversations c ON c.id = m.conversation_id
|
||||
WHERE c.status = 'active'
|
||||
GROUP BY m.conversation_id`
|
||||
).all() as MessageSummaryRow[]) summaries.set(row.conversation_id, row)
|
||||
}
|
||||
const summaryOf = (id: string): MessageSummaryRow => summaries.get(id) ??
|
||||
{ conversation_id: id, count: 0, streaming: null, firstRole: null, latestMessageAt: null }
|
||||
const detailed = conversations.filter(conversation =>
|
||||
!detailIds || detailIds.has(conversation.id) || activeIds.has(conversation.id) ||
|
||||
Boolean(summaryOf(conversation.id).streaming))
|
||||
const messagesByConversation = new Map<string, MessageRow[]>()
|
||||
if (detailed.length > 0) {
|
||||
for (const row of database.prepare(
|
||||
`SELECT id, conversation_id, role, content, state, metadata_json, created_at
|
||||
FROM messages
|
||||
WHERE conversation_id IN (SELECT value FROM json_each(?))
|
||||
ORDER BY conversation_id, sequence ASC`
|
||||
).all(JSON.stringify(detailed.map(conversation => conversation.id))) as MessageRow[]) {
|
||||
const list = messagesByConversation.get(row.conversation_id)
|
||||
if (list) list.push(row)
|
||||
else messagesByConversation.set(row.conversation_id, [row])
|
||||
}
|
||||
}
|
||||
const detailedIds = new Set(detailed.map(conversation => conversation.id))
|
||||
return conversations.map((conversation) => {
|
||||
if (detailIds && !detailIds.has(conversation.id) && !activeIds.has(conversation.id)) {
|
||||
const summary = summaryStatement.get(conversation.id, conversation.id) as {
|
||||
count: number; streaming: number | null; firstRole: ConversationMessage['role'] | null; latestMessageAt: string | null
|
||||
}
|
||||
if (!summary.streaming) {
|
||||
return {
|
||||
...toConversationSnapshot(conversation, []),
|
||||
messageSummary: {
|
||||
count: summary.count,
|
||||
firstRole: summary.firstRole ?? undefined,
|
||||
latestMessageAt: summary.latestMessageAt === null ? undefined : Date.parse(summary.latestMessageAt)
|
||||
}
|
||||
if (!detailedIds.has(conversation.id)) {
|
||||
const summary = summaryOf(conversation.id)
|
||||
return {
|
||||
...toConversationSnapshot(conversation, []),
|
||||
messageSummary: {
|
||||
count: summary.count,
|
||||
firstRole: summary.firstRole ?? undefined,
|
||||
latestMessageAt: summary.latestMessageAt === null ? undefined : Date.parse(summary.latestMessageAt)
|
||||
}
|
||||
}
|
||||
}
|
||||
return toConversationSnapshot(conversation, messageStatement.all(conversation.id) as MessageRow[])
|
||||
return toConversationSnapshot(conversation, messagesByConversation.get(conversation.id) ?? [])
|
||||
})
|
||||
}
|
||||
|
||||
/** Registers the Unicode case-folding matcher used by conversation search. */
|
||||
private foldedSearchDatabase(): DatabaseSync {
|
||||
const database = this.requireDatabase()
|
||||
if (this.foldedSearchConnection !== database) {
|
||||
// Same folding as the renderer's search (String#toLocaleLowerCase), which
|
||||
// SQLite's ASCII-only lower()/LIKE cannot reproduce for non-ASCII text.
|
||||
database.function('goodbuddy_folded_includes', { deterministic: true }, (text, needle) =>
|
||||
typeof text === 'string' && typeof needle === 'string' &&
|
||||
text.toLocaleLowerCase().includes(needle) ? 1 : 0)
|
||||
this.foldedSearchConnection = database
|
||||
}
|
||||
return database
|
||||
}
|
||||
|
||||
searchConversations(query: string): string[] {
|
||||
const normalized = query.trim().toLocaleLowerCase()
|
||||
if (!normalized) return []
|
||||
const database = this.requireDatabase()
|
||||
// Read only searchable text, never tool/subagent/image metadata. Use the
|
||||
// same Unicode case folding as the renderer's existing search.
|
||||
const messages = database.prepare('SELECT content FROM messages WHERE conversation_id = ?')
|
||||
return this.listConversationRows().filter(conversation =>
|
||||
conversation.title.toLocaleLowerCase().includes(normalized) ||
|
||||
(messages.all(conversation.id) as { content: string }[]).some(message =>
|
||||
message.content.toLocaleLowerCase().includes(normalized))
|
||||
).map(conversation => conversation.id)
|
||||
// Read only searchable text, never tool/subagent/image metadata. One query
|
||||
// with the same Unicode case folding as the renderer's existing search.
|
||||
return (this.foldedSearchDatabase().prepare(
|
||||
`SELECT c.id FROM conversations c
|
||||
WHERE c.status = 'active' AND (
|
||||
goodbuddy_folded_includes(c.title, ?1)
|
||||
OR EXISTS (SELECT 1 FROM messages m WHERE m.conversation_id = c.id
|
||||
AND goodbuddy_folded_includes(m.content, ?1)))
|
||||
ORDER BY c.pinned DESC, c.updated_at DESC`
|
||||
).all(normalized) as { id: string }[]).map(row => row.id)
|
||||
}
|
||||
|
||||
/** Conversation search on the readonly worker; falls back to the synchronous query. */
|
||||
searchConversationsAsync(query: string, signal?: AbortSignal): Promise<string[]> {
|
||||
this.requireDatabase()
|
||||
if (!query.trim()) return Promise.resolve([])
|
||||
return readWithFallback(this.readonlyQueryReader(), 'searchConversations', [query], signal,
|
||||
() => this.searchConversations(query))
|
||||
}
|
||||
|
||||
getConversation(conversationId: string): ConversationSnapshot {
|
||||
@@ -4924,6 +4969,25 @@ export class AssistantDatabase {
|
||||
this.database = new DatabaseSync(this.databasePath, { readOnly: true, timeout: 5_000 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes heavy read-only queries (story graph, conversation search) through a
|
||||
* read-only worker thread. In-memory databases keep the synchronous path.
|
||||
*/
|
||||
enableReadonlyWorker(workerPath: string): void {
|
||||
this.readonlyWorkerPath = workerPath
|
||||
}
|
||||
|
||||
private readonlyQueryReader(): ReadonlyQueryReader | undefined {
|
||||
if (!this.readonlyWorkerPath || this.databasePath === ':memory:') return undefined
|
||||
this.readonlyReader ??= new ReadonlyQueryReader('assistant', this.databasePath, this.readonlyWorkerPath)
|
||||
return this.readonlyReader
|
||||
}
|
||||
|
||||
/** Test hook for the worker crash and cancellation paths. */
|
||||
get readonlyWorkerForTest(): ReadonlyQueryReader | undefined {
|
||||
return this.readonlyQueryReader()
|
||||
}
|
||||
|
||||
private readExecutionStatsSnapshot(
|
||||
scope: ExecutionStatsInput,
|
||||
activeRequestIds: ReadonlySet<string>
|
||||
@@ -9236,6 +9300,13 @@ export class AssistantDatabase {
|
||||
return readStoryGraph(this.requireDatabase(), name, input, projectId, signal)
|
||||
}
|
||||
|
||||
/** Story graph read on the readonly worker; falls back to the synchronous read. */
|
||||
readStoryGraphAsync(name: import('../../shared/story-graph-tools').StoryGraphToolName, input: unknown, projectId?: string, signal?: AbortSignal): Promise<Record<string, unknown>> {
|
||||
this.requireDatabase()
|
||||
return readWithFallback(this.readonlyQueryReader(), 'readStoryGraph', [name, input, projectId], signal,
|
||||
() => this.readStoryGraph(name, input, projectId, signal))
|
||||
}
|
||||
|
||||
getSupervisionResult(id: string): { id: string; summary: string } | undefined {
|
||||
return this.requireDatabase().prepare('SELECT id, summary FROM supervision_results WHERE id = ?').get(id) as { id: string; summary: string } | undefined
|
||||
}
|
||||
|
||||
@@ -138,3 +138,25 @@ it('pages Unicode snapshots, reads explicit current versions, and reports deleti
|
||||
f.sql.prepare('UPDATE supervision_sources SET locator_json = ? WHERE id = ?').run(JSON.stringify({ detail: 'x'.repeat(100_001) }), source.id!)
|
||||
expect(() => f.read('story_graph_read_source', { source_reference_id: source.id })).toThrow('response_too_large')
|
||||
})
|
||||
|
||||
it('prefilters story lines by scope in SQL with the exact normalized match, and caps the scanned objects', async () => {
|
||||
const f = await fixture()
|
||||
f.save('Decision A', '2026-09-01T00:00:00Z')
|
||||
const insert = f.sql.prepare('INSERT INTO story_lines (id, scope_json, title, created_at, updated_at) VALUES (?, ?, ?, ?, ?)')
|
||||
const now = '2026-09-01T00:00:00.000Z'
|
||||
// Unsorted / duplicate ids still normalize to the requested scope; supersets and other kinds do not.
|
||||
insert.run('dup', JSON.stringify({ kind: 'projects', projectIds: [f.project.id, f.project.id] }), 'Decision dup', now, now)
|
||||
insert.run('superset', JSON.stringify({ kind: 'projects', projectIds: [f.other.id, f.project.id] }), 'Decision superset', now, now)
|
||||
insert.run('global', JSON.stringify({ kind: 'global' }), 'Decision global', now, now)
|
||||
const lines = (scope?: unknown) => ((f.read('story_graph_search', { query: 'Decision', page_size: 50, object_types: ['story_line'], ...(scope ? { scope } : {}) }) as Page)
|
||||
.items.map(item => item.object_ref.id)).sort()
|
||||
const all = f.sql.prepare('SELECT id FROM story_lines').all().map(row => String(row.id))
|
||||
expect(lines()).toEqual(expect.arrayContaining(['dup']))
|
||||
expect(lines()).not.toContain('superset')
|
||||
expect(lines()).not.toContain('global')
|
||||
expect(lines({ kind: 'global' })).toEqual(['global'])
|
||||
expect(lines({ kind: 'projects', projectIds: [f.project.id, f.other.id] })).toEqual(['superset'])
|
||||
expect(all.length).toBeGreaterThanOrEqual(4)
|
||||
expect(() => readStoryGraph(f.sql, 'story_graph_search', { query: 'Decision' }, f.project.id, undefined, 2)).toThrow('scan_limit_exceeded')
|
||||
expect(readStoryGraph(f.sql, 'story_graph_search', { query: 'Decision' }, f.project.id)).toMatchObject({ page: { complete: true } })
|
||||
})
|
||||
|
||||
@@ -21,6 +21,8 @@ type Fact = {
|
||||
source_reference_ids: string[]
|
||||
data: Row
|
||||
}
|
||||
/** Upper bound on projected objects per read (PERF-15). Normal scopes are far below it. */
|
||||
export const STORY_GRAPH_MAX_FACTS = 50_000
|
||||
const digest = (value: unknown): string => createHash('sha256').update(JSON.stringify(value)).digest('hex')
|
||||
const cursorSchema = z.object({ key: z.string(), revision: z.string(), index: z.number().int().nonnegative(), offset: z.number().int().nonnegative() }).strict()
|
||||
const normalizeScope = (scope: HeartbeatScope): HeartbeatScope => scope.kind === 'global' ? scope : { kind: 'projects', projectIds: [...new Set(scope.projectIds)].sort() }
|
||||
@@ -48,7 +50,8 @@ export function queryTerms(query: string): string[] {
|
||||
}
|
||||
|
||||
// This is a projection of existing storage, not a second graph or a historical state store.
|
||||
export function readStoryGraph(db: DatabaseSync, name: StoryGraphToolName, input: unknown, projectId?: string, signal?: AbortSignal): Record<string, unknown> {
|
||||
export function readStoryGraph(db: DatabaseSync, name: StoryGraphToolName, input: unknown, projectId?: string, signal?: AbortSignal,
|
||||
maxFacts = STORY_GRAPH_MAX_FACTS): Record<string, unknown> {
|
||||
signal?.throwIfAborted()
|
||||
if (input && typeof input === 'object' && ('as_of' in input || ('mode' in input && input.mode === 'as_of'))) throw new Error('unsupported_mode: as_of is not supported')
|
||||
const args = name === 'story_graph_search' ? storyGraphSearchSchema.parse(input)
|
||||
@@ -139,8 +142,18 @@ export function readStoryGraph(db: DatabaseSync, name: StoryGraphToolName, input
|
||||
}
|
||||
const facts: Fact[] = []
|
||||
const results = new Map<string, Row>()
|
||||
const stories = db.prepare('SELECT * FROM story_lines ORDER BY id').all().filter(row => sameScope(row.scope_json))
|
||||
// SQL prefilter on the scope kind and project membership (a superset of exact
|
||||
// matches); the exact normalized comparison still runs in JS.
|
||||
const stories = db.prepare(`SELECT * FROM story_lines
|
||||
WHERE json_extract(scope_json, '$.kind') = ?
|
||||
AND (? IS NULL OR NOT EXISTS (SELECT 1 FROM json_each(?) requested
|
||||
WHERE requested.value NOT IN (SELECT value FROM json_each(story_lines.scope_json, '$.projectIds'))))
|
||||
ORDER BY id`).all(scope.kind, scope.kind === 'projects' ? 1 : null, scope.kind === 'projects' ? JSON.stringify(scope.projectIds) : '[]')
|
||||
.filter(row => sameScope(row.scope_json))
|
||||
const add = (type: string, data: Row, result?: Row, sources?: string[]): void => {
|
||||
// Bound the in-memory projection; a scope this large must be narrowed rather than truncated,
|
||||
// since a partial projection would yield misleading revisions and cursors.
|
||||
if (facts.length >= maxFacts) throw new Error(`scan_limit_exceeded: more than ${maxFacts} stored objects in scope; narrow the scope`)
|
||||
facts.push({ object_ref: { type, id: String(data.id) }, object_revision: digest(data), result_id: result ? String(result.id) : null, scope,
|
||||
event_time: type === 'event' ? timestamp(data.occurred_at) : null,
|
||||
review_generated_at: result ? timestamp(result.created_at) : null,
|
||||
|
||||
Reference in New Issue
Block a user