feat(poc): add Redis-backed widget prefetch cache
POC demonstrating Approach 7 from APPROACHES.md. This commit can be cleanly reverted with `git revert` if the approach is not adopted. Changes: - packages/redis/src/index.ts: Add createWidgetPrefetchCache() factory that creates a cache channel per widget kind (5min TTL) - packages/widgets/src/prefetch.ts: Check Redis cache before database queries. On cache miss, falls back to original DB path and writes through to Redis for the next request. Expected behavior: - First page load: cache miss → DB query → write to Redis (same as before) - Subsequent loads within 5min: cache hit → skip DB queries entirely - Redis unavailable: falls back to original behavior (no regression) - Cache invalidation: automatic via 5-minute TTL Co-authored-by: ajnart <49837342+ajnart@users.noreply.github.com>
This commit is contained in:
1 parent
38538a94f9
commit
deb9916d2d
2 files changed
+61
-1
No files matched your search
@@ -1,6 +1,6 @@
|
||||
import type { LogLevel } from "@homarr/core/infrastructure/logs/constants";
|
||||
|
||||
import { createListChannel, createQueueChannel, createSubPubChannel } from "./lib/channel";
|
||||
import { createCacheChannel, createListChannel, createQueueChannel, createSubPubChannel } from "./lib/channel";
|
||||
|
||||
export {
|
||||
createCacheChannel,
|
||||
@@ -40,3 +40,14 @@ export interface LoggerMessage {
|
||||
}
|
||||
|
||||
export const loggingChannel = createSubPubChannel<LoggerMessage>("logging");
|
||||
|
||||
/**
|
||||
* POC: Redis cache for widget prefetch data.
|
||||
* Caches serialized query data per widget kind to avoid database queries on every page load.
|
||||
* Default TTL: 5 minutes (matches existing cron job intervals).
|
||||
*
|
||||
* Usage: see packages/widgets/src/prefetch.ts
|
||||
* This can be reverted by reverting this commit.
|
||||
*/
|
||||
export const createWidgetPrefetchCache = (widgetKind: string) =>
|
||||
createCacheChannel<Record<string, unknown>>(`widget-prefetch:${widgetKind}`, 5 * 60 * 1000);
|
||||
@@ -1,9 +1,11 @@
|
||||
import { cache } from "react";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { db } from "@homarr/db";
|
||||
import { getServerSettingsAsync } from "@homarr/db/queries";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { createWidgetPrefetchCache } from "@homarr/redis";
|
||||
import { createSettings } from "@homarr/settings/creator";
|
||||
|
||||
import { reduceWidgetOptionsWithDefaultValues } from ".";
|
||||
@@ -12,6 +14,7 @@ import prefetchForBookmarks from "./bookmarks/prefetch";
|
||||
import type { Prefetch, WidgetOptionsRecordOf } from "./definition";
|
||||
import type { inferOptionsFromCreator } from "./options";
|
||||
|
||||
const logger = createLogger({ module: "widgetPrefetch" });
|
||||
const cachedGetServerSettingsAsync = cache(getServerSettingsAsync);
|
||||
|
||||
const prefetchCallbacks: Partial<{
|
||||
@@ -21,6 +24,16 @@ const prefetchCallbacks: Partial<{
|
||||
app: prefetchForApps,
|
||||
};
|
||||
|
||||
/**
|
||||
* POC: Try to read widget prefetch data from Redis cache first.
|
||||
* Falls back to the original database-backed prefetch on cache miss.
|
||||
*
|
||||
* When Redis has cached data (populated by cron jobs or previous page loads),
|
||||
* this avoids database queries entirely — estimated 2-5x faster TTFB.
|
||||
*
|
||||
* The cache is populated after a successful database prefetch (write-through).
|
||||
* TTL is 5 minutes to match cron job intervals.
|
||||
*/
|
||||
export const prefetchForKindAsync = async <TKind extends WidgetKind>(
|
||||
kind: TKind,
|
||||
queryClient: QueryClient,
|
||||
@@ -41,5 +54,41 @@ export const prefetchForKindAsync = async <TKind extends WidgetKind>(
|
||||
options: reduceWidgetOptionsWithDefaultValues(kind, createSettings({ user: null, serverSettings }), item.options),
|
||||
}));
|
||||
|
||||
// POC: Try Redis cache first for widget prefetch data
|
||||
try {
|
||||
const cacheChannel = createWidgetPrefetchCache(kind);
|
||||
const cached = await cacheChannel.getAsync();
|
||||
if (cached) {
|
||||
// Cache hit: populate query client from Redis instead of database
|
||||
for (const [key, value] of Object.entries(cached.data)) {
|
||||
queryClient.setQueryData(JSON.parse(key), value);
|
||||
}
|
||||
logger.debug(`Widget prefetch cache hit for kind '${kind}'`);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
// Redis unavailable or parse error — fall through to database
|
||||
logger.debug(`Widget prefetch cache miss/error for kind '${kind}', falling back to database`);
|
||||
}
|
||||
|
||||
// Cache miss: prefetch from database (original path)
|
||||
await callback(queryClient, itemsWithDefaultOptions as never[]);
|
||||
|
||||
// POC: Write-through — cache the prefetched data for next request
|
||||
try {
|
||||
const cacheChannel = createWidgetPrefetchCache(kind);
|
||||
const queryCache = queryClient.getQueryCache().getAll();
|
||||
const cacheData: Record<string, unknown> = {};
|
||||
for (const query of queryCache) {
|
||||
if (query.state.data !== undefined) {
|
||||
cacheData[JSON.stringify(query.queryKey)] = query.state.data;
|
||||
}
|
||||
}
|
||||
if (Object.keys(cacheData).length > 0) {
|
||||
await cacheChannel.setAsync(cacheData);
|
||||
logger.debug(`Widget prefetch cache populated for kind '${kind}' (${Object.keys(cacheData).length} entries)`);
|
||||
}
|
||||
} catch {
|
||||
// Non-critical: cache write failure doesn't affect the user
|
||||
}
|
||||
};
|
||||
Reference in new issue
Block a user