feat(storage): move desktop SQLite ownership into a storage utility process
Main no longer opens desktop business SQLite. A single utility-process
storage owner holds the Assistant, Knowledge, attachment, document-result,
remote-binding and native model-ledger databases and runs whole domain
transactions; Main and Runtime callers use explicit async domain operations.
- Remove the Main readWithFallback path and the supervision worker's
separate business write connection; manifests are scanned read-only and
committed by the sole writer.
- Remote events are ACKed only after commit; process loss reports
unconfirmed writes instead of replaying them.
- Use native utilityProcess messaging with one in-flight bound (32) and
ordered waiting instead of capacity rejection and retries.
- Start the storage process before the window so startup overlaps.
- Keep short tool output in memory and store full output once in owned
storage; paging, branching, restart and deletion reuse that copy.
- Put required runtime launch files under goodbuddy-prefixed owned temp
directories and reclaim confirmed stale ones; stop writing duplicate
parsed.md.
- Add lint guards for SQLite ownership and architecture, concurrency
acceptance fixtures, and maintenance/architecture documentation.
Validation: typecheck and lint pass. Alternating baseline (edf019aa) vs
candidate runs show no regression on single-session, C01, C02, C04 and a
5-minute C06; Main p99 drops from ~21 ms to ~16.7 ms under concurrency.
Fresh portable copy (1.75M events) upgrade content unchanged; real-model
output paging passes with 4 external requests. Full npm test runs had
1-9 different timing-sensitive failures per run that pass when rerun.
Not covered: C03 review publication, C07 source body, app-level C05.
This commit is contained in:
+82
-8
@@ -6,16 +6,17 @@
|
||||
const { spawn } = require('node:child_process')
|
||||
const { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } = require('node:fs')
|
||||
const { createServer } = require('node:http')
|
||||
const { tmpdir } = require('node:os')
|
||||
const { join, resolve } = require('node:path')
|
||||
|
||||
const root = resolve(__dirname, '..')
|
||||
const runnerRoot = resolve(__dirname, '..')
|
||||
const root = resolve(process.env.GB_PERF_ROOT || runnerRoot)
|
||||
const concurrentMode = process.env.GB_PERF_MODE?.startsWith('C')
|
||||
// 11434 is the fresh-profile default endpoint. Another GoodBuddy (or Ollama)
|
||||
// may already use it; GB_PERF_MODEL_PORT moves the fake model and points the
|
||||
// isolated profile at it through the GOODBUDDY_MODEL_* environment variables.
|
||||
const defaultModelPort = 11434
|
||||
const modelPort = Number(process.env.GB_PERF_MODEL_PORT || defaultModelPort)
|
||||
const totalTimeoutMs = Number(process.env.GB_PERF_TIMEOUT_MS || 600_000)
|
||||
const totalTimeoutMs = Number(process.env.GB_PERF_TIMEOUT_MS || (process.env.GB_PERF_MODE === 'C06' ? 900_000 : 600_000))
|
||||
const keepProfile = process.env.GB_PERF_KEEP_PROFILE === '1'
|
||||
const streamBytes = Number(process.env.GB_PERF_STREAM_BYTES || 40_000)
|
||||
const chunkChars = Number(process.env.GB_PERF_CHUNK_CHARS || 12)
|
||||
@@ -55,6 +56,11 @@ function startModelServer(ledger) {
|
||||
response.end(JSON.stringify({ object: 'list', data: [{ id: 'qwen3', object: 'model' }] }))
|
||||
return
|
||||
}
|
||||
if (request.method === 'GET' && request.url === '/c03-knowledge.txt') {
|
||||
response.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' })
|
||||
response.end('C03 deterministic knowledge source\n'.repeat(80))
|
||||
return
|
||||
}
|
||||
if (request.method !== 'POST' || !request.url?.endsWith('/chat/completions')) {
|
||||
response.writeHead(404).end()
|
||||
return
|
||||
@@ -67,6 +73,55 @@ function startModelServer(ledger) {
|
||||
const messages = Array.isArray(parsed.messages) ? parsed.messages : []
|
||||
const last = messages.at(-1)
|
||||
const lastText = typeof last?.content === 'string' ? last.content : JSON.stringify(last?.content ?? '')
|
||||
if (concurrentMode && lastText.includes('gb-fault:hang')) {
|
||||
entry.kind = 'fault-hung'
|
||||
entry.identity = 'fault-hung'
|
||||
entry.stream = true
|
||||
entry.aborted = false
|
||||
// Keep the production request in flight until the UI cancels it.
|
||||
response.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' })
|
||||
const timer = setTimeout(() => {
|
||||
if (!response.destroyed) response.end('data: [DONE]\n\n')
|
||||
}, 120_000)
|
||||
response.on('close', () => { clearTimeout(timer); entry.aborted = !response.writableFinished; entry.finishedAt = Date.now() })
|
||||
return
|
||||
}
|
||||
if (concurrentMode && messages.some(message => typeof message.content === 'string' && message.content.includes('gb-concurrent:'))) {
|
||||
const marker = messages.map(message => typeof message.content === 'string' ? message.content : '').join('\n').match(/gb-concurrent:(parent|child):([a-zA-Z0-9-]+)/)
|
||||
const child = marker?.[1] === 'child'
|
||||
const toolsDone = messages.some(message => message.role === 'tool')
|
||||
entry.kind = child ? 'concurrent-child' : 'concurrent-parent'
|
||||
entry.identity = marker?.[2]
|
||||
entry.stream = parsed.stream === true
|
||||
const durationMs = child ? 12_000 : toolsDone ? 1_000 : 12_000
|
||||
const count = Math.ceil(durationMs / 16)
|
||||
const calls = !child && !toolsDone ? [0, 1].map(index => ({ index, id: `child-${marker?.[2]}-${index}`, type: 'function', function: { name: 'subagent_delegate', arguments: JSON.stringify({ task: `gb-concurrent:child:${marker?.[2]}-${index} deterministic output` }) } })) : []
|
||||
entry.offeredFragments = count
|
||||
entry.offeredToolCalls = calls.length
|
||||
entry.chunks = 0
|
||||
entry.bytes = 0
|
||||
entry.latenessMs = []
|
||||
response.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' })
|
||||
const send = (delta, finish_reason = null) => response.write(`data: ${JSON.stringify({ id: entry.identity, object: 'chat.completion.chunk', model: 'qwen3', choices: [{ index: 0, delta, finish_reason }] })}\n\n`)
|
||||
const started = performance.now()
|
||||
const tick = () => {
|
||||
if (response.destroyed) { entry.aborted = true; entry.settledAt = Date.now(); return }
|
||||
while (entry.chunks < count && performance.now() >= started + entry.chunks * 16) {
|
||||
entry.latenessMs.push(performance.now() - started - entry.chunks * 16)
|
||||
const text = `[${entry.identity}:${String(entry.chunks).padStart(5, '0')}] sample\n`
|
||||
send({ content: text })
|
||||
entry.bytes += Buffer.byteLength(text)
|
||||
entry.chunks++
|
||||
}
|
||||
if (entry.chunks < count) { setTimeout(tick, 4); return }
|
||||
if (calls.length) send({ tool_calls: calls })
|
||||
send({}, calls.length ? 'tool_calls' : 'stop')
|
||||
response.end('data: [DONE]\n\n')
|
||||
entry.finishedAt = Date.now()
|
||||
}
|
||||
tick()
|
||||
return
|
||||
}
|
||||
const benchmark = lastText.includes('perf-stream')
|
||||
entry.kind = benchmark ? 'benchmark-stream' : 'auxiliary'
|
||||
entry.stream = parsed.stream === true
|
||||
@@ -153,10 +208,27 @@ function printSummary(report) {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const taskDirectory = join(runnerRoot, 'temp', 'goodbuddy-concurrent-upgrade')
|
||||
mkdirSync(taskDirectory, { recursive: true })
|
||||
if (process.argv.includes('--launch')) {
|
||||
const { openSync, closeSync } = require('node:fs')
|
||||
const output = resolve(process.env.GB_PERF_OUTPUT || join(taskDirectory, `run-${Date.now()}`))
|
||||
mkdirSync(output, { recursive: true })
|
||||
const log = openSync(join(output, 'execution.log'), 'w')
|
||||
const child = spawn(process.execPath, [__filename], {
|
||||
detached: true, stdio: ['ignore', log, log],
|
||||
env: { ...process.env, GB_PERF_OUTPUT: output }
|
||||
})
|
||||
closeSync(log)
|
||||
writeFileSync(join(output, 'supervisor.json'), JSON.stringify({ pid: child.pid, startedAt: new Date().toISOString(), root, mode: process.env.GB_PERF_MODE, timeoutMs: totalTimeoutMs }))
|
||||
child.unref()
|
||||
console.log(`Supervisor ${child.pid}; logs ${output}`)
|
||||
return
|
||||
}
|
||||
const required = ['out/main/index.js', 'out/preload/index.cjs', 'out/renderer/index.html']
|
||||
const missing = required.filter(file => !existsSync(join(root, file)))
|
||||
if (missing.length) throw new Error(`Missing production build (${missing.join(', ')}); run npm run build:bundle first`)
|
||||
const runDirectory = mkdtempSync(join(tmpdir(), 'goodbuddy-app-perf-'))
|
||||
const runDirectory = mkdtempSync(join(taskDirectory, 'profile-'))
|
||||
const outputDirectory = resolve(process.env.GB_PERF_OUTPUT || join(runDirectory, 'artifacts'))
|
||||
mkdirSync(outputDirectory, { recursive: true })
|
||||
const ledger = []
|
||||
@@ -166,7 +238,8 @@ async function main() {
|
||||
} catch (error) {
|
||||
throw new Error(`Cannot listen on 127.0.0.1:${modelPort} for the fake model (${error.code ?? error.message}); stop the local service using that port, or set GB_PERF_MODEL_PORT to a free port`, { cause: error })
|
||||
}
|
||||
const environment = { ...process.env, GB_PERF_DIRECTORY: runDirectory, GB_PERF_ARTIFACTS: outputDirectory, GB_PERF_ROOT: root }
|
||||
const environment = { ...process.env, GB_PERF_DIRECTORY: runDirectory, GB_PERF_ARTIFACTS: outputDirectory, GB_PERF_ROOT: root, TEMP: runDirectory, TMP: runDirectory, TMPDIR: runDirectory }
|
||||
if (concurrentMode) environment.GB_PERF_DRIVER_TIMEOUT_MS = String(totalTimeoutMs - 30_000)
|
||||
delete environment.ELECTRON_RUN_AS_NODE
|
||||
delete environment.ELECTRON_RENDERER_URL
|
||||
for (const key of ['GOODBUDDY_MODEL_API_KEY', 'GOODBUDDY_MODEL_BASE_URL', 'GOODBUDDY_MODEL_NAME', 'GOODBUDDY_BIGTOKEN_API_KEY', 'GOODBUDDY_BIGTOKEN_BASE_URL', 'GOODBUDDY_BIGTOKEN_MODEL']) delete environment[key]
|
||||
@@ -179,7 +252,8 @@ async function main() {
|
||||
environment.GOODBUDDY_WORKSPACE = join(runDirectory, 'workspace')
|
||||
mkdirSync(environment.GOODBUDDY_WORKSPACE, { recursive: true })
|
||||
const started = Date.now()
|
||||
const child = spawn(require('electron'), [join(root, 'tests', 'support', 'app-perf-driver.mjs')], { cwd: root, env: environment, stdio: 'inherit', windowsHide: false })
|
||||
const child = spawn(require('electron'), [join(runnerRoot, 'tests', 'support', 'app-perf-driver.mjs')], { cwd: root, env: environment, stdio: 'inherit', windowsHide: false })
|
||||
writeFileSync(join(outputDirectory, 'owned-processes.json'), JSON.stringify({ supervisorPid: process.pid, electronPid: child.pid, root, runDirectory, startedAt: new Date().toISOString() }))
|
||||
let timedOut = false
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true
|
||||
@@ -200,13 +274,13 @@ async function main() {
|
||||
total: ledger.length,
|
||||
benchmarkStreams: ledger.filter(entry => entry.kind === 'benchmark-stream').length,
|
||||
auxiliary: ledger.filter(entry => entry.kind === 'auxiliary').length,
|
||||
ledger: ledger.map(({ method, path, kind, stream, chunks, bytes, aborted, startedAt, finishedAt }) => ({ method, path, kind, stream, chunks, bytes, aborted, durationMs: finishedAt ? finishedAt - startedAt : null }))
|
||||
ledger: ledger.map(({ method, path, kind, stream, chunks, bytes, aborted, startedAt, finishedAt, identity, offeredFragments, offeredToolCalls, latenessMs }) => ({ method, path, kind, stream, chunks, bytes, aborted, startedAt, finishedAt, identity, offeredFragments, offeredToolCalls, latenessMs, durationMs: finishedAt ? finishedAt - startedAt : null }))
|
||||
}
|
||||
if (timedOut || exit.code !== 0) report.status = 'failed'
|
||||
writeFileSync(resultPath, JSON.stringify(report, null, 2))
|
||||
printSummary(report)
|
||||
console.log(`\nreport: ${resultPath}`)
|
||||
if (!keepProfile) rmSync(join(runDirectory, 'profile'), { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
|
||||
if (!keepProfile) rmSync(runDirectory, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
|
||||
process.exitCode = String(report.status).startsWith('passed') ? 0 : 1
|
||||
// PERF-17: GB_PERF_CHECK=1 gates the run on build/perf-thresholds.json
|
||||
// (exit 1 on a regression, 2 when the report cannot be judged, e.g. throttled).
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
// Knowledge retrieval benchmark (PERF-05). Bundles the production
|
||||
// Owner-local knowledge/read-worker microbenchmark (PERF-05), not a Main IPC probe.
|
||||
// Bundles the production
|
||||
// KnowledgeDatabase with esbuild, seeds synthetic libraries of increasing size
|
||||
// in a temporary SQLite file and times the synchronous search calls that the
|
||||
// Electron Main process runs inline during retrieval. Because these calls are
|
||||
// synchronous, their duration is the time Main is blocked per query.
|
||||
// storage owner runs during retrieval. Synchronous duration measures this harness's
|
||||
// event loop, not the Electron Main process of the application.
|
||||
const { buildSync } = require('esbuild')
|
||||
const { createHash } = require('node:crypto')
|
||||
const { mkdtempSync, rmSync, writeFileSync } = require('node:fs')
|
||||
const { tmpdir } = require('node:os')
|
||||
const { mkdtempSync, rmSync, writeFileSync, mkdirSync } = require('node:fs')
|
||||
const { join, resolve } = require('node:path')
|
||||
const { monitorEventLoopDelay, performance } = require('node:perf_hooks')
|
||||
|
||||
@@ -31,12 +31,16 @@ const dimensions = Number(process.env.GB_KPERF_DIMENSIONS || 384)
|
||||
const chunksPerDocument = 100
|
||||
const queries = Number(process.env.GB_KPERF_QUERIES || 15)
|
||||
|
||||
const directory = mkdtempSync(join(tmpdir(), 'goodbuddy-knowledge-perf-'))
|
||||
const temporaryRoot = join(root, 'temp', 'goodbuddy-storage-foundation')
|
||||
mkdirSync(temporaryRoot, { recursive: true })
|
||||
const directory = mkdtempSync(join(temporaryRoot, 'knowledge-perf-'))
|
||||
const bundle = join(directory, 'knowledge-database.cjs')
|
||||
const workerBundle = join(directory, 'readonly-query-worker.cjs')
|
||||
const readerBundle = join(directory, 'readonly-query-reader.cjs')
|
||||
for (const [entry, outfile] of [
|
||||
['src/main/knowledge/knowledge-database.ts', bundle],
|
||||
['src/main/readonly-query-worker.ts', workerBundle]
|
||||
['src/main/readonly-query-worker.ts', workerBundle],
|
||||
['src/main/readonly-query-reader.ts', readerBundle]
|
||||
]) {
|
||||
buildSync({
|
||||
entryPoints: [join(root, entry)],
|
||||
@@ -51,6 +55,8 @@ for (const [entry, outfile] of [
|
||||
})
|
||||
}
|
||||
const { KnowledgeDatabase } = require(bundle)
|
||||
const { ReadonlyQueryReader } = require(readerBundle)
|
||||
const ownedDatabases = new Set(), readers = new Set()
|
||||
|
||||
// Deterministic pseudo-random generator so runs are comparable.
|
||||
let seed = 42
|
||||
@@ -76,7 +82,7 @@ function time(body) {
|
||||
}
|
||||
const round = value => Math.round(value * 10) / 10
|
||||
|
||||
// Main event-loop delay while `queries` searches run back to back. A sync
|
||||
// Harness event-loop delay while `queries` searches run back to back. A sync
|
||||
// search blocks the loop for its whole duration; a worker search should not.
|
||||
async function loopDelay(body) {
|
||||
const histogram = monitorEventLoopDelay({ resolution: 5 })
|
||||
@@ -103,6 +109,7 @@ async function main() {
|
||||
for (const size of sizes) {
|
||||
const path = join(directory, `knowledge-${size}.sqlite`)
|
||||
const database = new KnowledgeDatabase(path)
|
||||
ownedDatabases.add(database)
|
||||
database.initialize()
|
||||
const library = database.createKnowledgeBase({ name: `Perf ${size}`, storageMode: 'reference' })
|
||||
const sqlite = database.database
|
||||
@@ -119,6 +126,7 @@ async function main() {
|
||||
|
||||
// Reopen so timings start from a fresh connection, like an app restart.
|
||||
const reopened = new KnowledgeDatabase(path)
|
||||
ownedDatabases.add(reopened)
|
||||
reopened.initialize()
|
||||
const query = vector()
|
||||
const entry = {
|
||||
@@ -129,20 +137,24 @@ async function main() {
|
||||
vectorMs: time(() => reopened.vectorSearch({ knowledgeBaseId: library.id, provider: 'perf', model: 'perf-model', vector: query, limit: 40 })),
|
||||
hybridMs: time(() => reopened.hybridSearchWithDiagnostics(hybridOptions(library.id, query)))
|
||||
}
|
||||
// Main event-loop delay: synchronous search on Main vs the readonly worker.
|
||||
// Harness loop delay: owner-local synchronous search vs its explicit read worker.
|
||||
entry.mainLoopDelaySyncMs = await loopDelay(async () => reopened.hybridSearchWithDiagnostics(hybridOptions(library.id, query)))
|
||||
reopened.enableReadonlyWorker(workerBundle)
|
||||
entry.mainLoopDelayWorkerMs = await loopDelay(() => reopened.hybridSearchWithDiagnosticsAsync(hybridOptions(library.id, query)))
|
||||
const reader = new ReadonlyQueryReader('knowledge', path, workerBundle)
|
||||
readers.add(reader)
|
||||
entry.mainLoopDelayWorkerMs = await loopDelay(() => reader.call('hybridSearchWithDiagnostics', [hybridOptions(library.id, query)]))
|
||||
await reader.close()
|
||||
reopened.close()
|
||||
results.push(entry)
|
||||
console.log(`[kperf] ${size} chunks: fts p95 ${entry.ftsMs.p95} ms, vector p95 ${entry.vectorMs.p95} ms, hybrid p95 ${entry.hybridMs.p95} ms (seed ${entry.seedSeconds} s)`)
|
||||
console.log(`[kperf] ${size} chunks: Main loop delay max sync ${entry.mainLoopDelaySyncMs.max} ms vs worker ${entry.mainLoopDelayWorkerMs.max} ms (p99 ${entry.mainLoopDelaySyncMs.p99} / ${entry.mainLoopDelayWorkerMs.p99} ms; per query ${entry.mainLoopDelaySyncMs.perQueryMs} / ${entry.mainLoopDelayWorkerMs.perQueryMs} ms)`)
|
||||
}
|
||||
const output = process.env.GB_KPERF_OUTPUT
|
||||
if (output) writeFileSync(output, JSON.stringify({ kind: 'goodbuddy-knowledge-retrieval-benchmark', node: process.version, results }, null, 2))
|
||||
if (output) writeFileSync(output, JSON.stringify({ kind: 'goodbuddy-knowledge-retrieval-benchmark', executionBoundary: 'owner-local', node: process.version, results }, null, 2))
|
||||
console.table(results.map(r => ({ chunks: r.chunks, 'fts p50/p95': `${r.ftsMs.p50} / ${r.ftsMs.p95}`, 'vector p50/p95': `${r.vectorMs.p50} / ${r.vectorMs.p95}`, 'hybrid p50/p95': `${r.hybridMs.p50} / ${r.hybridMs.p95}`, 'loop max sync/worker': `${r.mainLoopDelaySyncMs.max} / ${r.mainLoopDelayWorkerMs.max}` })))
|
||||
}
|
||||
const hybridOptions = (knowledgeBaseId, vector) => ({ knowledgeBaseId, query: 'lighthouse budget', limit: 40, provider: 'perf', model: 'perf-model', vector, graphEnabled: false, candidateMultiplier: 1 })
|
||||
main().catch(error => { process.exitCode = 1; console.error(error) }).finally(() => {
|
||||
main().catch(error => { process.exitCode = 1; console.error(error) }).finally(async () => {
|
||||
await Promise.all([...readers].map(reader => reader.close()))
|
||||
for (const database of ownedDatabases) database.close()
|
||||
rmSync(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
|
||||
})
|
||||
|
||||
+25
-14
@@ -1,10 +1,11 @@
|
||||
// Main-process database benchmark (PERF-15). Bundles the production
|
||||
// Owner-local database/read-worker microbenchmark (PERF-15). Not a Main IPC latency probe.
|
||||
// Bundles the production
|
||||
// AssistantDatabase and readonly worker with esbuild, seeds a temporary
|
||||
// assistant database (default 1,000 conversations / 20,000 messages and 24,000
|
||||
// activity records) and measures, for the IPC paths the renderer hits often:
|
||||
// - per-call time
|
||||
// - Main event-loop delay while the calls run back to back
|
||||
// for the previous synchronous path ("sync") and the current path ("after").
|
||||
// - harness event-loop delay while the calls run back to back
|
||||
// for owner-local SQL ("sync") and explicit read-worker dispatch ("after").
|
||||
// conversations:list-summaries sync listConversationSummaries vs worker
|
||||
// conversations:get sync getConversation vs worker
|
||||
// activity-history:replace legacy replace (zod parse + BEGIN IMMEDIATE +
|
||||
@@ -17,7 +18,6 @@
|
||||
// GB_MPERF_CALLS, GB_MPERF_OUTPUT (JSON), GB_MPERF_NODE=1 (skip Electron).
|
||||
const { buildSync } = require('esbuild')
|
||||
const { mkdtempSync, rmSync, writeFileSync, mkdirSync } = require('node:fs')
|
||||
const { tmpdir } = require('node:os')
|
||||
const { join, resolve } = require('node:path')
|
||||
const { randomUUID } = require('node:crypto')
|
||||
const { monitorEventLoopDelay, performance } = require('node:perf_hooks')
|
||||
@@ -41,13 +41,17 @@ const messageCount = Number(process.env.GB_MPERF_MESSAGES || 20000)
|
||||
const activityCount = Number(process.env.GB_MPERF_ACTIVITY || 24000)
|
||||
const calls = Number(process.env.GB_MPERF_CALLS || 15)
|
||||
|
||||
const directory = mkdtempSync(join(tmpdir(), 'goodbuddy-main-perf-'))
|
||||
const temporaryRoot = join(root, 'temp', 'goodbuddy-storage-foundation')
|
||||
mkdirSync(temporaryRoot, { recursive: true })
|
||||
const directory = mkdtempSync(join(temporaryRoot, 'main-perf-'))
|
||||
const bundle = join(directory, 'assistant-database.cjs')
|
||||
const workerBundle = join(directory, 'readonly-query-worker.cjs')
|
||||
const schemaBundle = join(directory, 'assistant-contracts.cjs')
|
||||
const readerBundle = join(directory, 'readonly-query-reader.cjs')
|
||||
for (const [entry, outfile] of [
|
||||
['src/main/assistant/assistant-database.ts', bundle],
|
||||
['src/main/readonly-query-worker.ts', workerBundle],
|
||||
['src/main/readonly-query-reader.ts', readerBundle],
|
||||
['src/shared/assistant-contracts.ts', schemaBundle]
|
||||
]) {
|
||||
buildSync({
|
||||
@@ -56,6 +60,8 @@ for (const [entry, outfile] of [
|
||||
})
|
||||
}
|
||||
const { AssistantDatabase } = require(bundle)
|
||||
const { ReadonlyQueryReader } = require(readerBundle)
|
||||
let ownedDatabase, reader
|
||||
const { activityHistorySnapshotSchema } = require(schemaBundle)
|
||||
|
||||
let seed = 42
|
||||
@@ -70,7 +76,7 @@ const percentile = (values, p) => {
|
||||
|
||||
/**
|
||||
* Runs `body` `calls` times (after one warm-up) and reports the per-call wall
|
||||
* time and the Main event-loop delay histogram (p99/max) over the run.
|
||||
* time and the harness event-loop delay histogram (p99/max) over the run.
|
||||
*/
|
||||
async function measure(body) {
|
||||
await body(-1)
|
||||
@@ -154,6 +160,7 @@ async function main() {
|
||||
mkdirSync(workspace)
|
||||
const path = join(directory, 'assistant.sqlite')
|
||||
const seeding = new AssistantDatabase(path)
|
||||
ownedDatabase = seeding
|
||||
seeding.initialize(workspace)
|
||||
const seedStarted = performance.now()
|
||||
const projectId = seeding.listProjects()[0].id
|
||||
@@ -186,6 +193,7 @@ async function main() {
|
||||
seeding.close()
|
||||
|
||||
const database = new AssistantDatabase(path)
|
||||
ownedDatabase = database
|
||||
database.initialize(workspace)
|
||||
const raw = database.database
|
||||
const totalMessages = counts.reduce((sum, value) => sum + value, 0)
|
||||
@@ -199,10 +207,10 @@ async function main() {
|
||||
results.listSummariesSync = await measure(async () => database.listConversationSummaries(detailIds))
|
||||
results.getLargestSync = await measure(async () => database.getConversation(largest))
|
||||
results.getTypicalSync = await measure(async () => database.getConversation(typical))
|
||||
database.enableReadonlyWorker(workerBundle)
|
||||
results.listSummariesWorker = await measure(() => database.listConversationSummariesAsync(detailIds))
|
||||
results.getLargestWorker = await measure(() => database.getConversationAsync(largest))
|
||||
results.getTypicalWorker = await measure(() => database.getConversationAsync(typical))
|
||||
reader = new ReadonlyQueryReader('assistant', path, workerBundle)
|
||||
results.listSummariesWorker = await measure(() => reader.call('listConversationSummaries', [detailIds]))
|
||||
results.getLargestWorker = await measure(() => reader.call('getConversation', [largest]))
|
||||
results.getTypicalWorker = await measure(() => reader.call('getConversation', [typical]))
|
||||
|
||||
// The renderer sends a fresh structured clone of the whole list every save.
|
||||
const unchanged = () => structuredClone({ records, legacyHistoryMayBeIncomplete: false })
|
||||
@@ -236,11 +244,12 @@ async function main() {
|
||||
results.activityFirstPageSync = await measure(async () => database.getActivityHistoryPage(firstPageRequest))
|
||||
results.activitySummarySync = await measure(async () => database.getActivityHistorySummary(summaryRequest()))
|
||||
results.activityStartupWorker = await measure(async () => {
|
||||
const page = await database.getActivityHistoryPageAsync(firstPageRequest)
|
||||
await database.getActivityHistorySummaryAsync({ conversationIds: [...new Set(page.records.map(record => record.conversationId))] })
|
||||
const page = await reader.call('activityHistoryPage', [firstPageRequest])
|
||||
await reader.call('activityHistorySummary', [{ conversationIds: [...new Set(page.records.map(record => record.conversationId))] }])
|
||||
})
|
||||
results.activityFullLoadBytes = JSON.stringify(database.getActivityHistory()).length
|
||||
results.activityFirstPageBytes = JSON.stringify(database.getActivityHistoryPage(firstPageRequest)).length
|
||||
await reader.close()
|
||||
database.close()
|
||||
|
||||
console.log(`[mperf] seeded ${results.conversations} conversations / ${results.messages} messages (largest ${results.largestConversationMessages}), ${results.activityRecords} activity records in ${seedSeconds} s`)
|
||||
@@ -264,9 +273,11 @@ async function main() {
|
||||
})))
|
||||
console.log(`[mperf] activity payload: full list ${results.activityFullLoadBytes} bytes, first page ${results.activityFirstPageBytes} bytes`)
|
||||
const output = process.env.GB_MPERF_OUTPUT
|
||||
if (output) writeFileSync(output, JSON.stringify({ kind: 'goodbuddy-main-database-benchmark', node: process.version, results }, null, 2))
|
||||
if (output) writeFileSync(output, JSON.stringify({ kind: 'goodbuddy-main-database-benchmark', executionBoundary: 'owner-local', node: process.version, results }, null, 2))
|
||||
}
|
||||
|
||||
main().catch(error => { process.exitCode = 1; console.error(error) }).finally(() => {
|
||||
main().catch(error => { process.exitCode = 1; console.error(error) }).finally(async () => {
|
||||
await reader?.close()
|
||||
ownedDatabase?.close()
|
||||
rmSync(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
|
||||
})
|
||||
|
||||
+20
-10
@@ -1,8 +1,8 @@
|
||||
// Main-process database WRITE benchmark on a real data directory (PERF-15).
|
||||
// Owner-local database WRITE microbenchmark on a closed data copy (PERF-15).
|
||||
// Copies assistant.sqlite (+ -wal/-shm) from GB_WPERF_DATA (for example the
|
||||
// portable build's data folder) into a temporary directory - the source is
|
||||
// never opened - then measures, with the production AssistantDatabase, the
|
||||
// writes Main performs synchronously while a run streams:
|
||||
// synchronous repository writes. This is not a production Main IPC latency probe:
|
||||
// task-events:subagent replay of the largest real subagent progress stream
|
||||
// (restored events, re-compacted on write, as live)
|
||||
// task-events:tool replay of real tool events
|
||||
@@ -10,14 +10,14 @@
|
||||
// conversations:save the renderer's 500 ms local save (header + the
|
||||
// streaming assistant message) for a real conversation
|
||||
// activity:update one incremental activity change
|
||||
// For each: per-call time and the longest Main event-loop block.
|
||||
// For each: per-call time and the longest owner-local harness event-loop block.
|
||||
// Event replays are paced at GB_WPERF_RATE events/s (default 270, the busiest
|
||||
// second in real data; 0 = back to back).
|
||||
// Env: GB_WPERF_DATA (required), GB_WPERF_EVENTS (default 2000),
|
||||
// GB_WPERF_OUTPUT (JSON), GB_WPERF_KEEP=1 (keep the copy).
|
||||
const { buildSync } = require('esbuild')
|
||||
const { mkdtempSync, rmSync, writeFileSync, mkdirSync, copyFileSync, cpSync, existsSync } = require('node:fs')
|
||||
const { tmpdir } = require('node:os')
|
||||
const { Worker } = require('node:worker_threads')
|
||||
const { join, resolve } = require('node:path')
|
||||
const { randomUUID } = require('node:crypto')
|
||||
const { performance } = require('node:perf_hooks')
|
||||
@@ -43,7 +43,10 @@ if (!source || !existsSync(join(source, 'assistant.sqlite'))) {
|
||||
}
|
||||
const eventLimit = Number(process.env.GB_WPERF_EVENTS || 2000)
|
||||
|
||||
const directory = mkdtempSync(join(tmpdir(), 'goodbuddy-write-perf-'))
|
||||
const temporaryRoot = join(root, 'temp', 'goodbuddy-storage-foundation')
|
||||
mkdirSync(temporaryRoot, { recursive: true })
|
||||
const directory = mkdtempSync(join(temporaryRoot, 'write-perf-'))
|
||||
let ownedDatabase, checkpoint, checkpointExit
|
||||
const bundle = join(directory, 'assistant-database.cjs')
|
||||
const progressBundle = join(directory, 'subagent-progress.cjs')
|
||||
for (const [entry, outfile] of [
|
||||
@@ -115,6 +118,7 @@ async function main() {
|
||||
const workspace = join(directory, 'workspace')
|
||||
mkdirSync(workspace)
|
||||
const database = new AssistantDatabase(join(directory, 'assistant.sqlite'))
|
||||
ownedDatabase = database
|
||||
const openStarted = performance.now()
|
||||
database.initialize(workspace)
|
||||
const raw = database.database
|
||||
@@ -124,9 +128,12 @@ async function main() {
|
||||
results.synchronous = raw.prepare('PRAGMA synchronous').get().synchronous
|
||||
if (process.env.GB_WPERF_AUTOCHECKPOINT) raw.exec(`PRAGMA wal_autocheckpoint = ${process.env.GB_WPERF_AUTOCHECKPOINT}`)
|
||||
// As in production: checkpoints run on a worker thread (GB_WPERF_CHECKPOINT_WORKER=0 disables).
|
||||
if (process.env.GB_WPERF_CHECKPOINT_WORKER !== '0' && typeof database.enableWalCheckpointWorker === 'function') {
|
||||
database.enableWalCheckpointWorker(join(directory, 'readonly-query-worker.cjs'))
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
if (process.env.GB_WPERF_CHECKPOINT_WORKER !== '0') {
|
||||
checkpoint = new Worker(join(directory, 'readonly-query-worker.cjs'), {
|
||||
workerData: { kind: 'checkpoint', databasePath: join(directory, 'assistant.sqlite'), intervalMs: 250 }
|
||||
})
|
||||
checkpointExit = new Promise(resolve => checkpoint.once('exit', resolve))
|
||||
await new Promise((resolve, reject) => { checkpoint.once('message', resolve); checkpoint.once('error', reject) })
|
||||
}
|
||||
results.walAutocheckpoint = raw.prepare('PRAGMA wal_autocheckpoint').get().wal_autocheckpoint
|
||||
const count = (sql, ...args) => Number(raw.prepare(sql).get(...args).n)
|
||||
@@ -208,6 +215,7 @@ async function main() {
|
||||
}
|
||||
results.walBytes = existsSync(join(directory, 'assistant.sqlite-wal'))
|
||||
? require('node:fs').statSync(join(directory, 'assistant.sqlite-wal')).size : 0
|
||||
if (checkpoint) { checkpoint.postMessage({ type: 'close' }); await checkpointExit; checkpoint = undefined }
|
||||
database.close()
|
||||
|
||||
console.log(`[wperf] ${results.taskEvents} task events, ${results.messages} messages, ${results.activityRecords} activity records; open ${results.openMs} ms`)
|
||||
@@ -226,10 +234,12 @@ async function main() {
|
||||
for (const [name, r] of rows) if (r.slowCalls.length) console.log(`[wperf] slow calls (index, ms) ${name}: ${JSON.stringify(r.slowCalls)}`)
|
||||
console.log(`[wperf] synchronous=${results.synchronous} wal_autocheckpoint=${results.walAutocheckpoint} wal=${results.walBytes} B`)
|
||||
const output = process.env.GB_WPERF_OUTPUT
|
||||
if (output) writeFileSync(output, JSON.stringify({ kind: 'goodbuddy-main-write-benchmark', node: process.version, results }, null, 2))
|
||||
if (output) writeFileSync(output, JSON.stringify({ kind: 'goodbuddy-main-write-benchmark', executionBoundary: 'owner-local', node: process.version, results }, null, 2))
|
||||
}
|
||||
|
||||
main().catch(error => { process.exitCode = 1; console.error(error) }).finally(() => {
|
||||
main().catch(error => { process.exitCode = 1; console.error(error) }).finally(async () => {
|
||||
if (checkpoint) { checkpoint.postMessage({ type: 'close' }); await checkpointExit }
|
||||
ownedDatabase?.close()
|
||||
if (process.env.GB_WPERF_KEEP === '1') console.log(`[wperf] kept ${directory}`)
|
||||
else rmSync(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user