diff --git a/packages/request-handler/src/lib/request-handler.spec.ts b/packages/request-handler/src/lib/request-handler.spec.ts index dddaf781d..f0804601b 100644 --- a/packages/request-handler/src/lib/request-handler.spec.ts +++ b/packages/request-handler/src/lib/request-handler.spec.ts @@ -3,7 +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 type { CacheEntry, SharedCacheAdapter } from "./request-handler"; import { createRequestHandler } from "./request-handler"; import { createWidgetRequestHandler } from "./widget-request-handler"; @@ -80,6 +80,46 @@ describe("createRequestHandler", () => { expect(sharedCache.acquireRefreshLockAsync).not.toHaveBeenCalled(); }); + it("waits out a refresh lock held by another process instead of stampeding the upstream", async () => { + vi.useFakeTimers(); + const timestamp = new Date(); + let sharedEntry: CacheEntry | null = null; + const requestAsync = vi.fn(async () => "upstream"); + const sharedCache = { + generation: "shared-1", + isShared: true, + getAsync: vi.fn(async () => sharedEntry), + setAsync: vi.fn(async () => undefined), + // A different process/pod already holds the refresh lock. + acquireRefreshLockAsync: vi.fn(async () => null), + releaseRefreshLockAsync: vi.fn(async () => undefined), + }; + const handler = createRequestHandler({ + cacheTtlMs: 1_000, + requestAsync, + getSharedCacheAsync: async () => sharedCache, + }); + + const pending = handler.handler({ id: "same" }).getDataAsync(); + + // Poll well past the old fixed wait window while the lock is still held and the + // shared cache is empty: the waiter must keep waiting, not fetch upstream itself. + await vi.advanceTimersByTimeAsync(5_000); + expect(requestAsync).not.toHaveBeenCalled(); + + // The lock holder finishes and publishes the entry to the shared cache. + sharedEntry = { + data: "shared", + timestamp, + expiresAt: Date.now() + 1_000, + staleUntil: Date.now() + 1_000, + }; + await vi.advanceTimersByTimeAsync(100); + + await expect(pending).resolves.toMatchObject({ data: "shared", timestamp }); + expect(requestAsync).not.toHaveBeenCalled(); + }); + it("deduplicates while the shared cache is being resolved", async () => { const sharedCacheRequest = deferred>(); const upstreamRequest = deferred(); diff --git a/packages/request-handler/src/lib/request-handler.ts b/packages/request-handler/src/lib/request-handler.ts index 4c5db9734..672a7c89a 100644 --- a/packages/request-handler/src/lib/request-handler.ts +++ b/packages/request-handler/src/lib/request-handler.ts @@ -26,7 +26,12 @@ export interface SharedCacheAdapter { const MAX_CACHE_SIZE = 1000; const DEFAULT_TTL_MS = 10_000; const DEFAULT_STALE_IF_ERROR_TTL_MS = 5 * 60_000; -const SHARED_REFRESH_WAIT_MS = 750; +// A waiter that loses the refresh lock polls the shared cache until the lock holder +// publishes the entry (the holder writes it before releasing the lock). This window +// must be >= the shared-cache refresh lock TTL (REFRESH_LOCK_TTL_SECONDS in +// shared-cache.ts, currently 15s); otherwise every waiting pod gives up early and +// stampedes the upstream on a cold cache while the holder is still refreshing. +const SHARED_REFRESH_WAIT_MS = 15_000; const SHARED_REFRESH_POLL_MS = 75; const delayAsync = async (durationMs: number) =>