From 23eda7bf8d42c71b3198a76509b2594ae5eb5251 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Thu, 1 Oct 2026 17:37:15 +0800 Subject: [PATCH] fix(sessions): avoid history scans for empty indexed pages --- ...sionService.sessionListPerformance.test.ts | 120 ++++++++++++++++++ src/server/services/sessionService.ts | 9 +- 2 files changed, 124 insertions(+), 5 deletions(-) create mode 100644 src/server/services/sessionService.sessionListPerformance.test.ts diff --git a/src/server/services/sessionService.sessionListPerformance.test.ts b/src/server/services/sessionService.sessionListPerformance.test.ts new file mode 100644 index 00000000..d544ec26 --- /dev/null +++ b/src/server/services/sessionService.sessionListPerformance.test.ts @@ -0,0 +1,120 @@ +import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test' +import * as fs from 'node:fs/promises' +import * as os from 'node:os' +import * as path from 'node:path' +import { SessionService } from './sessionService.js' +import { openLocalIndexDatabase, type LocalIndexDatabase } from './localIndex/database.js' +import { createSessionIndex, type LocalIndexGateway } from './localIndex/sessionIndex.js' +import { createSessionProjector } from './localIndex/sessionProjector.js' +import type { LocalIndexStatus } from './localIndex/types.js' +import { sanitizePath } from '../../utils/sessionStoragePortable.js' + +describe('indexed session list empty-page performance', () => { + let configDir: string + let previousHome: string | undefined + let previousConfigDir: string | undefined + let database: LocalIndexDatabase | undefined + + beforeEach(async () => { + previousHome = process.env.HOME + previousConfigDir = process.env.CLAUDE_CONFIG_DIR + configDir = await fs.mkdtemp(path.join(os.tmpdir(), 'qa004-session-list-')) + process.env.HOME = configDir + process.env.CLAUDE_CONFIG_DIR = configDir + }) + + afterEach(async () => { + database?.close() + database = undefined + if (previousHome === undefined) delete process.env.HOME + else process.env.HOME = previousHome + if (previousConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR + else process.env.CLAUDE_CONFIG_DIR = previousConfigDir + await fs.rm(configDir, { recursive: true, force: true }) + }) + + async function fixture(teamWorkers = false) { + const workDir = path.join(configDir, 'workspace') + const projectPath = sanitizePath(workDir) + await fs.mkdir(workDir) + await fs.mkdir(path.join(configDir, 'projects', projectPath), { recursive: true }) + database = openLocalIndexDatabase({ path: path.join(configDir, 'index.sqlite') }) + const index = createSessionIndex(database) + const projector = createSessionProjector({ database, index, scope: configDir }) + const ids = [] + for (let n = 0; n < 6; n += 1) { + const sessionId = `${String(n).padStart(8, '0')}-1111-4111-8111-111111111111` + ids.push(sessionId) + const filePath = path.join(configDir, 'projects', projectPath, `${sessionId}.jsonl`) + const timestamp = new Date(Date.UTC(2026, 0, 1, 0, n)).toISOString() + // Synthetic history: enough body data to prove an empty-page fallback + // parses real transcripts, without a CPU/wall-clock threshold. + await fs.writeFile(filePath, [ + { type: 'session-meta', workDir, ...(teamWorkers ? { entrypoint: 'claude-desktop-team-worker' } : {}) }, + { type: 'user', timestamp, message: { role: 'user', content: `Session ${n}` } }, + { type: 'assistant', timestamp, message: { role: 'assistant', content: 'x'.repeat(256 * 1024) } }, + ].map(entry => JSON.stringify(entry)).join('\n') + '\n') + const stat = await fs.stat(filePath) + await projector.projectSource({ + path: filePath, sessionId, projectPath, modifiedAtMs: stat.mtimeMs, + fallbackCreatedAt: stat.birthtime.toISOString(), fallbackModifiedAt: stat.mtime.toISOString(), + fallbackWorkDir: workDir, + }) + } + const status: LocalIndexStatus = { + mode: 'on', state: 'ready', discovered: 6, indexed: 6, degradedSources: 0, + databaseBytes: 0, walBytes: 0, lastUpdatedAt: '2026-01-01T00:10:00.000Z', lastErrorCode: null, + } + const gateway: LocalIndexGateway = { + ...index, getMode: () => 'on', getPublicStatus: () => status, isSessionScopeReady: () => true, + start: async () => {}, stop: async () => {}, rebuild: async () => status, + } + return { gateway, ids, workDir } + } + + it.each(['past-end', 'zero-limit', 'unmatched-project', 'workers-only'] as const)( + 'does not discover or parse historical transcripts for a ready %s page', + async (scenario) => { + const { gateway } = await fixture(scenario === 'workers-only') + let now = 1_000 + const service = new SessionService(gateway, { now: () => now }) + const internals = service as unknown as { + discoverSessionFiles: (...args: unknown[]) => Promise + scanSessionListSummary: (...args: unknown[]) => Promise + } + const discover = spyOn(internals, 'discoverSessionFiles') + const scan = spyOn(internals, 'scanSessionListSummary') + const options = scenario === 'past-end' ? { limit: 20, offset: 6 } + : scenario === 'zero-limit' ? { limit: 0 } + : scenario === 'unmatched-project' ? { project: path.join(configDir, 'missing-workspace') } + : { limit: 20 } + try { + const result = await service.listSessions(options) + expect(result).toEqual({ sessions: [], total: scenario === 'past-end' || scenario === 'zero-limit' ? 6 : 0 }) + // Expire the legacy page cache and make concurrent requests. A ready + // empty page remains authoritative regardless of fallback cache warmth. + now += 6_000 + expect(await Promise.all([service.listSessions(options), service.listSessions(options)])).toEqual([result, result]) + expect(scan).not.toHaveBeenCalled() + expect(discover).not.toHaveBeenCalled() + } finally { + discover.mockRestore() + scan.mockRestore() + } + }, + ) + + it('still falls back if an empty index page was returned during a read failure', async () => { + const { gateway, ids } = await fixture() + gateway.listSessions = () => { + gateway.getPublicStatus = () => ({ + ...gatewayStatus, state: 'degraded', lastErrorCode: 'LOCAL_INDEX_READ_FAILED', + }) + return { sessions: [], total: 0 } + } + const gatewayStatus = gateway.getPublicStatus() + const result = await new SessionService(gateway).listSessions({ limit: 20 }) + expect(result.total).toBe(6) + expect(result.sessions.map(session => session.id)).toEqual([...ids].reverse()) + }) +}) diff --git a/src/server/services/sessionService.ts b/src/server/services/sessionService.ts index 544e07a2..6b2779e6 100644 --- a/src/server/services/sessionService.ts +++ b/src/server/services/sessionService.ts @@ -3675,11 +3675,10 @@ export class SessionService { const status = this.localIndexGateway.getPublicStatus() if (requireReady && status.state !== 'ready') return null if (indexedPage.sessions.length === 0) { - // Building must not fall through to a full JSONL scan. The sidebar - // already treats an empty building page as loading. - return status.state === 'building' - ? { sessions: [], total: indexedPage.total } - : null + // A usable index owns empty pages too (including an exhausted offset, + // a zero limit or a project without matches). Scanning history cannot + // fill those pages and makes a cold empty query parse every transcript. + return { sessions: [], total: indexedPage.total } } const sessions: SessionListItem[] = []