perf(sessions): reuse validated overview cache on startup

This commit is contained in:
digua committed 2026-08-08 21:47:47 +08:00
1 parent 3b123b15f2
commit 5e8d1caf30
9 files changed
+281 -14

No files matched your search

+1
View File
@@ -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",
@@ -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 () => {
@@ -3,10 +3,11 @@ import type { RuntimeRouteContext } from '../../context/runtime'
import { appLogger, memberService } from '@openchatlab/node-runtime'
import { apiErrorFromUnknown } from '../../errors'
type MemberRouteContext = Pick<RuntimeRouteContext, 'sessionAdapter'>
type MemberRouteContext = Pick<RuntimeRouteContext, 'sessionAdapter' | 'pathProvider'>
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 }
}
)
@@ -10,6 +10,7 @@ type SessionRouteContext = Pick<RuntimeRouteContext, 'sessionAdapter' | 'pathPro
export function registerSessionRoutes(server: FastifyInstance, ctx: SessionRouteContext): void {
const { sessionAdapter: adapter } = ctx
const cacheDir = ctx.pathProvider.getCacheDir()
// Lazy: only owner-profile routes need preferences.json access
let preferencesInstance: PreferencesManager | null = null
@@ -21,6 +22,7 @@ export function registerSessionRoutes(server: FastifyInstance, ctx: SessionRoute
server.get('/_web/sessions', async () => {
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 })
}
@@ -9,6 +9,7 @@ export {
renameSession,
updateSessionOwnerId,
deleteSession,
resolveValidatedSessionOverview,
} from './session-service'
export type { AnalysisSessionDTO, ListSessionsOptions } from './session-service'
@@ -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
}
/**
@@ -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,
+7 -1
View File
@@ -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
@@ -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 <path> --cache-dir <path> [options]
Creates valid group-chat databases and matching overview caches for local
startup benchmarks. Both output directories must be empty.
Options:
--sessions <count> Session databases to create (default: 100)
--messages <count> 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
})