From a78506f3e47dbd5b71111fd38fec92576a096013 Mon Sep 17 00:00:00 2001 From: "Ronny.Herrgesell" Date: Mon, 11 May 2026 15:56:47 +0200 Subject: [PATCH] perf: optimize dashboard refresh from >500ms to ~140ms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite getToolUsageSummary() from NĂ—5 dynamic queries to single prepared CTE query. Collapse getTokenSummary() from 4 queries to 1. Remove date() function wrapping in WHERE clauses to enable index usage. Add 2s TTL cache on /api/stats route. Move maintenance off request path to background timers. Add mmap_size/cache_size pragmas and idx_sessions_last_seen index. --- src/dashboard/index.ts | 5 +- src/dashboard/routes/stats-route.ts | 55 ++++--- src/db/message/sqlite-message-repo.ts | 59 ++++--- src/db/migrations.ts | 5 + src/db/sqlite-repository.ts | 2 + src/db/tool-call/sqlite-tool-call-repo.ts | 180 +++++++++++----------- tests/unit/dashboard/routes.test.ts | 111 ++++++++----- 7 files changed, 245 insertions(+), 172 deletions(-) diff --git a/src/dashboard/index.ts b/src/dashboard/index.ts index 5c57bad..da1068e 100644 --- a/src/dashboard/index.ts +++ b/src/dashboard/index.ts @@ -35,12 +35,15 @@ export function createDashboard(deps: DashboardDeps) { const dailyTokens = createDailyTokensService(readRepos); const routes = [ - createStatsRoute(sessionStats, dailyTokens, readRepos, maintenance), + createStatsRoute(sessionStats, dailyTokens, readRepos), createDirectoriesRoute(sessionStats), createPageRoute(sessionStats, dailyTokens, readRepos), ]; startServer(port, routes); + + setInterval(() => maintenance.maybeAggregate(), 60_000); + setInterval(() => maintenance.maybeGC(), 60_000); }, }; } diff --git a/src/dashboard/routes/stats-route.ts b/src/dashboard/routes/stats-route.ts index 148e73b..7c181e2 100644 --- a/src/dashboard/routes/stats-route.ts +++ b/src/dashboard/routes/stats-route.ts @@ -1,47 +1,66 @@ import type { Repos } from "../../db/repos"; import type { DailyTokensService } from "../services/daily-tokens-service"; -import type { MaintenanceService } from "../services/maintenance-service"; import type { SessionStatsService } from "../services/session-stats-service"; import { renderSessionsFragment } from "../templates/sessions-fragment"; import type { RouteHandler } from "./route-handler"; +interface CacheEntry { + html: string; + expiry: number; +} + +interface StatsRouteOptions { + cacheTtlMs?: number; +} + export function createStatsRoute( sessionStats: SessionStatsService, dailyTokens: DailyTokensService, repos: Repos, - maintenance: MaintenanceService, + options?: StatsRouteOptions, ): RouteHandler { + const cache = new Map(); + const CACHE_TTL_MS = options?.cacheTtlMs ?? 2000; + return { match(url: URL): boolean { return url.pathname === "/api/stats"; }, handle(_req: Request, url: URL): Response { - maintenance.maybeAggregate(); - maintenance.maybeGC(); - try { const dirFilter = url.searchParams.get("dir") || undefined; + const cacheKey = dirFilter ?? "__all__"; + const now = Date.now(); + const cached = cache.get(cacheKey); + + if (cached && now < cached.expiry) { + return new Response(cached.html, { + headers: { "Content-Type": "text/html; charset=utf-8" }, + }); + } + const directories = sessionStats.getDistinctDirectories(); const sessions = sessionStats.getSessionStats(dirFilter); const summary = dailyTokens.getTokenSummary(); const daily = dailyTokens.getDailyTokens(); const dailyModel = dailyTokens.getDailyTokensByModel(); const toolGroups = repos.toolCalls.getToolUsageSummary(); - return new Response( - renderSessionsFragment( - sessions, - summary, - daily, - dailyModel, - toolGroups, - directories, - dirFilter, - ), - { - headers: { "Content-Type": "text/html; charset=utf-8" }, - }, + const html = renderSessionsFragment( + sessions, + summary, + daily, + dailyModel, + toolGroups, + directories, + dirFilter, ); + + cache.set(cacheKey, { html, expiry: now + CACHE_TTL_MS }); + + return new Response(html, { + headers: { "Content-Type": "text/html; charset=utf-8" }, + }); } catch (e) { return new Response(`
DB error: ${e}
`, { headers: { "Content-Type": "text/html; charset=utf-8" }, diff --git a/src/db/message/sqlite-message-repo.ts b/src/db/message/sqlite-message-repo.ts index 1d53f47..cbdd3a2 100644 --- a/src/db/message/sqlite-message-repo.ts +++ b/src/db/message/sqlite-message-repo.ts @@ -10,6 +10,8 @@ import type { export class SqliteMessageRepo implements MessageRepo { private readonly upsertMessageStmt; + private readonly tokenSummaryStmt; + private readonly todayTokensStmt; constructor(private readonly db: Database) { this.upsertMessageStmt = this.db.prepare(` @@ -28,6 +30,28 @@ export class SqliteMessageRepo implements MessageRepo { cost = excluded.cost, agent = COALESCE(excluded.agent, messages.agent) `); + + this.tokenSummaryStmt = this.db.prepare(` + SELECT + COALESCE(SUM(CASE WHEN timestamp >= date('now') AND timestamp < date('now', '+1 day') + THEN input_tokens + cache_read_tokens + output_tokens + reasoning_tokens END), 0) AS today, + COALESCE(SUM(CASE WHEN timestamp >= date('now', 'weekday 1', '-7 days') + THEN input_tokens + cache_read_tokens + output_tokens + reasoning_tokens END), 0) AS this_week, + COALESCE(SUM(CASE WHEN timestamp >= date('now', 'start of month') + THEN input_tokens + cache_read_tokens + output_tokens + reasoning_tokens END), 0) AS this_month, + COALESCE(SUM(CASE WHEN timestamp >= date('now', 'start of month', '-1 month') + AND timestamp < date('now', 'start of month') + THEN input_tokens + cache_read_tokens + output_tokens + reasoning_tokens END), 0) AS last_month + FROM messages + WHERE timestamp >= date('now', 'start of month', '-1 month') + `); + + this.todayTokensStmt = this.db.prepare(` + SELECT ? AS date, + COALESCE(SUM(input_tokens + cache_read_tokens + output_tokens + reasoning_tokens), 0) AS total + FROM messages + WHERE timestamp >= ? AND timestamp < date(?, '+1 day') + `); } upsert(data: MessageData): void { @@ -65,35 +89,22 @@ export class SqliteMessageRepo implements MessageRepo { } getTokenSummary(): TokenSummary { - const sum = (where: string): number => { - const row = this.db - .prepare(` - SELECT COALESCE(SUM(input_tokens + cache_read_tokens + output_tokens + reasoning_tokens), 0) AS total - FROM messages WHERE ${where} - `) - .get() as { total?: number }; - return Number(row?.total ?? 0); + const row = this.tokenSummaryStmt.get() as { + today: number; + this_week: number; + this_month: number; + last_month: number; }; - return { - today: sum("date(timestamp) = date('now')"), - thisWeek: sum("timestamp >= date('now', 'weekday 1', '-7 days')"), - thisMonth: sum("timestamp >= date('now', 'start of month')"), - lastMonth: sum( - "timestamp >= date('now', 'start of month', '-1 month') AND timestamp < date('now', 'start of month')", - ), + today: Number(row.today), + thisWeek: Number(row.this_week), + thisMonth: Number(row.this_month), + lastMonth: Number(row.last_month), }; } getTodayTokens(today: string): DailyTokens { - return this.db - .prepare(` - SELECT ? AS date, - COALESCE(SUM(input_tokens + cache_read_tokens + output_tokens + reasoning_tokens), 0) AS total - FROM messages - WHERE date(timestamp) = ? - `) - .get(today, today) as DailyTokens; + return this.todayTokensStmt.get(today, today, today) as DailyTokens; } getDailyTokensByModel(): DailyModelTokens[] { @@ -112,7 +123,7 @@ export class SqliteMessageRepo implements MessageRepo { deleteOlderThan(cutoffDate: string): number { const result = this.db - .prepare("DELETE FROM messages WHERE date(timestamp) < ?") + .prepare("DELETE FROM messages WHERE timestamp < ?") .run(cutoffDate); return result.changes; } diff --git a/src/db/migrations.ts b/src/db/migrations.ts index c1e8bce..7693fc4 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -92,6 +92,11 @@ export const MIGRATIONS: Migration[] = [ `CREATE INDEX IF NOT EXISTS idx_sessions_first_seen ON sessions(first_seen)`, ); }, + (db) => { + db.run( + `CREATE INDEX IF NOT EXISTS idx_sessions_last_seen ON sessions(last_seen)`, + ); + }, ]; export function getSchemaVersion(): number { diff --git a/src/db/sqlite-repository.ts b/src/db/sqlite-repository.ts index 36bd17a..e7cb732 100644 --- a/src/db/sqlite-repository.ts +++ b/src/db/sqlite-repository.ts @@ -12,6 +12,8 @@ function setupConnection(db: Database, readonly: boolean): void { db.run("PRAGMA journal_mode = WAL"); db.run("PRAGMA synchronous = NORMAL"); } + db.run("PRAGMA mmap_size = 268435456"); // 256MB; OS only maps pages actually read + db.run("PRAGMA cache_size = -8000"); // 8MB page cache } function assertReadableVersion(db: Database): void { diff --git a/src/db/tool-call/sqlite-tool-call-repo.ts b/src/db/tool-call/sqlite-tool-call-repo.ts index 2315228..938847e 100644 --- a/src/db/tool-call/sqlite-tool-call-repo.ts +++ b/src/db/tool-call/sqlite-tool-call-repo.ts @@ -3,18 +3,53 @@ import type { AgentCallRow, ToolCallData, ToolCallRepo, - ToolCountSummary, ToolGroupSummary, } from "./tool-call-repo"; export class SqliteToolCallRepo implements ToolCallRepo { private readonly insertToolCallStmt; + private readonly toolUsageSummaryStmt; constructor(private readonly db: Database) { this.insertToolCallStmt = this.db.prepare(` INSERT OR IGNORE INTO tool_calls (session_id, call_id, tool_name, agent_type, description, agent, model_id, provider_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?) `); + + this.toolUsageSummaryStmt = this.db.prepare(` + WITH resolved AS ( + SELECT + tc.tool_name, + tc.timestamp, + COALESCE(tc.agent, + (SELECT m.agent FROM messages m + WHERE m.session_id = tc.session_id AND m.agent IS NOT NULL + ORDER BY m.timestamp DESC LIMIT 1)) AS agent, + COALESCE(tc.provider_id, + (SELECT m.provider_id FROM messages m + WHERE m.session_id = tc.session_id AND m.provider_id IS NOT NULL + ORDER BY m.timestamp DESC LIMIT 1)) AS provider_id, + COALESCE(tc.model_id, + (SELECT m.model_id FROM messages m + WHERE m.session_id = tc.session_id AND m.model_id IS NOT NULL + ORDER BY m.timestamp DESC LIMIT 1)) AS model_id + FROM tool_calls tc + ) + SELECT + agent, + provider_id, + model_id, + tool_name, + MAX(timestamp) AS latest_timestamp, + SUM(CASE WHEN timestamp >= date('now') AND timestamp < date('now', '+1 day') THEN 1 ELSE 0 END) AS today, + SUM(CASE WHEN timestamp >= date('now', 'weekday 1', '-7 days') THEN 1 ELSE 0 END) AS this_week, + SUM(CASE WHEN timestamp >= date('now', 'start of month') THEN 1 ELSE 0 END) AS this_month, + SUM(CASE WHEN timestamp >= date('now', 'start of month', '-1 month') + AND timestamp < date('now', 'start of month') THEN 1 ELSE 0 END) AS last_month + FROM resolved + GROUP BY agent, provider_id, model_id, tool_name + ORDER BY agent, provider_id, model_id + `); } insert(data: ToolCallData): void { @@ -42,102 +77,59 @@ export class SqliteToolCallRepo implements ToolCallRepo { } getToolUsageSummary(): ToolGroupSummary[] { - const timeFilters = { - today: "date(tc.timestamp) = date('now')", - thisWeek: "tc.timestamp >= date('now', 'weekday 1', '-7 days')", - thisMonth: "tc.timestamp >= date('now', 'start of month')", - lastMonth: - "tc.timestamp >= date('now', 'start of month', '-1 month') AND tc.timestamp < date('now', 'start of month')", - }; - - const groups = this.db - .prepare(` - SELECT DISTINCT - COALESCE(tc.agent, - (SELECT m.agent FROM messages m WHERE m.session_id = tc.session_id AND m.agent IS NOT NULL ORDER BY m.timestamp DESC LIMIT 1), - '__none__') AS agent, - COALESCE(tc.provider_id, - (SELECT m.provider_id FROM messages m WHERE m.session_id = tc.session_id AND m.provider_id IS NOT NULL ORDER BY m.timestamp DESC LIMIT 1), - '__none__') AS provider_id, - COALESCE(tc.model_id, - (SELECT m.model_id FROM messages m WHERE m.session_id = tc.session_id AND m.model_id IS NOT NULL ORDER BY m.timestamp DESC LIMIT 1), - '__none__') AS model_id - FROM tool_calls tc - ORDER BY agent, provider_id, model_id - `) - .all() as { agent: string; provider_id: string; model_id: string }[]; - - const results: ToolGroupSummary[] = []; - - for (const group of groups) { - const agentVal = group.agent === "__none__" ? null : group.agent; - const providerVal = - group.provider_id === "__none__" ? null : group.provider_id; - const modelVal = group.model_id === "__none__" ? null : group.model_id; - - const escapeSql = (v: string): string => v.replaceAll("'", "''"); - const agentFilter = - agentVal === null - ? "tc.agent IS NULL AND NOT EXISTS (SELECT 1 FROM messages m WHERE m.session_id = tc.session_id AND m.agent IS NOT NULL)" - : `COALESCE(tc.agent, (SELECT m.agent FROM messages m WHERE m.session_id = tc.session_id AND m.agent IS NOT NULL ORDER BY m.timestamp DESC LIMIT 1)) = '${escapeSql(agentVal)}'`; - const providerFilter = - providerVal === null - ? "tc.provider_id IS NULL AND NOT EXISTS (SELECT 1 FROM messages m WHERE m.session_id = tc.session_id AND m.provider_id IS NOT NULL)" - : `COALESCE(tc.provider_id, (SELECT m.provider_id FROM messages m WHERE m.session_id = tc.session_id AND m.provider_id IS NOT NULL ORDER BY m.timestamp DESC LIMIT 1)) = '${escapeSql(providerVal)}'`; - const modelFilter = - modelVal === null - ? "tc.model_id IS NULL AND NOT EXISTS (SELECT 1 FROM messages m WHERE m.session_id = tc.session_id AND m.model_id IS NOT NULL)" - : `COALESCE(tc.model_id, (SELECT m.model_id FROM messages m WHERE m.session_id = tc.session_id AND m.model_id IS NOT NULL ORDER BY m.timestamp DESC LIMIT 1)) = '${escapeSql(modelVal)}'`; - - const groupWhere = `${agentFilter} AND ${providerFilter} AND ${modelFilter}`; - const toolRows: Record = {}; - - for (const [period, timeWhere] of Object.entries(timeFilters)) { - const rows = this.db - .prepare(` - SELECT tc.tool_name, COUNT(*) AS cnt - FROM tool_calls tc - WHERE ${groupWhere} AND ${timeWhere} - GROUP BY tc.tool_name - `) - .all() as { tool_name: string; cnt: number }[]; - - for (const row of rows) { - if (!toolRows[row.tool_name]) { - toolRows[row.tool_name] = { - tool_name: row.tool_name, - today: 0, - thisWeek: 0, - thisMonth: 0, - lastMonth: 0, - }; - } - toolRows[row.tool_name]![ - period as keyof Omit - ] = row.cnt; - } + interface FlatRow { + agent: string | null; + provider_id: string | null; + model_id: string | null; + tool_name: string; + latest_timestamp: string | null; + today: number; + this_week: number; + this_month: number; + last_month: number; + } + + const rows = this.toolUsageSummaryStmt.all() as FlatRow[]; + + const groupMap = new Map(); + + for (const row of rows) { + const key = `${row.agent ?? "__none__"}|${row.provider_id ?? "__none__"}|${row.model_id ?? "__none__"}`; + let group = groupMap.get(key); + if (!group) { + group = { + agent: row.agent ?? null, + provider_id: row.provider_id ?? null, + model_id: row.model_id ?? null, + latest_timestamp: row.latest_timestamp ?? null, + tools: [], + }; + groupMap.set(key, group); + } + + if ( + row.latest_timestamp && + (!group.latest_timestamp || + row.latest_timestamp > group.latest_timestamp) + ) { + group.latest_timestamp = row.latest_timestamp; } - const tools = Object.values(toolRows).sort( + group.tools.push({ + tool_name: row.tool_name, + today: row.today, + thisWeek: row.this_week, + thisMonth: row.this_month, + lastMonth: row.last_month, + }); + } + + const results = Array.from(groupMap.values()); + + for (const group of results) { + group.tools.sort( (a, b) => b.thisMonth + b.lastMonth - (a.thisMonth + a.lastMonth), ); - const latestRow = this.db - .prepare(` - SELECT MAX(tc.timestamp) AS latest_timestamp - FROM tool_calls tc - WHERE ${groupWhere} - `) - .get() as { latest_timestamp: string | null }; - - if (tools.length > 0) { - results.push({ - agent: agentVal, - provider_id: providerVal, - model_id: modelVal, - latest_timestamp: latestRow?.latest_timestamp ?? null, - tools, - }); - } } results.sort((a, b) => { @@ -154,7 +146,7 @@ export class SqliteToolCallRepo implements ToolCallRepo { deleteOlderThan(cutoffDate: string): number { const result = this.db - .prepare("DELETE FROM tool_calls WHERE date(timestamp) < ?") + .prepare("DELETE FROM tool_calls WHERE timestamp < ?") .run(cutoffDate); return result.changes; } diff --git a/tests/unit/dashboard/routes.test.ts b/tests/unit/dashboard/routes.test.ts index c97eda8..5345692 100644 --- a/tests/unit/dashboard/routes.test.ts +++ b/tests/unit/dashboard/routes.test.ts @@ -3,7 +3,6 @@ import { createDirectoriesRoute } from "../../../src/dashboard/routes/directorie import { createPageRoute } from "../../../src/dashboard/routes/page-route"; import { createStatsRoute } from "../../../src/dashboard/routes/stats-route"; import type { DailyTokensService } from "../../../src/dashboard/services/daily-tokens-service"; -import type { MaintenanceService } from "../../../src/dashboard/services/maintenance-service"; import type { SessionStatsService } from "../../../src/dashboard/services/session-stats-service"; import type { Repos } from "../../../src/db/repos"; @@ -24,14 +23,6 @@ function makeStubDailyTokens(): DailyTokensService { }; } -function makeStubMaintenance(): MaintenanceService { - return { - runInitial: () => {}, - maybeAggregate: () => {}, - maybeGC: () => {}, - }; -} - function makeStubRepos(): Repos { return { sessions: { @@ -76,7 +67,6 @@ describe("StatsRoute", () => { makeStubSessionStats(), makeStubDailyTokens(), makeStubRepos(), - makeStubMaintenance(), ); expect(route.match(new URL("http://localhost/api/stats"))).toBe(true); expect(route.match(new URL("http://localhost/"))).toBe(false); @@ -87,54 +77,105 @@ describe("StatsRoute", () => { makeStubSessionStats(), makeStubDailyTokens(), makeStubRepos(), - makeStubMaintenance(), ); const req = new Request("http://localhost/api/stats"); const res = route.handle(req, new URL(req.url)); expect(res.headers.get("Content-Type")).toContain("text/html"); }); - - test("calls maintenance on each request", () => { - let aggregateCalled = false; - let gcCalled = false; - const maintenance = makeStubMaintenance(); - maintenance.maybeAggregate = () => { - aggregateCalled = true; - }; - maintenance.maybeGC = () => { - gcCalled = true; + test("returns error HTML on DB failure", () => { + const sessionStats: SessionStatsService = { + getSessionStats: () => { + throw new Error("DB error"); + }, + getDistinctDirectories: () => { + throw new Error("DB error"); + }, }; - const route = createStatsRoute( - makeStubSessionStats(), + sessionStats, makeStubDailyTokens(), makeStubRepos(), - maintenance, ); const req = new Request("http://localhost/api/stats"); - route.handle(req, new URL(req.url)); - expect(aggregateCalled).toBe(true); - expect(gcCalled).toBe(true); + const res = route.handle(req, new URL(req.url)); + expect(res.status).toBe(200); }); - test("returns error HTML on DB failure", () => { + test("serves cached response within TTL", async () => { + let callCount = 0; const sessionStats: SessionStatsService = { getSessionStats: () => { - throw new Error("DB error"); + callCount++; + return []; }, - getDistinctDirectories: () => { - throw new Error("DB error"); + getDistinctDirectories: () => [], + }; + const route = createStatsRoute( + sessionStats, + makeStubDailyTokens(), + makeStubRepos(), + ); + const url = new URL("http://localhost/api/stats"); + const req1 = new Request(url.toString()); + const res1 = route.handle(req1, url); + const body1 = await res1.text(); + + const req2 = new Request(url.toString()); + const res2 = route.handle(req2, url); + const body2 = await res2.text(); + + expect(callCount).toBe(1); + expect(body1).toBe(body2); + }); + + test("refreshes cache after TTL expires", async () => { + let callCount = 0; + const sessionStats: SessionStatsService = { + getSessionStats: () => { + callCount++; + return []; }, + getDistinctDirectories: () => [], }; const route = createStatsRoute( sessionStats, makeStubDailyTokens(), makeStubRepos(), - makeStubMaintenance(), + { cacheTtlMs: 1 }, ); - const req = new Request("http://localhost/api/stats"); - const res = route.handle(req, new URL(req.url)); - expect(res.status).toBe(200); + const url = new URL("http://localhost/api/stats"); + + route.handle(new Request(url.toString()), url); + expect(callCount).toBe(1); + + await new Promise((r) => setTimeout(r, 5)); + + route.handle(new Request(url.toString()), url); + expect(callCount).toBe(2); + }); + + test("caches separately per directory filter", () => { + let lastDir: string | undefined; + const sessionStats: SessionStatsService = { + getSessionStats: (dir) => { + lastDir = dir; + return []; + }, + getDistinctDirectories: () => [], + }; + const route = createStatsRoute( + sessionStats, + makeStubDailyTokens(), + makeStubRepos(), + ); + + const url1 = new URL("http://localhost/api/stats?dir=/a"); + route.handle(new Request(url1.toString()), url1); + expect(lastDir).toBe("/a"); + + const url2 = new URL("http://localhost/api/stats?dir=/b"); + route.handle(new Request(url2.toString()), url2); + expect(lastDir).toBe("/b"); }); });