fix(server): cache session list summaries

Reduce repeated /api/sessions?limit=400 scans by caching per-file JSONL list summaries keyed by mtime and size. This targets #894 diagnostics where sidebar refreshes reported client_api_request_failed timeouts on session list and inspection paths.

Tested: bun test src/server/__tests__/sessions.test.ts

Tested: bun run check:server

Tested: temporary source server /api/sessions?limit=400 first call 0.80s; after waiting past the 5s list cache TTL, second call 0.03s with identical 399 session IDs.

Not-tested: Windows release build retest for #894 diagnostics timeout.

Confidence: high

Scope-risk: narrow
This commit is contained in:
程序员阿江(Relakkes) 2026-06-23 17:54:20 +08:00
parent 9c02e7f4eb
commit c907144788
2 changed files with 89 additions and 2 deletions

View File

@ -607,6 +607,57 @@ describe('SessionService', () => {
expect(scanCount).toBe(3)
})
it('should reuse unchanged file summaries after the list response cache is cleared', async () => {
const sessionFiles: Array<{ id: string; filePath: string }> = []
for (let i = 0; i < 3; i++) {
const id = `2500000${i.toString(16)}-bbbb-cccc-dddd-eeeeeeeeeeee`
const filePath = await writeSessionFile('-tmp-file-summary-cache', id, [
makeSnapshotEntry(),
makeUserEntry(`Cached file summary ${i}`),
])
const mtime = new Date(Date.now() - i * 1000)
await fs.utimes(filePath, mtime, mtime)
sessionFiles.push({ id, filePath })
}
const serviceWithSpy = service as unknown as {
scanSessionListSummary: (...args: unknown[]) => Promise<unknown>
}
const serviceInternals = service as unknown as {
sessionListCache: Map<string, unknown>
}
const originalScanSessionListSummary = serviceWithSpy.scanSessionListSummary.bind(service)
let scanCount = 0
serviceWithSpy.scanSessionListSummary = async (...args) => {
scanCount += 1
return originalScanSessionListSummary(...args)
}
await service.listSessions({ limit: 3, offset: 0 })
expect(scanCount).toBe(3)
serviceInternals.sessionListCache.clear()
const second = await service.listSessions({ limit: 3, offset: 0 })
expect(second.sessions).toHaveLength(3)
expect(scanCount).toBe(3)
await fs.appendFile(
sessionFiles[1]!.filePath,
`${JSON.stringify({
type: 'custom-title',
customTitle: 'Changed cached file summary',
timestamp: new Date().toISOString(),
})}\n`,
'utf-8',
)
serviceInternals.sessionListCache.clear()
const third = await service.listSessions({ limit: 3, offset: 0 })
expect(third.sessions.find((session) => session.id === sessionFiles[1]!.id)?.title)
.toBe('Changed cached file summary')
expect(scanCount).toBe(4)
})
it('should invalidate cached list metadata after writes', async () => {
const sessionId = '30000000-bbbb-cccc-dddd-eeeeeeeeeeee'
await writeSessionFile('-tmp-cache-invalidation', sessionId, [

View File

@ -5,7 +5,7 @@
* Desktop App CLI
*/
import { createReadStream } from 'node:fs'
import { createReadStream, type Stats } from 'node:fs'
import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import * as os from 'node:os'
@ -286,6 +286,12 @@ type SessionListSummary = {
worktreeSession?: PersistedWorktreeSession | null
}
type SessionListSummaryCacheEntry = {
mtimeMs: number
size: number
summary: SessionListSummary
}
const VALID_SESSION_PERMISSION_MODES = new Set([
'default',
'acceptEdits',
@ -318,6 +324,7 @@ export class SessionService {
expiresAt: number
result: { sessions: SessionListItem[]; total: number }
}>()
private readonly sessionListSummaryCache = new Map<string, SessionListSummaryCacheEntry>()
private sessionListCacheKey(options?: {
project?: string
@ -342,6 +349,35 @@ export class SessionService {
this.sessionListCache.clear()
}
private cloneSessionListSummary(summary: SessionListSummary): SessionListSummary {
return {
...summary,
repository: summary.repository ? { ...summary.repository } : undefined,
worktreeSession: summary.worktreeSession
? { ...summary.worktreeSession }
: summary.worktreeSession,
}
}
private async getCachedSessionListSummary(
filePath: string,
projectDir: string,
stat: Stats,
): Promise<SessionListSummary> {
const cached = this.sessionListSummaryCache.get(filePath)
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
return this.cloneSessionListSummary(cached.summary)
}
const summary = await this.scanSessionListSummary(filePath, projectDir, stat)
this.sessionListSummaryCache.set(filePath, {
mtimeMs: stat.mtimeMs,
size: stat.size,
summary: this.cloneSessionListSummary(summary),
})
return summary
}
// --------------------------------------------------------------------------
// Config helpers
// --------------------------------------------------------------------------
@ -1746,7 +1782,7 @@ export class SessionService {
const items: SessionListItem[] = []
for (const { filePath, projectDir, sessionId, stat } of paginatedFiles) {
try {
const summary = await this.scanSessionListSummary(filePath, projectDir, stat)
const summary = await this.getCachedSessionListSummary(filePath, projectDir, stat)
const workDir = summary.workDir
const projectRoot = await this.resolveProjectRootFromSessionMetadata({
worktreeSession: summary.worktreeSession,