mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 08:37:49 +00:00
267 lines
10 KiB
TypeScript
267 lines
10 KiB
TypeScript
import assert from 'node:assert/strict'
|
|
import { rm, stat } from 'node:fs/promises'
|
|
import { join } from 'node:path'
|
|
import { setImmediate as yieldToEventLoop } from 'node:timers/promises'
|
|
import {
|
|
createSessionParseStats,
|
|
parseAgentSessionFileCached,
|
|
resetSessionParseCacheForTests
|
|
} from '../../src/main/ai-vault/session-scanner-parse-cache'
|
|
import { resetTranscriptConsumersForTests } from '../../src/main/ai-vault/session-transcript-consumers'
|
|
import { requestWholeTranscriptRead } from '../../src/main/ai-vault/session-transcript-reader'
|
|
import { registerSessionSearchIndexConsumer } from '../../src/main/ai-vault-search/session-search-index-consumer'
|
|
import { SessionSearchStore } from '../../src/main/ai-vault-search/session-search-store'
|
|
import { writeSyntheticTranscriptCorpus } from '../../src/main/ai-vault-search/session-search-synthetic-corpus'
|
|
import { sessionCandidate } from '../../src/main/ai-vault-search/session-search-transcript-fixtures'
|
|
import SyncDatabase from '../../src/main/sqlite/sync-database'
|
|
|
|
// Measures the real transcript reader and search store over a synthetic corpus.
|
|
// Never point this at a real transcript tree.
|
|
|
|
/**
|
|
* How long the longest single transaction held the process.
|
|
*
|
|
* With one transaction per file that is the whole stall a file costs, so it is
|
|
* the number the commit ceiling exists to bound. Measured by wrapping `exec`,
|
|
* because the writer's transactions are the only ones this benchmark runs.
|
|
*/
|
|
function recordTransactionDurations(durations: number[]): () => void {
|
|
const exec = SyncDatabase.prototype.exec
|
|
let started = 0
|
|
SyncDatabase.prototype.exec = function (this: SyncDatabase, sql: string): void {
|
|
if (sql === 'BEGIN IMMEDIATE') {
|
|
started = performance.now()
|
|
}
|
|
exec.call(this, sql)
|
|
if (sql === 'COMMIT' && started > 0) {
|
|
durations.push(performance.now() - started)
|
|
started = 0
|
|
}
|
|
}
|
|
return () => {
|
|
SyncDatabase.prototype.exec = exec
|
|
}
|
|
}
|
|
|
|
/** The writer commits synchronously, so a peer chain samples the gap each read leaves. */
|
|
async function sampleLoopStalls(running: () => boolean, stalls: number[]): Promise<void> {
|
|
let previous = performance.now()
|
|
while (running()) {
|
|
await yieldToEventLoop()
|
|
const now = performance.now()
|
|
stalls.push(now - previous)
|
|
previous = now
|
|
}
|
|
}
|
|
|
|
function tableBytes(db: SyncDatabase): Record<string, number> {
|
|
const rows = db.prepare('SELECT name, sum(pgsize) AS bytes FROM dbstat GROUP BY name').all() as {
|
|
name: string
|
|
bytes: number
|
|
}[]
|
|
const group = (prefix: string): number =>
|
|
rows
|
|
.filter((row) => row.name === prefix || row.name.startsWith(`${prefix}_`))
|
|
.reduce((sum, row) => sum + row.bytes, 0)
|
|
return {
|
|
messagesFts: group('messages_fts'),
|
|
messages: group('messages') - group('messages_fts'),
|
|
sessions: group('sessions'),
|
|
total: rows.reduce((sum, row) => sum + row.bytes, 0)
|
|
}
|
|
}
|
|
|
|
function assertIndexedMessages(db: SyncDatabase, expected: number): number {
|
|
const { n } = db
|
|
.prepare('SELECT count(*) AS n FROM messages m JOIN sessions s ON s.id = m.session_row_id')
|
|
.get() as { n: number }
|
|
assert.equal(n, expected, 'indexed message count')
|
|
return n
|
|
}
|
|
|
|
async function checkpointedFileBytes(db: SyncDatabase, path: string): Promise<number> {
|
|
// Flush committed WAL pages before reporting the final database footprint.
|
|
const [checkpoint] = db.pragma('wal_checkpoint(TRUNCATE)') as { busy: number }[]
|
|
assert.equal(checkpoint?.busy, 0, 'storage measurement requires a completed checkpoint')
|
|
return (await stat(path)).size
|
|
}
|
|
|
|
// The default corpus puts tool output at about half the message text; set this
|
|
// far higher to price the tool-row cap against the real 80-97 % band.
|
|
const toolResultWords = Number(process.env.ORCA_SEARCH_BENCH_TOOL_WORDS ?? 200)
|
|
const corpus = await writeSyntheticTranscriptCorpus({ toolResultWords })
|
|
const indexPath = join(corpus.root, 'index.sqlite')
|
|
try {
|
|
const errors: unknown[] = []
|
|
const store = new SessionSearchStore(indexPath, (error) => errors.push(error))
|
|
const unregister = registerSessionSearchIndexConsumer(store)
|
|
const stalls: number[] = []
|
|
const transactions: number[] = []
|
|
const restoreExec = recordTransactionDurations(transactions)
|
|
let indexing = true
|
|
try {
|
|
const stats = createSessionParseStats()
|
|
const started = performance.now()
|
|
const sampler = sampleLoopStalls(() => indexing, stalls)
|
|
for (const path of corpus.files) {
|
|
await parseAgentSessionFileCached(
|
|
await sessionCandidate('claude', path),
|
|
process.platform,
|
|
stats
|
|
)
|
|
}
|
|
indexing = false
|
|
await sampler
|
|
restoreExec()
|
|
const rebuildMs = performance.now() - started
|
|
assert.deepEqual(errors, [])
|
|
|
|
const reader = new SyncDatabase(indexPath, { readonly: true })
|
|
try {
|
|
const rows = assertIndexedMessages(reader, corpus.messageCount)
|
|
const sessions = (
|
|
reader.prepare('SELECT count(*) AS n FROM sessions').get() as {
|
|
n: number
|
|
}
|
|
).n
|
|
assert.equal(sessions, corpus.files.length)
|
|
const bytes = tableBytes(reader)
|
|
const perMb = (value: number): number =>
|
|
Math.round((value / (corpus.transcriptBytes / (1024 * 1024))) * 10) / 10
|
|
const fileBytes = await checkpointedFileBytes(store.connection, indexPath)
|
|
stalls.sort((a, b) => a - b)
|
|
transactions.sort((a, b) => a - b)
|
|
console.log(
|
|
JSON.stringify(
|
|
{
|
|
platform: process.platform,
|
|
node: process.version,
|
|
transcriptMb: Math.round((corpus.transcriptBytes / (1024 * 1024)) * 100) / 100,
|
|
toolResultWords,
|
|
sessions,
|
|
rows,
|
|
rebuildMs: Math.round(rebuildMs),
|
|
rowsPerSecond: Math.round(rows / (rebuildMs / 1000)),
|
|
transcriptMbPerSecond:
|
|
Math.round((corpus.transcriptBytes / (1024 * 1024) / (rebuildMs / 1000)) * 100) / 100,
|
|
bytesPerTranscriptMb: {
|
|
messagesFts: perMb(bytes.messagesFts),
|
|
messages: perMb(bytes.messages),
|
|
sessions: perMb(bytes.sessions),
|
|
total: perMb(bytes.total)
|
|
},
|
|
writeAmplification: Math.round((bytes.total / corpus.transcriptBytes) * 100) / 100,
|
|
fileWriteAmplification: Math.round((fileBytes / corpus.transcriptBytes) * 100) / 100,
|
|
transactions: transactions.length,
|
|
maxTransactionMs: Math.round((transactions.at(-1) ?? 0) * 100) / 100,
|
|
maxLoopStallMs: Math.round(stalls.at(-1) ?? 0),
|
|
p95LoopStallMs: Math.round(stalls[Math.floor(stalls.length * 0.95)] ?? 0),
|
|
loopStallSamples: stalls.length,
|
|
parseStats: stats
|
|
},
|
|
null,
|
|
2
|
|
)
|
|
)
|
|
} finally {
|
|
reader.close()
|
|
}
|
|
} finally {
|
|
indexing = false
|
|
restoreExec()
|
|
unregister()
|
|
resetTranscriptConsumersForTests()
|
|
resetSessionParseCacheForTests()
|
|
store.close()
|
|
}
|
|
} finally {
|
|
await rm(corpus.root, { recursive: true, force: true })
|
|
}
|
|
|
|
// Phase two: one transcript far larger than any real one, to price the ceiling
|
|
// that decides whether a file commits once or in chunks.
|
|
const largeTurns = Number(process.env.ORCA_SEARCH_BENCH_LARGE_TURNS ?? 23_000)
|
|
const large = await writeSyntheticTranscriptCorpus({
|
|
sessions: 1,
|
|
turnsPerSession: largeTurns,
|
|
seed: 2
|
|
})
|
|
const largeIndexPath = join(large.root, 'index.sqlite')
|
|
try {
|
|
const errors: unknown[] = []
|
|
const store = new SessionSearchStore(largeIndexPath, (error) => errors.push(error))
|
|
const unregister = registerSessionSearchIndexConsumer(store)
|
|
const transactions: number[] = []
|
|
const restoreExec = recordTransactionDurations(transactions)
|
|
try {
|
|
const stats = createSessionParseStats()
|
|
const started = performance.now()
|
|
await parseAgentSessionFileCached(
|
|
await sessionCandidate('claude', large.files[0]!),
|
|
process.platform,
|
|
stats
|
|
)
|
|
const indexMs = performance.now() - started
|
|
restoreExec()
|
|
assert.deepEqual(errors, [])
|
|
assertIndexedMessages(store.connection, large.messageCount)
|
|
transactions.sort((a, b) => a - b)
|
|
|
|
// The same file again, over a generation the index already holds. That is
|
|
// the pass a growing transcript really costs, and the one whose transaction
|
|
// used to be sized by the old session rather than by the chunk being
|
|
// written. The drain that reclaims the cut-loose generation runs after the
|
|
// commit, so its bounded batches are in `replaceTransactions` too.
|
|
const replaceTransactions: number[] = []
|
|
const restoreReplaceExec = recordTransactionDurations(replaceTransactions)
|
|
requestWholeTranscriptRead(large.files[0]!)
|
|
const replaceStarted = performance.now()
|
|
await parseAgentSessionFileCached(
|
|
await sessionCandidate('claude', large.files[0]!),
|
|
process.platform,
|
|
stats
|
|
)
|
|
const replaceMs = performance.now() - replaceStarted
|
|
// Finishes whatever the scheduled drain has not reached, so the reclaim is
|
|
// priced rather than left half done under the next measurement.
|
|
const reclaimStarted = performance.now()
|
|
await store.purgeOlderThan(null)
|
|
const reclaimMs = performance.now() - reclaimStarted
|
|
restoreReplaceExec()
|
|
assert.deepEqual(errors, [])
|
|
assertIndexedMessages(store.connection, large.messageCount)
|
|
replaceTransactions.sort((a, b) => a - b)
|
|
|
|
console.log(
|
|
JSON.stringify(
|
|
{
|
|
phase: 'single-large-file',
|
|
transcriptMb: Math.round((large.transcriptBytes / (1024 * 1024)) * 100) / 100,
|
|
indexMs: Math.round(indexMs),
|
|
transactions: transactions.length,
|
|
maxTransactionMs: Math.round(transactions.at(-1) ?? 0),
|
|
replaceMs: Math.round(replaceMs),
|
|
replaceTransactions: replaceTransactions.length,
|
|
maxReplaceTransactionMs: Math.round(replaceTransactions.at(-1) ?? 0),
|
|
reclaimMs: Math.round(reclaimMs),
|
|
indexMb:
|
|
Math.round(
|
|
((await checkpointedFileBytes(store.connection, largeIndexPath)) / (1024 * 1024)) *
|
|
100
|
|
) / 100
|
|
},
|
|
null,
|
|
2
|
|
)
|
|
)
|
|
} finally {
|
|
restoreExec()
|
|
unregister()
|
|
resetTranscriptConsumersForTests()
|
|
resetSessionParseCacheForTests()
|
|
store.close()
|
|
}
|
|
} finally {
|
|
await rm(large.root, { recursive: true, force: true })
|
|
}
|