merge: stack widget modernization on dnd kit
This commit is contained in:
940 files changed
+104475
-33649
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;
|
||||
};
|
||||
@@ -0,0 +1,244 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
type StoredValue = {
|
||||
expiresAt: number | null;
|
||||
value: unknown;
|
||||
};
|
||||
|
||||
const values = new Map<string, StoredValue>();
|
||||
const locks = new Set<string>();
|
||||
const listReleases = vi.fn();
|
||||
const redisOperation = vi.fn();
|
||||
|
||||
return {
|
||||
env: { NO_EXTERNAL_CONNECTION: false },
|
||||
listReleases,
|
||||
redisOperation,
|
||||
values,
|
||||
locks,
|
||||
removeFreshResult() {
|
||||
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 () => {
|
||||
mocks.redisOperation();
|
||||
const stored = mocks.values.get(name);
|
||||
if (!stored) return null;
|
||||
if (stored.expiresAt !== null && Date.now() >= stored.expiresAt) {
|
||||
mocks.values.delete(name);
|
||||
return null;
|
||||
}
|
||||
return stored.value;
|
||||
},
|
||||
setAsync: async (value: unknown, options?: { ttlSeconds?: number }) => {
|
||||
mocks.redisOperation();
|
||||
mocks.values.set(name, {
|
||||
value,
|
||||
expiresAt: options?.ttlSeconds ? Date.now() + options.ttlSeconds * 1_000 : null,
|
||||
});
|
||||
},
|
||||
removeAsync: async () => {
|
||||
mocks.redisOperation();
|
||||
mocks.values.delete(name);
|
||||
},
|
||||
}),
|
||||
createLockChannel: (name: string) => ({
|
||||
acquireAsync: async () => {
|
||||
mocks.redisOperation();
|
||||
if (mocks.locks.has(name)) return null;
|
||||
mocks.locks.add(name);
|
||||
return "test-lock-token";
|
||||
},
|
||||
releaseAsync: async () => {
|
||||
mocks.redisOperation();
|
||||
mocks.locks.delete(name);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("octokit", () => ({
|
||||
Octokit: class {
|
||||
rest = {
|
||||
repos: {
|
||||
listReleases: mocks.listReleases,
|
||||
},
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
const successfulResponse = {
|
||||
data: [
|
||||
{
|
||||
body_html: undefined,
|
||||
html_url: "https://github.com/homarr-labs/homarr/releases/tag/v99.0.0",
|
||||
name: "v99.0.0",
|
||||
prerelease: false,
|
||||
tag_name: "v99.0.0",
|
||||
},
|
||||
],
|
||||
headers: {},
|
||||
status: 200,
|
||||
url: "",
|
||||
};
|
||||
|
||||
const loadHandlerAsync = async () => {
|
||||
const { updateCheckerRequestHandler } = await import("../update-checker");
|
||||
return updateCheckerRequestHandler.handler({});
|
||||
};
|
||||
|
||||
describe("persisted update checker cache", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.restoreAllMocks();
|
||||
mocks.env.NO_EXTERNAL_CONNECTION = false;
|
||||
mocks.listReleases.mockReset();
|
||||
mocks.redisOperation.mockReset();
|
||||
mocks.values.clear();
|
||||
mocks.locks.clear();
|
||||
});
|
||||
|
||||
test("checks GitHub once for repeated page loads inside 24 hours", async () => {
|
||||
mocks.listReleases.mockResolvedValue(successfulResponse);
|
||||
const handler = await loadHandlerAsync();
|
||||
|
||||
const first = await handler.getDataAsync();
|
||||
const second = await handler.getDataAsync();
|
||||
|
||||
expect(first.data).toEqual(second.data);
|
||||
expect(mocks.listReleases).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("deduplicates concurrent checks", async () => {
|
||||
mocks.listReleases.mockImplementation(async () => {
|
||||
await Promise.resolve();
|
||||
return successfulResponse;
|
||||
});
|
||||
const handler = await loadHandlerAsync();
|
||||
|
||||
const results = await Promise.all(Array.from({ length: 10 }, async () => await handler.getDataAsync()));
|
||||
|
||||
expect(results.every(({ data }) => data.availableUpdates.length === 1)).toBe(true);
|
||||
expect(mocks.listReleases).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("deduplicates concurrent checks across simulated processes", async () => {
|
||||
let completeGitHubRequest: (() => void) | undefined;
|
||||
mocks.listReleases.mockImplementation(
|
||||
async () =>
|
||||
await new Promise<typeof successfulResponse>((resolve) => {
|
||||
completeGitHubRequest = () => resolve(successfulResponse);
|
||||
}),
|
||||
);
|
||||
const firstHandler = await loadHandlerAsync();
|
||||
vi.resetModules();
|
||||
const secondHandler = await loadHandlerAsync();
|
||||
|
||||
const firstRequest = firstHandler.getDataAsync();
|
||||
await vi.waitFor(() => expect(mocks.listReleases).toHaveBeenCalledOnce());
|
||||
const secondRequest = secondHandler.getDataAsync();
|
||||
completeGitHubRequest?.();
|
||||
|
||||
const results = await Promise.all([firstRequest, secondRequest]);
|
||||
|
||||
expect(results[0].data).toEqual(results[1].data);
|
||||
expect(mocks.listReleases).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("refreshes at the 24-hour boundary, not before it", async () => {
|
||||
let now = 1_000;
|
||||
vi.spyOn(Date, "now").mockImplementation(() => now);
|
||||
mocks.listReleases.mockResolvedValue(successfulResponse);
|
||||
const handler = await loadHandlerAsync();
|
||||
await handler.getDataAsync();
|
||||
|
||||
now += 24 * 60 * 60 * 1_000 - 1;
|
||||
await handler.getDataAsync();
|
||||
expect(mocks.listReleases).toHaveBeenCalledTimes(1);
|
||||
|
||||
now += 1;
|
||||
await handler.getDataAsync();
|
||||
expect(mocks.listReleases).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("reuses the Redis result after a simulated process restart", async () => {
|
||||
mocks.listReleases.mockResolvedValue(successfulResponse);
|
||||
const firstHandler = await loadHandlerAsync();
|
||||
await firstHandler.getDataAsync();
|
||||
|
||||
vi.resetModules();
|
||||
const restartedHandler = await loadHandlerAsync();
|
||||
await restartedHandler.getDataAsync();
|
||||
|
||||
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);
|
||||
mocks.listReleases.mockResolvedValueOnce(successfulResponse).mockRejectedValue(new Error("GitHub unavailable"));
|
||||
const handler = await loadHandlerAsync();
|
||||
const fresh = await handler.getDataAsync();
|
||||
mocks.removeFreshResult();
|
||||
|
||||
const stale = await handler.getDataAsync();
|
||||
now += 24 * 60 * 60 * 1_000 - 1;
|
||||
const repeated = await handler.getDataAsync();
|
||||
|
||||
expect(stale.data).toEqual(fresh.data);
|
||||
expect(repeated.data).toEqual(fresh.data);
|
||||
expect(mocks.listReleases).toHaveBeenCalledTimes(2);
|
||||
|
||||
now += 1;
|
||||
const retried = await handler.getDataAsync();
|
||||
|
||||
expect(retried.data).toEqual(fresh.data);
|
||||
expect(mocks.listReleases).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
test("does not contact GitHub when external connections are disabled", async () => {
|
||||
mocks.env.NO_EXTERNAL_CONNECTION = true;
|
||||
const handler = await loadHandlerAsync();
|
||||
|
||||
await expect(handler.getDataAsync()).resolves.toMatchObject({ data: { availableUpdates: [] } });
|
||||
expect(mocks.listReleases).not.toHaveBeenCalled();
|
||||
expect(mocks.redisOperation).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -5,19 +5,114 @@ import { env } from "@homarr/common/env";
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
import { createRequestHandler } from "./lib/request-handler";
|
||||
import { createGetSetChannel, createLockChannel } from "@homarr/redis";
|
||||
|
||||
import packageJson from "../../../package.json";
|
||||
|
||||
const logger = createLogger({ module: "updateCheckerRequestHandler" });
|
||||
|
||||
export const updateCheckerRequestHandler = createRequestHandler({
|
||||
async requestAsync(_) {
|
||||
const updateCheckTtlSeconds = 24 * 60 * 60;
|
||||
const updateCheckLockTtlSeconds = 2 * 60;
|
||||
const updateCheckWaitIntervalMs = 100;
|
||||
const updateCheckWaitAttempts = 50;
|
||||
|
||||
type UpdateCheckCacheEntry = {
|
||||
availableUpdates: Update[];
|
||||
attemptedAt: number;
|
||||
checkedAt: number | null;
|
||||
};
|
||||
|
||||
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) => {
|
||||
setTimeout(resolve, durationMs);
|
||||
});
|
||||
|
||||
const waitForConcurrentUpdateCheckAsync = async () => {
|
||||
for (let attempt = 0; attempt < updateCheckWaitAttempts; attempt++) {
|
||||
await waitAsync(updateCheckWaitIntervalMs);
|
||||
const cached = await freshUpdateCheckChannel.getAsync();
|
||||
if (cached) return cached;
|
||||
}
|
||||
|
||||
return await staleUpdateCheckChannel.getAsync();
|
||||
};
|
||||
|
||||
const getCachedAvailableUpdatesAsync = async (): Promise<UpdateCheckCacheEntry> => {
|
||||
if (env.NO_EXTERNAL_CONNECTION) {
|
||||
return {
|
||||
availableUpdates: [],
|
||||
attemptedAt: Date.now(),
|
||||
checkedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
try {
|
||||
const attemptedAt = Date.now();
|
||||
const availableUpdates = await getAvailableUpdatesAsync(packageJson.version);
|
||||
return { availableUpdates };
|
||||
},
|
||||
cacheTtlMs: 30 * 60 * 1000,
|
||||
});
|
||||
const result: UpdateCheckCacheEntry = {
|
||||
availableUpdates,
|
||||
attemptedAt,
|
||||
checkedAt: attemptedAt,
|
||||
};
|
||||
await staleUpdateCheckChannel.setAsync(result);
|
||||
await freshUpdateCheckChannel.setAsync(result, { ttlSeconds: updateCheckTtlSeconds });
|
||||
return result;
|
||||
} catch (error) {
|
||||
const stale = await staleUpdateCheckChannel.getAsync();
|
||||
const result: UpdateCheckCacheEntry = {
|
||||
availableUpdates: stale?.availableUpdates ?? [],
|
||||
attemptedAt: Date.now(),
|
||||
checkedAt: stale?.checkedAt ?? null,
|
||||
};
|
||||
await freshUpdateCheckChannel.setAsync(result, { ttlSeconds: updateCheckTtlSeconds });
|
||||
logger.warn("Failed to fetch available updates; suppressing retries for 24 hours", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
servingStale: stale !== null,
|
||||
});
|
||||
return result;
|
||||
} finally {
|
||||
try {
|
||||
await updateCheckLock.releaseAsync(lockToken);
|
||||
} catch (error) {
|
||||
logger.warn("Failed to release the update-check lock", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const updateCheckerRequestHandler = {
|
||||
handler: (_input: Record<string, never>) => ({
|
||||
getDataAsync: async () => {
|
||||
const result = await getCachedAvailableUpdatesAsync();
|
||||
return {
|
||||
data: { availableUpdates: result.availableUpdates },
|
||||
timestamp: new Date(result.checkedAt ?? result.attemptedAt),
|
||||
};
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
interface Update {
|
||||
name: string | null;
|
||||
|
||||
Reference in new issue
Block a user