perf(web): batch dashboard tile hydration
This commit is contained in:
parent
63b008095f
commit
de87464ef1
7 changed files with 518 additions and 40 deletions
|
|
@ -52,6 +52,11 @@ type DashboardTileResponse =
|
||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type DashboardTilesBatchResponse = {
|
||||||
|
state: "ready";
|
||||||
|
tiles: DashboardTileResponse[];
|
||||||
|
};
|
||||||
|
|
||||||
type DashboardTileHydrationResult = "aborted" | "failed" | "ready";
|
type DashboardTileHydrationResult = "aborted" | "failed" | "ready";
|
||||||
|
|
||||||
const dashboardTileHydrationConcurrency = 6;
|
const dashboardTileHydrationConcurrency = 6;
|
||||||
|
|
@ -292,6 +297,7 @@ export default function App() {
|
||||||
const modelIds = tiles.map(dashboardTileModelId);
|
const modelIds = tiles.map(dashboardTileModelId);
|
||||||
|
|
||||||
void runViewportAwareDashboardHydrationQueue({
|
void runViewportAwareDashboardHydrationQueue({
|
||||||
|
batchSize: dashboardTileHydrationConcurrency,
|
||||||
collectVisibleModelIds: async () => {
|
collectVisibleModelIds: async () => {
|
||||||
await waitForDashboardRenderFrame(signal);
|
await waitForDashboardRenderFrame(signal);
|
||||||
return collectVisibleDashboardModelIds({
|
return collectVisibleDashboardModelIds({
|
||||||
|
|
@ -321,6 +327,23 @@ export default function App() {
|
||||||
tileBackoff.recordFailure(key);
|
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,
|
items: tiles,
|
||||||
onAllItemsSettled: () => performanceMarks.markAllTilesSettled(),
|
onAllItemsSettled: () => performanceMarks.markAllTilesSettled(),
|
||||||
onVisibleItemsSettled: () => performanceMarks.markVisibleTilesReady(),
|
onVisibleItemsSettled: () => performanceMarks.markVisibleTilesReady(),
|
||||||
|
|
@ -335,6 +358,69 @@ export default function App() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function hydrateDashboardTileBatch(
|
||||||
|
tiles: DashboardTileReference[],
|
||||||
|
run: number,
|
||||||
|
signal: AbortSignal,
|
||||||
|
snapshotContext: DashboardTileSnapshotStoreContext,
|
||||||
|
): Promise<Array<{ result: DashboardTileHydrationResult; tile: DashboardTileReference }>> {
|
||||||
|
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(
|
async function hydrateDashboardTile(
|
||||||
tile: DashboardTileReference,
|
tile: DashboardTileReference,
|
||||||
run: number,
|
run: number,
|
||||||
|
|
@ -347,11 +433,48 @@ export default function App() {
|
||||||
const response = await fetch(dashboardTileUrl(tile), { signal });
|
const response = await fetch(dashboardTileUrl(tile), { signal });
|
||||||
const tileResponse = (await response.json()) as DashboardTileResponse;
|
const tileResponse = (await response.json()) as DashboardTileResponse;
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return "failed";
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = applyDashboardTileHydrationResponse(
|
||||||
|
tile,
|
||||||
|
tileResponse,
|
||||||
|
run,
|
||||||
|
signal,
|
||||||
|
snapshotContext,
|
||||||
|
);
|
||||||
|
if (result !== "ready") {
|
||||||
|
return signal.aborted || cancelled || run !== hydrationRun
|
||||||
|
? "aborted"
|
||||||
|
: "failed";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "ready";
|
||||||
|
} catch (error) {
|
||||||
|
if (!isAbortError(error) && !cancelled) {
|
||||||
|
console.error("Dashboard tile hydration failed", error);
|
||||||
|
return "failed";
|
||||||
|
}
|
||||||
|
return "aborted";
|
||||||
|
} finally {
|
||||||
|
finishDashboardTileHydration(key, run, signal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDashboardTileHydrationResponse(
|
||||||
|
tile: DashboardTileReference,
|
||||||
|
tileResponse: DashboardTileResponse | undefined,
|
||||||
|
run: number,
|
||||||
|
signal: AbortSignal,
|
||||||
|
snapshotContext: DashboardTileSnapshotStoreContext,
|
||||||
|
): DashboardTileHydrationResult {
|
||||||
if (
|
if (
|
||||||
cancelled ||
|
cancelled ||
|
||||||
signal.aborted ||
|
signal.aborted ||
|
||||||
run !== hydrationRun ||
|
run !== hydrationRun ||
|
||||||
!response.ok ||
|
!tileResponse ||
|
||||||
|
dashboardTileKey(tileResponse.tile) !== dashboardTileKey(tile) ||
|
||||||
tileResponse.state !== "ready"
|
tileResponse.state !== "ready"
|
||||||
) {
|
) {
|
||||||
return signal.aborted || cancelled || run !== hydrationRun
|
return signal.aborted || cancelled || run !== hydrationRun
|
||||||
|
|
@ -374,13 +497,13 @@ export default function App() {
|
||||||
});
|
});
|
||||||
performanceMarks.markFirstTileReady();
|
performanceMarks.markFirstTileReady();
|
||||||
return "ready";
|
return "ready";
|
||||||
} catch (error) {
|
|
||||||
if (!isAbortError(error) && !cancelled) {
|
|
||||||
console.error("Dashboard tile hydration failed", error);
|
|
||||||
return "failed";
|
|
||||||
}
|
}
|
||||||
return "aborted";
|
|
||||||
} finally {
|
function finishDashboardTileHydration(
|
||||||
|
key: string,
|
||||||
|
run: number,
|
||||||
|
signal: AbortSignal,
|
||||||
|
) {
|
||||||
if (!cancelled && !signal.aborted && run === hydrationRun) {
|
if (!cancelled && !signal.aborted && run === hydrationRun) {
|
||||||
setHydratingItemIds((current) => {
|
setHydratingItemIds((current) => {
|
||||||
const next = new Set(current);
|
const next = new Set(current);
|
||||||
|
|
@ -389,7 +512,6 @@ export default function App() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const detachRefreshLifecycle = attachDashboardRefreshLifecycle({
|
const detachRefreshLifecycle = attachDashboardRefreshLifecycle({
|
||||||
documentTarget: document,
|
documentTarget: document,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
createDashboardTileBackoff,
|
createDashboardTileBackoff,
|
||||||
createDashboardRequestAborter,
|
createDashboardRequestAborter,
|
||||||
runDashboardHydrationQueue,
|
runDashboardHydrationQueue,
|
||||||
|
runDashboardHydrationBatchQueue,
|
||||||
runViewportAwareDashboardHydrationQueue,
|
runViewportAwareDashboardHydrationQueue,
|
||||||
shouldPauseDashboardRefresh,
|
shouldPauseDashboardRefresh,
|
||||||
splitDashboardHydrationItemsByVisibility,
|
splitDashboardHydrationItemsByVisibility,
|
||||||
|
|
@ -121,6 +122,25 @@ describe("dashboard refresh lifecycle", () => {
|
||||||
expect(maxActive).toBe(2);
|
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 () => {
|
test("hydrates visible items before deferred items and waits for idle", async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const idleReleases: Array<() => void> = [];
|
const idleReleases: Array<() => void> = [];
|
||||||
|
|
|
||||||
|
|
@ -152,10 +152,12 @@ export async function runDashboardHydrationQueue<TItem>(
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DashboardViewportHydrationQueueOptions<TItem> {
|
export interface DashboardViewportHydrationQueueOptions<TItem> {
|
||||||
|
batchSize?: number;
|
||||||
collectVisibleModelIds: () => Promise<ReadonlySet<string>>;
|
collectVisibleModelIds: () => Promise<ReadonlySet<string>>;
|
||||||
concurrency: number;
|
concurrency: number;
|
||||||
getModelId: (item: TItem) => string;
|
getModelId: (item: TItem) => string;
|
||||||
hydrate: (item: TItem) => Promise<void> | void;
|
hydrate: (item: TItem) => Promise<void> | void;
|
||||||
|
hydrateBatch?: (items: TItem[]) => Promise<void> | void;
|
||||||
items: TItem[];
|
items: TItem[];
|
||||||
onAllItemsSettled?: () => void;
|
onAllItemsSettled?: () => void;
|
||||||
onVisibleItemsSettled?: () => void;
|
onVisibleItemsSettled?: () => void;
|
||||||
|
|
@ -175,12 +177,7 @@ export async function runViewportAwareDashboardHydrationQueue<TItem>(
|
||||||
visibleModelIds,
|
visibleModelIds,
|
||||||
});
|
});
|
||||||
|
|
||||||
await runDashboardHydrationQueue({
|
await runDashboardHydrationItems(options, visible);
|
||||||
concurrency: options.concurrency,
|
|
||||||
hydrate: options.hydrate,
|
|
||||||
items: visible,
|
|
||||||
signal: options.signal,
|
|
||||||
});
|
|
||||||
if (options.signal.aborted) return;
|
if (options.signal.aborted) return;
|
||||||
options.onVisibleItemsSettled?.();
|
options.onVisibleItemsSettled?.();
|
||||||
|
|
||||||
|
|
@ -192,13 +189,44 @@ export async function runViewportAwareDashboardHydrationQueue<TItem>(
|
||||||
await options.waitForIdle();
|
await options.waitForIdle();
|
||||||
if (options.signal.aborted) return;
|
if (options.signal.aborted) return;
|
||||||
|
|
||||||
|
await runDashboardHydrationItems(options, deferred);
|
||||||
|
options.onAllItemsSettled?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runDashboardHydrationItems<TItem>(
|
||||||
|
options: DashboardViewportHydrationQueueOptions<TItem>,
|
||||||
|
items: TItem[],
|
||||||
|
): Promise<void> {
|
||||||
|
if (!options.hydrateBatch) {
|
||||||
await runDashboardHydrationQueue({
|
await runDashboardHydrationQueue({
|
||||||
concurrency: options.concurrency,
|
concurrency: options.concurrency,
|
||||||
hydrate: options.hydrate,
|
hydrate: options.hydrate,
|
||||||
items: deferred,
|
items,
|
||||||
signal: options.signal,
|
signal: options.signal,
|
||||||
});
|
});
|
||||||
options.onAllItemsSettled?.();
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await runDashboardHydrationBatchQueue({
|
||||||
|
batchSize: options.batchSize ?? options.concurrency,
|
||||||
|
hydrateBatch: options.hydrateBatch,
|
||||||
|
items,
|
||||||
|
signal: options.signal,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runDashboardHydrationBatchQueue<TItem>(options: {
|
||||||
|
batchSize: number;
|
||||||
|
hydrateBatch: (items: TItem[]) => Promise<void> | void;
|
||||||
|
items: TItem[];
|
||||||
|
signal: AbortSignal;
|
||||||
|
}): Promise<void> {
|
||||||
|
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<TItem>(options: {
|
export function splitDashboardHydrationItemsByVisibility<TItem>(options: {
|
||||||
|
|
|
||||||
53
apps/web/src/server/index.test.ts
Normal file
53
apps/web/src/server/index.test.ts
Normal file
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
import { extname, normalize } from "node:path";
|
import { extname, normalize } from "node:path";
|
||||||
import { handleAgentDashboardRoute } from "./routes/agent-dashboard";
|
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 host = process.env.HOST || "0.0.0.0";
|
||||||
const port = Number(process.env.PORT || 3000);
|
const port = Number(process.env.PORT || 3000);
|
||||||
|
|
@ -23,6 +27,11 @@ export async function handleRequest(request: Request): Promise<Response> {
|
||||||
return handleDashboardRoute();
|
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 (url.pathname.startsWith("/api/dashboard/tile/")) {
|
||||||
if (request.method !== "GET") return methodNotAllowed(["GET"]);
|
if (request.method !== "GET") return methodNotAllowed(["GET"]);
|
||||||
return handleDashboardTileRoute(url.pathname);
|
return handleDashboardTileRoute(url.pathname);
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,10 @@ import { dimensionLabDashboardFixture } from "$lib/dashboard-seed/dimensionlab";
|
||||||
import {
|
import {
|
||||||
createDashboardTileCache,
|
createDashboardTileCache,
|
||||||
dashboardTileCacheKey,
|
dashboardTileCacheKey,
|
||||||
|
handleDashboardTilesRoute,
|
||||||
handleDashboardTileRoute,
|
handleDashboardTileRoute,
|
||||||
loadDashboardResponse,
|
loadDashboardResponse,
|
||||||
|
loadDashboardTilesResponse,
|
||||||
loadDashboardTileResponse,
|
loadDashboardTileResponse,
|
||||||
type DashboardTileResolutionLogEvent,
|
type DashboardTileResolutionLogEvent,
|
||||||
} from "./dashboard";
|
} 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<string, () => 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<void>((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 () => {
|
test("does not hydrate tile routes when live datasources are disabled", async () => {
|
||||||
const previous = process.env.DISABLE_LIVE_DATASOURCES;
|
const previous = process.env.DISABLE_LIVE_DATASOURCES;
|
||||||
process.env.DISABLE_LIVE_DATASOURCES = "1";
|
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() {
|
function telemetryFetch() {
|
||||||
return vi.fn(async (input: RequestInfo | URL) => {
|
return vi.fn(async (input: RequestInfo | URL) => {
|
||||||
const url = String(input);
|
const url = String(input);
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,8 @@ const dashboardTileResponseHeaders = {
|
||||||
"Cache-Control": "private, max-age=5, stale-while-revalidate=30",
|
"Cache-Control": "private, max-age=5, stale-while-revalidate=30",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const dashboardBatchTileConcurrency = 6;
|
||||||
|
|
||||||
const defaultDashboardTileCache = createDashboardTileCache();
|
const defaultDashboardTileCache = createDashboardTileCache();
|
||||||
|
|
||||||
interface DashboardTileCacheResult {
|
interface DashboardTileCacheResult {
|
||||||
|
|
@ -60,6 +62,11 @@ export interface DashboardTileResolutionLogEvent {
|
||||||
tileKey: string;
|
tileKey: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DashboardTilesBatchResponse {
|
||||||
|
state: "ready";
|
||||||
|
tiles: DashboardTileResolution[];
|
||||||
|
}
|
||||||
|
|
||||||
export function createDashboardTileCache(): DashboardTileCache {
|
export function createDashboardTileCache(): DashboardTileCache {
|
||||||
const entries = new Map<string, DashboardTileCacheEntry>();
|
const entries = new Map<string, DashboardTileCacheEntry>();
|
||||||
const inFlight = new Map<string, Promise<DashboardTileResolution>>();
|
const inFlight = new Map<string, Promise<DashboardTileResolution>>();
|
||||||
|
|
@ -236,6 +243,16 @@ export async function loadDashboardTileResponse(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function loadDashboardTilesResponse(
|
||||||
|
tiles: DashboardTileReference[],
|
||||||
|
options: LoadDashboardResponseOptions = {},
|
||||||
|
): Promise<DashboardTilesBatchResponse> {
|
||||||
|
return {
|
||||||
|
state: "ready",
|
||||||
|
tiles: await resolveDashboardTilesBatch(tiles, options),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function handleDashboardRoute(): Promise<Response> {
|
export async function handleDashboardRoute(): Promise<Response> {
|
||||||
return Response.json(await loadDashboardResponse());
|
return Response.json(await loadDashboardResponse());
|
||||||
}
|
}
|
||||||
|
|
@ -259,6 +276,23 @@ export async function handleDashboardTileRoute(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function handleDashboardTilesRoute(
|
||||||
|
request: Request,
|
||||||
|
options: LoadDashboardResponseOptions = {},
|
||||||
|
): Promise<Response> {
|
||||||
|
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 {
|
export function dashboardTileCacheKey(tile: DashboardTileReference): string {
|
||||||
return JSON.stringify(tile);
|
return JSON.stringify(tile);
|
||||||
}
|
}
|
||||||
|
|
@ -296,6 +330,98 @@ function dashboardTileErrorCategory(error: unknown): string {
|
||||||
return "unknown";
|
return "unknown";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resolveDashboardTilesBatch(
|
||||||
|
tiles: DashboardTileReference[],
|
||||||
|
options: LoadDashboardResponseOptions,
|
||||||
|
): Promise<DashboardTileResolution[]> {
|
||||||
|
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<DashboardTileReference[] | null> {
|
||||||
|
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 {
|
class DashboardTileCacheResolutionError extends Error {
|
||||||
readonly cache: "hit" | "miss";
|
readonly cache: "hit" | "miss";
|
||||||
readonly coalesced: boolean;
|
readonly coalesced: boolean;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue