fix(sessions): stop full transcript parses on every session lookup

Every session route resolves its transcript first, and that lookup parsed
the whole file to rank candidates even when only one file matched. The
desktop pages a long session's history request by request, so reopening
it cost one full parse per page and grew quadratically with file size.

Skip the content check when a single file matches, and stop a multi-file
check at the first conversation record. Ranking and API responses are
unchanged.
This commit is contained in:
程序员阿江(Relakkes)
2026-10-03 21:56:05 +08:00
parent fa28fb8b21
commit b8b149f28e
4 changed files with 332 additions and 34 deletions
+205
View File
@@ -0,0 +1,205 @@
/**
* Replays the requests the desktop sends when a session is opened, against a
* real server booted in a quality-gate sandbox, and reports how long each one
* takes. Local diagnostic only: it is not part of any PR lane.
*
* bun run scripts/perf/session-open-benchmark.ts --transcript a.jsonl [--transcript b.jsonl] [--rounds 2] [--sidebar]
*
* Each `--transcript` is copied (read-only) into the sandbox and concatenated
* into one session, with `sessionId`/`cwd` rewritten so nothing points back at
* the source. The sandbox is deleted afterwards.
*/
import { createReadStream, createWriteStream, existsSync, mkdirSync, readFileSync } from 'node:fs'
import { stat } from 'node:fs/promises'
import { createServer } from 'node:net'
import { join, resolve } from 'node:path'
import { createInterface } from 'node:readline'
import { createQualityGateSandbox } from '../quality-gate/sandbox.js'
import { sanitizePath } from '../../src/utils/sessionStoragePortable.js'
const REQUEST_TIMEOUT_MS = 120_000
const SESSION_ID = '0b0e1c5e-5e55-4a11-9b0b-0000000b0e1c'
type Timing = { name: string; ms: number; status: number | 'timeout' | 'error'; bytes: number }
function parseArgs(argv: string[]) {
const transcripts: string[] = []
let rounds = 2
let sidebar = false
let keep = false
for (let index = 0; index < argv.length; index++) {
const arg = argv[index]
if (arg === '--transcript') transcripts.push(resolve(argv[++index]!))
else if (arg === '--rounds') rounds = Number(argv[++index])
else if (arg === '--sidebar') sidebar = true
else if (arg === '--keep') keep = true
else throw new Error(`Unknown argument: ${arg}`)
}
if (transcripts.length === 0) throw new Error('Pass at least one --transcript <path>')
return { transcripts, rounds, sidebar, keep }
}
async function freePort(): Promise<number> {
return new Promise((resolvePort, reject) => {
const server = createServer()
server.once('error', reject)
server.listen(0, '127.0.0.1', () => {
const address = server.address()
server.close(() => resolvePort(typeof address === 'object' && address ? address.port : 0))
})
})
}
async function copyTranscripts(sources: string[], target: string, workDir: string) {
const output = createWriteStream(target)
let records = 0
let conversation = 0
for (const source of sources) {
const lines = createInterface({ input: createReadStream(source), crlfDelay: Infinity })
for await (const line of lines) {
if (!line.trim()) continue
let text = line
try {
const entry = JSON.parse(line) as Record<string, unknown>
if ('sessionId' in entry) entry.sessionId = SESSION_ID
if ('cwd' in entry) entry.cwd = workDir
const message = entry.message as { role?: unknown } | undefined
if ((entry.type === 'user' || entry.type === 'assistant') && message?.role) conversation++
text = JSON.stringify(entry)
} catch {
// Keep malformed lines byte-for-byte; the server must tolerate them.
}
if (!output.write(text + '\n')) await new Promise(done => output.once('drain', done))
records++
}
}
await new Promise<void>((done, reject) => output.end((error?: Error | null) => error ? reject(error) : done()))
return { records, conversation, bytes: (await stat(target)).size }
}
async function timed(base: string, name: string, path: string): Promise<{ timing: Timing; body: unknown }> {
const started = performance.now()
try {
const response = await fetch(`${base}${path}`, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) })
const text = await response.text()
let body: unknown = null
try { body = JSON.parse(text) } catch { /* non-JSON error bodies are still timed */ }
return { timing: { name, ms: performance.now() - started, status: response.status, bytes: text.length }, body }
} catch (error) {
const status = (error as Error).name === 'TimeoutError' ? 'timeout' : 'error'
return { timing: { name, ms: performance.now() - started, status, bytes: 0 }, body: null }
}
}
type HistoryPage = { messages?: unknown[]; page?: { nextCursor?: string | null } }
/** Mirrors desktop/src/api/sessions.ts getFullHistory: mode=full, then every cursor page. */
async function fullHistory(base: string, timings: Timing[]) {
const started = performance.now()
const first = await timed(base, 'messages?mode=full', `/api/sessions/${SESSION_ID}/messages?mode=full`)
timings.push(first.timing)
let cursor = (first.body as HistoryPage | null)?.page?.nextCursor ?? null
let messages = (first.body as HistoryPage | null)?.messages?.length ?? 0
let pages = 0
let slowestPage = 0
let failed = first.timing.status !== 200
while (cursor && !failed) {
const page = await timed(base, 'messages?cursor', `/api/sessions/${SESSION_ID}/messages?${new URLSearchParams({ cursor })}`)
pages++
slowestPage = Math.max(slowestPage, page.timing.ms)
if (page.timing.status !== 200) {
timings.push(page.timing)
failed = true
break
}
messages += (page.body as HistoryPage | null)?.messages?.length ?? 0
cursor = (page.body as HistoryPage | null)?.page?.nextCursor ?? null
}
timings.push({ name: `history total (${pages} cursor pages, slowest ${Math.round(slowestPage)}ms, ${messages} msgs)`, ms: performance.now() - started, status: failed ? 'error' : 200, bytes: 0 })
}
async function openSession(base: string, sidebar: boolean): Promise<Timing[]> {
const timings: Timing[] = []
const id = SESSION_ID
const burst: Array<Promise<void>> = [
fullHistory(base, timings),
...[
['summary', `/api/sessions/${id}/summary`],
['git-info', `/api/sessions/${id}/git-info`],
['slash-commands', `/api/sessions/${id}/slash-commands`],
['chat/status', `/api/sessions/${id}/chat/status`],
['inspection(context)', `/api/sessions/${id}/inspection?includeContext=1&contextOnly=1`],
['workspace/status#1', `/api/sessions/${id}/workspace/status`],
['team plan', `/api/teams/session/${id}/plan`],
].map(([name, path]) => timed(base, name!, path!).then(result => { timings.push(result.timing) })),
]
if (sidebar) burst.push(timed(base, 'sidebar list', '/api/sessions?limit=60&perProjectLimit=6').then(result => { timings.push(result.timing) }))
await Promise.all(burst)
// MessageList asks for these only after history is ready.
await Promise.all([
timed(base, 'turn-checkpoints', `/api/sessions/${id}/turn-checkpoints`),
timed(base, 'workspace/status#2', `/api/sessions/${id}/workspace/status`),
].map(request => request.then(result => { timings.push(result.timing) })))
return timings
}
async function main() {
const options = parseArgs(process.argv.slice(2))
for (const transcript of options.transcripts) {
if (!existsSync(transcript)) throw new Error(`Transcript not found: ${transcript}`)
}
const sandbox = createQualityGateSandbox({ label: 'session-open' })
const workDir = join(sandbox.home, 'workdir')
mkdirSync(workDir, { recursive: true })
const projectDir = join(sandbox.configDir, 'projects', sanitizePath(workDir))
mkdirSync(projectDir, { recursive: true })
const target = join(projectDir, `${SESSION_ID}.jsonl`)
let server: ReturnType<typeof Bun.spawn> | undefined
try {
const copied = await copyTranscripts(options.transcripts, target, workDir)
console.log(`transcript: ${copied.records} records, ${copied.conversation} conversation messages, ${(copied.bytes / 1048576).toFixed(1)} MiB`)
const port = await freePort()
const base = `http://127.0.0.1:${port}`
const logPath = join(sandbox.home, 'server.log')
server = Bun.spawn(['bun', 'run', 'src/server/index.ts', '--host', '127.0.0.1', '--port', String(port)], {
env: { ...sandbox.env, SERVER_PORT: String(port), CLAUDE_CLI_PATH: resolve('src/server/__tests__/fixtures/mock-sdk-cli.ts') },
stdout: Bun.file(logPath),
stderr: Bun.file(logPath),
})
const bootDeadline = Date.now() + 60_000
while (true) {
try {
if ((await fetch(`${base}/health`)).ok) break
} catch { /* not listening yet */ }
if (Date.now() > bootDeadline) throw new Error(`Server did not start; see ${logPath}`)
await Bun.sleep(200)
}
for (let round = 1; round <= options.rounds; round++) {
const started = performance.now()
const timings = await openSession(base, options.sidebar)
console.log(`\nround ${round}: ${((performance.now() - started) / 1000).toFixed(1)}s wall`)
for (const timing of timings.sort((a, b) => b.ms - a.ms)) {
const flag = timing.status === 'timeout' || timing.ms >= REQUEST_TIMEOUT_MS ? ' <-- exceeds 120s' : ''
console.log(` ${(timing.ms / 1000).toFixed(2).padStart(8)}s ${String(timing.status).padEnd(7)} ${timing.name}${flag}`)
}
}
const diagnostics = join(sandbox.configDir, 'cc-haha', 'diagnostics', 'diagnostics.jsonl')
if (existsSync(diagnostics)) {
const stalls = readFileSync(diagnostics, 'utf8').split('\n').filter(line => line.includes('event_loop'))
if (stalls.length) console.log(`\nevent-loop stall diagnostics: ${stalls.length}`)
}
} finally {
server?.kill()
await server?.exited
const mutations = sandbox.detectUserStateMutations()
if (mutations.length) console.error(`Real user state changed: ${mutations.join(', ')}`)
if (options.keep) console.log(`sandbox kept at ${sandbox.home}`)
else sandbox.cleanup()
if (mutations.length) process.exitCode = 1
}
}
await main()
@@ -3,6 +3,7 @@ import * as fs from 'node:fs/promises'
import * as os from 'node:os'
import * as path from 'node:path'
import { SessionService } from '../services/sessionService.js'
import * as boundedSessionHistory from '../services/boundedSessionHistory.js'
import type {
IndexedSessionRow,
LocalIndexGateway,
@@ -277,6 +278,20 @@ describe('SessionService local-index routing parity', () => {
])
})
it('resolves a single indexed match from its stat without reading records', async () => {
const filePath = await writeSession('-tmp-only', SESSION_A, 'Only', '2026-07-15T00:01:00.000Z')
const gateway = new FakeLocalIndexGateway()
gateway.setReady()
gateway.matches = [{ filePath, projectDir: '-tmp-only' }]
const service = new SessionService(gateway)
const reads = spyOn(boundedSessionHistory, 'streamBoundedHistory')
try {
expect((await service.findSessionFile(SESSION_A))?.filePath).toBe(filePath)
expect(gateway.findCalls).toBe(1)
expect(reads).not.toHaveBeenCalled()
} finally { reads.mockRestore() }
})
it('falls back after an indexed read failure and suppresses retries during cooldown', async () => {
await writeSession(
'-tmp-project',
+59
View File
@@ -14,6 +14,7 @@ import {
prepareSessionWorkspace,
} from '../services/repositoryLaunchService.js'
import { conversationService } from '../services/conversationService.js'
import * as boundedSessionHistory from '../services/boundedSessionHistory.js'
import { clearCommandsCache } from '../../commands.js'
import { parseJSONL } from '../../utils/json.js'
import { formatSessionCollaborationPrompt } from '../../utils/sessionCollaborationEnvelope.js'
@@ -586,6 +587,64 @@ describe('SessionService', () => {
expect((await service.findSessionFile(sessionId))?.filePath).toBe(transcript)
})
// Every session route resolves its file first. Reading the whole transcript
// here made each history page of a long session cost a full parse, so a
// 2,500-message session reopened in quadratic time.
describe('session file lookup cost', () => {
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
function countDeliveredRecords() {
const delivered = new Map<string, number>()
const original = boundedSessionHistory.streamBoundedHistory
const spy = spyOn(boundedSessionHistory, 'streamBoundedHistory').mockImplementation(
(filePath, onEntry, signal, options) => original(filePath, (...args) => {
delivered.set(filePath, (delivered.get(filePath) ?? 0) + 1)
onEntry(...args)
}, signal, options),
)
return { delivered, spy }
}
it('resolves a lone transcript without reading its records', async () => {
const transcript = await writeSessionFile('-tmp-lone', sessionId, [
makeSnapshotEntry(),
...Array.from({ length: 50 }, (_, n) => makeUserEntry(`turn ${n}`)),
])
const { delivered, spy } = countDeliveredRecords()
try {
expect((await service.findSessionFile(sessionId))?.filePath).toBe(transcript)
expect(delivered.size).toBe(0)
} finally { spy.mockRestore() }
})
it('stops reading a candidate at its first conversation record', async () => {
const transcript = await writeSessionFile('-tmp-long-worktree', sessionId, [
makeSnapshotEntry(),
makeUserEntry('first turn'),
...Array.from({ length: 200 }, (_, n) => makeAssistantEntry(`reply ${n}`)),
])
const placeholder = await writeSessionFile('-tmp-newer-placeholder', sessionId, [makeSnapshotEntry()])
const later = new Date(Date.now() + 60_000)
await fs.utimes(placeholder, later, later)
const { delivered, spy } = countDeliveredRecords()
try {
expect((await service.findSessionFile(sessionId))?.filePath).toBe(transcript)
expect(delivered.get(transcript)).toBe(2)
expect(delivered.get(placeholder)).toBe(1)
} finally { spy.mockRestore() }
})
it('still recognizes a conversation on a final line without a newline', async () => {
const transcript = await writeSessionFile('-tmp-unterminated', sessionId, [makeSnapshotEntry()])
await fs.appendFile(transcript, JSON.stringify(makeUserEntry('last turn')))
const placeholder = await writeSessionFile('-tmp-unterminated-placeholder', sessionId, [makeSnapshotEntry()])
const later = new Date(Date.now() + 60_000)
await fs.utimes(placeholder, later, later)
expect((await service.findSessionFile(sessionId))?.filePath).toBe(transcript)
})
})
it('should return empty list when no sessions exist', async () => {
const result = await service.listSessions()
expect(result.sessions).toEqual([])
+53 -34
View File
@@ -1903,18 +1903,43 @@ export class SessionService {
}
private async fileHasConversationTranscript(filePath: string): Promise<boolean> {
let hasTranscript = false
const scan = await withHistoryReadBudget(undefined, () => streamBoundedHistory(
filePath,
entry => {
if (!hasTranscript && this.hasConversationTranscript([entry as RawEntry])) hasTranscript = true
},
undefined,
{ maxRecordBytes: HISTORY_SEMANTIC_RECORD_BYTES },
), 'metadata')
// An oversized record may be the only conversation turn. Prefer that
// transcript over a newer metadata-only placeholder until it can be read.
return hasTranscript || scan.oversizedRecords > 0
// The first conversation record settles the answer. Scanning on would make
// every session lookup cost a full parse of the transcript.
const found = new AbortController()
try {
const scan = await withHistoryReadBudget(undefined, () => streamBoundedHistory(
filePath,
entry => {
if (this.hasConversationTranscript([entry as RawEntry])) found.abort()
},
found.signal,
{ maxRecordBytes: HISTORY_SEMANTIC_RECORD_BYTES },
), 'metadata')
// An oversized record may be the only conversation turn. Prefer that
// transcript over a newer metadata-only placeholder until it can be read.
// A final line without a newline is reported after the last abort check.
return found.signal.aborted || scan.oversizedRecords > 0
} catch (error) {
if (found.signal.aborted && error === found.signal.reason) return true
throw error
}
}
/** Content is only needed to choose between several files for one session. */
private async rankSessionFileMatches<T extends { filePath: string; mtimeMs: number }>(
matches: T[],
onReadError: (error: unknown) => 'drop' | 'fail',
): Promise<T[] | null> {
if (matches.length < 2) return matches
const ranked: Array<T & { hasTranscript: boolean }> = []
for (const match of matches) {
try {
ranked.push({ ...match, hasTranscript: await this.fileHasConversationTranscript(match.filePath) })
} catch (error) {
if (onReadError(error) === 'fail') return null
}
}
return ranked.sort((a, b) => Number(b.hasTranscript) - Number(a.hasTranscript) || b.mtimeMs - a.mtimeMs || a.filePath.localeCompare(b.filePath))
}
// --------------------------------------------------------------------------
@@ -2736,8 +2761,10 @@ export class SessionService {
const projectsRoot = indexedMatches.length > 0
? await fs.realpath(this.getProjectsDir())
: null
const hydratedMatches: Array<SessionFileMatch & { mtimeMs: number; hasTranscript: boolean }> = []
const hydratedMatches: Array<SessionFileMatch & { mtimeMs: number }> = []
let hydrationFailed = false
const failUnlessMissing = (error: unknown) =>
(error as NodeJS.ErrnoException).code === 'ENOENT' ? 'drop' as const : 'fail' as const
for (const match of indexedMatches) {
try {
const stat = await this.validateIndexedTranscriptPath(
@@ -2746,26 +2773,24 @@ export class SessionService {
sessionId,
projectsRoot!,
)
hydratedMatches.push({
...match,
mtimeMs: stat.mtimeMs,
hasTranscript: await this.fileHasConversationTranscript(match.filePath),
})
hydratedMatches.push({ ...match, mtimeMs: stat.mtimeMs })
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
if (failUnlessMissing(error) === 'fail') {
hydrationFailed = true
break
}
}
}
const rankedMatches = hydrationFailed
? null
: await this.rankSessionFileMatches(hydratedMatches, failUnlessMissing)
if (!rankedMatches) hydrationFailed = true
if (
!hydrationFailed &&
hydratedMatches.length > 0 &&
rankedMatches &&
rankedMatches.length > 0 &&
indexedMutationEpoch === getSharedSessionMutationState(this.localIndexGateway).epoch
) {
return hydratedMatches
.sort((a, b) => Number(b.hasTranscript) - Number(a.hasTranscript) || b.mtimeMs - a.mtimeMs || a.filePath.localeCompare(b.filePath))
.map(({ filePath, projectDir }) => ({ filePath, projectDir }))
return rankedMatches.map(({ filePath, projectDir }) => ({ filePath, projectDir }))
}
if (hydrationFailed) this.markIndexReadFailure()
}
@@ -2791,25 +2816,19 @@ export class SessionService {
return []
}
const matches: Array<{ filePath: string; projectDir: string; mtimeMs: number; hasTranscript: boolean }> = []
const matches: Array<{ filePath: string; projectDir: string; mtimeMs: number }> = []
for (const dir of projectDirs) {
const filePath = path.join(projectsDir, dir, `${sessionId}.jsonl`)
try {
const stat = await fs.stat(filePath)
matches.push({
filePath,
projectDir: dir,
mtimeMs: stat.mtimeMs,
hasTranscript: await this.fileHasConversationTranscript(filePath),
})
matches.push({ filePath, projectDir: dir, mtimeMs: stat.mtimeMs })
} catch {
continue
}
}
return matches
.sort((a, b) => Number(b.hasTranscript) - Number(a.hasTranscript) || b.mtimeMs - a.mtimeMs || a.filePath.localeCompare(b.filePath))
.map(({ filePath, projectDir }) => ({ filePath, projectDir }))
const ranked = await this.rankSessionFileMatches(matches, () => 'drop')
return ranked!.map(({ filePath, projectDir }) => ({ filePath, projectDir }))
}
async findSessionFile(