mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-08-03 17:01:15 +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)
|
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 () => {
|
it('should invalidate cached list metadata after writes', async () => {
|
||||||
const sessionId = '30000000-bbbb-cccc-dddd-eeeeeeeeeeee'
|
const sessionId = '30000000-bbbb-cccc-dddd-eeeeeeeeeeee'
|
||||||
await writeSessionFile('-tmp-cache-invalidation', sessionId, [
|
await writeSessionFile('-tmp-cache-invalidation', sessionId, [
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
* 确保 Desktop App 与 CLI 的数据完全互通。
|
* 确保 Desktop App 与 CLI 的数据完全互通。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createReadStream } from 'node:fs'
|
import { createReadStream, type Stats } from 'node:fs'
|
||||||
import * as fs from 'node:fs/promises'
|
import * as fs from 'node:fs/promises'
|
||||||
import * as path from 'node:path'
|
import * as path from 'node:path'
|
||||||
import * as os from 'node:os'
|
import * as os from 'node:os'
|
||||||
@ -286,6 +286,12 @@ type SessionListSummary = {
|
|||||||
worktreeSession?: PersistedWorktreeSession | null
|
worktreeSession?: PersistedWorktreeSession | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SessionListSummaryCacheEntry = {
|
||||||
|
mtimeMs: number
|
||||||
|
size: number
|
||||||
|
summary: SessionListSummary
|
||||||
|
}
|
||||||
|
|
||||||
const VALID_SESSION_PERMISSION_MODES = new Set([
|
const VALID_SESSION_PERMISSION_MODES = new Set([
|
||||||
'default',
|
'default',
|
||||||
'acceptEdits',
|
'acceptEdits',
|
||||||
@ -318,6 +324,7 @@ export class SessionService {
|
|||||||
expiresAt: number
|
expiresAt: number
|
||||||
result: { sessions: SessionListItem[]; total: number }
|
result: { sessions: SessionListItem[]; total: number }
|
||||||
}>()
|
}>()
|
||||||
|
private readonly sessionListSummaryCache = new Map<string, SessionListSummaryCacheEntry>()
|
||||||
|
|
||||||
private sessionListCacheKey(options?: {
|
private sessionListCacheKey(options?: {
|
||||||
project?: string
|
project?: string
|
||||||
@ -342,6 +349,35 @@ export class SessionService {
|
|||||||
this.sessionListCache.clear()
|
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
|
// Config helpers
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
@ -1746,7 +1782,7 @@ export class SessionService {
|
|||||||
const items: SessionListItem[] = []
|
const items: SessionListItem[] = []
|
||||||
for (const { filePath, projectDir, sessionId, stat } of paginatedFiles) {
|
for (const { filePath, projectDir, sessionId, stat } of paginatedFiles) {
|
||||||
try {
|
try {
|
||||||
const summary = await this.scanSessionListSummary(filePath, projectDir, stat)
|
const summary = await this.getCachedSessionListSummary(filePath, projectDir, stat)
|
||||||
const workDir = summary.workDir
|
const workDir = summary.workDir
|
||||||
const projectRoot = await this.resolveProjectRootFromSessionMetadata({
|
const projectRoot = await this.resolveProjectRootFromSessionMetadata({
|
||||||
worktreeSession: summary.worktreeSession,
|
worktreeSession: summary.worktreeSession,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user