Skip to content

Commit a921b94

Browse files
committed
fix: treat null cached results as cache misses to enable retries and prevent concurrent attempts
1 parent 89d3e61 commit a921b94

File tree

2 files changed

+41
-2
lines changed

2 files changed

+41
-2
lines changed

lib/cache.test.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,10 +97,37 @@ describe("cached assets", () => {
9797
expect(stored?.url).toBe("https://cdn/new.webp");
9898
});
9999

100-
it("propagates not_found via cached null", async () => {
100+
it("retries when null is cached (treats null as miss)", async () => {
101101
const indexKey = ns("test", "asset4");
102102
const lockKey = ns("lock", "test", "asset4");
103103

104+
// Pre-seed cache with null result (simulating previous failure)
105+
const { redis } = await import("@/lib/redis");
106+
await redis.set(indexKey, {
107+
url: null,
108+
expiresAtMs: Date.now() + 1000,
109+
});
110+
111+
let produceCalled = false;
112+
const result = await getOrCreateCachedAsset<{ source: string }>({
113+
indexKey,
114+
lockKey,
115+
ttlSeconds: 60,
116+
produceAndUpload: async () => {
117+
produceCalled = true;
118+
return { url: "https://cdn/recovered.webp" };
119+
},
120+
});
121+
122+
// Should have retried and returned new URL
123+
expect(result).toEqual({ url: "https://cdn/recovered.webp" });
124+
expect(produceCalled).toBe(true);
125+
});
126+
127+
it("writes null to cache to prevent concurrent retries", async () => {
128+
const indexKey = ns("test", "asset5");
129+
const lockKey = ns("lock", "test", "asset5");
130+
104131
const result = await getOrCreateCachedAsset<{ source: string }>({
105132
indexKey,
106133
lockKey,
@@ -109,6 +136,13 @@ describe("cached assets", () => {
109136
});
110137

111138
expect(result).toEqual({ url: null });
139+
140+
// Null should still be written to cache
141+
const { redis } = await import("@/lib/redis");
142+
const stored = (await redis.get(indexKey)) as {
143+
url?: string | null;
144+
} | null;
145+
expect(stored?.url).toBe(null);
112146
});
113147

114148
it("schedules blob deletion with grace period beyond Redis TTL", async () => {

lib/cache.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,8 +172,13 @@ export async function getOrCreateCachedAsset<T extends Record<string, unknown>>(
172172
if (typeof cachedUrl === "string") {
173173
return { url: cachedUrl };
174174
}
175+
// Treat null as cache miss - allows retry on transient failures
176+
// Null will still be written to cache with lock to prevent concurrent retries
175177
if (cachedUrl === null) {
176-
return { url: null };
178+
console.debug(
179+
`[cache] null result in cache ${indexKey}, treating as miss for retry`,
180+
);
181+
// Fall through to retry logic
177182
}
178183
}
179184
} catch (err) {

0 commit comments

Comments
 (0)