From 5e8d1caf30fc0a12bc8855a18793be20dcfa04ce Mon Sep 17 00:00:00 2001 From: digua Date: Sat, 8 Aug 2026 00:12:25 +0800 Subject: [PATCH] perf(sessions): reuse validated overview cache on startup --- package.json | 1 + .../src/routes/shared-routes.test.ts | 73 ++++++++- .../http-routes/src/routes/web/members.ts | 9 +- .../http-routes/src/routes/web/sessions.ts | 6 +- packages/node-runtime/src/services/index.ts | 1 + .../src/services/member-service.ts | 31 +++- .../src/services/session-service.ts | 18 +++ scripts/bench-cli-web-startup.mjs | 8 +- scripts/create-startup-benchmark-fixture.mjs | 148 ++++++++++++++++++ 9 files changed, 281 insertions(+), 14 deletions(-) create mode 100644 scripts/create-startup-benchmark-fixture.mjs diff --git a/package.json b/package.json index 43204f2f..cd4137d0 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "build:web-wasm": "vite build --config vite.web-wasm.config.mts", "build:web-ai-spike": "vite build --config tests/web-ai-sdk-spike/vite.config.mts", "bench:startup": "node scripts/bench-cli-web-startup.mjs", + "bench:startup:fixture": "node scripts/create-startup-benchmark-fixture.mjs", "docs:dev": "pnpm --filter @openchatlab/docs dev", "docs:build": "pnpm --filter @openchatlab/docs build", "docs:preview": "pnpm --filter @openchatlab/docs preview", diff --git a/packages/http-routes/src/routes/shared-routes.test.ts b/packages/http-routes/src/routes/shared-routes.test.ts index ce1326ce..89a807e7 100644 --- a/packages/http-routes/src/routes/shared-routes.test.ts +++ b/packages/http-routes/src/routes/shared-routes.test.ts @@ -13,7 +13,13 @@ import path from 'node:path' import Database from 'better-sqlite3' import Fastify, { type FastifyInstance } from 'fastify' import type { DatabaseAdapter, PathProvider, PreparedStatement, RunResult } from '@openchatlab/core' -import { PreferencesManager, type DatabaseManager, type SessionRuntimeAdapter } from '@openchatlab/node-runtime' +import { + CACHE_KEY_OVERVIEW, + PreferencesManager, + setCache, + type DatabaseManager, + type SessionRuntimeAdapter, +} from '@openchatlab/node-runtime' import type { HttpRouteContext } from '../context' import { registerSharedRoutes } from '../register' import { registerRestSessionRoutes } from './rest/sessions' @@ -87,7 +93,8 @@ function createSessionDb(): TestSqliteDb { platform_id TEXT, account_name TEXT, group_nickname TEXT, - avatar TEXT + avatar TEXT, + aliases TEXT DEFAULT '[]' ); CREATE TABLE message ( id INTEGER PRIMARY KEY, @@ -223,6 +230,34 @@ describe('registerSharedRoutes smoke tests', () => { assert.ok(Array.isArray(resp.json())) }) + it('GET /_web/sessions reuses a fingerprint-validated overview cache', async () => { + const db = createSessionDb() + const routeApp = Fastify() + const ctx = createTestContext(new Map([['chat-1', db]])) + setCache( + 'chat-1', + CACHE_KEY_OVERVIEW, + { + totalMessages: 321, + totalMembers: 12, + firstMessageTs: 100, + lastMessageTs: 300, + maxMessageId: 3, + }, + ctx.pathProvider.getCacheDir() + ) + registerSessionRoutes(routeApp, ctx) + await routeApp.ready() + + const resp = await routeApp.inject({ method: 'GET', url: '/_web/sessions' }) + + await routeApp.close() + db.close() + assert.equal(resp.statusCode, 200) + assert.equal(resp.json()[0].messageCount, 321) + assert.equal(resp.json()[0].memberCount, 12) + }) + it('GET /_web/sessions/:id returns 404 for missing session', async () => { const resp = await app.inject({ method: 'GET', url: '/_web/sessions/nonexistent' }) assert.equal(resp.statusCode, 404) @@ -360,6 +395,10 @@ describe('registerSharedRoutes smoke tests', () => { registerSharedRoutes(routeApp, createTestContext(new Map([['chat-1', db]]))) await routeApp.ready() + const initialSessionsResp = await routeApp.inject({ method: 'GET', url: '/_web/sessions' }) + assert.equal(initialSessionsResp.json()[0].messageCount, 4) + assert.equal(initialSessionsResp.json()[0].memberCount, 3) + const resp = await routeApp.inject({ method: 'POST', url: '/_web/sessions/chat-1/members/batch-delete', @@ -368,6 +407,7 @@ describe('registerSharedRoutes smoke tests', () => { const remainingMembers = db.prepare('SELECT id FROM member ORDER BY id').all() const remainingMessages = db.prepare('SELECT sender_id AS senderId FROM message ORDER BY id').all() const remainingHistory = db.prepare('SELECT member_id AS memberId FROM member_name_history ORDER BY id').all() + const refreshedSessionsResp = await routeApp.inject({ method: 'GET', url: '/_web/sessions' }) await routeApp.close() db.close() @@ -377,6 +417,35 @@ describe('registerSharedRoutes smoke tests', () => { assert.deepEqual(remainingMembers, [{ id: 3 }]) assert.deepEqual(remainingMessages, [{ senderId: 3 }]) assert.deepEqual(remainingHistory, [{ memberId: 3 }]) + assert.equal(refreshedSessionsResp.json()[0].messageCount, 1) + assert.equal(refreshedSessionsResp.json()[0].memberCount, 1) + }) + + it('POST /_web/sessions/:id/members/merge invalidates cached member totals without changing message ids', async () => { + const db = createSessionDb() + const routeApp = Fastify() + const sessionId = 'merge-cache-chat' + registerSharedRoutes(routeApp, createTestContext(new Map([[sessionId, db]]))) + await routeApp.ready() + + const initialSessionsResp = await routeApp.inject({ method: 'GET', url: '/_web/sessions' }) + assert.equal(initialSessionsResp.json()[0].messageCount, 3) + assert.equal(initialSessionsResp.json()[0].memberCount, 2) + + const mergeResp = await routeApp.inject({ + method: 'POST', + url: `/_web/sessions/${sessionId}/members/merge`, + payload: { memberId1: 1, memberId2: 2 }, + }) + const refreshedSessionsResp = await routeApp.inject({ method: 'GET', url: '/_web/sessions' }) + + await routeApp.close() + db.close() + + assert.equal(mergeResp.statusCode, 200) + assert.deepEqual(mergeResp.json(), { success: true }) + assert.equal(refreshedSessionsResp.json()[0].messageCount, 3) + assert.equal(refreshedSessionsResp.json()[0].memberCount, 1) }) it('POST /_web/sessions/:id/members/batch-delete rejects an empty selection', async () => { diff --git a/packages/http-routes/src/routes/web/members.ts b/packages/http-routes/src/routes/web/members.ts index 95ace206..10e251ec 100644 --- a/packages/http-routes/src/routes/web/members.ts +++ b/packages/http-routes/src/routes/web/members.ts @@ -3,10 +3,11 @@ import type { RuntimeRouteContext } from '../../context/runtime' import { appLogger, memberService } from '@openchatlab/node-runtime' import { apiErrorFromUnknown } from '../../errors' -type MemberRouteContext = Pick +type MemberRouteContext = Pick export function registerMemberRoutes(server: FastifyInstance, ctx: MemberRouteContext): void { const { sessionAdapter: adapter } = ctx + const cacheDir = ctx.pathProvider.getCacheDir() server.get<{ Params: { id: string } }>('/_web/sessions/:id/members', async (request) => { return memberService.getMembers(adapter, request.params.id) @@ -37,7 +38,7 @@ export function registerMemberRoutes(server: FastifyInstance, ctx: MemberRouteCo '/_web/sessions/:id/members/:memberId', async (request) => { const memberId = parseInt(request.params.memberId, 10) - memberService.deleteMember(adapter, request.params.id, memberId) + memberService.deleteMember(adapter, request.params.id, memberId, cacheDir) return { success: true } } ) @@ -59,7 +60,7 @@ export function registerMemberRoutes(server: FastifyInstance, ctx: MemberRouteCo const uniqueMemberIds = Array.from(new Set(memberIds as number[])) try { - const success = memberService.deleteMembers(adapter, request.params.id, uniqueMemberIds) + const success = memberService.deleteMembers(adapter, request.params.id, uniqueMemberIds, cacheDir) if (!success) { throw new Error('Member deletion transaction returned no result') } @@ -81,7 +82,7 @@ export function registerMemberRoutes(server: FastifyInstance, ctx: MemberRouteCo '/_web/sessions/:id/members/merge', async (request) => { const { memberId1, memberId2 } = request.body - memberService.mergeMembers(adapter, request.params.id, memberId1, memberId2) + memberService.mergeMembers(adapter, request.params.id, memberId1, memberId2, cacheDir) return { success: true } } ) diff --git a/packages/http-routes/src/routes/web/sessions.ts b/packages/http-routes/src/routes/web/sessions.ts index 21069e78..78e3799e 100644 --- a/packages/http-routes/src/routes/web/sessions.ts +++ b/packages/http-routes/src/routes/web/sessions.ts @@ -10,6 +10,7 @@ type SessionRouteContext = Pick { const aiChatCounts = ctx.aiChatManager?.getAIChatCountsBySession() return sessionService.listAnalysisSessions(adapter, { + resolveOverview: (db, sessionId) => sessionService.resolveValidatedSessionOverview(db, sessionId, cacheDir), enrichSession: aiChatCounts?.size ? (dto) => ({ ...dto, aiConversationCount: aiChatCounts.get(dto.id) ?? 0 }) : undefined, @@ -28,7 +30,9 @@ export function registerSessionRoutes(server: FastifyInstance, ctx: SessionRoute }) server.get<{ Params: { id: string } }>('/_web/sessions/:id', async (request) => { - const session = sessionService.getAnalysisSession(adapter, request.params.id) + const session = sessionService.getAnalysisSession(adapter, request.params.id, { + resolveOverview: (db, sessionId) => sessionService.resolveValidatedSessionOverview(db, sessionId, cacheDir), + }) if (!session) { throw Object.assign(new Error(`Session not found: ${request.params.id}`), { statusCode: 404 }) } diff --git a/packages/node-runtime/src/services/index.ts b/packages/node-runtime/src/services/index.ts index d11ee5f0..4516e165 100644 --- a/packages/node-runtime/src/services/index.ts +++ b/packages/node-runtime/src/services/index.ts @@ -9,6 +9,7 @@ export { renameSession, updateSessionOwnerId, deleteSession, + resolveValidatedSessionOverview, } from './session-service' export type { AnalysisSessionDTO, ListSessionsOptions } from './session-service' diff --git a/packages/node-runtime/src/services/member-service.ts b/packages/node-runtime/src/services/member-service.ts index 710b9f59..ef3eb215 100644 --- a/packages/node-runtime/src/services/member-service.ts +++ b/packages/node-runtime/src/services/member-service.ts @@ -16,6 +16,7 @@ import { deleteMembers as coreDeleteMembers, } from '@openchatlab/core' import type { MemberWithAliases, MembersPaginationParams, MembersPaginatedResult } from '@openchatlab/core' +import { CACHE_KEY_MEMBERS, CACHE_KEY_OVERVIEW, invalidateCache } from '../cache/session-cache' import type { SessionRuntimeAdapter } from './adapters' export type { MemberWithAliases, MembersPaginationParams, MembersPaginatedResult } @@ -28,6 +29,11 @@ export interface MembersPaginatedDTO { totalPages: number } +function invalidateMemberMutationCaches(sessionId: string, cacheDir: string): void { + invalidateCache(sessionId, cacheDir, CACHE_KEY_OVERVIEW) + invalidateCache(sessionId, cacheDir, CACHE_KEY_MEMBERS) +} + /** * Get all members with aliases for a session. */ @@ -81,19 +87,29 @@ export function mergeMembers( adapter: SessionRuntimeAdapter, sessionId: string, memberId1: number, - memberId2: number + memberId2: number, + cacheDir: string ): boolean { const db = adapter.ensureWritable(sessionId) - return coreMergeMembers(db, memberId1, memberId2) + const success = coreMergeMembers(db, memberId1, memberId2) + if (success) invalidateMemberMutationCaches(sessionId, cacheDir) + return success } /** * Delete a member and all associated data: * - messages, member_name_history, member row (in transaction) */ -export function deleteMember(adapter: SessionRuntimeAdapter, sessionId: string, memberId: number): boolean { +export function deleteMember( + adapter: SessionRuntimeAdapter, + sessionId: string, + memberId: number, + cacheDir: string +): boolean { const db = adapter.ensureWritable(sessionId) - return coreDeleteMember(db, memberId) + const success = coreDeleteMember(db, memberId) + if (success) invalidateMemberMutationCaches(sessionId, cacheDir) + return success } /** @@ -102,10 +118,13 @@ export function deleteMember(adapter: SessionRuntimeAdapter, sessionId: string, export function deleteMembers( adapter: SessionRuntimeAdapter, sessionId: string, - memberIds: readonly number[] + memberIds: readonly number[], + cacheDir: string ): boolean { const db = adapter.ensureWritable(sessionId) - return coreDeleteMembers(db, memberIds) + const success = coreDeleteMembers(db, memberIds) + if (success) invalidateMemberMutationCaches(sessionId, cacheDir) + return success } /** diff --git a/packages/node-runtime/src/services/session-service.ts b/packages/node-runtime/src/services/session-service.ts index a62e8e06..ce1ec24d 100644 --- a/packages/node-runtime/src/services/session-service.ts +++ b/packages/node-runtime/src/services/session-service.ts @@ -8,6 +8,7 @@ import { getSessionInfo, + getSessionOverview, getSessionMeta, getSummaryCount, getPrivateChatMemberAvatar, @@ -18,6 +19,7 @@ import { } from '@openchatlab/core' import type { CoreSessionInfo, DatabaseAdapter, SessionOverview } from '@openchatlab/core' import type { SessionRuntimeAdapter } from './adapters' +import { getValidatedOverviewCache } from '../cache/session-cache' export interface AnalysisSessionDTO extends CoreSessionInfo { id: string @@ -48,6 +50,22 @@ export interface ListSessionsOptions { enrichSession?(dto: AnalysisSessionDTO): AnalysisSessionDTO } +/** + * Resolve a fresh session overview through the shared JSON cache. Cache I/O or + * validation failures fall back to live SQL so the session catalog stays usable. + */ +export function resolveValidatedSessionOverview( + db: DatabaseAdapter, + sessionId: string, + cacheDir: string +): SessionOverview { + try { + return getValidatedOverviewCache(db, sessionId, cacheDir) + } catch { + return getSessionOverview(db) + } +} + function buildSession( db: DatabaseAdapter, id: string, diff --git a/scripts/bench-cli-web-startup.mjs b/scripts/bench-cli-web-startup.mjs index 89d40dce..2d60b268 100644 --- a/scripts/bench-cli-web-startup.mjs +++ b/scripts/bench-cli-web-startup.mjs @@ -18,6 +18,12 @@ function parsePositiveInteger(value, flag) { return parsed } +function parseNonNegativeInteger(value, flag) { + const parsed = Number.parseInt(value, 10) + if (!Number.isInteger(parsed) || parsed < 0) throw new Error(`${flag} must be a non-negative integer`) + return parsed +} + function parseArgs(argv) { const options = { url: DEFAULT_URL, @@ -40,7 +46,7 @@ function parseArgs(argv) { if (arg === '--url') options.url = next() else if (arg === '--runs') options.runs = parsePositiveInteger(next(), arg) - else if (arg === '--warmups') options.warmups = parsePositiveInteger(next(), arg) + else if (arg === '--warmups') options.warmups = parseNonNegativeInteger(next(), arg) else if (arg === '--timeout') options.timeoutMs = parsePositiveInteger(next(), arg) else if (arg === '--chrome') options.chromePath = next() else if (arg === '--json') options.json = true diff --git a/scripts/create-startup-benchmark-fixture.mjs b/scripts/create-startup-benchmark-fixture.mjs new file mode 100644 index 00000000..05227139 --- /dev/null +++ b/scripts/create-startup-benchmark-fixture.mjs @@ -0,0 +1,148 @@ +#!/usr/bin/env node + +import { existsSync } from 'node:fs' +import { mkdir, readdir, writeFile } from 'node:fs/promises' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import Database from 'better-sqlite3' + +function parsePositiveInteger(value, flag) { + const parsed = Number.parseInt(value, 10) + if (!Number.isInteger(parsed) || parsed <= 0) throw new Error(`${flag} must be a positive integer`) + return parsed +} + +function parseArgs(argv) { + const options = { sessions: 100, messages: 200 } + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index] + if (arg === '--') continue + const next = () => { + const value = argv[index + 1] + if (!value) throw new Error(`${arg} requires a value`) + index += 1 + return value + } + if (arg === '--database-dir') options.databaseDir = resolve(next()) + else if (arg === '--cache-dir') options.cacheDir = resolve(next()) + else if (arg === '--sessions') options.sessions = parsePositiveInteger(next(), arg) + else if (arg === '--messages') options.messages = parsePositiveInteger(next(), arg) + else if (arg === '--help') { + console.log(`Usage: pnpm bench:startup:fixture -- --database-dir --cache-dir [options] + +Creates valid group-chat databases and matching overview caches for local +startup benchmarks. Both output directories must be empty. + +Options: + --sessions Session databases to create (default: 100) + --messages Messages in each session (default: 200) +`) + process.exit(0) + } else throw new Error(`Unknown option: ${arg}`) + } + if (!options.databaseDir || !options.cacheDir) { + throw new Error('--database-dir and --cache-dir are required') + } + return options +} + +async function ensureEmptyDirectory(directory) { + if (existsSync(directory) && (await readdir(directory)).length > 0) { + throw new Error(`Refusing to write benchmark fixtures into non-empty directory: ${directory}`) + } + await mkdir(directory, { recursive: true }) +} + +const SCHEMA = ` + CREATE TABLE meta ( + name TEXT, + platform TEXT, + type TEXT, + imported_at INTEGER, + group_id TEXT, + group_avatar TEXT, + owner_id TEXT, + session_gap_threshold INTEGER + ); + CREATE TABLE member ( + id INTEGER PRIMARY KEY, + platform_id TEXT, + account_name TEXT, + group_nickname TEXT, + avatar TEXT + ); + CREATE TABLE message ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sender_id INTEGER, + ts INTEGER, + type INTEGER, + content TEXT, + platform_message_id TEXT + ); + CREATE TABLE segment ( + id INTEGER PRIMARY KEY, + start_ts INTEGER, + end_ts INTEGER, + message_count INTEGER, + is_manual INTEGER DEFAULT 0, + summary TEXT + ); +` + +async function main() { + const options = parseArgs(process.argv.slice(2)) + await ensureEmptyDirectory(options.databaseDir) + await ensureEmptyDirectory(options.cacheDir) + const nativeBinding = fileURLToPath(new URL('../apps/cli/native/better_sqlite3.node', import.meta.url)) + + for (let index = 1; index <= options.sessions; index += 1) { + const sessionId = `benchmark-${String(index).padStart(3, '0')}` + const db = new Database(resolve(options.databaseDir, `${sessionId}.db`), { nativeBinding }) + try { + db.exec(SCHEMA) + db.prepare( + `INSERT INTO meta (name, platform, type, imported_at, group_id, owner_id) + VALUES (?, 'benchmark', 'group', ?, ?, NULL)` + ).run(`Benchmark Session ${index}`, 2_000_000_000 - index, sessionId) + db.prepare( + `INSERT INTO member (id, platform_id, account_name, group_nickname, avatar) + VALUES (1, 'member-1', 'Benchmark Member', 'Benchmark Member', NULL)` + ).run() + + const insertMessage = db.prepare( + `INSERT INTO message (sender_id, ts, type, content, platform_message_id) + VALUES (1, ?, 0, 'benchmark message', ?)` + ) + db.transaction(() => { + for (let message = 1; message <= options.messages; message += 1) { + insertMessage.run(1_700_000_000 + message, `${sessionId}-${message}`) + } + })() + } finally { + db.close() + } + + const cache = { + overview: { + data: { + totalMessages: options.messages, + totalMembers: 1, + firstMessageTs: 1_700_000_001, + lastMessageTs: 1_700_000_000 + options.messages, + maxMessageId: options.messages, + }, + ts: Math.floor(Date.now() / 1_000), + }, + } + await writeFile(resolve(options.cacheDir, `${sessionId}.cache.json`), JSON.stringify(cache), 'utf8') + } + + console.log(`Created ${options.sessions} cached sessions (${options.messages} messages each).`) + console.log(`Databases: ${options.databaseDir}`) + console.log(`Caches: ${options.cacheDir}`) +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)) + process.exitCode = 1 +})