From de87464ef1286314e1d9668571d98884898edbf0 Mon Sep 17 00:00:00 2001 From: vince Date: Sat, 20 Jun 2026 15:12:18 +0200 Subject: [PATCH] perf(web): batch dashboard tile hydration --- apps/web/src/App.tsx | 178 +++++++++++++++--- .../src/lib/client/dashboard-refresh.test.ts | 20 ++ apps/web/src/lib/client/dashboard-refresh.ts | 50 +++-- apps/web/src/server/index.test.ts | 53 ++++++ apps/web/src/server/index.ts | 11 +- apps/web/src/server/routes/dashboard.test.ts | 120 ++++++++++++ apps/web/src/server/routes/dashboard.ts | 126 +++++++++++++ 7 files changed, 518 insertions(+), 40 deletions(-) create mode 100644 apps/web/src/server/index.test.ts diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index ac3b31b..74c34c3 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -52,6 +52,11 @@ type DashboardTileResponse = message: string; }; +type DashboardTilesBatchResponse = { + state: "ready"; + tiles: DashboardTileResponse[]; +}; + type DashboardTileHydrationResult = "aborted" | "failed" | "ready"; const dashboardTileHydrationConcurrency = 6; @@ -292,6 +297,7 @@ export default function App() { const modelIds = tiles.map(dashboardTileModelId); void runViewportAwareDashboardHydrationQueue({ + batchSize: dashboardTileHydrationConcurrency, collectVisibleModelIds: async () => { await waitForDashboardRenderFrame(signal); return collectVisibleDashboardModelIds({ @@ -321,6 +327,23 @@ export default function App() { tileBackoff.recordFailure(key); } }, + hydrateBatch: async (batch) => { + const results = await hydrateDashboardTileBatch( + batch, + run, + signal, + snapshotContext, + ); + + for (const { result, tile } of results) { + const key = dashboardTileKey(tile); + if (result === "ready") { + tileBackoff.recordSuccess(key); + } else if (result === "failed") { + tileBackoff.recordFailure(key); + } + } + }, items: tiles, onAllItemsSettled: () => performanceMarks.markAllTilesSettled(), onVisibleItemsSettled: () => performanceMarks.markVisibleTilesReady(), @@ -335,6 +358,69 @@ export default function App() { }); } + async function hydrateDashboardTileBatch( + tiles: DashboardTileReference[], + run: number, + signal: AbortSignal, + snapshotContext: DashboardTileSnapshotStoreContext, + ): Promise> { + try { + const response = await fetch("/api/dashboard/tiles", { + body: JSON.stringify({ tiles }), + headers: { "Content-Type": "application/json" }, + method: "POST", + signal, + }); + const batchResponse = (await response.json()) as DashboardTilesBatchResponse; + + if ( + cancelled || + signal.aborted || + run !== hydrationRun || + !response.ok || + batchResponse.state !== "ready" + ) { + throw new Error("Dashboard tile batch hydration failed"); + } + + const responsesByKey = new Map( + batchResponse.tiles.map((tileResponse) => [ + dashboardTileKey(tileResponse.tile), + tileResponse, + ]), + ); + + return tiles.map((tile) => { + const key = dashboardTileKey(tile); + try { + return { + tile, + result: applyDashboardTileHydrationResponse( + tile, + responsesByKey.get(key), + run, + signal, + snapshotContext, + ), + }; + } finally { + finishDashboardTileHydration(key, run, signal); + } + }); + } catch (error) { + if (signal.aborted || cancelled || run !== hydrationRun) { + return tiles.map((tile) => ({ result: "aborted", tile })); + } + + return Promise.all( + tiles.map(async (tile) => ({ + tile, + result: await hydrateDashboardTile(tile, run, signal, snapshotContext), + })), + ); + } + } + async function hydrateDashboardTile( tile: DashboardTileReference, run: number, @@ -347,32 +433,23 @@ export default function App() { const response = await fetch(dashboardTileUrl(tile), { signal }); const tileResponse = (await response.json()) as DashboardTileResponse; - if ( - cancelled || - signal.aborted || - run !== hydrationRun || - !response.ok || - tileResponse.state !== "ready" - ) { + if (!response.ok) { + return "failed"; + } + + const result = applyDashboardTileHydrationResponse( + tile, + tileResponse, + run, + signal, + snapshotContext, + ); + if (result !== "ready") { return signal.aborted || cancelled || run !== hydrationRun ? "aborted" : "failed"; } - const readyTileResponse = tileResponse; - setDashboard((current) => - current?.state === "ready" - ? { - ...current, - document: applyDashboardTile(current.document, readyTileResponse), - } - : current, - ); - tileSnapshotStore.saveReadyTile({ - ...snapshotContext, - response: readyTileResponse, - }); - performanceMarks.markFirstTileReady(); return "ready"; } catch (error) { if (!isAbortError(error) && !cancelled) { @@ -381,13 +458,58 @@ export default function App() { } return "aborted"; } finally { - if (!cancelled && !signal.aborted && run === hydrationRun) { - setHydratingItemIds((current) => { - const next = new Set(current); - next.delete(key); - return next; - }); - } + finishDashboardTileHydration(key, run, signal); + } + } + + function applyDashboardTileHydrationResponse( + tile: DashboardTileReference, + tileResponse: DashboardTileResponse | undefined, + run: number, + signal: AbortSignal, + snapshotContext: DashboardTileSnapshotStoreContext, + ): DashboardTileHydrationResult { + if ( + cancelled || + signal.aborted || + run !== hydrationRun || + !tileResponse || + dashboardTileKey(tileResponse.tile) !== dashboardTileKey(tile) || + tileResponse.state !== "ready" + ) { + return signal.aborted || cancelled || run !== hydrationRun + ? "aborted" + : "failed"; + } + + const readyTileResponse = tileResponse; + setDashboard((current) => + current?.state === "ready" + ? { + ...current, + document: applyDashboardTile(current.document, readyTileResponse), + } + : current, + ); + tileSnapshotStore.saveReadyTile({ + ...snapshotContext, + response: readyTileResponse, + }); + performanceMarks.markFirstTileReady(); + return "ready"; + } + + function finishDashboardTileHydration( + key: string, + run: number, + signal: AbortSignal, + ) { + if (!cancelled && !signal.aborted && run === hydrationRun) { + setHydratingItemIds((current) => { + const next = new Set(current); + next.delete(key); + return next; + }); } } diff --git a/apps/web/src/lib/client/dashboard-refresh.test.ts b/apps/web/src/lib/client/dashboard-refresh.test.ts index 2a2f25d..e17eab2 100644 --- a/apps/web/src/lib/client/dashboard-refresh.test.ts +++ b/apps/web/src/lib/client/dashboard-refresh.test.ts @@ -9,6 +9,7 @@ import { createDashboardTileBackoff, createDashboardRequestAborter, runDashboardHydrationQueue, + runDashboardHydrationBatchQueue, runViewportAwareDashboardHydrationQueue, shouldPauseDashboardRefresh, splitDashboardHydrationItemsByVisibility, @@ -121,6 +122,25 @@ describe("dashboard refresh lifecycle", () => { expect(maxActive).toBe(2); }); + test("hydrates queued work in fixed-size batches", async () => { + const batches: number[][] = []; + + await runDashboardHydrationBatchQueue({ + batchSize: 3, + hydrateBatch: (items) => { + batches.push(items); + }, + items: [1, 2, 3, 4, 5, 6, 7], + signal: new AbortController().signal, + }); + + expect(batches).toEqual([ + [1, 2, 3], + [4, 5, 6], + [7], + ]); + }); + test("hydrates visible items before deferred items and waits for idle", async () => { const calls: string[] = []; const idleReleases: Array<() => void> = []; diff --git a/apps/web/src/lib/client/dashboard-refresh.ts b/apps/web/src/lib/client/dashboard-refresh.ts index a9aa3a5..76cc83d 100644 --- a/apps/web/src/lib/client/dashboard-refresh.ts +++ b/apps/web/src/lib/client/dashboard-refresh.ts @@ -152,10 +152,12 @@ export async function runDashboardHydrationQueue( } export interface DashboardViewportHydrationQueueOptions { + batchSize?: number; collectVisibleModelIds: () => Promise>; concurrency: number; getModelId: (item: TItem) => string; hydrate: (item: TItem) => Promise | void; + hydrateBatch?: (items: TItem[]) => Promise | void; items: TItem[]; onAllItemsSettled?: () => void; onVisibleItemsSettled?: () => void; @@ -175,12 +177,7 @@ export async function runViewportAwareDashboardHydrationQueue( visibleModelIds, }); - await runDashboardHydrationQueue({ - concurrency: options.concurrency, - hydrate: options.hydrate, - items: visible, - signal: options.signal, - }); + await runDashboardHydrationItems(options, visible); if (options.signal.aborted) return; options.onVisibleItemsSettled?.(); @@ -192,13 +189,44 @@ export async function runViewportAwareDashboardHydrationQueue( await options.waitForIdle(); if (options.signal.aborted) return; - await runDashboardHydrationQueue({ - concurrency: options.concurrency, - hydrate: options.hydrate, - items: deferred, + await runDashboardHydrationItems(options, deferred); + options.onAllItemsSettled?.(); +} + +async function runDashboardHydrationItems( + options: DashboardViewportHydrationQueueOptions, + items: TItem[], +): Promise { + if (!options.hydrateBatch) { + await runDashboardHydrationQueue({ + concurrency: options.concurrency, + hydrate: options.hydrate, + items, + signal: options.signal, + }); + return; + } + + await runDashboardHydrationBatchQueue({ + batchSize: options.batchSize ?? options.concurrency, + hydrateBatch: options.hydrateBatch, + items, signal: options.signal, }); - options.onAllItemsSettled?.(); +} + +export async function runDashboardHydrationBatchQueue(options: { + batchSize: number; + hydrateBatch: (items: TItem[]) => Promise | void; + items: TItem[]; + signal: AbortSignal; +}): Promise { + const batchSize = Math.max(1, Math.floor(options.batchSize)); + + for (let index = 0; index < options.items.length; index += batchSize) { + if (options.signal.aborted) return; + await options.hydrateBatch(options.items.slice(index, index + batchSize)); + } } export function splitDashboardHydrationItemsByVisibility(options: { diff --git a/apps/web/src/server/index.test.ts b/apps/web/src/server/index.test.ts new file mode 100644 index 0000000..9e5a34d --- /dev/null +++ b/apps/web/src/server/index.test.ts @@ -0,0 +1,53 @@ +import { afterAll, describe, expect, test, vi } from "vitest"; +import { handleRequest } from "./index"; + +describe("server request routing", () => { + const consoleInfo = vi.spyOn(console, "info").mockImplementation(() => undefined); + + afterAll(() => { + consoleInfo.mockRestore(); + }); + + test("routes dashboard tile batch requests", async () => { + const response = await handleRequest( + new Request("https://example.test/api/dashboard/tiles", { + method: "POST", + body: JSON.stringify({ + tiles: [ + { + kind: "status", + stripId: "footer-status", + id: "auto-refresh", + }, + ], + }), + }), + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + state: "ready", + tiles: [ + { + state: "ready", + tile: { + kind: "status", + stripId: "footer-status", + id: "auto-refresh", + }, + }, + ], + }); + }); + + test("rejects non-post dashboard tile batch requests", async () => { + const response = await handleRequest( + new Request("https://example.test/api/dashboard/tiles", { + method: "GET", + }), + ); + + expect(response.status).toBe(405); + expect(response.headers.get("allow")).toBe("POST"); + }); +}); diff --git a/apps/web/src/server/index.ts b/apps/web/src/server/index.ts index ba2135f..cf624ed 100644 --- a/apps/web/src/server/index.ts +++ b/apps/web/src/server/index.ts @@ -1,6 +1,10 @@ import { extname, normalize } from "node:path"; import { handleAgentDashboardRoute } from "./routes/agent-dashboard"; -import { handleDashboardRoute, handleDashboardTileRoute } from "./routes/dashboard"; +import { + handleDashboardRoute, + handleDashboardTileRoute, + handleDashboardTilesRoute, +} from "./routes/dashboard"; const host = process.env.HOST || "0.0.0.0"; const port = Number(process.env.PORT || 3000); @@ -23,6 +27,11 @@ export async function handleRequest(request: Request): Promise { return handleDashboardRoute(); } + if (url.pathname === "/api/dashboard/tiles") { + if (request.method !== "POST") return methodNotAllowed(["POST"]); + return handleDashboardTilesRoute(request); + } + if (url.pathname.startsWith("/api/dashboard/tile/")) { if (request.method !== "GET") return methodNotAllowed(["GET"]); return handleDashboardTileRoute(url.pathname); diff --git a/apps/web/src/server/routes/dashboard.test.ts b/apps/web/src/server/routes/dashboard.test.ts index d40e263..a0054e2 100644 --- a/apps/web/src/server/routes/dashboard.test.ts +++ b/apps/web/src/server/routes/dashboard.test.ts @@ -3,8 +3,10 @@ import { dimensionLabDashboardFixture } from "$lib/dashboard-seed/dimensionlab"; import { createDashboardTileCache, dashboardTileCacheKey, + handleDashboardTilesRoute, handleDashboardTileRoute, loadDashboardResponse, + loadDashboardTilesResponse, loadDashboardTileResponse, type DashboardTileResolutionLogEvent, } from "./dashboard"; @@ -563,6 +565,115 @@ describe("dashboard API route", () => { ); }); + test("serves batch tile route responses", async () => { + const response = await handleDashboardTilesRoute( + new Request("https://example.test/api/dashboard/tiles", { + method: "POST", + body: JSON.stringify({ + tiles: [ + { + kind: "status", + stripId: "footer-status", + id: "auto-refresh", + }, + ], + }), + }), + { + refreshSeedDocument: true, + seedIfEmpty: true, + tileCache: createDashboardTileCache(), + }, + ); + + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe( + "private, max-age=5, stale-while-revalidate=30", + ); + await expect(response.json()).resolves.toMatchObject({ + state: "ready", + tiles: [ + { + state: "ready", + tile: { + kind: "status", + stripId: "footer-status", + id: "auto-refresh", + }, + }, + ], + }); + }); + + test("rejects invalid batch tile requests", async () => { + const response = await handleDashboardTilesRoute( + new Request("https://example.test/api/dashboard/tiles", { + method: "POST", + body: JSON.stringify({ + tiles: [{ kind: "service", id: "missing-group" }], + }), + }), + ); + + expect(response.status).toBe(400); + }); + + test("resolves batch tile responses with server concurrency capped at six", async () => { + let active = 0; + let maxActive = 0; + const started: string[] = []; + const releases = new Map void>(); + const service = dimensionLabDashboardFixture.serviceGroups + .flatMap((group) => group.services)[0]; + if (!service) throw new Error("missing service fixture"); + const tiles = Array.from({ length: 7 }, (_, index) => ({ + kind: "service" as const, + groupId: "essentials", + id: `service-${index}`, + })); + + const batch = loadDashboardTilesResponse(tiles, { + refreshSeedDocument: true, + seedIfEmpty: true, + tileCache: { + async resolve(key) { + active += 1; + maxActive = Math.max(maxActive, active); + started.push(key); + await new Promise((resolve) => releases.set(key, resolve)); + active -= 1; + + return { + cache: "miss", + coalesced: false, + response: { + state: "ready", + tile: JSON.parse(key), + item: service, + }, + }; + }, + }, + }); + + await waitFor(() => started.length === 6); + expect(maxActive).toBe(6); + releases.get(started[0])?.(); + await waitFor(() => started.length === 7); + for (const release of releases.values()) release(); + + await expect(batch).resolves.toMatchObject({ + state: "ready", + tiles: expect.arrayContaining([ + expect.objectContaining({ + state: "ready", + tile: tiles[0], + }), + ]), + }); + expect(maxActive).toBe(6); + }); + test("does not hydrate tile routes when live datasources are disabled", async () => { const previous = process.env.DISABLE_LIVE_DATASOURCES; process.env.DISABLE_LIVE_DATASOURCES = "1"; @@ -598,6 +709,15 @@ function jsonResponse(payload: unknown): Response { }); } +async function waitFor(predicate: () => boolean) { + for (let attempt = 0; attempt < 20; attempt += 1) { + if (predicate()) return; + await Promise.resolve(); + } + + throw new Error("condition was not met"); +} + function telemetryFetch() { return vi.fn(async (input: RequestInfo | URL) => { const url = String(input); diff --git a/apps/web/src/server/routes/dashboard.ts b/apps/web/src/server/routes/dashboard.ts index 42c4088..2917689 100644 --- a/apps/web/src/server/routes/dashboard.ts +++ b/apps/web/src/server/routes/dashboard.ts @@ -43,6 +43,8 @@ const dashboardTileResponseHeaders = { "Cache-Control": "private, max-age=5, stale-while-revalidate=30", }; +const dashboardBatchTileConcurrency = 6; + const defaultDashboardTileCache = createDashboardTileCache(); interface DashboardTileCacheResult { @@ -60,6 +62,11 @@ export interface DashboardTileResolutionLogEvent { tileKey: string; } +export interface DashboardTilesBatchResponse { + state: "ready"; + tiles: DashboardTileResolution[]; +} + export function createDashboardTileCache(): DashboardTileCache { const entries = new Map(); const inFlight = new Map>(); @@ -236,6 +243,16 @@ export async function loadDashboardTileResponse( } } +export async function loadDashboardTilesResponse( + tiles: DashboardTileReference[], + options: LoadDashboardResponseOptions = {}, +): Promise { + return { + state: "ready", + tiles: await resolveDashboardTilesBatch(tiles, options), + }; +} + export async function handleDashboardRoute(): Promise { return Response.json(await loadDashboardResponse()); } @@ -259,6 +276,23 @@ export async function handleDashboardTileRoute( }); } +export async function handleDashboardTilesRoute( + request: Request, + options: LoadDashboardResponseOptions = {}, +): Promise { + const tiles = await parseDashboardTilesBatchRequest(request); + if (!tiles) { + return Response.json( + { ok: false, message: "Invalid dashboard tiles request" }, + { status: 400 }, + ); + } + + return Response.json(await loadDashboardTilesResponse(tiles, options), { + headers: dashboardTileResponseHeaders, + }); +} + export function dashboardTileCacheKey(tile: DashboardTileReference): string { return JSON.stringify(tile); } @@ -296,6 +330,98 @@ function dashboardTileErrorCategory(error: unknown): string { return "unknown"; } +async function resolveDashboardTilesBatch( + tiles: DashboardTileReference[], + options: LoadDashboardResponseOptions, +): Promise { + const results: DashboardTileResolution[] = new Array(tiles.length); + let nextIndex = 0; + + async function worker() { + while (nextIndex < tiles.length) { + const index = nextIndex; + nextIndex += 1; + results[index] = await loadDashboardTileResponse(tiles[index], options); + } + } + + await Promise.all( + Array.from( + { length: Math.min(dashboardBatchTileConcurrency, tiles.length) }, + () => worker(), + ), + ); + + return results; +} + +async function parseDashboardTilesBatchRequest( + request: Request, +): Promise { + try { + const body = await request.json() as unknown; + if ( + typeof body !== "object" || + body === null || + !("tiles" in body) || + !Array.isArray(body.tiles) + ) { + return null; + } + + const tiles = body.tiles.map(parseDashboardTileReference); + return tiles.every((tile): tile is DashboardTileReference => tile !== null) + ? tiles + : null; + } catch { + return null; + } +} + +function parseDashboardTileReference(value: unknown): DashboardTileReference | null { + if (typeof value !== "object" || value === null || !("kind" in value)) { + return null; + } + + if ( + (value.kind === "telemetry" || value.kind === "module") && + "id" in value && + typeof value.id === "string" + ) { + return { kind: value.kind, id: value.id }; + } + + if ( + value.kind === "service" && + "groupId" in value && + typeof value.groupId === "string" && + "id" in value && + typeof value.id === "string" + ) { + return { + kind: "service", + groupId: value.groupId, + id: value.id, + }; + } + + if ( + value.kind === "status" && + "stripId" in value && + typeof value.stripId === "string" && + "id" in value && + typeof value.id === "string" + ) { + return { + kind: "status", + stripId: value.stripId, + id: value.id, + }; + } + + return null; +} + class DashboardTileCacheResolutionError extends Error { readonly cache: "hit" | "miss"; readonly coalesced: boolean;