Merge pull request 'perf(web): adapt dashboard refresh hydration' (#49)
Merge PR #49 from codex/dashboard-adaptive-hydration
This commit is contained in:
commit
b6de67f081
3 changed files with 502 additions and 16 deletions
|
|
@ -9,11 +9,16 @@ import type {
|
|||
import type { DashboardRuntimeState } from "$lib/server/dashboard";
|
||||
import {
|
||||
attachDashboardRefreshLifecycle,
|
||||
collectVisibleDashboardModelIds,
|
||||
createDashboardRefreshDelay,
|
||||
createDashboardTileSnapshotStore,
|
||||
createDashboardTileBackoff,
|
||||
createDashboardRequestAborter,
|
||||
runDashboardHydrationQueue,
|
||||
runViewportAwareDashboardHydrationQueue,
|
||||
shouldPauseDashboardRefresh,
|
||||
waitForDashboardHydrationIdle,
|
||||
type DashboardIntersectionObserverFactory,
|
||||
type DashboardTileReference,
|
||||
type DashboardTileSnapshotStoreContext,
|
||||
type RestoredDashboardTileSnapshot,
|
||||
} from "$lib/client/dashboard-refresh";
|
||||
|
|
@ -29,12 +34,6 @@ import {
|
|||
type UiDashboardPreview,
|
||||
} from "@dimensionlab/ui";
|
||||
|
||||
type DashboardTileReference =
|
||||
| { kind: "telemetry"; id: string }
|
||||
| { kind: "service"; groupId: string; id: string }
|
||||
| { kind: "module"; id: string }
|
||||
| { kind: "status"; stripId: string; id: string };
|
||||
|
||||
type DashboardTileResponse =
|
||||
| {
|
||||
state: "ready";
|
||||
|
|
@ -55,6 +54,8 @@ type DashboardTileResponse =
|
|||
type DashboardTileHydrationResult = "aborted" | "failed" | "ready";
|
||||
|
||||
const dashboardTileHydrationConcurrency = 6;
|
||||
const dashboardFallbackRefreshIntervalMs = 30_000;
|
||||
const dashboardViewportObservationTimeoutMs = 80;
|
||||
type DashboardTileSnapshotStore = ReturnType<typeof createDashboardTileSnapshotStore>;
|
||||
|
||||
export function AppStateView({
|
||||
|
|
@ -170,8 +171,10 @@ export default function App() {
|
|||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let refreshTimer: number | undefined;
|
||||
let lastRefreshIntervalMs = dashboardFallbackRefreshIntervalMs;
|
||||
let hydrationRun = 0;
|
||||
const requestAborter = createDashboardRequestAborter();
|
||||
const refreshDelay = createDashboardRefreshDelay();
|
||||
const tileBackoff = createDashboardTileBackoff();
|
||||
const tileSnapshotStore = createDashboardTileSnapshotStore(
|
||||
getTileSnapshotStorage(),
|
||||
|
|
@ -185,12 +188,22 @@ export default function App() {
|
|||
}
|
||||
|
||||
function clearRefreshTimer() {
|
||||
if (refreshTimer) {
|
||||
window.clearInterval(refreshTimer);
|
||||
if (refreshTimer !== undefined) {
|
||||
window.clearTimeout(refreshTimer);
|
||||
refreshTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleNextRefresh(baseDelayMs: number) {
|
||||
clearRefreshTimer();
|
||||
if (cancelled || refreshPaused()) return;
|
||||
|
||||
refreshTimer = window.setTimeout(() => {
|
||||
refreshTimer = undefined;
|
||||
void loadDashboard();
|
||||
}, refreshDelay.nextDelayMs(baseDelayMs));
|
||||
}
|
||||
|
||||
function pauseRefreshes() {
|
||||
clearRefreshTimer();
|
||||
requestAborter.abortActiveRequests();
|
||||
|
|
@ -200,6 +213,7 @@ export default function App() {
|
|||
async function loadDashboard() {
|
||||
if (cancelled || refreshPaused()) return;
|
||||
|
||||
clearRefreshTimer();
|
||||
const shellSignal = requestAborter.beginShellRun();
|
||||
|
||||
try {
|
||||
|
|
@ -207,6 +221,7 @@ export default function App() {
|
|||
const nextDashboard = (await response.json()) as DashboardRuntimeState;
|
||||
|
||||
if (cancelled || shellSignal.aborted || refreshPaused()) return;
|
||||
refreshDelay.recordSuccess();
|
||||
const restored = restoreDashboardTileSnapshots(
|
||||
nextDashboard,
|
||||
nextDashboard.state === "ready"
|
||||
|
|
@ -247,22 +262,20 @@ export default function App() {
|
|||
setHydratingItemIds(new Set());
|
||||
}
|
||||
|
||||
clearRefreshTimer();
|
||||
|
||||
const refreshIntervalSeconds =
|
||||
nextDashboard.state === "ready"
|
||||
? nextDashboard.document.metadata.refreshIntervalSeconds
|
||||
: undefined;
|
||||
|
||||
if (refreshIntervalSeconds && !refreshPaused()) {
|
||||
refreshTimer = window.setInterval(
|
||||
() => void loadDashboard(),
|
||||
refreshIntervalSeconds * 1000,
|
||||
);
|
||||
lastRefreshIntervalMs = refreshIntervalSeconds * 1000;
|
||||
scheduleNextRefresh(lastRefreshIntervalMs);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isAbortError(error) && !cancelled) {
|
||||
refreshDelay.recordFailure();
|
||||
console.error("Dashboard refresh failed", error);
|
||||
scheduleNextRefresh(lastRefreshIntervalMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -273,8 +286,23 @@ export default function App() {
|
|||
signal: AbortSignal,
|
||||
snapshotContext: DashboardTileSnapshotStoreContext,
|
||||
) {
|
||||
void runDashboardHydrationQueue({
|
||||
const modelIds = tiles.map(dashboardTileModelId);
|
||||
|
||||
void runViewportAwareDashboardHydrationQueue({
|
||||
collectVisibleModelIds: async () => {
|
||||
await waitForDashboardRenderFrame(signal);
|
||||
return collectVisibleDashboardModelIds({
|
||||
clearTimeout: window.clearTimeout.bind(window),
|
||||
createObserver: getDashboardIntersectionObserverFactory(),
|
||||
documentTarget: document,
|
||||
modelIds,
|
||||
setTimeout: window.setTimeout.bind(window),
|
||||
signal,
|
||||
timeoutMs: dashboardViewportObservationTimeoutMs,
|
||||
});
|
||||
},
|
||||
concurrency: dashboardTileHydrationConcurrency,
|
||||
getModelId: dashboardTileModelId,
|
||||
hydrate: async (tile) => {
|
||||
const key = dashboardTileKey(tile);
|
||||
const result = await hydrateDashboardTile(
|
||||
|
|
@ -292,6 +320,13 @@ export default function App() {
|
|||
},
|
||||
items: tiles,
|
||||
signal,
|
||||
waitForIdle: () =>
|
||||
waitForDashboardHydrationIdle({
|
||||
...getDashboardIdleCallbacks(),
|
||||
clearTimeout: window.clearTimeout.bind(window),
|
||||
setTimeout: window.setTimeout.bind(window),
|
||||
signal,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -406,6 +441,53 @@ function getTileSnapshotStorage(): Storage | undefined {
|
|||
}
|
||||
}
|
||||
|
||||
type DashboardIdleWindow = Window & {
|
||||
cancelIdleCallback?: (handle: number) => void;
|
||||
requestIdleCallback?: (
|
||||
callback: () => void,
|
||||
options?: { timeout?: number },
|
||||
) => number;
|
||||
};
|
||||
|
||||
function getDashboardIdleCallbacks() {
|
||||
const idleWindow = window as DashboardIdleWindow;
|
||||
return {
|
||||
cancelIdleCallback: idleWindow.cancelIdleCallback?.bind(idleWindow),
|
||||
requestIdleCallback: idleWindow.requestIdleCallback?.bind(idleWindow),
|
||||
};
|
||||
}
|
||||
|
||||
function getDashboardIntersectionObserverFactory():
|
||||
| DashboardIntersectionObserverFactory
|
||||
| undefined {
|
||||
if (typeof window.IntersectionObserver === "undefined") return undefined;
|
||||
|
||||
return (callback) =>
|
||||
new window.IntersectionObserver((entries) => {
|
||||
callback(entries);
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForDashboardRenderFrame(signal: AbortSignal): Promise<void> {
|
||||
if (signal.aborted) return;
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
let settled = false;
|
||||
let frame: number | undefined;
|
||||
|
||||
function finish() {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (frame !== undefined) window.cancelAnimationFrame(frame);
|
||||
signal.removeEventListener("abort", finish);
|
||||
resolve();
|
||||
}
|
||||
|
||||
signal.addEventListener("abort", finish, { once: true });
|
||||
frame = window.requestAnimationFrame(finish);
|
||||
});
|
||||
}
|
||||
|
||||
function isAbortError(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
|
|
@ -539,6 +621,11 @@ function dashboardTileKey(tile: DashboardTileReference): string {
|
|||
return `${tile.kind}:${tile.id}`;
|
||||
}
|
||||
|
||||
function dashboardTileModelId(tile: DashboardTileReference): string {
|
||||
if (tile.kind === "status") return `${tile.stripId}:${tile.id}`;
|
||||
return tile.id;
|
||||
}
|
||||
|
||||
function dashboardTileUrl(tile: DashboardTileReference): string {
|
||||
const parts = tile.kind === "status"
|
||||
? ["api", "dashboard", "tile", tile.kind, tile.stripId, tile.id]
|
||||
|
|
|
|||
|
|
@ -1,11 +1,17 @@
|
|||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
attachDashboardRefreshLifecycle,
|
||||
collectVisibleDashboardModelIds,
|
||||
createDashboardRefreshDelay,
|
||||
createDashboardTileSnapshotStore,
|
||||
createDashboardTileBackoff,
|
||||
createDashboardRequestAborter,
|
||||
runDashboardHydrationQueue,
|
||||
runViewportAwareDashboardHydrationQueue,
|
||||
shouldPauseDashboardRefresh,
|
||||
splitDashboardHydrationItemsByVisibility,
|
||||
waitForDashboardHydrationIdle,
|
||||
type DashboardIntersectionEntry,
|
||||
} from "./dashboard-refresh";
|
||||
|
||||
describe("dashboard refresh lifecycle", () => {
|
||||
|
|
@ -113,6 +119,134 @@ describe("dashboard refresh lifecycle", () => {
|
|||
expect(maxActive).toBe(2);
|
||||
});
|
||||
|
||||
test("hydrates visible items before deferred items and waits for idle", async () => {
|
||||
const calls: string[] = [];
|
||||
const idleReleases: Array<() => void> = [];
|
||||
|
||||
const hydration = runViewportAwareDashboardHydrationQueue({
|
||||
collectVisibleModelIds: async () => new Set(["b"]),
|
||||
concurrency: 1,
|
||||
getModelId: (item) => item,
|
||||
hydrate: async (item) => {
|
||||
calls.push(item);
|
||||
},
|
||||
items: ["a", "b", "c"],
|
||||
signal: new AbortController().signal,
|
||||
waitForIdle: async () => {
|
||||
calls.push("idle");
|
||||
await new Promise<void>((resolve) => idleReleases.push(resolve));
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => calls.includes("idle"));
|
||||
expect(calls).toEqual(["b", "idle"]);
|
||||
|
||||
idleReleases.shift()?.();
|
||||
await hydration;
|
||||
expect(calls).toEqual(["b", "idle", "a", "c"]);
|
||||
});
|
||||
|
||||
test("splits visible hydration items while preserving document order", () => {
|
||||
expect(splitDashboardHydrationItemsByVisibility({
|
||||
getModelId: (item) => item.id,
|
||||
items: [{ id: "status" }, { id: "telemetry" }, { id: "service" }],
|
||||
visibleModelIds: new Set(["service", "status"]),
|
||||
})).toEqual({
|
||||
visible: [{ id: "status" }, { id: "service" }],
|
||||
deferred: [{ id: "telemetry" }],
|
||||
});
|
||||
});
|
||||
|
||||
test("collects visible data-model-id elements with IntersectionObserver", async () => {
|
||||
const elements = [
|
||||
modelElement("status"),
|
||||
modelElement("telemetry"),
|
||||
modelElement("unrelated"),
|
||||
];
|
||||
let callback:
|
||||
| ((entries: DashboardIntersectionEntry[]) => void)
|
||||
| undefined;
|
||||
let finishObservation: (() => void) | undefined;
|
||||
let disconnected = false;
|
||||
const observed: string[] = [];
|
||||
|
||||
const visible = collectVisibleDashboardModelIds({
|
||||
createObserver: (observerCallback) => {
|
||||
callback = observerCallback;
|
||||
return {
|
||||
disconnect() {
|
||||
disconnected = true;
|
||||
},
|
||||
observe(element) {
|
||||
observed.push(element.getAttribute("data-model-id") || "");
|
||||
},
|
||||
};
|
||||
},
|
||||
documentTarget: {
|
||||
querySelectorAll: () => elements,
|
||||
},
|
||||
modelIds: ["status", "telemetry"],
|
||||
setTimeout: (handler) => {
|
||||
finishObservation = handler;
|
||||
return 1 as unknown as ReturnType<typeof setTimeout>;
|
||||
},
|
||||
clearTimeout: () => undefined,
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
|
||||
callback?.([
|
||||
{
|
||||
isIntersecting: false,
|
||||
intersectionRatio: 0,
|
||||
target: elements[0],
|
||||
},
|
||||
{
|
||||
isIntersecting: true,
|
||||
target: elements[1],
|
||||
},
|
||||
]);
|
||||
finishObservation?.();
|
||||
|
||||
expect(await visible).toEqual(new Set(["telemetry"]));
|
||||
expect(observed).toEqual(["status", "telemetry"]);
|
||||
expect(disconnected).toBe(true);
|
||||
});
|
||||
|
||||
test("waits for requestIdleCallback when available", async () => {
|
||||
let idleCallback: (() => void) | undefined;
|
||||
let cancelledIdle: number | undefined;
|
||||
const wait = waitForDashboardHydrationIdle({
|
||||
cancelIdleCallback: (handle) => {
|
||||
cancelledIdle = handle;
|
||||
},
|
||||
requestIdleCallback: (callback) => {
|
||||
idleCallback = callback;
|
||||
return 7;
|
||||
},
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
|
||||
expect(cancelledIdle).toBeUndefined();
|
||||
idleCallback?.();
|
||||
await wait;
|
||||
expect(cancelledIdle).toBe(7);
|
||||
});
|
||||
|
||||
test("jitters refresh delays and slows repeated failures", () => {
|
||||
const delay = createDashboardRefreshDelay({
|
||||
jitterRatio: 0.1,
|
||||
random: () => 1,
|
||||
});
|
||||
|
||||
expect(delay.nextDelayMs(1_000)).toBe(1_100);
|
||||
delay.recordFailure();
|
||||
expect(delay.nextDelayMs(1_000)).toBe(2_200);
|
||||
delay.recordFailure();
|
||||
expect(delay.nextDelayMs(1_000)).toBe(4_400);
|
||||
delay.recordSuccess();
|
||||
expect(delay.nextDelayMs(1_000)).toBe(1_100);
|
||||
});
|
||||
|
||||
test("backs off failed tile keys and resets after success", () => {
|
||||
const backoff = createDashboardTileBackoff();
|
||||
|
||||
|
|
@ -299,3 +433,11 @@ function createMemoryStorage(): Storage {
|
|||
},
|
||||
};
|
||||
}
|
||||
|
||||
function modelElement(modelId: string) {
|
||||
return {
|
||||
getAttribute(name: string) {
|
||||
return name === "data-model-id" ? modelId : null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -151,6 +151,263 @@ export async function runDashboardHydrationQueue<TItem>(
|
|||
);
|
||||
}
|
||||
|
||||
export interface DashboardViewportHydrationQueueOptions<TItem> {
|
||||
collectVisibleModelIds: () => Promise<ReadonlySet<string>>;
|
||||
concurrency: number;
|
||||
getModelId: (item: TItem) => string;
|
||||
hydrate: (item: TItem) => Promise<void> | void;
|
||||
items: TItem[];
|
||||
signal: AbortSignal;
|
||||
waitForIdle: () => Promise<void>;
|
||||
}
|
||||
|
||||
export async function runViewportAwareDashboardHydrationQueue<TItem>(
|
||||
options: DashboardViewportHydrationQueueOptions<TItem>,
|
||||
): Promise<void> {
|
||||
const visibleModelIds = await options.collectVisibleModelIds();
|
||||
if (options.signal.aborted) return;
|
||||
|
||||
const { visible, deferred } = splitDashboardHydrationItemsByVisibility({
|
||||
getModelId: options.getModelId,
|
||||
items: options.items,
|
||||
visibleModelIds,
|
||||
});
|
||||
|
||||
await runDashboardHydrationQueue({
|
||||
concurrency: options.concurrency,
|
||||
hydrate: options.hydrate,
|
||||
items: visible,
|
||||
signal: options.signal,
|
||||
});
|
||||
|
||||
if (!deferred.length || options.signal.aborted) return;
|
||||
|
||||
await options.waitForIdle();
|
||||
if (options.signal.aborted) return;
|
||||
|
||||
await runDashboardHydrationQueue({
|
||||
concurrency: options.concurrency,
|
||||
hydrate: options.hydrate,
|
||||
items: deferred,
|
||||
signal: options.signal,
|
||||
});
|
||||
}
|
||||
|
||||
export function splitDashboardHydrationItemsByVisibility<TItem>(options: {
|
||||
getModelId: (item: TItem) => string;
|
||||
items: TItem[];
|
||||
visibleModelIds: ReadonlySet<string>;
|
||||
}): {
|
||||
deferred: TItem[];
|
||||
visible: TItem[];
|
||||
} {
|
||||
const visible: TItem[] = [];
|
||||
const deferred: TItem[] = [];
|
||||
|
||||
for (const item of options.items) {
|
||||
if (options.visibleModelIds.has(options.getModelId(item))) {
|
||||
visible.push(item);
|
||||
} else {
|
||||
deferred.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return { visible, deferred };
|
||||
}
|
||||
|
||||
export interface DashboardModelElement {
|
||||
getAttribute(name: string): string | null;
|
||||
}
|
||||
|
||||
export interface DashboardViewportElementSource {
|
||||
querySelectorAll(selector: string): ArrayLike<DashboardModelElement>;
|
||||
}
|
||||
|
||||
export interface DashboardIntersectionEntry {
|
||||
intersectionRatio?: number;
|
||||
isIntersecting: boolean;
|
||||
target: DashboardModelElement;
|
||||
}
|
||||
|
||||
export interface DashboardIntersectionObserver {
|
||||
disconnect(): void;
|
||||
observe(element: DashboardModelElement): void;
|
||||
}
|
||||
|
||||
export type DashboardIntersectionObserverFactory = (
|
||||
callback: (entries: DashboardIntersectionEntry[]) => void,
|
||||
) => DashboardIntersectionObserver;
|
||||
|
||||
type DashboardTimerHandle = ReturnType<typeof setTimeout>;
|
||||
|
||||
export interface DashboardVisibleModelIdCollectorOptions {
|
||||
clearTimeout?: (handle: DashboardTimerHandle) => void;
|
||||
createObserver?: DashboardIntersectionObserverFactory;
|
||||
documentTarget: DashboardViewportElementSource;
|
||||
modelIds: Iterable<string>;
|
||||
setTimeout?: (callback: () => void, timeoutMs: number) => DashboardTimerHandle;
|
||||
signal: AbortSignal;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export async function collectVisibleDashboardModelIds(
|
||||
options: DashboardVisibleModelIdCollectorOptions,
|
||||
): Promise<Set<string>> {
|
||||
const targetIds = new Set(options.modelIds);
|
||||
if (!targetIds.size || options.signal.aborted) return new Set();
|
||||
|
||||
const elements = Array.from(
|
||||
options.documentTarget.querySelectorAll("[data-model-id]"),
|
||||
).filter((element) => {
|
||||
const modelId = element.getAttribute("data-model-id");
|
||||
return modelId ? targetIds.has(modelId) : false;
|
||||
});
|
||||
|
||||
if (!elements.length) return new Set();
|
||||
if (!options.createObserver) return targetIds;
|
||||
const createObserver = options.createObserver;
|
||||
|
||||
const setTimer =
|
||||
options.setTimeout ||
|
||||
((callback: () => void, timeoutMs: number) =>
|
||||
globalThis.setTimeout(callback, timeoutMs));
|
||||
const clearTimer =
|
||||
options.clearTimeout ||
|
||||
((handle: DashboardTimerHandle) => globalThis.clearTimeout(handle));
|
||||
const timeoutMs = options.timeoutMs ?? 80;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const visibleModelIds = new Set<string>();
|
||||
let settled = false;
|
||||
let timeoutHandle: DashboardTimerHandle | undefined;
|
||||
let observer: DashboardIntersectionObserver | undefined;
|
||||
|
||||
function cleanup() {
|
||||
if (timeoutHandle !== undefined) {
|
||||
clearTimer(timeoutHandle);
|
||||
}
|
||||
observer?.disconnect();
|
||||
options.signal.removeEventListener("abort", finish);
|
||||
}
|
||||
|
||||
function finish() {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
resolve(visibleModelIds);
|
||||
}
|
||||
|
||||
observer = createObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const modelId = entry.target.getAttribute("data-model-id");
|
||||
if (
|
||||
modelId &&
|
||||
targetIds.has(modelId) &&
|
||||
(entry.isIntersecting || (entry.intersectionRatio ?? 0) > 0)
|
||||
) {
|
||||
visibleModelIds.add(modelId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for (const element of elements) {
|
||||
observer.observe(element);
|
||||
}
|
||||
|
||||
options.signal.addEventListener("abort", finish, { once: true });
|
||||
timeoutHandle = setTimer(finish, timeoutMs);
|
||||
});
|
||||
}
|
||||
|
||||
export interface DashboardHydrationIdleOptions {
|
||||
cancelIdleCallback?: (handle: number) => void;
|
||||
clearTimeout?: (handle: DashboardTimerHandle) => void;
|
||||
requestIdleCallback?: (
|
||||
callback: () => void,
|
||||
options?: { timeout?: number },
|
||||
) => number;
|
||||
setTimeout?: (callback: () => void, timeoutMs: number) => DashboardTimerHandle;
|
||||
signal: AbortSignal;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export async function waitForDashboardHydrationIdle(
|
||||
options: DashboardHydrationIdleOptions,
|
||||
): Promise<void> {
|
||||
if (options.signal.aborted) return;
|
||||
|
||||
const setTimer =
|
||||
options.setTimeout ||
|
||||
((callback: () => void, timeoutMs: number) =>
|
||||
globalThis.setTimeout(callback, timeoutMs));
|
||||
const clearTimer =
|
||||
options.clearTimeout ||
|
||||
((handle: DashboardTimerHandle) => globalThis.clearTimeout(handle));
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
let settled = false;
|
||||
let idleHandle: number | undefined;
|
||||
let timeoutHandle: DashboardTimerHandle | undefined;
|
||||
|
||||
function cleanup() {
|
||||
if (idleHandle !== undefined) {
|
||||
options.cancelIdleCallback?.(idleHandle);
|
||||
}
|
||||
if (timeoutHandle !== undefined) {
|
||||
clearTimer(timeoutHandle);
|
||||
}
|
||||
options.signal.removeEventListener("abort", finish);
|
||||
}
|
||||
|
||||
function finish() {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
resolve();
|
||||
}
|
||||
|
||||
options.signal.addEventListener("abort", finish, { once: true });
|
||||
if (options.requestIdleCallback) {
|
||||
idleHandle = options.requestIdleCallback(finish, {
|
||||
timeout: options.timeoutMs ?? 1_000,
|
||||
});
|
||||
} else {
|
||||
timeoutHandle = setTimer(finish, 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export interface DashboardRefreshDelayOptions {
|
||||
failureMultiplierLimit?: number;
|
||||
jitterRatio?: number;
|
||||
random?: () => number;
|
||||
}
|
||||
|
||||
export function createDashboardRefreshDelay(
|
||||
options: DashboardRefreshDelayOptions = {},
|
||||
) {
|
||||
const random = options.random || Math.random;
|
||||
const jitterRatio = options.jitterRatio ?? 0.1;
|
||||
const failureMultiplierLimit = options.failureMultiplierLimit ?? 8;
|
||||
let consecutiveFailures = 0;
|
||||
|
||||
return {
|
||||
nextDelayMs(baseDelayMs: number): number {
|
||||
const failureMultiplier = consecutiveFailures
|
||||
? Math.min(2 ** consecutiveFailures, failureMultiplierLimit)
|
||||
: 1;
|
||||
const jitterFactor = 1 + ((random() * 2) - 1) * jitterRatio;
|
||||
return Math.max(0, Math.round(baseDelayMs * failureMultiplier * jitterFactor));
|
||||
},
|
||||
recordFailure(): void {
|
||||
consecutiveFailures += 1;
|
||||
},
|
||||
recordSuccess(): void {
|
||||
consecutiveFailures = 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const dashboardTileBackoffDelaysMs = [15_000, 30_000, 60_000, 120_000];
|
||||
|
||||
export function createDashboardTileBackoff() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue