Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/basket/src/hooks/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ const getWebsiteByIdWithOwnerCached = cacheable(
prefix: cacheNamespaces.websiteWithOwner,
staleWhileRevalidate: true,
staleTime: 120,
queryTimeoutMs: 10_000,
}
);

Expand Down
100 changes: 100 additions & 0 deletions packages/redis/cacheable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -995,6 +995,106 @@ describe("cacheable", () => {
});
});

describe("query timeout", () => {
it("rejects when the underlying function exceeds queryTimeoutMs", async () => {
const cached = cacheable(
(): Promise<string> =>
new Promise((resolve) => setTimeout(() => resolve("late"), 5000)),
{
expireInSec: 60,
prefix: "qtimeout-basic",
queryTimeoutMs: 50,
}
);

await expect(cached()).rejects.toThrow("Query timeout");
}, 5000);

it("cleans up inflightRequests after a timeout so the next call retries", async () => {
let callCount = 0;
const original = mock((): Promise<string> => {
callCount += 1;
if (callCount === 1) {
return new Promise((resolve) =>
setTimeout(() => resolve("late"), 5000)
);
}
return Promise.resolve("fast");
});

const cached = cacheable(original, {
expireInSec: 60,
prefix: "qtimeout-retry",
queryTimeoutMs: 50,
});

await expect(cached()).rejects.toThrow("Query timeout");

const result = await cached();
expect(result).toBe("fast");
expect(original).toHaveBeenCalledTimes(2);
}, 5000);

it("all concurrent callers fail when the shared inflight promise times out", async () => {
const cached = cacheable(
(): Promise<string> =>
new Promise((resolve) => setTimeout(() => resolve("late"), 5000)),
{
expireInSec: 60,
prefix: "qtimeout-concurrent",
queryTimeoutMs: 50,
}
);

const results = await Promise.allSettled([
cached(),
cached(),
cached(),
]);

for (const result of results) {
expect(result.status).toBe("rejected");
if (result.status === "rejected") {
expect(result.reason.message).toBe("Query timeout");
}
}
}, 5000);

it("does not time out fast functions", async () => {
const cached = cacheable(async () => "quick", {
expireInSec: 60,
prefix: "qtimeout-fast",
queryTimeoutMs: 1000,
});
Comment on lines +1048 to +1068

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Missing test for background stale-while-revalidate timeout path

The triggerBackgroundRevalidation function now accepts queryTimeoutMs and uses withTimeout around the fn() call (line 223–225 of cacheable.ts), but the test suite has no case that exercises this path. A test with staleWhileRevalidate: true and a slow function would confirm the background fetch is bounded and that the stale value is still served to the caller (i.e. the timeout in background doesn't surface to callers).

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


const result = await cached();
expect(result).toBe("quick");
});

it("applies timeout even when Redis is unavailable (circuit-breaker path)", async () => {
// First break Redis so we go to the direct-call path
mockGet.mockImplementation(() => Promise.reject(new Error("down")));
const setup = cacheable(async () => "x", {
expireInSec: 1,
prefix: "qtimeout-setup",
});
await setup();

// Now use a slow function with a short timeout on the bypass path
const cached = cacheable(
(): Promise<string> =>
new Promise((resolve) => setTimeout(() => resolve("late"), 5000)),
{
expireInSec: 60,
prefix: "qtimeout-no-redis",
queryTimeoutMs: 50,
}
);

await expect(cached()).rejects.toThrow("Query timeout");
}, 5000);
});

describe("edge cases", () => {
it("works with no arguments", async () => {
const cached = cacheable(async () => "no-args", {
Expand Down
40 changes: 32 additions & 8 deletions packages/redis/cacheable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@ interface CacheOptions<
staleTime?: number;
staleWhileRevalidate?: boolean;
tags?: CacheTagger<T>;
/**
* Maximum milliseconds to wait for the underlying function (e.g. a DB
* query) before rejecting with a "Query timeout" error. When unset the
* function may hang indefinitely. Background stale-while-revalidate
* fetches are also bounded by this value.
*/
queryTimeoutMs?: number;
}

export type CacheableFunction<
Expand Down Expand Up @@ -86,12 +93,16 @@ function markRedisUnhealthy() {
lastRedisCheck = Date.now();
}

function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
function withTimeout<T>(
promise: Promise<T>,
ms: number,
message = "Redis timeout"
): Promise<T> {
let timer: ReturnType<typeof setTimeout>;
return Promise.race([
promise,
new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error("Redis timeout")), ms);
timer = setTimeout(() => reject(new Error(message)), ms);
}),
]).finally(() => clearTimeout(timer));
}
Expand Down Expand Up @@ -192,7 +203,8 @@ function triggerBackgroundRevalidation<
expireInSec: number,
staleTime: number,
prefix: string,
tags?: CacheTagger<T>
tags?: CacheTagger<T>,
queryTimeoutMs?: number
) {
if (activeRevalidations.has(key)) {
return;
Expand All @@ -208,7 +220,9 @@ function triggerBackgroundRevalidation<
return;
}

const fresh = await fn();
const fresh = await (queryTimeoutMs != null
? withTimeout(fn(), queryTimeoutMs, "Query timeout")
: fn());
if (fresh != null && redisAvailable) {
try {
const serialized = JSON.stringify(fresh);
Expand Down Expand Up @@ -241,6 +255,7 @@ export function cacheable<
staleWhileRevalidate = false,
staleTime = 0,
tags,
queryTimeoutMs,
} = typeof options === "number" ? { expireInSec: options } : options;

const cachePrefix = `cacheable:${prefix}`;
Expand All @@ -251,7 +266,9 @@ export function cacheable<
...args: Parameters<T>
): Promise<Awaited<ReturnType<T>>> => {
if (shouldSkipRedis()) {
return fn(...args);
return queryTimeoutMs != null
? withTimeout(fn(...args), queryTimeoutMs, "Query timeout")
: fn(...args);
}

const key = getKey(...args);
Expand All @@ -270,7 +287,9 @@ export function cacheable<
durationMs: performance.now() - lookupStartedAt,
hit: false,
});
return fn(...args);
return queryTimeoutMs != null
? withTimeout(fn(...args), queryTimeoutMs, "Query timeout")
: fn(...args);
}

timingFn?.({
Expand All @@ -287,7 +306,8 @@ export function cacheable<
expireInSec,
staleTime,
prefix,
tags
tags,
queryTimeoutMs
);
}

Expand All @@ -302,7 +322,11 @@ export function cacheable<
return (await inflightRequests.get(key)) as Awaited<ReturnType<T>>;
}

const promise = fn(...args);
const rawPromise = fn(...args);
const promise =
queryTimeoutMs != null
? withTimeout(rawPromise, queryTimeoutMs, "Query timeout")
: rawPromise;
inflightRequests.set(key, promise);

try {
Expand Down
Loading