Compare commits
No commits in common. "63b008095f02b39341b46c649c594e7fefcdd32b" and "b6de67f0812f5e6d82ca6a64da91971a3f4c00c0" have entirely different histories.
63b008095f
...
b6de67f081
5 changed files with 22 additions and 448 deletions
|
|
@ -10,7 +10,6 @@ import type { DashboardRuntimeState } from "$lib/server/dashboard";
|
||||||
import {
|
import {
|
||||||
attachDashboardRefreshLifecycle,
|
attachDashboardRefreshLifecycle,
|
||||||
collectVisibleDashboardModelIds,
|
collectVisibleDashboardModelIds,
|
||||||
createDashboardPerformanceMarks,
|
|
||||||
createDashboardRefreshDelay,
|
createDashboardRefreshDelay,
|
||||||
createDashboardTileSnapshotStore,
|
createDashboardTileSnapshotStore,
|
||||||
createDashboardTileBackoff,
|
createDashboardTileBackoff,
|
||||||
|
|
@ -175,7 +174,6 @@ 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(
|
||||||
|
|
@ -224,7 +222,6 @@ 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"
|
||||||
|
|
@ -322,8 +319,6 @@ export default function App() {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
items: tiles,
|
items: tiles,
|
||||||
onAllItemsSettled: () => performanceMarks.markAllTilesSettled(),
|
|
||||||
onVisibleItemsSettled: () => performanceMarks.markVisibleTilesReady(),
|
|
||||||
signal,
|
signal,
|
||||||
waitForIdle: () =>
|
waitForIdle: () =>
|
||||||
waitForDashboardHydrationIdle({
|
waitForDashboardHydrationIdle({
|
||||||
|
|
@ -372,7 +367,6 @@ 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,9 +2,7 @@ import { describe, expect, test } from "vitest";
|
||||||
import {
|
import {
|
||||||
attachDashboardRefreshLifecycle,
|
attachDashboardRefreshLifecycle,
|
||||||
collectVisibleDashboardModelIds,
|
collectVisibleDashboardModelIds,
|
||||||
createDashboardPerformanceMarks,
|
|
||||||
createDashboardRefreshDelay,
|
createDashboardRefreshDelay,
|
||||||
dashboardPerformanceMarks,
|
|
||||||
createDashboardTileSnapshotStore,
|
createDashboardTileSnapshotStore,
|
||||||
createDashboardTileBackoff,
|
createDashboardTileBackoff,
|
||||||
createDashboardRequestAborter,
|
createDashboardRequestAborter,
|
||||||
|
|
@ -133,8 +131,6 @@ 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");
|
||||||
|
|
@ -143,18 +139,11 @@ describe("dashboard refresh lifecycle", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => calls.includes("idle"));
|
await waitFor(() => calls.includes("idle"));
|
||||||
expect(calls).toEqual(["b", "visible-settled", "idle"]);
|
expect(calls).toEqual(["b", "idle"]);
|
||||||
|
|
||||||
idleReleases.shift()?.();
|
idleReleases.shift()?.();
|
||||||
await hydration;
|
await hydration;
|
||||||
expect(calls).toEqual([
|
expect(calls).toEqual(["b", "idle", "a", "c"]);
|
||||||
"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", () => {
|
||||||
|
|
@ -258,30 +247,6 @@ 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,8 +157,6 @@ 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>;
|
||||||
}
|
}
|
||||||
|
|
@ -181,13 +179,8 @@ 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) {
|
if (!deferred.length || options.signal.aborted) return;
|
||||||
options.onAllItemsSettled?.();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await options.waitForIdle();
|
await options.waitForIdle();
|
||||||
if (options.signal.aborted) return;
|
if (options.signal.aborted) return;
|
||||||
|
|
@ -198,7 +191,6 @@ 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: {
|
||||||
|
|
@ -416,51 +408,6 @@ 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 { afterAll, afterEach, describe, expect, test, vi } from "vitest";
|
import { 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,20 +6,9 @@ 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"),
|
||||||
|
|
@ -165,13 +154,12 @@ 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 () =>
|
||||||
now += 7;
|
jsonResponse({
|
||||||
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, {
|
||||||
|
|
@ -202,68 +190,6 @@ 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;
|
||||||
|
|
@ -342,7 +268,6 @@ 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) => {
|
||||||
|
|
@ -353,7 +278,6 @@ 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,
|
||||||
|
|
@ -361,7 +285,6 @@ 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,
|
||||||
|
|
@ -373,104 +296,6 @@ 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,7 +20,6 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
@ -36,7 +35,7 @@ export interface DashboardTileCache {
|
||||||
ttlMs: number,
|
ttlMs: number,
|
||||||
now: number,
|
now: number,
|
||||||
load: () => Promise<DashboardTileResolution>,
|
load: () => Promise<DashboardTileResolution>,
|
||||||
): Promise<DashboardTileCacheResult>;
|
): Promise<DashboardTileResolution>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dashboardTileResponseHeaders = {
|
const dashboardTileResponseHeaders = {
|
||||||
|
|
@ -45,21 +44,6 @@ 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>>();
|
||||||
|
|
@ -68,28 +52,11 @@ 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 {
|
return cached.response;
|
||||||
cache: "hit",
|
|
||||||
coalesced: false,
|
|
||||||
response: cached.response,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const active = inFlight.get(key);
|
const active = inFlight.get(key);
|
||||||
if (active) {
|
if (active) return 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) => {
|
||||||
|
|
@ -107,11 +74,7 @@ export function createDashboardTileCache(): DashboardTileCache {
|
||||||
});
|
});
|
||||||
|
|
||||||
inFlight.set(key, request);
|
inFlight.set(key, request);
|
||||||
return request.then((response) => ({
|
return request;
|
||||||
cache: "miss" as const,
|
|
||||||
coalesced: false,
|
|
||||||
response,
|
|
||||||
}));
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -162,26 +125,13 @@ export async function loadDashboardTileResponse(
|
||||||
options.disableLiveDatasources ||
|
options.disableLiveDatasources ||
|
||||||
process.env.DISABLE_LIVE_DATASOURCES === "1"
|
process.env.DISABLE_LIVE_DATASOURCES === "1"
|
||||||
) {
|
) {
|
||||||
const tileKey = dashboardTileCacheKey(tile);
|
return {
|
||||||
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,
|
||||||
|
|
@ -189,51 +139,22 @@ export async function loadDashboardTileResponse(
|
||||||
});
|
});
|
||||||
|
|
||||||
if (dashboard.state !== "ready") {
|
if (dashboard.state !== "ready") {
|
||||||
const response = {
|
return {
|
||||||
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();
|
||||||
|
|
||||||
try {
|
return cache.resolve(
|
||||||
const result = await cache.resolve(
|
dashboardTileCacheKey(tile),
|
||||||
tileKey,
|
|
||||||
dashboardTileTtlMs(dashboard.document, tile),
|
dashboardTileTtlMs(dashboard.document, tile),
|
||||||
now,
|
now,
|
||||||
() => resolveDashboardTile(dashboard.document, tile, options),
|
() => 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> {
|
||||||
|
|
@ -263,84 +184,6 @@ 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