mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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:
parent
9c02e7f4eb
commit
c907144788
@ -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, [
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user