-
Notifications
You must be signed in to change notification settings - Fork 2.1k
perf(windows): move local KG writes off main thread #8008
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,23 +1,132 @@ | ||
| import { ipcMain } from 'electron' | ||
| import { app, ipcMain } from 'electron' | ||
| import { join } from 'path' | ||
| import { Worker } from 'worker_threads' | ||
| import { | ||
| execSafeSelect, | ||
| getFileIndexDigest, | ||
| getLocalKGStatus, | ||
| queryKgNodes, | ||
| replaceLocalGraph, | ||
| searchIndexedFiles | ||
| } from './db' | ||
| import { guardSelect } from '../../shared/sqlGuard' | ||
| import type { LocalKnowledgeGraph } from '../../shared/types' | ||
|
|
||
| // All local-knowledge-graph IPC. Kept in this dedicated module so registration | ||
| // is a single append in index.ts (conflict discipline with the concurrent | ||
| // integrations/Settings work). | ||
| // --------------------------------------------------------------------------- | ||
| // KG write worker | ||
| // | ||
| // Writes run in a worker_thread so the Electron main thread stays free for | ||
| // IPC during the synchronous DELETE+INSERT transaction. | ||
| // | ||
| // Lifecycle: | ||
| // - Worker is created lazily on the first kg:saveGraph call. | ||
| // - At most one write runs at a time; subsequent kg:saveGraph calls are | ||
| // coalesced — only the latest pending graph is kept. | ||
| // - Reads (queryNodes / status) run on the main thread via WAL mode. | ||
| // - kgSnapshot caches the last successfully written graph so empty-query | ||
| // reads skip SQLite entirely. | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| let worker: Worker | null = null | ||
| let workerBusy = false | ||
| let pendingGraph: LocalKnowledgeGraph | null = null | ||
| let lastDispatched: LocalKnowledgeGraph | null = null | ||
| let kgSnapshot: LocalKnowledgeGraph | null = null | ||
|
|
||
| function dbPath(): string { | ||
| return process.env.OMI_DB_PATH ?? join(app.getPath('userData'), 'omi.db') | ||
| } | ||
|
|
||
| function workerScriptPath(): string { | ||
| // Packaged builds: kgWorker.js is unpacked from the asar (see electron-builder.yml). | ||
| // Dev: vite emits kgWorker.js into out/main/ alongside index.js. | ||
| if (app.isPackaged) { | ||
| return join(process.resourcesPath, 'app.asar.unpacked', 'out', 'main', 'kgWorker.js') | ||
| } | ||
| return join(__dirname, 'kgWorker.js') | ||
| } | ||
|
|
||
| function ensureWorker(): Worker { | ||
| if (worker) return worker | ||
| worker = new Worker(workerScriptPath(), { workerData: { dbPath: dbPath() } }) | ||
| worker.on('message', (msg: { type: string; ms?: number; message?: string }) => { | ||
| if (msg.type === 'done') { | ||
| kgSnapshot = lastDispatched | ||
| } else if (msg.type === 'error') { | ||
| console.error('[kg:worker] saveGraph error:', msg.message) | ||
| } | ||
| workerBusy = false | ||
| flushPending() | ||
| }) | ||
| worker.on('error', (err) => { | ||
| console.error('[kg:worker] crash:', err.message) | ||
| worker = null | ||
| workerBusy = false | ||
| flushPending() | ||
| }) | ||
| return worker | ||
| } | ||
|
|
||
| function flushPending(): void { | ||
| if (pendingGraph !== null) { | ||
| const next = pendingGraph | ||
| pendingGraph = null | ||
| dispatch(next) | ||
| } | ||
| } | ||
|
|
||
| function dispatch(graph: LocalKnowledgeGraph): void { | ||
| workerBusy = true | ||
| lastDispatched = graph | ||
| try { | ||
| ensureWorker().postMessage({ type: 'replace', nodes: graph.nodes, edges: graph.edges }) | ||
| } catch (err) { | ||
| // Worker construction failed (e.g. missing kgWorker.js in packaged build). | ||
| // Reset state so future saves can retry rather than being silently dropped. | ||
| console.error('[kg:worker] failed to dispatch:', (err as Error).message) | ||
| worker = null | ||
| workerBusy = false | ||
| flushPending() | ||
| } | ||
| } | ||
|
|
||
| function enqueueGraph(graph: LocalKnowledgeGraph): void { | ||
| if (workerBusy) { | ||
| pendingGraph = graph | ||
| return | ||
| } | ||
| dispatch(graph) | ||
| } | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // IPC handlers | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| export function registerKgHandlers(): void { | ||
| ipcMain.handle('kg:fileIndexDigest', async () => getFileIndexDigest()) | ||
| ipcMain.handle('kg:saveGraph', async (_e, graph: LocalKnowledgeGraph) => replaceLocalGraph(graph)) | ||
| ipcMain.handle('kg:status', async () => getLocalKGStatus()) | ||
| ipcMain.handle('kg:queryNodes', async (_e, q: string, limit?: number) => queryKgNodes(q, limit)) | ||
|
|
||
| // Offloaded to worker — returns immediately, write completes asynchronously. | ||
| ipcMain.handle('kg:saveGraph', (_e, graph: LocalKnowledgeGraph) => { | ||
| enqueueGraph(graph) | ||
| }) | ||
|
Comment on lines
+107
to
+110
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Previously |
||
|
|
||
| ipcMain.handle('kg:status', () => getLocalKGStatus()) | ||
|
|
||
| ipcMain.handle('kg:queryNodes', (_e, q: string, limit?: number) => { | ||
| // Resolve cap once so snapshot and DB paths always return the same count. | ||
| const cap = limit ?? 80 | ||
| if (q === '' && kgSnapshot !== null) { | ||
| // Hot path: serve from in-memory snapshot, no SQLite access required. | ||
| const nodes = kgSnapshot.nodes | ||
| .slice() | ||
| .sort((a, b) => b.createdAt - a.createdAt) | ||
| .slice(0, cap) | ||
| const idSet = new Set(nodes.map((n) => n.id)) | ||
| const edges = kgSnapshot.edges.filter((e) => idSet.has(e.sourceId) || idSet.has(e.targetId)) | ||
| return { nodes, edges } | ||
|
Comment on lines
+117
to
+125
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When |
||
| } | ||
| return queryKgNodes(q, cap) | ||
| }) | ||
|
|
||
| ipcMain.handle('kg:searchFiles', async (_e, q: string, fileType?: string, limit?: number) => | ||
| searchIndexedFiles(q, fileType, limit) | ||
| ) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,72 @@ | ||
| /** | ||
| * KG write worker — runs in a Node.js worker_thread so the Electron main | ||
| * thread stays free for IPC during the synchronous SQLite replace transaction. | ||
| * | ||
| * Protocol (parentPort messages): | ||
| * Receive: { type: 'replace'; nodes: KgNode[]; edges: KgEdge[] } | ||
| * Send: { type: 'done'; ms: number } | ||
| * { type: 'error'; message: string } | ||
| * | ||
| * workerData: { dbPath: string } | ||
| */ | ||
| import { parentPort, workerData } from 'worker_threads' | ||
| import Database from 'better-sqlite3' | ||
|
|
||
| const d = new Database((workerData as { dbPath: string }).dbPath) | ||
| // WAL: readers on the main thread are not blocked while we hold the write lock. | ||
| d.pragma('journal_mode = WAL') | ||
| d.pragma('synchronous = NORMAL') | ||
|
|
||
| // Prepare all statements once at startup. | ||
| const insertNode = d.prepare( | ||
| `INSERT OR REPLACE INTO local_kg_nodes | ||
| (id, label, node_type, summary, source, created_at, aliases_json, source_refs) | ||
| VALUES (@id, @label, @nodeType, @summary, @source, @createdAt, @aliasesJson, @sourceRefs)` | ||
| ) | ||
| const insertEdge = d.prepare( | ||
| `INSERT OR REPLACE INTO local_kg_edges (id, source_id, target_id, label, created_at) | ||
| VALUES (@id, @sourceId, @targetId, @label, @createdAt)` | ||
| ) | ||
| const deleteEdges = d.prepare('DELETE FROM local_kg_edges') | ||
| const deleteNodes = d.prepare('DELETE FROM local_kg_nodes') | ||
|
|
||
| type KgNode = { | ||
| id: string | ||
| label: string | ||
| nodeType: string | ||
| summary: string | ||
| source: string | ||
| createdAt: number | ||
| aliases?: string[] | ||
| sourceRefs?: string[] | ||
| } | ||
| type KgEdge = { id: string; sourceId: string; targetId: string; label: string; createdAt: number } | ||
|
|
||
| const doReplace = d.transaction((nodes: KgNode[], edges: KgEdge[]) => { | ||
| deleteEdges.run() | ||
| deleteNodes.run() | ||
| for (const n of nodes) { | ||
| insertNode.run({ | ||
| id: n.id, | ||
| label: n.label, | ||
| nodeType: n.nodeType, | ||
| summary: n.summary, | ||
| source: n.source, | ||
| createdAt: n.createdAt, | ||
| aliasesJson: n.aliases?.length ? JSON.stringify(n.aliases) : null, | ||
| sourceRefs: n.sourceRefs?.length ? JSON.stringify(n.sourceRefs) : null | ||
| }) | ||
| } | ||
| for (const e of edges) insertEdge.run(e) | ||
| }) | ||
|
|
||
| parentPort!.on('message', (msg: { type: string; nodes: KgNode[]; edges: KgEdge[] }) => { | ||
| if (msg.type !== 'replace') return | ||
| const t0 = performance.now() | ||
| try { | ||
| doReplace(msg.nodes, msg.edges) | ||
| parentPort!.postMessage({ type: 'done', ms: Math.round(performance.now() - t0) }) | ||
| } catch (err) { | ||
| parentPort!.postMessage({ type: 'error', message: (err as Error).message }) | ||
| } | ||
| }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
new Worker()throwsworkerBusyis set totrueon line 78 beforeensureWorker()is called. Ifnew Worker(workerScriptPath(), ...)throws synchronously — for example becausekgWorker.jsis missing fromapp.asar.unpackedin a misconfigured packaged build, or the path resolution fails — the exception propagates out ofdispatch()uncaught, leavingworkerBusy = truepermanently. Every subsequentkg:saveGraphcall then writes topendingGraphand returns, butflushPending()is never called becauseworkerBusynever clears. All future graph saves are silently dropped for the lifetime of the session.