fix: namespace request-handler cache per handler and stop persist retries in demo mode
The in-memory request handler cache introduced in #6075 keyed entries by JSON.stringify(input) in a Map shared across all handlers. Any two handlers querying the same integration with equal options (e.g. {}) collided and read each other's data — with the mock integration serving every widget kind, all demo widgets received whichever handler's data was cached first (beszel getting health-monitoring payloads, etc.). Cache keys are now namespaced per handler definition. Also disable the query-cache persister after the first FORBIDDEN response so demo mode doesn't retry a mutation that is always rejected.
This commit is contained in:
3 files changed
+86
-42
No files matched your search
@@ -1,22 +1,39 @@
|
||||
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
|
||||
import { TRPCClientError } from "@trpc/client";
|
||||
import superjson from "superjson";
|
||||
|
||||
import { fetchApi } from "@homarr/api/client";
|
||||
import { getActiveQueryCacheBoardId, queryCacheStoragePrefix } from "@homarr/api/query-cache";
|
||||
|
||||
// Set once the server rejects a persist mutation as read-only (demo mode),
|
||||
// so we stop retrying a mutation that can never succeed.
|
||||
let persistDisabled = false;
|
||||
|
||||
const isReadOnlyError = (error: unknown) => error instanceof TRPCClientError && error.data?.code === "FORBIDDEN";
|
||||
|
||||
const queryCacheStorage = {
|
||||
getItem: (_key: string) => null as string | null,
|
||||
async setItem(_key: string, value: string) {
|
||||
if (persistDisabled) return;
|
||||
const boardId = getActiveQueryCacheBoardId();
|
||||
if (!boardId) return;
|
||||
await fetchApi.queryCache.setItem.mutate({ boardId, value }).catch((error) => {
|
||||
if (isReadOnlyError(error)) {
|
||||
persistDisabled = true;
|
||||
return;
|
||||
}
|
||||
console.warn("[query-cache] persist failed", error);
|
||||
});
|
||||
},
|
||||
async removeItem(_key: string) {
|
||||
if (persistDisabled) return;
|
||||
const boardId = getActiveQueryCacheBoardId();
|
||||
if (!boardId) return;
|
||||
await fetchApi.queryCache.removeItem.mutate({ boardId }).catch((error) => {
|
||||
if (isReadOnlyError(error)) {
|
||||
persistDisabled = true;
|
||||
return;
|
||||
}
|
||||
console.warn("[query-cache] remove failed", error);
|
||||
});
|
||||
},
|
||||
|
||||
@@ -14,17 +14,28 @@ interface Options<TData, TKind extends IntegrationKind, TInput extends Record<st
|
||||
requestAsync: (integration: IntegrationOfKind<TKind>, input: TInput) => Promise<TData>;
|
||||
}
|
||||
|
||||
let integrationHandlerCounter = 0;
|
||||
|
||||
export const createIntegrationRequestHandler = <
|
||||
TData,
|
||||
TKind extends IntegrationKind,
|
||||
TInput extends Record<string, unknown>,
|
||||
>(
|
||||
options: Options<TData, TKind, TInput>,
|
||||
) => ({
|
||||
handler: (integration: IntegrationOfKind<TKind>, itemOptions: TInput) => {
|
||||
const inner = createRequestHandler<TData, { integrationId: string; options: TInput }>({
|
||||
requestAsync: async (input) => options.requestAsync(integration, input.options),
|
||||
});
|
||||
return inner.handler({ integrationId: integration.id, options: itemOptions });
|
||||
},
|
||||
});
|
||||
) => {
|
||||
// Stable per handler definition: the inner request handler is re-created on every
|
||||
// call, so without an explicit cacheKey each call would get a fresh namespace
|
||||
// (no cache hits) — and without any namespace different handlers hitting the same
|
||||
// integration with equal options would share cache entries and cross their data.
|
||||
const cacheKey = `integration-handler-${integrationHandlerCounter++}`;
|
||||
|
||||
return {
|
||||
handler: (integration: IntegrationOfKind<TKind>, itemOptions: TInput) => {
|
||||
const inner = createRequestHandler<TData, { integrationId: string; options: TInput }>({
|
||||
cacheKey,
|
||||
requestAsync: async (input) => options.requestAsync(integration, input.options),
|
||||
});
|
||||
return inner.handler({ integrationId: integration.id, options: itemOptions });
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,6 +1,13 @@
|
||||
interface Options<TData, TInput extends Record<string, unknown>> {
|
||||
requestAsync: (input: TInput) => Promise<TData>;
|
||||
cacheTtlMs?: number;
|
||||
/**
|
||||
* Namespace for cache entries of this handler. Defaults to a unique id per
|
||||
* createRequestHandler call. Callers that re-create handlers per request
|
||||
* (e.g. createIntegrationRequestHandler) must pass a stable key themselves,
|
||||
* otherwise the cache never hits.
|
||||
*/
|
||||
cacheKey?: string;
|
||||
}
|
||||
|
||||
type CacheEntry<TData> = { data: TData; timestamp: Date; expiresAt: number };
|
||||
@@ -18,43 +25,52 @@ const evictExpired = () => {
|
||||
}
|
||||
};
|
||||
|
||||
let autoCacheKeyCounter = 0;
|
||||
|
||||
export const createRequestHandler = <TData, TInput extends Record<string, unknown>>(
|
||||
options: Options<TData, TInput>,
|
||||
) => ({
|
||||
handler: (input: TInput) => ({
|
||||
async getDataAsync(): Promise<{ data: TData; timestamp: Date }> {
|
||||
const ttl = options.cacheTtlMs ?? DEFAULT_TTL_MS;
|
||||
const key = JSON.stringify(input);
|
||||
) => {
|
||||
// The cache map is shared between all handlers, so entries have to be namespaced
|
||||
// per handler — otherwise handlers receiving the same input (e.g. the same
|
||||
// integrationId with empty options) would read each other's data.
|
||||
const cacheKey = options.cacheKey ?? `handler-${autoCacheKeyCounter++}`;
|
||||
|
||||
const cached = cache.get(key) as CacheEntry<TData> | undefined;
|
||||
if (cached && Date.now() < cached.expiresAt) {
|
||||
return { data: cached.data, timestamp: cached.timestamp };
|
||||
}
|
||||
if (cached) cache.delete(key);
|
||||
return {
|
||||
handler: (input: TInput) => ({
|
||||
async getDataAsync(): Promise<{ data: TData; timestamp: Date }> {
|
||||
const ttl = options.cacheTtlMs ?? DEFAULT_TTL_MS;
|
||||
const key = `${cacheKey}:${JSON.stringify(input)}`;
|
||||
|
||||
const existing = inflight.get(key);
|
||||
if (existing) return existing as Promise<CacheEntry<TData>>;
|
||||
const cached = cache.get(key) as CacheEntry<TData> | undefined;
|
||||
if (cached && Date.now() < cached.expiresAt) {
|
||||
return { data: cached.data, timestamp: cached.timestamp };
|
||||
}
|
||||
if (cached) cache.delete(key);
|
||||
|
||||
const promise = options
|
||||
.requestAsync(input)
|
||||
.then((data) => {
|
||||
if (cache.size >= MAX_CACHE_SIZE) evictExpired();
|
||||
if (cache.size >= MAX_CACHE_SIZE) {
|
||||
const oldest = cache.keys().next().value;
|
||||
if (oldest) cache.delete(oldest);
|
||||
}
|
||||
const entry: CacheEntry<TData> = { data, timestamp: new Date(), expiresAt: Date.now() + ttl };
|
||||
cache.set(key, entry as CacheEntry<unknown>);
|
||||
inflight.delete(key);
|
||||
return entry;
|
||||
})
|
||||
.catch((err) => {
|
||||
inflight.delete(key);
|
||||
throw err;
|
||||
});
|
||||
const existing = inflight.get(key);
|
||||
if (existing) return existing as Promise<CacheEntry<TData>>;
|
||||
|
||||
inflight.set(key, promise as Promise<CacheEntry<unknown>>);
|
||||
return promise;
|
||||
},
|
||||
}),
|
||||
});
|
||||
const promise = options
|
||||
.requestAsync(input)
|
||||
.then((data) => {
|
||||
if (cache.size >= MAX_CACHE_SIZE) evictExpired();
|
||||
if (cache.size >= MAX_CACHE_SIZE) {
|
||||
const oldest = cache.keys().next().value;
|
||||
if (oldest) cache.delete(oldest);
|
||||
}
|
||||
const entry: CacheEntry<TData> = { data, timestamp: new Date(), expiresAt: Date.now() + ttl };
|
||||
cache.set(key, entry as CacheEntry<unknown>);
|
||||
inflight.delete(key);
|
||||
return entry;
|
||||
})
|
||||
.catch((err) => {
|
||||
inflight.delete(key);
|
||||
throw err;
|
||||
});
|
||||
|
||||
inflight.set(key, promise as Promise<CacheEntry<unknown>>);
|
||||
return promise;
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
Reference in new issue
Block a user