perf: optimize dashboard loading and builds

This commit is contained in:
Thomas Camlong committed 2026-08-02 15:58:16 +02:00
1 parent d90e762e4b
commit de801f3f00
135 files changed
+8521 -1718

No files matched your search

@@ -14,6 +14,7 @@ interface Options<TData, TKind extends IntegrationKind, TInput extends Record<st
requestAsync: (integration: IntegrationOfKind<TKind>, input: TInput) => Promise<TData>;
cacheTtlMs?: number;
fallbackToStaleOnError?: boolean;
staleIfErrorTtlMs?: number;
}
export const createIntegrationRequestHandler = <
@@ -23,24 +24,18 @@ export const createIntegrationRequestHandler = <
>(
options: Options<TData, TKind, TInput>,
) => {
const integrationMap = new Map<string, IntegrationOfKind<TKind>>();
const inner = createRequestHandler<TData, { integrationId: string; options: TInput }>({
requestAsync: async (input) => {
const integration = integrationMap.get(input.integrationId);
if (!integration) {
throw new Error(`Integration ${input.integrationId} not found in cache`);
}
return options.requestAsync(integration, input.options);
},
const inner = createRequestHandler<TData, { integration: IntegrationOfKind<TKind>; options: TInput }>({
requestAsync: async ({ integration, options: itemOptions }) => options.requestAsync(integration, itemOptions),
getCacheKey: ({ integration, options: itemOptions }) =>
JSON.stringify({ integrationId: integration.id, itemOptions }),
cacheTtlMs: options.cacheTtlMs,
fallbackToStaleOnError: options.fallbackToStaleOnError,
staleIfErrorTtlMs: options.staleIfErrorTtlMs,
});
return {
invalidateCache: inner.invalidateCache,
handler: (integration: IntegrationOfKind<TKind>, itemOptions: TInput) => {
integrationMap.set(integration.id, integration);
return inner.handler({ integrationId: integration.id, options: itemOptions });
},
handler: (integration: IntegrationOfKind<TKind>, itemOptions: TInput) =>
inner.handler({ integration, options: itemOptions }),
};
};
@@ -0,0 +1,224 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Integration, IntegrationSecret } from "@homarr/db/schema";
import { createIntegrationRequestHandler } from "./integration-request-handler";
import { createRequestHandler } from "./request-handler";
import { createWidgetRequestHandler } from "./widget-request-handler";
const deferred = <T>() => {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
};
const createIntegration = (secret: string) =>
({
id: "integration-id",
kind: "sonarr",
decryptedSecrets: [{ kind: "apiKey", value: secret }],
}) as Omit<Integration, "kind"> & {
kind: "sonarr";
decryptedSecrets: Pick<IntegrationSecret, "kind" | "value">[];
externalUrl: string | null;
};
afterEach(() => {
vi.useRealTimers();
});
describe("createRequestHandler", () => {
it("deduplicates in-flight work and caches completed data", async () => {
let calls = 0;
const request = deferred<string>();
const handler = createRequestHandler({
requestAsync: async () => {
calls += 1;
return await request.promise;
},
});
const first = handler.handler({ id: "same" }).getDataAsync();
const second = handler.handler({ id: "same" }).getDataAsync();
expect(calls).toBe(1);
request.resolve("value");
await expect(Promise.all([first, second])).resolves.toMatchObject([{ data: "value" }, { data: "value" }]);
await expect(handler.handler({ id: "same" }).getDataAsync()).resolves.toMatchObject({ data: "value" });
expect(calls).toBe(1);
});
it("keeps in-flight deduplication but retains no payload when ttl is zero", async () => {
let calls = 0;
const request = deferred<{ call: number }>();
const handler = createRequestHandler({
cacheTtlMs: 0,
requestAsync: async () => {
calls += 1;
if (calls === 1) return await request.promise;
return { call: calls };
},
});
const first = handler.handler({ id: "same" }).getDataAsync();
const second = handler.handler({ id: "same" }).getDataAsync();
expect(calls).toBe(1);
request.resolve({ call: 1 });
await expect(Promise.all([first, second])).resolves.toMatchObject([{ data: { call: 1 } }, { data: { call: 1 } }]);
await expect(handler.handler({ id: "same" }).getDataAsync()).resolves.toMatchObject({ data: { call: 2 } });
expect(calls).toBe(2);
});
it("releases positive-ttl entries after expiry without another request", async () => {
vi.useFakeTimers();
let calls = 0;
const handler = createRequestHandler({
cacheTtlMs: 25,
requestAsync: async () => ({ call: ++calls }),
});
await expect(handler.handler({ id: "same" }).getDataAsync()).resolves.toMatchObject({ data: { call: 1 } });
await vi.advanceTimersByTimeAsync(25);
await expect(handler.handler({ id: "same" }).getDataAsync()).resolves.toMatchObject({ data: { call: 2 } });
expect(calls).toBe(2);
});
it("cancels scheduled expiry work when the cache is invalidated", async () => {
vi.useFakeTimers();
const handler = createRequestHandler({ cacheTtlMs: 25, requestAsync: async () => "value" });
await handler.handler({ id: "same" }).getDataAsync();
expect(vi.getTimerCount()).toBe(1);
handler.invalidateCache();
expect(vi.getTimerCount()).toBe(0);
});
it("bounds stale-on-error fallback and releases it while idle", async () => {
vi.useFakeTimers();
let calls = 0;
const handler = createRequestHandler({
cacheTtlMs: 25,
fallbackToStaleOnError: true,
staleIfErrorTtlMs: 50,
requestAsync: async () => {
calls += 1;
if (calls === 1) return "last-known-good";
throw new Error("upstream unavailable");
},
});
await expect(handler.handler({ id: "same" }).getDataAsync()).resolves.toMatchObject({
data: "last-known-good",
});
await vi.advanceTimersByTimeAsync(25);
await expect(handler.handler({ id: "same" }).getDataAsync()).resolves.toMatchObject({
data: "last-known-good",
});
await vi.advanceTimersByTimeAsync(51);
await expect(handler.handler({ id: "same" }).getDataAsync()).rejects.toThrow("upstream unavailable");
expect(calls).toBe(3);
});
it("reschedules staggered stale entries by their retirement deadline without spinning", async () => {
vi.useFakeTimers();
const handler = createRequestHandler({
cacheTtlMs: 10,
fallbackToStaleOnError: true,
staleIfErrorTtlMs: 100,
requestAsync: async ({ id }: { id: string }) => id,
});
await handler.handler({ id: "first" }).getDataAsync();
await vi.advanceTimersByTimeAsync(20);
await handler.handler({ id: "second" }).getDataAsync();
expect(vi.getTimerCount()).toBe(1);
await vi.advanceTimersByTimeAsync(90);
expect(vi.getTimerCount()).toBe(1);
await vi.advanceTimersByTimeAsync(20);
expect(vi.getTimerCount()).toBe(0);
});
it("evicts an empty-string cache key at the size limit", async () => {
let calls = 0;
const handler = createRequestHandler({
cacheTtlMs: 60_000,
getCacheKey: ({ id }: { id: string }) => id,
requestAsync: async () => ++calls,
});
await handler.handler({ id: "" }).getDataAsync();
for (let index = 0; index < 1000; index += 1) {
await handler.handler({ id: String(index) }).getDataAsync();
}
await handler.handler({ id: "" }).getDataAsync();
expect(calls).toBe(1002);
});
it("does not let a pre-invalidation request repopulate or clear newer work", async () => {
const staleRequest = deferred<string>();
const freshRequest = deferred<string>();
const requests = [staleRequest, freshRequest];
let calls = 0;
const handler = createRequestHandler({
requestAsync: async () => {
const request = requests[calls++];
if (!request) throw new Error("Unexpected request");
return await request.promise;
},
});
const stale = handler.handler({ id: "same" }).getDataAsync();
handler.invalidateCache();
const fresh = handler.handler({ id: "same" }).getDataAsync();
staleRequest.resolve("stale");
await expect(stale).resolves.toMatchObject({ data: "stale" });
const joinsFresh = handler.handler({ id: "same" }).getDataAsync();
expect(calls).toBe(2);
freshRequest.resolve("fresh");
await expect(Promise.all([fresh, joinsFresh])).resolves.toMatchObject([{ data: "fresh" }, { data: "fresh" }]);
await expect(handler.handler({ id: "same" }).getDataAsync()).resolves.toMatchObject({ data: "fresh" });
expect(calls).toBe(2);
});
});
describe("createIntegrationRequestHandler", () => {
it("keys requests by integration id and options without retaining secret values", async () => {
let calls = 0;
const request = deferred<string>();
const handler = createIntegrationRequestHandler<string, "sonarr", { page: number }>({
requestAsync: async () => {
calls += 1;
return await request.promise;
},
});
const first = handler.handler(createIntegration("first-secret"), { page: 1 }).getDataAsync();
const second = handler.handler(createIntegration("rotated-secret"), { page: 1 }).getDataAsync();
expect(calls).toBe(1);
request.resolve("value");
await expect(Promise.all([first, second])).resolves.toMatchObject([{ data: "value" }, { data: "value" }]);
});
});
describe("createWidgetRequestHandler", () => {
it("shares its cache across calls while keeping distinct inputs separate", async () => {
let calls = 0;
const handler = createWidgetRequestHandler({ requestAsync: async ({ id }: { id: string }) => `${id}-${++calls}` });
await expect(handler.handler({ id: "one" }).getDataAsync()).resolves.toMatchObject({ data: "one-1" });
await expect(handler.handler({ id: "one" }).getDataAsync()).resolves.toMatchObject({ data: "one-1" });
await expect(handler.handler({ id: "two" }).getDataAsync()).resolves.toMatchObject({ data: "two-2" });
expect(calls).toBe(2);
});
});
@@ -1,18 +1,21 @@
interface Options<TData, TInput extends Record<string, unknown>> {
requestAsync: (input: TInput) => Promise<TData>;
getCacheKey?: (input: TInput) => string;
cacheTtlMs?: number;
fallbackToStaleOnError?: boolean;
staleIfErrorTtlMs?: number;
}
type CacheEntry<TData> = { data: TData; timestamp: Date; expiresAt: number };
type CacheEntry<TData> = { data: TData; timestamp: Date; expiresAt: number; staleUntil: number };
const MAX_CACHE_SIZE = 1000;
const DEFAULT_TTL_MS = 10_000;
const DEFAULT_STALE_IF_ERROR_TTL_MS = 5 * 60_000;
const evictExpired = <TData>(cache: Map<string, CacheEntry<TData>>) => {
const now = Date.now();
for (const [key, entry] of cache) {
if (now >= entry.expiresAt) cache.delete(key);
if (now >= entry.staleUntil) cache.delete(key);
}
};
@@ -21,44 +24,98 @@ export const createRequestHandler = <TData, TInput extends Record<string, unknow
) => {
const cache = new Map<string, CacheEntry<TData>>();
const inflight = new Map<string, Promise<CacheEntry<TData>>>();
let expiryTimer: ReturnType<typeof setTimeout> | undefined;
let expiryTimerAt: number | undefined;
let generation = 0;
const scheduleExpiry = (candidateExpiryAt?: number) => {
if (candidateExpiryAt !== undefined && expiryTimerAt !== undefined && expiryTimerAt <= candidateExpiryAt) return;
if (expiryTimer) clearTimeout(expiryTimer);
expiryTimer = undefined;
expiryTimerAt = undefined;
if (cache.size === 0) return;
const nextExpiryAt = candidateExpiryAt ?? Math.min(...[...cache.values()].map(({ staleUntil }) => staleUntil));
expiryTimerAt = nextExpiryAt;
expiryTimer = setTimeout(
() => {
expiryTimer = undefined;
expiryTimerAt = undefined;
evictExpired(cache);
scheduleExpiry();
},
Math.max(0, nextExpiryAt - Date.now()),
);
expiryTimer.unref?.();
};
return {
invalidateCache: () => {
generation += 1;
cache.clear();
inflight.clear();
if (expiryTimer) clearTimeout(expiryTimer);
expiryTimer = undefined;
expiryTimerAt = undefined;
},
handler: (input: TInput) => ({
async getDataAsync(): Promise<{ data: TData; timestamp: Date }> {
const ttl = options.cacheTtlMs ?? DEFAULT_TTL_MS;
const key = JSON.stringify(input);
const key = options.getCacheKey?.(input) ?? JSON.stringify(input);
const cached = cache.get(key);
if (cached && Date.now() < cached.expiresAt) {
let cached = cache.get(key);
const now = Date.now();
if (cached && now < cached.expiresAt) {
return { data: cached.data, timestamp: cached.timestamp };
}
if (cached && now >= cached.staleUntil) {
cache.delete(key);
scheduleExpiry();
cached = undefined;
}
const existing = inflight.get(key);
if (existing) return existing;
const promise = options
const requestGeneration = generation;
let promise: Promise<CacheEntry<TData>>;
promise = options
.requestAsync(input)
.then((data) => {
if (cache.size >= MAX_CACHE_SIZE) evictExpired(cache);
if (cache.size >= MAX_CACHE_SIZE) {
const oldest = cache.keys().next().value;
if (oldest) cache.delete(oldest);
const expiresAt = Date.now() + ttl;
const staleIfErrorTtlMs = options.fallbackToStaleOnError
? Math.max(0, options.staleIfErrorTtlMs ?? DEFAULT_STALE_IF_ERROR_TTL_MS)
: 0;
const entry: CacheEntry<TData> = {
data,
timestamp: new Date(),
expiresAt,
staleUntil: expiresAt + staleIfErrorTtlMs,
};
if (ttl > 0 && generation === requestGeneration) {
evictExpired(cache);
if (cache.size >= MAX_CACHE_SIZE) {
const oldest = cache.keys().next().value;
if (oldest !== undefined) cache.delete(oldest);
}
cache.set(key, entry);
scheduleExpiry(entry.staleUntil);
}
const entry: CacheEntry<TData> = { data, timestamp: new Date(), expiresAt: Date.now() + ttl };
cache.set(key, entry);
inflight.delete(key);
return entry;
})
.catch((err) => {
inflight.delete(key);
if (options.fallbackToStaleOnError && cached) {
if (
options.fallbackToStaleOnError &&
cached &&
Date.now() < cached.staleUntil &&
generation === requestGeneration
) {
return cached;
}
throw err;
})
.finally(() => {
if (inflight.get(key) === promise) inflight.delete(key);
});
inflight.set(key, promise);
@@ -6,9 +6,7 @@ interface Options<TData, TInput extends Record<string, unknown>> {
export const createWidgetRequestHandler = <TData, TInput extends Record<string, unknown>>(
options: Options<TData, TInput>,
) => ({
handler: (widgetOptions: TInput) =>
createRequestHandler<TData, TInput>({
requestAsync: options.requestAsync,
}).handler(widgetOptions),
});
) => {
const inner = createRequestHandler<TData, TInput>({ requestAsync: options.requestAsync });
return inner;
};
@@ -20,13 +20,28 @@ const mocks = vi.hoisted(() => {
values,
locks,
removeFreshResult() {
values.delete("update-checker:fresh:v1");
for (const key of values.keys()) {
if (key.startsWith("update-checker:fresh:")) values.delete(key);
}
},
};
});
vi.mock("@homarr/common/env", () => ({ env: mocks.env }));
vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: vi.fn(),
}));
vi.mock("@homarr/core/infrastructure/logs", () => ({
createLogger: () => ({
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
}),
}));
vi.mock("@homarr/redis", () => ({
createGetSetChannel: (name: string) => ({
getAsync: async () => {
@@ -181,6 +196,20 @@ describe("persisted update checker cache", () => {
expect(mocks.listReleases).toHaveBeenCalledTimes(1);
});
test("ignores update results created for an older application version", async () => {
mocks.values.set("update-checker:fresh:v1", {
value: { availableUpdates: [], attemptedAt: 1, checkedAt: 1 },
expiresAt: null,
});
mocks.listReleases.mockResolvedValue(successfulResponse);
const handler = await loadHandlerAsync();
const result = await handler.getDataAsync();
expect(result.data.availableUpdates).toHaveLength(1);
expect(mocks.listReleases).toHaveBeenCalledOnce();
});
test("serves stale data and retries only after 24 hours following a provider failure", async () => {
let now = 1_000;
vi.spyOn(Date, "now").mockImplementation(() => now);
+10 -3
View File
@@ -22,9 +22,14 @@ type UpdateCheckCacheEntry = {
checkedAt: number | null;
};
const freshUpdateCheckChannel = createGetSetChannel<UpdateCheckCacheEntry>("update-checker:fresh:v1");
const staleUpdateCheckChannel = createGetSetChannel<UpdateCheckCacheEntry>("update-checker:stale:v1");
const updateCheckLock = createLockChannel("update-checker:lock:v1");
const updateCheckCacheVersion = `v2:${packageJson.version}`;
const freshUpdateCheckChannel = createGetSetChannel<UpdateCheckCacheEntry>(
`update-checker:fresh:${updateCheckCacheVersion}`,
);
const staleUpdateCheckChannel = createGetSetChannel<UpdateCheckCacheEntry>(
`update-checker:stale:${updateCheckCacheVersion}`,
);
const updateCheckLock = createLockChannel(`update-checker:lock:${updateCheckCacheVersion}`);
const waitAsync = async (durationMs: number) =>
await new Promise<void>((resolve) => {
@@ -53,8 +58,10 @@ const getCachedAvailableUpdatesAsync = async (): Promise<UpdateCheckCacheEntry>
const cached = await freshUpdateCheckChannel.getAsync();
if (cached) return cached;
const staleBeforeRefresh = await staleUpdateCheckChannel.getAsync();
const lockToken = await updateCheckLock.acquireAsync(updateCheckLockTtlSeconds);
if (!lockToken) {
if (staleBeforeRefresh) return staleBeforeRefresh;
const concurrentResult = await waitForConcurrentUpdateCheckAsync();
if (concurrentResult) return concurrentResult;
throw new Error("Timed out waiting for the update check");