perf(web): add dashboard hydration observability #50
5 changed files with 448 additions and 22 deletions
|
|
@ -10,6 +10,7 @@ import type { DashboardRuntimeState } from "$lib/server/dashboard";
|
||||||
import {
|
import {
|
||||||
attachDashboardRefreshLifecycle,
|
attachDashboardRefreshLifecycle,
|
||||||
collectVisibleDashboardModelIds,
|
collectVisibleDashboardModelIds,
|
||||||
|
createDashboardPerformanceMarks,
|
||||||
createDashboardRefreshDelay,
|
createDashboardRefreshDelay,
|
||||||
createDashboardTileSnapshotStore,
|
createDashboardTileSnapshotStore,
|
||||||
createDashboardTileBackoff,
|
createDashboardTileBackoff,
|
||||||
|
|
@ -174,6 +175,7 @@ export default function App() {
|
||||||
let lastRefreshIntervalMs = dashboardFallbackRefreshIntervalMs;
|
let lastRefreshIntervalMs = dashboardFallbackRefreshIntervalMs;
|
||||||
let hydrationRun = 0;
|
let hydrationRun = 0;
|
||||||
const requestAborter = createDashboardRequestAborter();
|
const requestAborter = createDashboardRequestAborter();
|
||||||
|
const performanceMarks = createDashboardPerformanceMarks();
|
||||||
const refreshDelay = createDashboardRefreshDelay();
|
const refreshDelay = createDashboardRefreshDelay();
|
||||||
const tileBackoff = createDashboardTileBackoff();
|
const tileBackoff = createDashboardTileBackoff();
|
||||||
const tileSnapshotStore = createDashboardTileSnapshotStore(
|
const tileSnapshotStore = createDashboardTileSnapshotStore(
|
||||||
|
|
@ -222,6 +224,7 @@ export default function App() {
|
||||||
|
|
||||||
if (cancelled || shellSignal.aborted || refreshPaused()) return;
|
if (cancelled || shellSignal.aborted || refreshPaused()) return;
|
||||||
refreshDelay.recordSuccess();
|
refreshDelay.recordSuccess();
|
||||||
|
performanceMarks.markShellLoad();
|
||||||
const restored = restoreDashboardTileSnapshots(
|
const restored = restoreDashboardTileSnapshots(
|
||||||
nextDashboard,
|
nextDashboard,
|
||||||
nextDashboard.state === "ready"
|
nextDashboard.state === "ready"
|
||||||
|
|
@ -319,6 +322,8 @@ export default function App() {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
items: tiles,
|
items: tiles,
|
||||||
|
onAllItemsSettled: () => performanceMarks.markAllTilesSettled(),
|
||||||
|
onVisibleItemsSettled: () => performanceMarks.markVisibleTilesReady(),
|
||||||
signal,
|
signal,
|
||||||
waitForIdle: () =>
|
waitForIdle: () =>
|
||||||
waitForDashboardHydrationIdle({
|
waitForDashboardHydrationIdle({
|
||||||
|
|
@ -367,6 +372,7 @@ export default function App() {
|
||||||
...snapshotContext,
|
...snapshotContext,
|
||||||
response: readyTileResponse,
|
response: readyTileResponse,
|
||||||
});
|
});
|
||||||
|
performanceMarks.markFirstTileReady();
|
||||||
return "ready";
|
return "ready";
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isAbortError(error) && !cancelled) {
|
if (!isAbortError(error) && !cancelled) {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@ import { describe, expect, test } from "vitest";
|
||||||
import {
|
import {
|
||||||
attachDashboardRefreshLifecycle,
|
attachDashboardRefreshLifecycle,
|
||||||
collectVisibleDashboardModelIds,
|
collectVisibleDashboardModelIds,
|
||||||
|
createDashboardPerformanceMarks,
|
||||||
createDashboardRefreshDelay,
|
createDashboardRefreshDelay,
|
||||||
|
dashboardPerformanceMarks,
|
||||||
createDashboardTileSnapshotStore,
|
createDashboardTileSnapshotStore,
|
||||||
createDashboardTileBackoff,
|
createDashboardTileBackoff,
|
||||||
createDashboardRequestAborter,
|
createDashboardRequestAborter,
|
||||||
|
|
@ -131,6 +133,8 @@ describe("dashboard refresh lifecycle", () => {
|
||||||
calls.push(item);
|
calls.push(item);
|
||||||
},
|
},
|
||||||
items: ["a", "b", "c"],
|
items: ["a", "b", "c"],
|
||||||
|
onAllItemsSettled: () => calls.push("all-settled"),
|
||||||
|
onVisibleItemsSettled: () => calls.push("visible-settled"),
|
||||||
signal: new AbortController().signal,
|
signal: new AbortController().signal,
|
||||||
waitForIdle: async () => {
|
waitForIdle: async () => {
|
||||||
calls.push("idle");
|
calls.push("idle");
|
||||||
|
|
@ -139,11 +143,18 @@ describe("dashboard refresh lifecycle", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => calls.includes("idle"));
|
await waitFor(() => calls.includes("idle"));
|
||||||
expect(calls).toEqual(["b", "idle"]);
|
expect(calls).toEqual(["b", "visible-settled", "idle"]);
|
||||||
|
|
||||||
idleReleases.shift()?.();
|
idleReleases.shift()?.();
|
||||||
await hydration;
|
await hydration;
|
||||||
expect(calls).toEqual(["b", "idle", "a", "c"]);
|
expect(calls).toEqual([
|
||||||
|
"b",
|
||||||
|
"visible-settled",
|
||||||
|
"idle",
|
||||||
|
"a",
|
||||||
|
"c",
|
||||||
|
"all-settled",
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("splits visible hydration items while preserving document order", () => {
|
test("splits visible hydration items while preserving document order", () => {
|
||||||
|
|
@ -247,6 +258,30 @@ describe("dashboard refresh lifecycle", () => {
|
||||||
expect(delay.nextDelayMs(1_000)).toBe(1_100);
|
expect(delay.nextDelayMs(1_000)).toBe(1_100);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("marks dashboard performance milestones once per shell run", () => {
|
||||||
|
const marks: string[] = [];
|
||||||
|
const performanceMarks = createDashboardPerformanceMarks({
|
||||||
|
mark: (name) => marks.push(name),
|
||||||
|
});
|
||||||
|
|
||||||
|
performanceMarks.markShellLoad();
|
||||||
|
performanceMarks.markFirstTileReady();
|
||||||
|
performanceMarks.markFirstTileReady();
|
||||||
|
performanceMarks.markVisibleTilesReady();
|
||||||
|
performanceMarks.markAllTilesSettled();
|
||||||
|
performanceMarks.markShellLoad();
|
||||||
|
performanceMarks.markFirstTileReady();
|
||||||
|
|
||||||
|
expect(marks).toEqual([
|
||||||
|
dashboardPerformanceMarks.shellLoad,
|
||||||
|
dashboardPerformanceMarks.firstTileReady,
|
||||||
|
dashboardPerformanceMarks.visibleTilesReady,
|
||||||
|
dashboardPerformanceMarks.allTilesSettled,
|
||||||
|
dashboardPerformanceMarks.shellLoad,
|
||||||
|
dashboardPerformanceMarks.firstTileReady,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test("backs off failed tile keys and resets after success", () => {
|
test("backs off failed tile keys and resets after success", () => {
|
||||||
const backoff = createDashboardTileBackoff();
|
const backoff = createDashboardTileBackoff();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -157,6 +157,8 @@ export interface DashboardViewportHydrationQueueOptions<TItem> {
|
||||||
getModelId: (item: TItem) => string;
|
getModelId: (item: TItem) => string;
|
||||||
hydrate: (item: TItem) => Promise<void> | void;
|
hydrate: (item: TItem) => Promise<void> | void;
|
||||||
items: TItem[];
|
items: TItem[];
|
||||||
|
onAllItemsSettled?: () => void;
|
||||||
|
onVisibleItemsSettled?: () => void;
|
||||||
signal: AbortSignal;
|
signal: AbortSignal;
|
||||||
waitForIdle: () => Promise<void>;
|
waitForIdle: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
@ -179,8 +181,13 @@ export async function runViewportAwareDashboardHydrationQueue<TItem>(
|
||||||
items: visible,
|
items: visible,
|
||||||
signal: options.signal,
|
signal: options.signal,
|
||||||
});
|
});
|
||||||
|
if (options.signal.aborted) return;
|
||||||
|
options.onVisibleItemsSettled?.();
|
||||||
|
|
||||||
if (!deferred.length || options.signal.aborted) return;
|
if (!deferred.length) {
|
||||||
|
options.onAllItemsSettled?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await options.waitForIdle();
|
await options.waitForIdle();
|
||||||
if (options.signal.aborted) return;
|
if (options.signal.aborted) return;
|
||||||
|
|
@ -191,6 +198,7 @@ export async function runViewportAwareDashboardHydrationQueue<TItem>(
|
||||||
items: deferred,
|
items: deferred,
|
||||||
signal: options.signal,
|
signal: options.signal,
|
||||||
});
|
});
|
||||||
|
options.onAllItemsSettled?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function splitDashboardHydrationItemsByVisibility<TItem>(options: {
|
export function splitDashboardHydrationItemsByVisibility<TItem>(options: {
|
||||||
|
|
@ -408,6 +416,51 @@ export function createDashboardRefreshDelay(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DashboardPerformanceMarkOptions {
|
||||||
|
mark?: (name: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dashboardPerformanceMarks = {
|
||||||
|
allTilesSettled: "dashboard:all-tiles-settled",
|
||||||
|
firstTileReady: "dashboard:first-tile-ready",
|
||||||
|
shellLoad: "dashboard:shell-load",
|
||||||
|
visibleTilesReady: "dashboard:visible-tiles-ready",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function createDashboardPerformanceMarks(
|
||||||
|
options: DashboardPerformanceMarkOptions = {},
|
||||||
|
) {
|
||||||
|
const mark = options.mark ||
|
||||||
|
globalThis.performance?.mark?.bind(globalThis.performance);
|
||||||
|
let firstTileReadyMarked = false;
|
||||||
|
|
||||||
|
function safeMark(name: string) {
|
||||||
|
try {
|
||||||
|
mark?.(name);
|
||||||
|
} catch {
|
||||||
|
// Performance marks are diagnostics only.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
markAllTilesSettled(): void {
|
||||||
|
safeMark(dashboardPerformanceMarks.allTilesSettled);
|
||||||
|
},
|
||||||
|
markFirstTileReady(): void {
|
||||||
|
if (firstTileReadyMarked) return;
|
||||||
|
firstTileReadyMarked = true;
|
||||||
|
safeMark(dashboardPerformanceMarks.firstTileReady);
|
||||||
|
},
|
||||||
|
markShellLoad(): void {
|
||||||
|
firstTileReadyMarked = false;
|
||||||
|
safeMark(dashboardPerformanceMarks.shellLoad);
|
||||||
|
},
|
||||||
|
markVisibleTilesReady(): void {
|
||||||
|
safeMark(dashboardPerformanceMarks.visibleTilesReady);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const dashboardTileBackoffDelaysMs = [15_000, 30_000, 60_000, 120_000];
|
const dashboardTileBackoffDelaysMs = [15_000, 30_000, 60_000, 120_000];
|
||||||
|
|
||||||
export function createDashboardTileBackoff() {
|
export function createDashboardTileBackoff() {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { describe, expect, test, vi } from "vitest";
|
import { afterAll, afterEach, describe, expect, test, vi } from "vitest";
|
||||||
import { dimensionLabDashboardFixture } from "$lib/dashboard-seed/dimensionlab";
|
import { dimensionLabDashboardFixture } from "$lib/dashboard-seed/dimensionlab";
|
||||||
import {
|
import {
|
||||||
createDashboardTileCache,
|
createDashboardTileCache,
|
||||||
|
|
@ -6,9 +6,20 @@ import {
|
||||||
handleDashboardTileRoute,
|
handleDashboardTileRoute,
|
||||||
loadDashboardResponse,
|
loadDashboardResponse,
|
||||||
loadDashboardTileResponse,
|
loadDashboardTileResponse,
|
||||||
|
type DashboardTileResolutionLogEvent,
|
||||||
} from "./dashboard";
|
} from "./dashboard";
|
||||||
|
|
||||||
describe("dashboard API route", () => {
|
describe("dashboard API route", () => {
|
||||||
|
const consoleInfo = vi.spyOn(console, "info").mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
consoleInfo.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
consoleInfo.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
test("returns the ready dashboard shell without hydrating live datasources", async () => {
|
test("returns the ready dashboard shell without hydrating live datasources", async () => {
|
||||||
const fetch = vi.spyOn(globalThis, "fetch").mockRejectedValue(
|
const fetch = vi.spyOn(globalThis, "fetch").mockRejectedValue(
|
||||||
new Error("live datasource fetch should not run for the shell response"),
|
new Error("live datasource fetch should not run for the shell response"),
|
||||||
|
|
@ -154,12 +165,13 @@ describe("dashboard API route", () => {
|
||||||
test("caches ready tile responses until the tile ttl expires", async () => {
|
test("caches ready tile responses until the tile ttl expires", async () => {
|
||||||
const cache = createDashboardTileCache();
|
const cache = createDashboardTileCache();
|
||||||
let now = 1_000;
|
let now = 1_000;
|
||||||
const fetch = vi.fn(async () =>
|
const fetch = vi.fn(async () => {
|
||||||
jsonResponse({
|
now += 7;
|
||||||
|
return jsonResponse({
|
||||||
status: "UP",
|
status: "UP",
|
||||||
ping: 42,
|
ping: 42,
|
||||||
}),
|
});
|
||||||
);
|
});
|
||||||
const tile = { kind: "service", groupId: "essentials", id: "vaultwarden" } as const;
|
const tile = { kind: "service", groupId: "essentials", id: "vaultwarden" } as const;
|
||||||
|
|
||||||
const first = await loadDashboardTileResponse(tile, {
|
const first = await loadDashboardTileResponse(tile, {
|
||||||
|
|
@ -190,6 +202,68 @@ describe("dashboard API route", () => {
|
||||||
expect(fetch).toHaveBeenCalledTimes(2);
|
expect(fetch).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("logs tile duration and cache hit or miss metadata", async () => {
|
||||||
|
let now = 1_000;
|
||||||
|
const logs: DashboardTileResolutionLogEvent[] = [];
|
||||||
|
const tile = { kind: "service", groupId: "essentials", id: "vaultwarden" } as const;
|
||||||
|
const service = dimensionLabDashboardFixture.serviceGroups
|
||||||
|
.flatMap((group) => group.services)
|
||||||
|
.find((item) => item.id === tile.id);
|
||||||
|
if (!service) throw new Error("missing service fixture");
|
||||||
|
|
||||||
|
let cacheCalls = 0;
|
||||||
|
const tileCache = {
|
||||||
|
async resolve() {
|
||||||
|
cacheCalls += 1;
|
||||||
|
if (cacheCalls === 1) now += 7;
|
||||||
|
const cacheState = cacheCalls === 1 ? "miss" as const : "hit" as const;
|
||||||
|
|
||||||
|
return {
|
||||||
|
cache: cacheState,
|
||||||
|
coalesced: false,
|
||||||
|
response: {
|
||||||
|
state: "ready" as const,
|
||||||
|
tile,
|
||||||
|
item: service,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await loadDashboardTileResponse(tile, {
|
||||||
|
logTileResolution: (event) => logs.push(event),
|
||||||
|
now: () => now,
|
||||||
|
refreshSeedDocument: true,
|
||||||
|
seedIfEmpty: true,
|
||||||
|
tileCache,
|
||||||
|
});
|
||||||
|
now += 10;
|
||||||
|
await loadDashboardTileResponse(tile, {
|
||||||
|
logTileResolution: (event) => logs.push(event),
|
||||||
|
now: () => now,
|
||||||
|
refreshSeedDocument: true,
|
||||||
|
seedIfEmpty: true,
|
||||||
|
tileCache,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(logs).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
cache: "miss",
|
||||||
|
coalesced: false,
|
||||||
|
durationMs: 7,
|
||||||
|
status: "ready",
|
||||||
|
tileKey: dashboardTileCacheKey(tile),
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
cache: "hit",
|
||||||
|
coalesced: false,
|
||||||
|
durationMs: 0,
|
||||||
|
status: "ready",
|
||||||
|
tileKey: dashboardTileCacheKey(tile),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test("keeps telemetry tiles cached for fifteen seconds", async () => {
|
test("keeps telemetry tiles cached for fifteen seconds", async () => {
|
||||||
const cache = createDashboardTileCache();
|
const cache = createDashboardTileCache();
|
||||||
let now = 1_000;
|
let now = 1_000;
|
||||||
|
|
@ -268,6 +342,7 @@ describe("dashboard API route", () => {
|
||||||
|
|
||||||
test("coalesces concurrent tile requests for the same cache key", async () => {
|
test("coalesces concurrent tile requests for the same cache key", async () => {
|
||||||
const cache = createDashboardTileCache();
|
const cache = createDashboardTileCache();
|
||||||
|
const logs: DashboardTileResolutionLogEvent[] = [];
|
||||||
let resolveFetch: ((response: Response) => void) | undefined;
|
let resolveFetch: ((response: Response) => void) | undefined;
|
||||||
const fetch = vi.fn(() =>
|
const fetch = vi.fn(() =>
|
||||||
new Promise<Response>((resolve) => {
|
new Promise<Response>((resolve) => {
|
||||||
|
|
@ -278,6 +353,7 @@ describe("dashboard API route", () => {
|
||||||
|
|
||||||
const first = loadDashboardTileResponse(tile, {
|
const first = loadDashboardTileResponse(tile, {
|
||||||
fetch,
|
fetch,
|
||||||
|
logTileResolution: (event) => logs.push(event),
|
||||||
now: () => 1_000,
|
now: () => 1_000,
|
||||||
refreshSeedDocument: true,
|
refreshSeedDocument: true,
|
||||||
seedIfEmpty: true,
|
seedIfEmpty: true,
|
||||||
|
|
@ -285,6 +361,7 @@ describe("dashboard API route", () => {
|
||||||
});
|
});
|
||||||
const second = loadDashboardTileResponse(tile, {
|
const second = loadDashboardTileResponse(tile, {
|
||||||
fetch,
|
fetch,
|
||||||
|
logTileResolution: (event) => logs.push(event),
|
||||||
now: () => 1_000,
|
now: () => 1_000,
|
||||||
refreshSeedDocument: true,
|
refreshSeedDocument: true,
|
||||||
seedIfEmpty: true,
|
seedIfEmpty: true,
|
||||||
|
|
@ -296,6 +373,104 @@ describe("dashboard API route", () => {
|
||||||
|
|
||||||
resolveFetch?.(jsonResponse({ status: "UP", ping: 42 }));
|
resolveFetch?.(jsonResponse({ status: "UP", ping: 42 }));
|
||||||
expect(await first).toEqual(await second);
|
expect(await first).toEqual(await second);
|
||||||
|
expect(logs).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
cache: "miss",
|
||||||
|
coalesced: false,
|
||||||
|
status: "ready",
|
||||||
|
tileKey: dashboardTileCacheKey(tile),
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
cache: "miss",
|
||||||
|
coalesced: true,
|
||||||
|
status: "ready",
|
||||||
|
tileKey: dashboardTileCacheKey(tile),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("logs coalesced metadata when shared tile requests fail", async () => {
|
||||||
|
const cache = createDashboardTileCache();
|
||||||
|
let rejectLoad: ((error: Error) => void) | undefined;
|
||||||
|
const load = vi.fn(() =>
|
||||||
|
new Promise<never>((_resolve, reject) => {
|
||||||
|
rejectLoad = reject;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const first = cache.resolve("tile-a", 30_000, 1_000, load);
|
||||||
|
const second = cache.resolve("tile-a", 30_000, 1_000, load);
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(load).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
rejectLoad?.(new TypeError("upstream failed"));
|
||||||
|
await expect(first).rejects.toThrow("upstream failed");
|
||||||
|
await expect(second).rejects.toMatchObject({
|
||||||
|
cache: "miss",
|
||||||
|
coalesced: true,
|
||||||
|
cause: expect.any(TypeError),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("logs coalesced metadata for failed tile cache resolution", async () => {
|
||||||
|
const logs: DashboardTileResolutionLogEvent[] = [];
|
||||||
|
const tile = { kind: "service", groupId: "essentials", id: "vaultwarden" } as const;
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
loadDashboardTileResponse(tile, {
|
||||||
|
logTileResolution: (event) => logs.push(event),
|
||||||
|
refreshSeedDocument: true,
|
||||||
|
seedIfEmpty: true,
|
||||||
|
tileCache: {
|
||||||
|
async resolve() {
|
||||||
|
throw {
|
||||||
|
cache: "miss",
|
||||||
|
cause: new TypeError("coalesced cache failed"),
|
||||||
|
coalesced: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("coalesced cache failed");
|
||||||
|
|
||||||
|
expect(logs).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
cache: "miss",
|
||||||
|
coalesced: true,
|
||||||
|
errorCategory: "TypeError",
|
||||||
|
status: "error",
|
||||||
|
tileKey: dashboardTileCacheKey(tile),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("logs error categories for failed tile cache resolution", async () => {
|
||||||
|
const logs: DashboardTileResolutionLogEvent[] = [];
|
||||||
|
const tile = { kind: "service", groupId: "essentials", id: "vaultwarden" } as const;
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
loadDashboardTileResponse(tile, {
|
||||||
|
logTileResolution: (event) => logs.push(event),
|
||||||
|
refreshSeedDocument: true,
|
||||||
|
seedIfEmpty: true,
|
||||||
|
tileCache: {
|
||||||
|
async resolve() {
|
||||||
|
throw new TypeError("cache failed");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("cache failed");
|
||||||
|
|
||||||
|
expect(logs).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
cache: "miss",
|
||||||
|
coalesced: false,
|
||||||
|
errorCategory: "TypeError",
|
||||||
|
status: "error",
|
||||||
|
tileKey: dashboardTileCacheKey(tile),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("uses structured tile cache keys when identifiers contain delimiters", () => {
|
test("uses structured tile cache keys when identifiers contain delimiters", () => {
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ export interface LoadDashboardResponseOptions
|
||||||
DatasourceResolutionOptions {
|
DatasourceResolutionOptions {
|
||||||
disableLiveDatasources?: boolean;
|
disableLiveDatasources?: boolean;
|
||||||
hydrateLiveDatasources?: boolean;
|
hydrateLiveDatasources?: boolean;
|
||||||
|
logTileResolution?: (event: DashboardTileResolutionLogEvent) => void;
|
||||||
now?: () => number;
|
now?: () => number;
|
||||||
tileCache?: DashboardTileCache;
|
tileCache?: DashboardTileCache;
|
||||||
}
|
}
|
||||||
|
|
@ -35,7 +36,7 @@ export interface DashboardTileCache {
|
||||||
ttlMs: number,
|
ttlMs: number,
|
||||||
now: number,
|
now: number,
|
||||||
load: () => Promise<DashboardTileResolution>,
|
load: () => Promise<DashboardTileResolution>,
|
||||||
): Promise<DashboardTileResolution>;
|
): Promise<DashboardTileCacheResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dashboardTileResponseHeaders = {
|
const dashboardTileResponseHeaders = {
|
||||||
|
|
@ -44,6 +45,21 @@ const dashboardTileResponseHeaders = {
|
||||||
|
|
||||||
const defaultDashboardTileCache = createDashboardTileCache();
|
const defaultDashboardTileCache = createDashboardTileCache();
|
||||||
|
|
||||||
|
interface DashboardTileCacheResult {
|
||||||
|
cache: "hit" | "miss";
|
||||||
|
coalesced: boolean;
|
||||||
|
response: DashboardTileResolution;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardTileResolutionLogEvent {
|
||||||
|
cache: "bypass" | "hit" | "miss";
|
||||||
|
coalesced: boolean;
|
||||||
|
durationMs: number;
|
||||||
|
errorCategory?: string;
|
||||||
|
status: DashboardTileResolution["state"] | "error";
|
||||||
|
tileKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
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>>();
|
||||||
|
|
@ -52,11 +68,28 @@ export function createDashboardTileCache(): DashboardTileCache {
|
||||||
async resolve(key, ttlMs, now, load) {
|
async resolve(key, ttlMs, now, load) {
|
||||||
const cached = entries.get(key);
|
const cached = entries.get(key);
|
||||||
if (cached && cached.expiresAt > now) {
|
if (cached && cached.expiresAt > now) {
|
||||||
return cached.response;
|
return {
|
||||||
|
cache: "hit",
|
||||||
|
coalesced: false,
|
||||||
|
response: cached.response,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const active = inFlight.get(key);
|
const active = inFlight.get(key);
|
||||||
if (active) return active;
|
if (active) {
|
||||||
|
return active
|
||||||
|
.then((response) => ({
|
||||||
|
cache: "miss" as const,
|
||||||
|
coalesced: true,
|
||||||
|
response,
|
||||||
|
}))
|
||||||
|
.catch((error) => {
|
||||||
|
throw new DashboardTileCacheResolutionError(error, {
|
||||||
|
cache: "miss",
|
||||||
|
coalesced: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const request = load()
|
const request = load()
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
|
|
@ -74,7 +107,11 @@ export function createDashboardTileCache(): DashboardTileCache {
|
||||||
});
|
});
|
||||||
|
|
||||||
inFlight.set(key, request);
|
inFlight.set(key, request);
|
||||||
return request;
|
return request.then((response) => ({
|
||||||
|
cache: "miss" as const,
|
||||||
|
coalesced: false,
|
||||||
|
response,
|
||||||
|
}));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -125,13 +162,26 @@ export async function loadDashboardTileResponse(
|
||||||
options.disableLiveDatasources ||
|
options.disableLiveDatasources ||
|
||||||
process.env.DISABLE_LIVE_DATASOURCES === "1"
|
process.env.DISABLE_LIVE_DATASOURCES === "1"
|
||||||
) {
|
) {
|
||||||
return {
|
const tileKey = dashboardTileCacheKey(tile);
|
||||||
|
const startedAt = options.now?.() ?? Date.now();
|
||||||
|
const response = {
|
||||||
state: "disabled",
|
state: "disabled",
|
||||||
tile,
|
tile,
|
||||||
message: "Live datasource hydration is disabled.",
|
message: "Live datasource hydration is disabled.",
|
||||||
};
|
} satisfies DashboardTileResolution;
|
||||||
|
logDashboardTileResolution(options, {
|
||||||
|
cache: "bypass",
|
||||||
|
coalesced: false,
|
||||||
|
durationMs: elapsedDashboardTileMs(startedAt, options),
|
||||||
|
status: response.state,
|
||||||
|
tileKey,
|
||||||
|
});
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tileKey = dashboardTileCacheKey(tile);
|
||||||
|
const startedAt = options.now?.() ?? Date.now();
|
||||||
|
|
||||||
const dashboard = loadDashboardRuntime(undefined, {
|
const dashboard = loadDashboardRuntime(undefined, {
|
||||||
refreshSeedDocument: options.refreshSeedDocument ?? true,
|
refreshSeedDocument: options.refreshSeedDocument ?? true,
|
||||||
seedIfEmpty: options.seedIfEmpty ?? true,
|
seedIfEmpty: options.seedIfEmpty ?? true,
|
||||||
|
|
@ -139,22 +189,51 @@ export async function loadDashboardTileResponse(
|
||||||
});
|
});
|
||||||
|
|
||||||
if (dashboard.state !== "ready") {
|
if (dashboard.state !== "ready") {
|
||||||
return {
|
const response = {
|
||||||
state: "not_found",
|
state: "not_found",
|
||||||
tile,
|
tile,
|
||||||
message: `Dashboard is not ready: ${dashboard.state}`,
|
message: `Dashboard is not ready: ${dashboard.state}`,
|
||||||
};
|
} satisfies DashboardTileResolution;
|
||||||
|
logDashboardTileResolution(options, {
|
||||||
|
cache: "bypass",
|
||||||
|
coalesced: false,
|
||||||
|
durationMs: elapsedDashboardTileMs(startedAt, options),
|
||||||
|
status: response.state,
|
||||||
|
tileKey,
|
||||||
|
});
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cache = options.tileCache || defaultDashboardTileCache;
|
const cache = options.tileCache || defaultDashboardTileCache;
|
||||||
const now = options.now?.() ?? Date.now();
|
const now = options.now?.() ?? Date.now();
|
||||||
|
|
||||||
return cache.resolve(
|
try {
|
||||||
dashboardTileCacheKey(tile),
|
const result = await cache.resolve(
|
||||||
dashboardTileTtlMs(dashboard.document, tile),
|
tileKey,
|
||||||
now,
|
dashboardTileTtlMs(dashboard.document, tile),
|
||||||
() => resolveDashboardTile(dashboard.document, tile, options),
|
now,
|
||||||
);
|
() => resolveDashboardTile(dashboard.document, tile, options),
|
||||||
|
);
|
||||||
|
logDashboardTileResolution(options, {
|
||||||
|
cache: result.cache,
|
||||||
|
coalesced: result.coalesced,
|
||||||
|
durationMs: elapsedDashboardTileMs(startedAt, options),
|
||||||
|
status: result.response.state,
|
||||||
|
tileKey,
|
||||||
|
});
|
||||||
|
return result.response;
|
||||||
|
} catch (error) {
|
||||||
|
const cacheError = dashboardTileCacheResolutionError(error);
|
||||||
|
logDashboardTileResolution(options, {
|
||||||
|
cache: cacheError?.cache ?? "miss",
|
||||||
|
coalesced: cacheError?.coalesced ?? false,
|
||||||
|
durationMs: elapsedDashboardTileMs(startedAt, options),
|
||||||
|
errorCategory: dashboardTileErrorCategory(cacheError?.cause ?? error),
|
||||||
|
status: "error",
|
||||||
|
tileKey,
|
||||||
|
});
|
||||||
|
throw cacheError?.cause ?? error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleDashboardRoute(): Promise<Response> {
|
export async function handleDashboardRoute(): Promise<Response> {
|
||||||
|
|
@ -184,6 +263,84 @@ export function dashboardTileCacheKey(tile: DashboardTileReference): string {
|
||||||
return JSON.stringify(tile);
|
return JSON.stringify(tile);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function logDashboardTileResolution(
|
||||||
|
options: LoadDashboardResponseOptions,
|
||||||
|
event: DashboardTileResolutionLogEvent,
|
||||||
|
): void {
|
||||||
|
if (options.logTileResolution) {
|
||||||
|
options.logTileResolution(event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info("dashboard.tile", event);
|
||||||
|
}
|
||||||
|
|
||||||
|
function elapsedDashboardTileMs(
|
||||||
|
startedAt: number,
|
||||||
|
options: LoadDashboardResponseOptions,
|
||||||
|
): number {
|
||||||
|
const now = options.now?.() ?? Date.now();
|
||||||
|
return Math.max(0, now - startedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dashboardTileErrorCategory(error: unknown): string {
|
||||||
|
if (
|
||||||
|
typeof error === "object" &&
|
||||||
|
error !== null &&
|
||||||
|
"name" in error &&
|
||||||
|
typeof error.name === "string"
|
||||||
|
) {
|
||||||
|
return error.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
class DashboardTileCacheResolutionError extends Error {
|
||||||
|
readonly cache: "hit" | "miss";
|
||||||
|
readonly coalesced: boolean;
|
||||||
|
override readonly cause: unknown;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
cause: unknown,
|
||||||
|
metadata: Pick<DashboardTileCacheResult, "cache" | "coalesced">,
|
||||||
|
) {
|
||||||
|
super("Dashboard tile cache resolution failed");
|
||||||
|
this.name = "DashboardTileCacheResolutionError";
|
||||||
|
this.cause = cause;
|
||||||
|
this.cache = metadata.cache;
|
||||||
|
this.coalesced = metadata.coalesced;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dashboardTileCacheResolutionError(
|
||||||
|
error: unknown,
|
||||||
|
): DashboardTileCacheResolutionFailure | null {
|
||||||
|
if (
|
||||||
|
typeof error === "object" &&
|
||||||
|
error !== null &&
|
||||||
|
"cache" in error &&
|
||||||
|
(error.cache === "hit" || error.cache === "miss") &&
|
||||||
|
"coalesced" in error &&
|
||||||
|
typeof error.coalesced === "boolean" &&
|
||||||
|
"cause" in error
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
cache: error.cache,
|
||||||
|
cause: error.cause,
|
||||||
|
coalesced: error.coalesced,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DashboardTileCacheResolutionFailure {
|
||||||
|
cache: "hit" | "miss";
|
||||||
|
cause: unknown;
|
||||||
|
coalesced: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
function dashboardTileTtlMs(
|
function dashboardTileTtlMs(
|
||||||
document: DashboardDocument,
|
document: DashboardDocument,
|
||||||
tile: DashboardTileReference,
|
tile: DashboardTileReference,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue