diff --git a/packages/api/src/cache-tags.spec.ts b/packages/api/src/cache-tags.spec.ts index 4c929dae3..eb7552d17 100644 --- a/packages/api/src/cache-tags.spec.ts +++ b/packages/api/src/cache-tags.spec.ts @@ -10,7 +10,6 @@ describe("cacheTags", () => { expect(cacheTags.boardList()).toBe("board-list"); expect(cacheTags.user("u1")).toBe("user:u1"); expect(cacheTags.integration("i1")).toBe("integration:i1"); - expect(cacheTags.app("a1")).toBe("app:a1"); }); it("boardByName uppercases consistently", () => { diff --git a/packages/api/src/cache-tags.ts b/packages/api/src/cache-tags.ts index d22fe22f4..0df0f7271 100644 --- a/packages/api/src/cache-tags.ts +++ b/packages/api/src/cache-tags.ts @@ -5,5 +5,4 @@ export const cacheTags = { boardList: () => "board-list" as const, user: (userId: string) => `user:${userId}` as const, integration: (integrationId: string) => `integration:${integrationId}` as const, - app: (appId: string) => `app:${appId}` as const, } as const; diff --git a/packages/api/src/integration-data-cache.ts b/packages/api/src/integration-data-cache.ts index d55fa8735..adcedf77f 100644 --- a/packages/api/src/integration-data-cache.ts +++ b/packages/api/src/integration-data-cache.ts @@ -1,6 +1,6 @@ // ponytail: module-level cache is single-instance only. -// Multi-instance deployments (REDIS_IS_EXTERNAL=true) bypass this and rely on -// the Redis-backed Next cache handler for cross-instance coordination. +// Multi-instance deployments (REDIS_IS_EXTERNAL=true) bypass this entirely — +// each request calls the upstream fetcher directly. const isMultiInstance = process.env.REDIS_IS_EXTERNAL === "true"; interface CachedEntry { diff --git a/packages/api/src/router/widgets/anchor-notes.ts b/packages/api/src/router/widgets/anchor-notes.ts index d431c0de9..445b5b0c9 100644 --- a/packages/api/src/router/widgets/anchor-notes.ts +++ b/packages/api/src/router/widgets/anchor-notes.ts @@ -7,6 +7,7 @@ import { anchorNotesListInputSchema, anchorNoteUpdateInputSchema, createIntegrat import { anchorNoteRequestHandler, anchorNotesListRequestHandler } from "@homarr/request-handler/anchor-notes"; import { createOneIntegrationMiddleware } from "../../middlewares/integration"; +import { invalidateIntegrationDataCache } from "../../integration-data-cache"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc"; const noteIdInput = z.object({ @@ -75,6 +76,7 @@ export const anchorNotesRouter = createTRPCRouter({ ...(normalizedContent !== undefined ? { content: normalizedContent } : {}), }); + invalidateIntegrationDataCache(ctx.integration.id); return updatedNote; } catch (error) { if (error instanceof ResponseError && error.statusCode === 400) { diff --git a/packages/api/src/router/widgets/dns-hole.ts b/packages/api/src/router/widgets/dns-hole.ts index 0318f1b1a..fde3dc291 100644 --- a/packages/api/src/router/widgets/dns-hole.ts +++ b/packages/api/src/router/widgets/dns-hole.ts @@ -5,6 +5,7 @@ import { createIntegrationAsync } from "@homarr/integrations"; import { dnsHoleRequestHandler } from "@homarr/request-handler/dns-hole"; import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration"; +import { invalidateIntegrationDataCache } from "../../integration-data-cache"; import { integrationQueryKey, settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc"; @@ -44,6 +45,7 @@ export const dnsHoleRouter = createTRPCRouter({ .mutation(async ({ ctx: { integration } }) => { const client = await createIntegrationAsync(integration); await client.enableAsync(); + invalidateIntegrationDataCache(integration.id); }), disable: protectedProcedure @@ -63,5 +65,6 @@ export const dnsHoleRouter = createTRPCRouter({ .mutation(async ({ ctx: { integration }, input }) => { const client = await createIntegrationAsync(integration); await client.disableAsync(input.duration); + invalidateIntegrationDataCache(integration.id); }), }); diff --git a/packages/api/src/router/widgets/downloads.ts b/packages/api/src/router/widgets/downloads.ts index c4de1e424..884090044 100644 --- a/packages/api/src/router/widgets/downloads.ts +++ b/packages/api/src/router/widgets/downloads.ts @@ -6,6 +6,7 @@ import { downloadClientRequestHandler } from "@homarr/request-handler/downloads" import type { IntegrationAction } from "../../middlewares/integration"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; +import { invalidateIntegrationDataCache } from "../../integration-data-cache"; import { integrationQueryKey, settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc"; @@ -53,6 +54,9 @@ export const downloadsRouter = createTRPCRouter({ await integrationInstance.pauseQueueAsync(); }), ); + for (const integration of ctx.integrations) { + invalidateIntegrationDataCache(integration.id); + } downloadClientRequestHandler.invalidateCache(); }), pauseItem: protectedProcedure @@ -65,6 +69,9 @@ export const downloadsRouter = createTRPCRouter({ await integrationInstance.pauseItemAsync(input.item); }), ); + for (const integration of ctx.integrations) { + invalidateIntegrationDataCache(integration.id); + } downloadClientRequestHandler.invalidateCache(); }), resume: protectedProcedure @@ -83,6 +90,9 @@ export const downloadsRouter = createTRPCRouter({ await integrationInstance.resumeQueueAsync(); }), ); + for (const integration of ctx.integrations) { + invalidateIntegrationDataCache(integration.id); + } downloadClientRequestHandler.invalidateCache(); }), resumeItem: protectedProcedure @@ -95,6 +105,9 @@ export const downloadsRouter = createTRPCRouter({ await integrationInstance.resumeItemAsync(input.item); }), ); + for (const integration of ctx.integrations) { + invalidateIntegrationDataCache(integration.id); + } downloadClientRequestHandler.invalidateCache(); }), deleteItem: protectedProcedure @@ -107,6 +120,9 @@ export const downloadsRouter = createTRPCRouter({ await integrationInstance.deleteItemAsync(input.item, input.fromDisk); }), ); + for (const integration of ctx.integrations) { + invalidateIntegrationDataCache(integration.id); + } downloadClientRequestHandler.invalidateCache(); }), }); diff --git a/packages/api/src/router/widgets/indexer-manager.ts b/packages/api/src/router/widgets/indexer-manager.ts index 53a6a20d0..b735ac4e7 100644 --- a/packages/api/src/router/widgets/indexer-manager.ts +++ b/packages/api/src/router/widgets/indexer-manager.ts @@ -6,6 +6,7 @@ import { indexerManagerRequestHandler } from "@homarr/request-handler/indexer-ma import type { IntegrationAction } from "../../middlewares/integration"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; +import { invalidateIntegrationDataCache } from "../../integration-data-cache"; import { integrationQueryKey, settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc"; @@ -45,5 +46,8 @@ export const indexerManagerRouter = createTRPCRouter({ }); }), ); + for (const integration of ctx.integrations) { + invalidateIntegrationDataCache(integration.id); + } }), }); diff --git a/packages/api/src/router/widgets/media-requests.ts b/packages/api/src/router/widgets/media-requests.ts index f2c85d477..e24d2a2bd 100644 --- a/packages/api/src/router/widgets/media-requests.ts +++ b/packages/api/src/router/widgets/media-requests.ts @@ -10,6 +10,7 @@ import { import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats"; import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration"; +import { invalidateIntegrationDataCache } from "../../integration-data-cache"; import { integrationQueryKey, settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc"; @@ -107,5 +108,6 @@ export const mediaRequestsRouter = createTRPCRouter({ } as const; await answerActions[input.answer](input.requestId); + invalidateIntegrationDataCache(integration.id); }), }); diff --git a/packages/api/src/router/widgets/options.ts b/packages/api/src/router/widgets/options.ts index 8dc5a0653..6f56567a6 100644 --- a/packages/api/src/router/widgets/options.ts +++ b/packages/api/src/router/widgets/options.ts @@ -7,6 +7,7 @@ import { getServerSettingsAsync } from "@homarr/db/queries"; import { boards, items } from "@homarr/db/schema"; import type { WidgetOptionsSettings } from "../../../../widgets/src/definition"; +import { invalidateBoardCache } from "../../cache-invalidation"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc"; import { throwIfActionForbiddenAsync } from "../board/board-access"; @@ -51,5 +52,7 @@ export const optionsRouter = createTRPCRouter({ .update(items) .set({ options: SuperJSON.stringify(options) }) .where(eq(items.id, input.itemId)); + + invalidateBoardCache(input.boardId); }), }); diff --git a/packages/api/src/router/widgets/smart-home.ts b/packages/api/src/router/widgets/smart-home.ts index c37468752..241c18d7f 100644 --- a/packages/api/src/router/widgets/smart-home.ts +++ b/packages/api/src/router/widgets/smart-home.ts @@ -6,6 +6,7 @@ import { smartHomeEntityStateRequestHandler } from "@homarr/request-handler/smar import type { IntegrationAction } from "../../middlewares/integration"; import { createOneIntegrationMiddleware } from "../../middlewares/integration"; +import { invalidateIntegrationDataCache } from "../../integration-data-cache"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc"; const createSmartHomeIntegrationMiddleware = (action: IntegrationAction) => @@ -41,6 +42,7 @@ export const smartHomeRouter = createTRPCRouter({ const client = await createIntegrationAsync(integration); const success = await client.triggerToggleAsync(input.entityId); + invalidateIntegrationDataCache(integration.id); return success; }), executeAutomation: protectedProcedure @@ -56,5 +58,6 @@ export const smartHomeRouter = createTRPCRouter({ .mutation(async ({ ctx: { integration }, input }) => { const client = await createIntegrationAsync(integration); await client.triggerAutomationAsync(input.automationId); + invalidateIntegrationDataCache(integration.id); }), }); diff --git a/packages/next-cache-handler/src/redis-handler.ts b/packages/next-cache-handler/src/redis-handler.ts index 965f6274f..cddcbd93e 100644 --- a/packages/next-cache-handler/src/redis-handler.ts +++ b/packages/next-cache-handler/src/redis-handler.ts @@ -152,13 +152,36 @@ export class RedisCacheHandler implements CacheHandler { async getExpiration(tags: string[]): Promise { if (tags.length === 0) return 0; + const missingTags: string[] = []; let maxTimestamp = 0; + for (const tag of tags) { const cached = this.tagCache.get(tag); - if (cached && cached > maxTimestamp) { - maxTimestamp = cached; + if (cached !== undefined) { + if (cached > maxTimestamp) maxTimestamp = cached; + } else { + missingTags.push(tag); } } + + if (missingTags.length > 0) { + try { + await this.redis.connect().catch(() => {}); + const keys = missingTags.map((t) => this.tagKey(t)); + const values = await this.redis.mget(...keys); + for (let i = 0; i < missingTags.length; i++) { + const val = values[i]; + if (val) { + const ts = Number(val); + this.tagCache.set(missingTags[i]!, ts); + if (ts > maxTimestamp) maxTimestamp = ts; + } + } + } catch { + // Fail open + } + } + return maxTimestamp; }