feat(widgets): consolidate integration metadata + shared response caching (#6691)

This commit is contained in:
Thomas authored and GitHub committed 2026-08-30 18:38:07 +02:00
1 parent 7d1d9062ed
commit 8d8dec5749
206 files changed
+3893 -1946

No files matched your search

@@ -1,22 +1,63 @@
import { createKeyedFingerprint } from "@homarr/common/server";
import type { Modify } from "@homarr/common/types";
import type { Integration, IntegrationSecret } from "@homarr/db/schema";
import type { IntegrationKind } from "@homarr/definitions";
import { invalidateIntegrationResponseCacheAsync } from "@homarr/redis";
import { createIntegrationSharedCacheAsync, hashSharedCacheInput } from "./shared-cache";
import { createRequestHandler } from "./request-handler";
import type { SharedCacheAdapter } from "./request-handler";
type IntegrationOfKind<TKind extends IntegrationKind> = Omit<Integration, "kind"> & {
kind: TKind;
decryptedSecrets: Modify<Pick<IntegrationSecret, "kind" | "value">, { value: string }>[];
decryptedSecrets: Modify<
Pick<IntegrationSecret, "kind" | "value"> & Partial<Pick<IntegrationSecret, "updatedAt">>,
{ value: string }
>[];
externalUrl: string | null;
};
interface Options<TData, TKind extends IntegrationKind, TInput extends Record<string, unknown>> {
requestAsync: (integration: IntegrationOfKind<TKind>, input: TInput) => Promise<TData>;
requestAsync: (integration: IntegrationOfKind<TKind>, input: TInput, signal: AbortSignal) => Promise<TData>;
cacheTtlMs?: number;
fallbackToStaleOnError?: boolean;
staleIfErrorTtlMs?: number;
requestTimeoutMs?: number;
cacheNamespace?: string;
cacheVersion?: string;
}
const getIntegrationCacheIdentity = <TKind extends IntegrationKind, TInput extends Record<string, unknown>>({
integration,
options,
}: {
integration: IntegrationOfKind<TKind>;
options: TInput;
}) => {
const secretRevisions = (integration.decryptedSecrets ?? [])
.map((secret) => ({
kind: secret.kind,
fingerprint: createKeyedFingerprint(JSON.stringify([secret.kind, secret.value])),
updatedAt: secret.updatedAt?.toISOString(),
}))
.toSorted((left, right) => {
const kindComparison = left.kind.localeCompare(right.kind);
if (kindComparison !== 0) return kindComparison;
return left.fingerprint.localeCompare(right.fingerprint);
});
return hashSharedCacheInput({
integration: {
id: integration.id,
kind: integration.kind,
url: integration.url,
externalUrl: integration.externalUrl,
secretRevisions,
},
options,
});
};
export const createIntegrationRequestHandler = <
TData,
TKind extends IntegrationKind,
@@ -24,17 +65,46 @@ export const createIntegrationRequestHandler = <
>(
options: Options<TData, TKind, TInput>,
) => {
const cacheNamespace = options.cacheNamespace;
let getSharedCacheAsync:
| ((
input: { integration: IntegrationOfKind<TKind>; options: TInput },
cacheIdentity: string,
) => Promise<SharedCacheAdapter<TData>>)
| undefined;
if (cacheNamespace) {
getSharedCacheAsync = async ({ integration }, cacheIdentity) =>
await createIntegrationSharedCacheAsync({
namespace: cacheNamespace,
integrationId: integration.id,
cacheIdentity,
cacheVersion: options.cacheVersion,
});
}
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 }),
requestAsync: async ({ integration, options: itemOptions }, signal) =>
options.requestAsync(integration, itemOptions, signal),
getCacheKey: getIntegrationCacheIdentity,
cacheTtlMs: options.cacheTtlMs,
fallbackToStaleOnError: options.fallbackToStaleOnError,
staleIfErrorTtlMs: options.staleIfErrorTtlMs,
requestTimeoutMs: options.requestTimeoutMs,
getSharedCacheAsync,
});
return {
invalidateCache: inner.invalidateCache,
/** Clears this handler's L1 and every shared response-cache namespace for these integrations. */
invalidateCacheAsync: async (integrationIds: readonly string[]) => {
inner.invalidateCache();
if (!cacheNamespace) return;
await Promise.all(
[...new Set(integrationIds)].map(async (integrationId) => {
await invalidateIntegrationResponseCacheAsync(integrationId);
}),
);
},
handler: (integration: IntegrationOfKind<TKind>, itemOptions: TInput) =>
inner.handler({ integration, options: itemOptions }),
};
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import type { Integration, IntegrationSecret } from "@homarr/db/schema";
import { createIntegrationRequestHandler } from "./integration-request-handler";
import type { CacheEntry, SharedCacheAdapter } from "./request-handler";
import { createRequestHandler } from "./request-handler";
import { createWidgetRequestHandler } from "./widget-request-handler";
@@ -52,6 +53,134 @@ describe("createRequestHandler", () => {
expect(calls).toBe(1);
});
it("uses a fresh shared cache entry before calling the upstream service", async () => {
const timestamp = new Date();
const requestAsync = vi.fn(async () => "upstream");
const sharedCache = {
generation: "shared-1",
isShared: true,
getAsync: vi.fn(async () => ({
data: "shared",
timestamp,
expiresAt: Date.now() + 1_000,
staleUntil: Date.now() + 1_000,
})),
setAsync: vi.fn(async () => undefined),
acquireRefreshLockAsync: vi.fn(async () => "lock"),
renewRefreshLockAsync: vi.fn(async () => true),
releaseRefreshLockAsync: vi.fn(async () => undefined),
};
const handler = createRequestHandler({
cacheTtlMs: 1_000,
requestAsync,
getSharedCacheAsync: async () => sharedCache,
});
await expect(handler.handler({ id: "same" }).getDataAsync()).resolves.toMatchObject({ data: "shared", timestamp });
expect(requestAsync).not.toHaveBeenCalled();
expect(sharedCache.acquireRefreshLockAsync).not.toHaveBeenCalled();
});
it("renews the refresh lock while the upstream request is still running", async () => {
vi.useFakeTimers();
const upstreamRequest = deferred<string>();
const renewRefreshLockAsync = vi.fn(async () => true);
const releaseRefreshLockAsync = vi.fn(async () => undefined);
const sharedCache: SharedCacheAdapter<string> = {
generation: "shared-1",
isShared: true,
getAsync: async () => null,
setAsync: async () => undefined,
acquireRefreshLockAsync: async () => "lock",
renewRefreshLockAsync,
releaseRefreshLockAsync,
};
const handler = createRequestHandler({
cacheTtlMs: 1_000,
requestAsync: async () => await upstreamRequest.promise,
getSharedCacheAsync: async () => sharedCache,
});
const pending = handler.handler({ id: "same" }).getDataAsync();
await vi.advanceTimersByTimeAsync(5_000);
expect(renewRefreshLockAsync).toHaveBeenCalledTimes(1);
upstreamRequest.resolve("upstream");
await expect(pending).resolves.toMatchObject({ data: "upstream" });
expect(releaseRefreshLockAsync).toHaveBeenCalledWith("lock");
});
it("waits out a refresh lock held by another process instead of stampeding the upstream", async () => {
vi.useFakeTimers();
const timestamp = new Date();
let sharedEntry: CacheEntry<string> | null = null;
const requestAsync = vi.fn(async () => "upstream");
const sharedCache = {
generation: "shared-1",
isShared: true,
getAsync: vi.fn(async () => sharedEntry),
setAsync: vi.fn(async () => undefined),
// A different process/pod already holds the refresh lock.
acquireRefreshLockAsync: vi.fn(async () => null),
renewRefreshLockAsync: vi.fn(async () => true),
releaseRefreshLockAsync: vi.fn(async () => undefined),
};
const handler = createRequestHandler<string, { id: string }>({
cacheTtlMs: 1_000,
requestAsync,
getSharedCacheAsync: async () => sharedCache,
});
const pending = handler.handler({ id: "same" }).getDataAsync();
// Poll well past the old fixed wait window while the lock is still held and the
// shared cache is empty: the waiter must keep waiting, not fetch upstream itself.
await vi.advanceTimersByTimeAsync(20_000);
expect(requestAsync).not.toHaveBeenCalled();
// The lock holder finishes and publishes the entry to the shared cache.
sharedEntry = {
data: "shared",
timestamp,
expiresAt: Date.now() + 1_000,
staleUntil: Date.now() + 1_000,
};
await vi.advanceTimersByTimeAsync(100);
await expect(pending).resolves.toMatchObject({ data: "shared", timestamp });
expect(requestAsync).not.toHaveBeenCalled();
});
it("deduplicates while the shared cache is being resolved", async () => {
const sharedCacheRequest = deferred<SharedCacheAdapter<string>>();
const upstreamRequest = deferred<string>();
const getSharedCacheAsync = vi.fn(async () => await sharedCacheRequest.promise);
const requestAsync = vi.fn(async () => await upstreamRequest.promise);
const handler = createRequestHandler({
cacheTtlMs: 1_000,
requestAsync,
getSharedCacheAsync,
});
const first = handler.handler({ id: "same" }).getDataAsync();
const second = handler.handler({ id: "same" }).getDataAsync();
expect(getSharedCacheAsync).toHaveBeenCalledTimes(1);
sharedCacheRequest.resolve({
generation: "shared-1",
isShared: false,
getAsync: async () => undefined,
setAsync: async () => undefined,
acquireRefreshLockAsync: async () => undefined,
renewRefreshLockAsync: async () => undefined,
releaseRefreshLockAsync: async () => undefined,
});
await vi.waitFor(() => expect(requestAsync).toHaveBeenCalledTimes(1));
upstreamRequest.resolve("value");
await expect(Promise.all([first, second])).resolves.toMatchObject([{ data: "value" }, { data: "value" }]);
});
it("keeps in-flight deduplication but retains no payload when ttl is zero", async () => {
let calls = 0;
const request = deferred<{ call: number }>();
@@ -193,7 +322,7 @@ describe("createRequestHandler", () => {
});
describe("createIntegrationRequestHandler", () => {
it("keys requests by integration id and options without retaining secret values", async () => {
it("does not deduplicate requests across rotated integration secrets", async () => {
let calls = 0;
const request = deferred<string>();
const handler = createIntegrationRequestHandler<string, "sonarr", { page: number }>({
@@ -203,11 +332,16 @@ describe("createIntegrationRequestHandler", () => {
},
});
const first = handler.handler(createIntegration("first-secret"), { page: 1 }).getDataAsync();
const duplicate = handler.handler(createIntegration("first-secret"), { page: 1 }).getDataAsync();
const second = handler.handler(createIntegration("rotated-secret"), { page: 1 }).getDataAsync();
expect(calls).toBe(1);
expect(calls).toBe(2);
request.resolve("value");
await expect(Promise.all([first, second])).resolves.toMatchObject([{ data: "value" }, { data: "value" }]);
await expect(Promise.all([first, duplicate, second])).resolves.toMatchObject([
{ data: "value" },
{ data: "value" },
{ data: "value" },
]);
});
});
@@ -1,16 +1,114 @@
interface Options<TData, TInput extends Record<string, unknown>> {
requestAsync: (input: TInput) => Promise<TData>;
requestAsync: (input: TInput, signal: AbortSignal) => Promise<TData>;
getCacheKey?: (input: TInput) => string;
cacheTtlMs?: number;
fallbackToStaleOnError?: boolean;
staleIfErrorTtlMs?: number;
requestTimeoutMs?: number;
getSharedCacheAsync?: (input: TInput, cacheIdentity: string) => Promise<SharedCacheAdapter<TData>>;
}
type CacheEntry<TData> = { data: TData; timestamp: Date; expiresAt: number; staleUntil: number };
export interface CacheEntry<TData> {
data: TData;
timestamp: Date;
expiresAt: number;
staleUntil: number;
}
export interface SharedCacheAdapter<TData> {
generation: string;
isShared: boolean;
getAsync: () => Promise<CacheEntry<TData> | null | undefined>;
setAsync: (entry: CacheEntry<TData>, refreshLockToken: string) => Promise<boolean | void>;
acquireRefreshLockAsync: () => Promise<string | null | undefined>;
renewRefreshLockAsync: (token: string) => Promise<boolean | undefined>;
releaseRefreshLockAsync: (token: string) => Promise<void>;
}
const MAX_CACHE_SIZE = 1000;
const MAX_INFLIGHT_REQUESTS = 100;
const MAX_UPSTREAM_SETTLEMENT_GRACE_MS = 60_000;
const DEFAULT_TTL_MS = 10_000;
const DEFAULT_STALE_IF_ERROR_TTL_MS = 5 * 60_000;
const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
const SHARED_REFRESH_POLL_MS = 100;
const SHARED_REFRESH_LOCK_RETRY_MS = 1_000;
const SHARED_REFRESH_STALE_WAIT_MS = 15_000;
const SHARED_REFRESH_MAX_WAIT_MS = 30_000;
// Must remain comfortably below REFRESH_LOCK_TTL_SECONDS in shared-cache.ts.
const SHARED_REFRESH_LOCK_RENEW_MS = 5_000;
class RequestHandlerTimeoutError extends Error {
constructor() {
super("Request handler deadline exceeded");
this.name = RequestHandlerTimeoutError.name;
}
}
class RequestHandlerOverloadedError extends Error {
constructor() {
super("Request handler concurrency limit exceeded");
this.name = RequestHandlerOverloadedError.name;
}
}
const throwIfDeadlineExceeded = (deadlineAt: number) => {
if (Date.now() >= deadlineAt) throw new RequestHandlerTimeoutError();
};
const delayAsync = async (durationMs: number, deadlineAt: number) => {
throwIfDeadlineExceeded(deadlineAt);
const remainingMs = deadlineAt - Date.now();
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, Math.min(durationMs, remainingMs));
timer.unref?.();
});
throwIfDeadlineExceeded(deadlineAt);
};
const waitForSettlementGraceAsync = async (settledAsync: Promise<void>, durationMs: number) => {
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, durationMs);
timer.unref?.();
void settledAsync.then(() => {
clearTimeout(timer);
resolve();
});
});
};
const startRefreshLockRenewal = <TData>(sharedCache: SharedCacheAdapter<TData>, token: string) => {
let isOwned = true;
let isStopped = false;
let timer: ReturnType<typeof setTimeout> | undefined;
const schedule = () => {
timer = setTimeout(() => {
void sharedCache.renewRefreshLockAsync(token).then(
(renewed) => {
if (renewed !== true) {
isOwned = false;
return;
}
if (!isStopped) schedule();
},
() => {
isOwned = false;
},
);
}, SHARED_REFRESH_LOCK_RENEW_MS);
timer.unref?.();
};
schedule();
return {
isOwned: () => isOwned,
stop: () => {
isStopped = true;
if (timer) clearTimeout(timer);
},
};
};
const evictExpired = <TData>(cache: Map<string, CacheEntry<TData>>) => {
const now = Date.now();
@@ -22,12 +120,94 @@ const evictExpired = <TData>(cache: Map<string, CacheEntry<TData>>) => {
export const createRequestHandler = <TData, TInput extends Record<string, unknown>>(
options: Options<TData, TInput>,
) => {
const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
if (!Number.isFinite(requestTimeoutMs) || requestTimeoutMs <= 0) {
throw new Error("Request timeout must be a positive finite duration");
}
const cache = new Map<string, CacheEntry<TData>>();
const inflight = new Map<string, Promise<CacheEntry<TData>>>();
const sharedCacheResolutions = new Map<string, Promise<SharedCacheAdapter<TData>>>();
const upstreamSettlements = new Map<string, Promise<void>>();
let expiryTimer: ReturnType<typeof setTimeout> | undefined;
let expiryTimerAt: number | undefined;
let generation = 0;
const storeInMemory = (key: string, entry: CacheEntry<TData>, requestGeneration: number) => {
if (generation !== requestGeneration || Date.now() >= entry.staleUntil) return;
evictExpired(cache);
cache.delete(key);
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 getFromMemory = (key: string) => {
const entry = cache.get(key);
if (!entry) return undefined;
cache.delete(key);
cache.set(key, entry);
return entry;
};
const startRequestWithDeadline = (input: TInput, deadlineAt: number, upstreamKey: string) => {
throwIfDeadlineExceeded(deadlineAt);
if (upstreamSettlements.has(upstreamKey) || upstreamSettlements.size >= MAX_INFLIGHT_REQUESTS) {
throw new RequestHandlerOverloadedError();
}
const controller = new AbortController();
const timeoutError = new RequestHandlerTimeoutError();
const remainingMs = deadlineAt - Date.now();
let upstreamRequest: Promise<TData>;
try {
upstreamRequest = Promise.resolve(options.requestAsync(input, controller.signal));
} catch (error) {
upstreamRequest = Promise.reject(error);
}
const settledAsync = upstreamRequest.then(
() => undefined,
() => undefined,
);
upstreamSettlements.set(upstreamKey, settledAsync);
void settledAsync.then(() => {
if (upstreamSettlements.get(upstreamKey) === settledAsync) upstreamSettlements.delete(upstreamKey);
});
const resultAsync = new Promise<TData>((resolve, reject) => {
const timer = setTimeout(() => {
controller.abort(timeoutError);
reject(timeoutError);
}, remainingMs);
timer.unref?.();
void upstreamRequest.then(resolve, reject).finally(() => clearTimeout(timer));
});
return { resultAsync, settledAsync };
};
const resolveSharedCacheAsync = (input: TInput, cacheIdentity: string, requestGeneration: number) => {
if (!options.getSharedCacheAsync) return undefined;
const resolutionKey = `${requestGeneration}:${cacheIdentity}`;
const existing = sharedCacheResolutions.get(resolutionKey);
if (existing) return existing;
if (sharedCacheResolutions.size >= MAX_INFLIGHT_REQUESTS) {
throw new RequestHandlerOverloadedError();
}
let promise: Promise<SharedCacheAdapter<TData>>;
promise = options.getSharedCacheAsync(input, cacheIdentity).finally(() => {
if (sharedCacheResolutions.get(resolutionKey) === promise) sharedCacheResolutions.delete(resolutionKey);
});
sharedCacheResolutions.set(resolutionKey, promise);
return promise;
};
const scheduleExpiry = (candidateExpiryAt?: number) => {
if (candidateExpiryAt !== undefined && expiryTimerAt !== undefined && expiryTimerAt <= candidateExpiryAt) return;
if (expiryTimer) clearTimeout(expiryTimer);
@@ -53,72 +233,149 @@ export const createRequestHandler = <TData, TInput extends Record<string, unknow
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 deadlineAt = Date.now() + requestTimeoutMs;
const ttl = options.cacheTtlMs ?? DEFAULT_TTL_MS;
const key = options.getCacheKey?.(input) ?? JSON.stringify(input);
let cached = cache.get(key);
const now = Date.now();
if (cached && now < cached.expiresAt) {
return { data: cached.data, timestamp: cached.timestamp };
const baseKey = options.getCacheKey?.(input) ?? JSON.stringify(input);
const requestGeneration = generation;
let sharedCache: SharedCacheAdapter<TData> | undefined;
const sharedCacheResolution = ttl > 0 ? resolveSharedCacheAsync(input, baseKey, requestGeneration) : undefined;
if (sharedCacheResolution) {
sharedCache = await sharedCacheResolution;
throwIfDeadlineExceeded(deadlineAt);
}
const key = `${sharedCache?.generation ?? "local"}:${baseKey}`;
const inflightKey = `${requestGeneration}:${key}`;
const existing = inflight.get(inflightKey);
if (existing) return existing;
let cached = getFromMemory(key);
const now = Date.now();
if (cached && now < cached.expiresAt) return cached;
if (cached && now >= cached.staleUntil) {
cache.delete(key);
scheduleExpiry();
cached = undefined;
}
if (upstreamSettlements.has(inflightKey) || inflight.size >= MAX_INFLIGHT_REQUESTS) {
if (options.fallbackToStaleOnError && cached) return cached;
throw new RequestHandlerOverloadedError();
}
const existing = inflight.get(key);
if (existing) return existing;
const requestGeneration = generation;
let cleanupAfterUpstreamAsync: Promise<void> | undefined;
let promise: Promise<CacheEntry<TData>>;
promise = options
.requestAsync(input)
.then((data) => {
const expiresAt = Date.now() + ttl;
promise = (async () => {
let stale = cached;
let lockToken: string | null | undefined;
if (sharedCache?.isShared) {
const sharedEntry = await sharedCache.getAsync();
if (sharedEntry && Date.now() < sharedEntry.expiresAt) {
storeInMemory(key, sharedEntry, requestGeneration);
return sharedEntry;
}
if (sharedEntry && Date.now() < sharedEntry.staleUntil) stale = sharedEntry;
lockToken = await sharedCache.acquireRefreshLockAsync();
if (lockToken === null) {
const returnStaleAt = Date.now() + SHARED_REFRESH_STALE_WAIT_MS;
const waitDeadlineAt = Math.min(deadlineAt, Date.now() + SHARED_REFRESH_MAX_WAIT_MS);
let retryLockAt = Date.now() + SHARED_REFRESH_LOCK_RETRY_MS;
while (lockToken === null && sharedCache.isShared) {
await delayAsync(SHARED_REFRESH_POLL_MS, waitDeadlineAt);
const refreshedEntry = await sharedCache.getAsync();
if (refreshedEntry && Date.now() < refreshedEntry.expiresAt) {
storeInMemory(key, refreshedEntry, requestGeneration);
return refreshedEntry;
}
if (refreshedEntry && Date.now() < refreshedEntry.staleUntil) stale = refreshedEntry;
if (Date.now() >= returnStaleAt && options.fallbackToStaleOnError && stale) {
storeInMemory(key, stale, requestGeneration);
return stale;
}
if (Date.now() >= retryLockAt) {
lockToken = await sharedCache.acquireRefreshLockAsync();
retryLockAt = Date.now() + SHARED_REFRESH_LOCK_RETRY_MS;
}
}
}
}
const refreshLockRenewal =
lockToken && sharedCache ? startRefreshLockRenewal(sharedCache, lockToken) : undefined;
let upstreamSettledAsync: Promise<void> | undefined;
try {
const request = startRequestWithDeadline(input, deadlineAt, inflightKey);
upstreamSettledAsync = request.settledAsync;
const data = await request.resultAsync;
const completedAt = Date.now();
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,
timestamp: new Date(completedAt),
expiresAt: completedAt + ttl,
staleUntil: completedAt + ttl + 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);
if (ttl > 0) {
let canStoreInMemory = true;
if (sharedCache?.isShared) {
canStoreInMemory = false;
if (lockToken && refreshLockRenewal?.isOwned() !== false) {
const stored = await sharedCache.setAsync(entry, lockToken);
if (stored !== false || !sharedCache.isShared) canStoreInMemory = true;
}
}
cache.set(key, entry);
scheduleExpiry(entry.staleUntil);
if (canStoreInMemory) storeInMemory(key, entry, requestGeneration);
}
return entry;
})
.catch((err) => {
} catch (error) {
if (
options.fallbackToStaleOnError &&
cached &&
Date.now() < cached.staleUntil &&
stale &&
Date.now() < stale.staleUntil &&
generation === requestGeneration
) {
return cached;
storeInMemory(key, stale, requestGeneration);
return stale;
}
throw err;
})
.finally(() => {
if (inflight.get(key) === promise) inflight.delete(key);
});
throw error;
} finally {
const cleanupAsync = async () => {
refreshLockRenewal?.stop();
if (lockToken) await sharedCache?.releaseRefreshLockAsync(lockToken);
};
if (upstreamSettledAsync) {
const settlementGraceMs = Math.min(requestTimeoutMs, MAX_UPSTREAM_SETTLEMENT_GRACE_MS);
cleanupAfterUpstreamAsync = waitForSettlementGraceAsync(upstreamSettledAsync, settlementGraceMs).then(
cleanupAsync,
);
} else {
await cleanupAsync();
}
}
})();
inflight.set(key, promise);
inflight.set(inflightKey, promise);
const removeInflight = () => {
const cleanupAsync = cleanupAfterUpstreamAsync ?? Promise.resolve();
void cleanupAsync.then(
() => {
if (inflight.get(inflightKey) === promise) inflight.delete(inflightKey);
},
() => {
if (inflight.get(inflightKey) === promise) inflight.delete(inflightKey);
},
);
};
void promise.then(removeInflight, removeInflight);
return promise;
},
}),
@@ -0,0 +1,249 @@
import { createHash } from "node:crypto";
import { serialize } from "superjson";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import {
createGetSetChannel,
createLockChannel,
getIntegrationCacheGenerationAsync,
getWidgetCacheGenerationAsync,
} from "@homarr/redis";
import type { IntegrationCacheGeneration, WidgetCacheGeneration } from "@homarr/redis";
import type { CacheEntry, SharedCacheAdapter } from "./request-handler";
const logger = createLogger({ module: "sharedRequestCache" });
// The dedicated cache client rejects commands after 500 ms. This guard stays
// above that deadline so lock outcomes are never abandoned while still pending.
const REDIS_OPERATION_TIMEOUT_MS = 750;
const REFRESH_LOCK_TTL_SECONDS = 15;
const SHARED_CACHE_ENVELOPE_VERSION = 2;
const DEFAULT_HANDLER_CACHE_VERSION = "v1";
const namespacePattern = /^[a-z0-9][a-z0-9:-]*$/;
const cacheVersionPattern = /^[a-z0-9][a-z0-9._-]*$/;
const isRecord = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === "object" && !Array.isArray(value);
const canonicalize = (value: unknown): unknown => {
if (Array.isArray(value)) return value.map(canonicalize);
if (!isRecord(value)) return value;
const result: Record<string, unknown> = {};
for (const key of Object.keys(value).toSorted()) {
result[key] = canonicalize(value[key]);
}
return result;
};
export const hashSharedCacheInput = (options: Record<string, unknown>) => {
const serialized = JSON.stringify(canonicalize(serialize(options)));
return createHash("sha256").update(serialized).digest("hex");
};
export const hashIntegrationCacheOptions = hashSharedCacheInput;
const withTimeoutAsync = async <T>(promise: Promise<T>, durationMs: number): Promise<T> => {
return await new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("Redis operation timed out")), durationMs);
timer.unref?.();
void promise.then(resolve, reject).finally(() => clearTimeout(timer));
});
};
interface SharedCacheEnvelope<TData> {
version: typeof SHARED_CACHE_ENVELOPE_VERSION;
entry: CacheEntry<TData>;
}
const isSharedCacheEnvelope = (value: unknown): value is SharedCacheEnvelope<unknown> => {
if (!isRecord(value) || value.version !== SHARED_CACHE_ENVELOPE_VERSION || !isRecord(value.entry)) return false;
const entry = value.entry;
if (!Object.hasOwn(entry, "data") || !(entry.timestamp instanceof Date)) return false;
const timestamp = entry.timestamp.getTime();
return (
Number.isFinite(timestamp) &&
typeof entry.expiresAt === "number" &&
Number.isFinite(entry.expiresAt) &&
typeof entry.staleUntil === "number" &&
Number.isFinite(entry.staleUntil) &&
timestamp <= entry.expiresAt &&
entry.expiresAt <= entry.staleUntil
);
};
interface IntegrationSharedCacheOptions {
namespace: string;
integrationId: string;
cacheIdentity: string;
cacheVersion?: string;
}
interface RedisSharedCacheOptions {
namespace: string;
cacheKind: "integration" | "widget";
cacheIdentity: string;
cacheVersion: string;
generation: string;
metadata?: Record<string, string>;
isShared?: boolean;
}
const logRedisFailure = (operation: string, metadata: Record<string, unknown>, error: unknown) => {
logger.warn(
new ErrorWithMetadata(
"Redis response cache operation failed",
{
...metadata,
operation,
},
{ cause: error },
),
);
};
const createRedisSharedCache = <TData>({
namespace,
cacheKind,
cacheIdentity,
cacheVersion,
generation,
metadata = {},
isShared: initialIsShared = true,
}: RedisSharedCacheOptions): SharedCacheAdapter<TData> => {
const logMetadata = { cacheNamespace: namespace, cacheKind, ...metadata };
const payloadName = `${cacheKind}-cache:payload:v2:${cacheVersion}:${namespace}:${generation}:${cacheIdentity}`;
const lockName = `${cacheKind}-cache:lock:v2:${cacheVersion}:${namespace}:${generation}:${cacheIdentity}`;
const payloadChannel = createGetSetChannel<unknown>(payloadName, { useBoundedCacheClient: true });
const lock = createLockChannel(lockName, { useBoundedCacheClient: true });
let isShared = initialIsShared;
const runRedisOperationAsync = async <T>(operation: string, callback: () => Promise<T>, fallback: T): Promise<T> => {
if (!isShared) return fallback;
try {
return await withTimeoutAsync(callback(), REDIS_OPERATION_TIMEOUT_MS);
} catch (error) {
isShared = false;
logRedisFailure(operation, logMetadata, error);
return fallback;
}
};
return {
generation,
get isShared() {
return isShared;
},
getAsync: async () => {
const value = await runRedisOperationAsync("payload-read", () => payloadChannel.getAsync(), undefined);
if (value === undefined || value === null) return value;
if (isSharedCacheEnvelope(value)) return value.entry as CacheEntry<TData>;
logger.warn("Ignored invalid Redis response cache entry", {
...logMetadata,
operation: "payload-validate",
});
await runRedisOperationAsync("payload-remove", () => payloadChannel.removeAsync(), undefined);
return null;
},
setAsync: async (entry, refreshLockToken) => {
const retentionMs = entry.staleUntil - Date.now();
if (retentionMs <= 0) return false;
const envelope: SharedCacheEnvelope<TData> = {
version: SHARED_CACHE_ENVELOPE_VERSION,
entry,
};
return await runRedisOperationAsync(
"payload-write",
() => lock.setIfOwnedAsync(refreshLockToken, payloadName, envelope, retentionMs),
undefined,
);
},
acquireRefreshLockAsync: async () =>
await runRedisOperationAsync(
"refresh-lock-acquire",
() => lock.acquireAsync(REFRESH_LOCK_TTL_SECONDS),
undefined,
),
renewRefreshLockAsync: async (token) =>
await runRedisOperationAsync(
"refresh-lock-renew",
() => lock.renewAsync(token, REFRESH_LOCK_TTL_SECONDS),
undefined,
),
releaseRefreshLockAsync: async (token) => {
try {
await withTimeoutAsync(lock.releaseAsync(token), REDIS_OPERATION_TIMEOUT_MS);
} catch (error) {
logRedisFailure("refresh-lock-release", logMetadata, error);
}
},
};
};
interface WidgetSharedCacheOptions {
namespace: string;
cacheIdentity: string;
cacheVersion?: string;
}
export const createWidgetSharedCacheAsync = async <TData>({
namespace,
cacheIdentity,
cacheVersion = DEFAULT_HANDLER_CACHE_VERSION,
}: WidgetSharedCacheOptions): Promise<SharedCacheAdapter<TData>> => {
if (typeof window !== "undefined") throw new Error("Widget request caching is server-only");
if (!namespacePattern.test(namespace)) throw new Error(`Invalid widget cache namespace '${namespace}'`);
if (!cacheVersionPattern.test(cacheVersion)) throw new Error(`Invalid widget cache version '${cacheVersion}'`);
const metadata = { cacheNamespace: namespace };
let generation: WidgetCacheGeneration;
try {
generation = await getWidgetCacheGenerationAsync(namespace);
} catch (error) {
logRedisFailure("generation-read", metadata, error);
generation = { value: "redis-unavailable", isShared: false };
}
return createRedisSharedCache<TData>({
namespace,
cacheKind: "widget",
cacheIdentity,
cacheVersion,
generation: generation.value,
isShared: generation.isShared,
});
};
export const createIntegrationSharedCacheAsync = async <TData>({
namespace,
integrationId,
cacheIdentity,
cacheVersion = DEFAULT_HANDLER_CACHE_VERSION,
}: IntegrationSharedCacheOptions): Promise<SharedCacheAdapter<TData>> => {
if (typeof window !== "undefined") throw new Error("Integration request caching is server-only");
if (!namespacePattern.test(namespace)) throw new Error(`Invalid integration cache namespace '${namespace}'`);
if (!cacheVersionPattern.test(cacheVersion)) throw new Error(`Invalid integration cache version '${cacheVersion}'`);
const metadata = { cacheNamespace: namespace, integrationId };
let generation: IntegrationCacheGeneration;
try {
generation = await getIntegrationCacheGenerationAsync(integrationId);
} catch (error) {
logRedisFailure("generation-read", metadata, error);
generation = { value: "redis-unavailable", isShared: false };
}
const sharedCache = createRedisSharedCache<TData>({
namespace,
cacheKind: "integration",
cacheIdentity,
cacheVersion,
generation: generation.value,
metadata: { integrationId },
isShared: generation.isShared,
});
return sharedCache;
};
@@ -1,15 +1,47 @@
import { invalidateWidgetCache } from "@homarr/redis";
import { createWidgetSharedCacheAsync, hashSharedCacheInput } from "./shared-cache";
import { createRequestHandler } from "./request-handler";
import type { SharedCacheAdapter } from "./request-handler";
interface Options<TData, TInput extends Record<string, unknown>> {
requestAsync: (input: TInput) => Promise<TData>;
requestAsync: (input: TInput, signal: AbortSignal) => Promise<TData>;
cacheTtlMs?: number;
fallbackToStaleOnError?: boolean;
staleIfErrorTtlMs?: number;
requestTimeoutMs?: number;
cacheNamespace?: string;
cacheVersion?: string;
}
export const createWidgetRequestHandler = <TData, TInput extends Record<string, unknown>>(
options: Options<TData, TInput>,
) => {
const inner = createRequestHandler<TData, TInput>(options);
return inner;
const cacheNamespace = options.cacheNamespace;
let getSharedCacheAsync: ((input: TInput, cacheIdentity: string) => Promise<SharedCacheAdapter<TData>>) | undefined;
if (cacheNamespace) {
getSharedCacheAsync = async (_input, cacheIdentity) =>
await createWidgetSharedCacheAsync<TData>({
namespace: cacheNamespace,
cacheIdentity,
cacheVersion: options.cacheVersion,
});
}
const inner = createRequestHandler<TData, TInput>({
...options,
cacheTtlMs: options.cacheTtlMs,
fallbackToStaleOnError: options.fallbackToStaleOnError,
staleIfErrorTtlMs: options.staleIfErrorTtlMs,
requestTimeoutMs: options.requestTimeoutMs,
getCacheKey: hashSharedCacheInput,
getSharedCacheAsync,
});
return {
invalidateCache: () => {
inner.invalidateCache();
if (cacheNamespace) invalidateWidgetCache(cacheNamespace);
},
handler: inner.handler,
};
};