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:
copilot-swe-agent[bot]andajnart committed 2026-03-15 15:26:04 +00:00
1 parent 38538a94f9
commit deb9916d2d
2 files changed
+61 -1

No files matched your search

+12 -1
View File
@@ -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);
+49
View File
@@ -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
}
};