refactor: simplify widget and integration architecture
This commit is contained in:
1 parent
c988c35b29
commit
8f9efd0074
179 files changed
+1509
-4187
No files matched your search
@@ -1,18 +1,16 @@
|
||||
import { getWidgetServerCachePolicy } from "@homarr/definitions";
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import type { DownloadClientJobsAndStatus } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
|
||||
const downloadsCachePolicy = getWidgetServerCachePolicy("downloads", "getJobsAndStatuses");
|
||||
|
||||
export const downloadClientRequestHandler = createIntegrationRequestHandler<
|
||||
DownloadClientJobsAndStatus,
|
||||
IntegrationKindByCategory<"downloadClient">,
|
||||
{ limit: number }
|
||||
>({
|
||||
cachePolicy: downloadsCachePolicy,
|
||||
cacheNamespace: "downloads:jobs-and-status",
|
||||
cacheTtlMs: 10 * 1000,
|
||||
async requestAsync(integration, input) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getClientJobsAndStatusAsync(input);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { Modify } from "@homarr/common/types";
|
||||
import type { Integration, IntegrationSecret } from "@homarr/db/schema";
|
||||
import type { IntegrationKind, WidgetModuleServerCachePolicy } from "@homarr/definitions";
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { invalidateIntegrationResponseCacheAsync } from "@homarr/redis";
|
||||
|
||||
import { createIntegrationSharedCacheAsync, hashIntegrationCacheOptions } from "./integration-shared-cache";
|
||||
import { createIntegrationSharedCacheAsync, hashIntegrationCacheOptions } from "./shared-cache";
|
||||
import { createRequestHandler } from "./request-handler";
|
||||
import type { SharedCacheAdapter } from "./request-handler";
|
||||
|
||||
@@ -18,7 +19,6 @@ interface Options<TData, TKind extends IntegrationKind, TInput extends Record<st
|
||||
fallbackToStaleOnError?: boolean;
|
||||
staleIfErrorTtlMs?: number;
|
||||
cacheNamespace?: string;
|
||||
cachePolicy?: WidgetModuleServerCachePolicy & { scope: "integration" };
|
||||
}
|
||||
|
||||
export const createIntegrationRequestHandler = <
|
||||
@@ -28,7 +28,7 @@ export const createIntegrationRequestHandler = <
|
||||
>(
|
||||
options: Options<TData, TKind, TInput>,
|
||||
) => {
|
||||
const cacheNamespace = options.cachePolicy?.namespace ?? options.cacheNamespace;
|
||||
const cacheNamespace = options.cacheNamespace;
|
||||
let getSharedCacheAsync:
|
||||
| ((input: { integration: IntegrationOfKind<TKind>; options: TInput }) => Promise<SharedCacheAdapter<TData>>)
|
||||
| undefined;
|
||||
@@ -45,14 +45,24 @@ export const createIntegrationRequestHandler = <
|
||||
requestAsync: async ({ integration, options: itemOptions }) => options.requestAsync(integration, itemOptions),
|
||||
getCacheKey: ({ integration, options: itemOptions }) =>
|
||||
`${integration.id}:${hashIntegrationCacheOptions(itemOptions)}`,
|
||||
cacheTtlMs: options.cachePolicy?.ttlMs ?? options.cacheTtlMs,
|
||||
fallbackToStaleOnError: options.cachePolicy?.staleIfErrorTtlMs !== undefined || options.fallbackToStaleOnError,
|
||||
staleIfErrorTtlMs: options.cachePolicy?.staleIfErrorTtlMs ?? options.staleIfErrorTtlMs,
|
||||
cacheTtlMs: options.cacheTtlMs,
|
||||
fallbackToStaleOnError: options.fallbackToStaleOnError,
|
||||
staleIfErrorTtlMs: options.staleIfErrorTtlMs,
|
||||
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 { SharedCacheAdapter } from "./request-handler";
|
||||
import { createRequestHandler } from "./request-handler";
|
||||
import { createWidgetRequestHandler } from "./widget-request-handler";
|
||||
|
||||
@@ -52,6 +53,62 @@ 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"),
|
||||
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("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,
|
||||
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 }>();
|
||||
|
||||
@@ -95,26 +95,27 @@ export const createRequestHandler = <TData, TInput extends Record<string, unknow
|
||||
async getDataAsync(): Promise<{ data: TData; timestamp: Date }> {
|
||||
const ttl = options.cacheTtlMs ?? DEFAULT_TTL_MS;
|
||||
const baseKey = options.getCacheKey?.(input) ?? JSON.stringify(input);
|
||||
const sharedCache = ttl > 0 ? await options.getSharedCacheAsync?.(input) : undefined;
|
||||
const key = `${sharedCache?.generation ?? "local"}:${baseKey}`;
|
||||
|
||||
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);
|
||||
const existing = inflight.get(baseKey);
|
||||
if (existing) return existing;
|
||||
|
||||
const requestGeneration = generation;
|
||||
let promise: Promise<CacheEntry<TData>>;
|
||||
promise = (async () => {
|
||||
let sharedCache: SharedCacheAdapter<TData> | undefined;
|
||||
if (ttl > 0 && options.getSharedCacheAsync) {
|
||||
sharedCache = await options.getSharedCacheAsync(input);
|
||||
}
|
||||
const key = `${sharedCache?.generation ?? "local"}:${baseKey}`;
|
||||
|
||||
let cached = cache.get(key);
|
||||
const now = Date.now();
|
||||
if (cached && now < cached.expiresAt) return cached;
|
||||
if (cached && now >= cached.staleUntil) {
|
||||
cache.delete(key);
|
||||
scheduleExpiry();
|
||||
cached = undefined;
|
||||
}
|
||||
|
||||
let stale = cached;
|
||||
let lockToken: string | null | undefined;
|
||||
|
||||
@@ -179,10 +180,10 @@ export const createRequestHandler = <TData, TInput extends Record<string, unknow
|
||||
if (lockToken) await sharedCache?.releaseRefreshLockAsync(lockToken);
|
||||
}
|
||||
})().finally(() => {
|
||||
if (inflight.get(key) === promise) inflight.delete(key);
|
||||
if (inflight.get(baseKey) === promise) inflight.delete(baseKey);
|
||||
});
|
||||
|
||||
inflight.set(key, promise);
|
||||
inflight.set(baseKey, promise);
|
||||
return promise;
|
||||
},
|
||||
}),
|
||||
|
||||
+4
-4
@@ -75,7 +75,7 @@ interface RedisSharedCacheOptions {
|
||||
const logRedisFailure = (operation: string, metadata: Record<string, unknown>, error: unknown) => {
|
||||
logger.warn(
|
||||
new ErrorWithMetadata(
|
||||
"Redis widget cache operation failed",
|
||||
"Redis response cache operation failed",
|
||||
{
|
||||
...metadata,
|
||||
operation,
|
||||
@@ -121,7 +121,7 @@ const createRedisSharedCache = <TData>({
|
||||
if (value === undefined || value === null) return value;
|
||||
if (isCacheEntry(value)) return value as CacheEntry<TData>;
|
||||
|
||||
logger.warn("Ignored invalid Redis widget cache entry", {
|
||||
logger.warn("Ignored invalid Redis response cache entry", {
|
||||
...logMetadata,
|
||||
operation: "payload-validate",
|
||||
});
|
||||
@@ -164,7 +164,7 @@ export const createWidgetSharedCacheAsync = async <TData>({
|
||||
const metadata = { cacheNamespace: namespace };
|
||||
let generation: WidgetCacheGeneration;
|
||||
try {
|
||||
generation = await withTimeoutAsync(getWidgetCacheGenerationAsync(namespace), REDIS_OPERATION_TIMEOUT_MS);
|
||||
generation = await getWidgetCacheGenerationAsync(namespace);
|
||||
} catch (error) {
|
||||
logRedisFailure("generation-read", metadata, error);
|
||||
generation = { value: "redis-unavailable", isShared: false };
|
||||
@@ -190,7 +190,7 @@ export const createIntegrationSharedCacheAsync = async <TData>({
|
||||
const metadata = { cacheNamespace: namespace, integrationId };
|
||||
let generation: IntegrationCacheGeneration;
|
||||
try {
|
||||
generation = await withTimeoutAsync(getIntegrationCacheGenerationAsync(integrationId), REDIS_OPERATION_TIMEOUT_MS);
|
||||
generation = await getIntegrationCacheGenerationAsync(integrationId);
|
||||
} catch (error) {
|
||||
logRedisFailure("generation-read", metadata, error);
|
||||
generation = { value: "redis-unavailable", isShared: false };
|
||||
@@ -1,7 +1,6 @@
|
||||
import { invalidateWidgetCache } from "@homarr/redis";
|
||||
import type { WidgetModuleServerCachePolicy } from "@homarr/definitions";
|
||||
|
||||
import { createWidgetSharedCacheAsync, hashSharedCacheInput } from "./integration-shared-cache";
|
||||
import { createWidgetSharedCacheAsync, hashSharedCacheInput } from "./shared-cache";
|
||||
import { createRequestHandler } from "./request-handler";
|
||||
import type { SharedCacheAdapter } from "./request-handler";
|
||||
|
||||
@@ -11,13 +10,12 @@ interface Options<TData, TInput extends Record<string, unknown>> {
|
||||
fallbackToStaleOnError?: boolean;
|
||||
staleIfErrorTtlMs?: number;
|
||||
cacheNamespace?: string;
|
||||
cachePolicy?: WidgetModuleServerCachePolicy & { scope: "shared" };
|
||||
}
|
||||
|
||||
export const createWidgetRequestHandler = <TData, TInput extends Record<string, unknown>>(
|
||||
options: Options<TData, TInput>,
|
||||
) => {
|
||||
const cacheNamespace = options.cachePolicy?.namespace ?? options.cacheNamespace;
|
||||
const cacheNamespace = options.cacheNamespace;
|
||||
let getSharedCacheAsync: ((input: TInput) => Promise<SharedCacheAdapter<TData>>) | undefined;
|
||||
if (cacheNamespace) {
|
||||
getSharedCacheAsync = async (input) =>
|
||||
@@ -29,9 +27,9 @@ export const createWidgetRequestHandler = <TData, TInput extends Record<string,
|
||||
|
||||
const inner = createRequestHandler<TData, TInput>({
|
||||
...options,
|
||||
cacheTtlMs: options.cachePolicy?.ttlMs ?? options.cacheTtlMs,
|
||||
fallbackToStaleOnError: options.cachePolicy?.staleIfErrorTtlMs !== undefined || options.fallbackToStaleOnError,
|
||||
staleIfErrorTtlMs: options.cachePolicy?.staleIfErrorTtlMs ?? options.staleIfErrorTtlMs,
|
||||
cacheTtlMs: options.cacheTtlMs,
|
||||
fallbackToStaleOnError: options.fallbackToStaleOnError,
|
||||
staleIfErrorTtlMs: options.staleIfErrorTtlMs,
|
||||
getCacheKey: hashSharedCacheInput,
|
||||
getSharedCacheAsync,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import type { IMediaOrganizerIntegration, MissingMediaItem, QueuedMediaItem } from "@homarr/integrations/types";
|
||||
import type { MissingMediaItem, QueuedMediaItem } from "@homarr/integrations/types";
|
||||
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
|
||||
@@ -18,7 +18,7 @@ export const mediaOrganizerRequestHandler = createIntegrationRequestHandler<
|
||||
>({
|
||||
cacheNamespace: "media-organizer:summary",
|
||||
async requestAsync(integration, input) {
|
||||
const integrationInstance = (await createIntegrationAsync(integration)) as unknown as IMediaOrganizerIntegration;
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
const [missingResult, queueResult] = await Promise.all([
|
||||
integrationInstance.getMissingAsync(input.pageSize),
|
||||
integrationInstance.getMediaQueueAsync(input.pageSize),
|
||||
|
||||
@@ -6,15 +6,12 @@ import { z } from "zod";
|
||||
import { ResponseError } from "@homarr/common/server";
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout";
|
||||
import { getWidgetServerCachePolicy } from "@homarr/definitions";
|
||||
|
||||
import { createWidgetRequestHandler } from "./lib/widget-request-handler";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
const weatherCachePolicy = getWidgetServerCachePolicy("weather", "atLocation");
|
||||
|
||||
const HOURLY_FORECAST_LENGTH = 24;
|
||||
const DAILY_FORECAST_LENGTH = 7;
|
||||
|
||||
@@ -143,7 +140,8 @@ const requestWeatherAsync = async (input: { latitude: number; longitude: number
|
||||
};
|
||||
|
||||
export const weatherRequestHandler = createWidgetRequestHandler({
|
||||
cachePolicy: weatherCachePolicy,
|
||||
cacheNamespace: "weather:at-location",
|
||||
cacheTtlMs: 5 * 60 * 1000,
|
||||
requestAsync: requestWeatherAsync,
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user