fix(request-handler): prevent shared-cache stampede while a refresh lock is held
Waiters that lost the refresh lock polled the shared cache for only 750ms before falling through to the upstream, but the refresh lock lives for 15s. On a cold cache with an upstream slower than 750ms, every waiting pod gave up and stampeded the origin (each also writing L2). Extend the poll window to match the lock TTL so waiters return the entry the holder publishes instead. Adds a regression test that fails without the fix.
This commit is contained in:
2 files changed
+47
-2
No files matched your search
@@ -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<string> | 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<string, { id: string }>({
|
||||
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<SharedCacheAdapter<string>>();
|
||||
const upstreamRequest = deferred<string>();
|
||||
|
||||
@@ -26,7 +26,12 @@ export interface SharedCacheAdapter<TData> {
|
||||
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) =>
|
||||
|
||||
Reference in new issue
Block a user