Skip to content

Commit 89d3e61

Browse files
committed
fix: add grace period to blob deletion scheduling
1 parent e42e8e6 commit 89d3e61

File tree

3 files changed

+106
-5
lines changed

3 files changed

+106
-5
lines changed

lib/blob.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export async function putBlob(options: {
1515
access: "public",
1616
contentType: options.contentType,
1717
cacheControlMaxAge: options.cacheControlMaxAge,
18-
allowOverwrite: true,
18+
allowOverwrite: true, // TODO: temporary fix until KV/blob storage self-heals
1919
});
2020

2121
return {

lib/cache.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,4 +110,81 @@ describe("cached assets", () => {
110110

111111
expect(result).toEqual({ url: null });
112112
});
113+
114+
it("schedules blob deletion with grace period beyond Redis TTL", async () => {
115+
const indexKey = ns("test", "grace-test");
116+
const lockKey = ns("lock", "grace-test");
117+
const purgeQueue = "test-queue";
118+
const ttl = 60; // 1 minute
119+
const gracePeriod = 300; // 5 minutes
120+
121+
const beforeMs = Date.now();
122+
123+
await getOrCreateCachedAsset({
124+
indexKey,
125+
lockKey,
126+
ttlSeconds: ttl,
127+
purgeQueue,
128+
blobGracePeriodSeconds: gracePeriod,
129+
produceAndUpload: async () => ({
130+
url: "https://cdn/grace.webp",
131+
key: "grace-key",
132+
}),
133+
});
134+
135+
const afterMs = Date.now();
136+
137+
const { redis } = await import("@/lib/redis");
138+
const purgeKey = ns("purge", purgeQueue);
139+
const members = (await redis.zrange(purgeKey, 0, -1)) as string[];
140+
141+
expect(members).toHaveLength(1);
142+
expect(members[0]).toBe("https://cdn/grace.webp");
143+
144+
const scheduledDeleteMs = await redis.zscore(purgeKey, members[0]);
145+
expect(scheduledDeleteMs).not.toBeNull();
146+
147+
const expectedMinMs = beforeMs + (ttl + gracePeriod) * 1000;
148+
const expectedMaxMs = afterMs + (ttl + gracePeriod) * 1000;
149+
150+
expect(scheduledDeleteMs).toBeGreaterThanOrEqual(expectedMinMs);
151+
expect(scheduledDeleteMs).toBeLessThanOrEqual(expectedMaxMs);
152+
});
153+
154+
it("uses 24-hour default grace period when not specified", async () => {
155+
const indexKey = ns("test", "default-grace");
156+
const lockKey = ns("lock", "default-grace");
157+
const purgeQueue = "default-queue";
158+
const ttl = 3600; // 1 hour
159+
160+
const beforeMs = Date.now();
161+
162+
await getOrCreateCachedAsset({
163+
indexKey,
164+
lockKey,
165+
ttlSeconds: ttl,
166+
purgeQueue,
167+
// No blobGracePeriodSeconds specified - should default to 86400 (24 hours)
168+
produceAndUpload: async () => ({
169+
url: "https://cdn/default.webp",
170+
}),
171+
});
172+
173+
const afterMs = Date.now();
174+
175+
const { redis } = await import("@/lib/redis");
176+
const purgeKey = ns("purge", purgeQueue);
177+
const members = (await redis.zrange(purgeKey, 0, -1)) as string[];
178+
179+
expect(members).toHaveLength(1);
180+
const scheduledDeleteMs = await redis.zscore(purgeKey, members[0]);
181+
expect(scheduledDeleteMs).not.toBeNull();
182+
183+
const defaultGracePeriod = 86400; // 24 hours
184+
const expectedMinMs = beforeMs + (ttl + defaultGracePeriod) * 1000;
185+
const expectedMaxMs = afterMs + (ttl + defaultGracePeriod) * 1000;
186+
187+
expect(scheduledDeleteMs).toBeGreaterThanOrEqual(expectedMinMs);
188+
expect(scheduledDeleteMs).toBeLessThanOrEqual(expectedMaxMs);
189+
});
113190
});

lib/cache.ts

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -118,9 +118,25 @@ export async function acquireLockOrWaitForResult<T = unknown>(options: {
118118
}
119119

120120
type CachedAssetOptions<TProduceMeta extends Record<string, unknown>> = {
121+
/**
122+
* Index key for the Redis cache
123+
*/
121124
indexKey: string;
125+
/**
126+
* Lock key for the Redis cache
127+
*/
122128
lockKey: string;
123-
ttlSeconds: number;
129+
/**
130+
* TTL in seconds for the Redis cache
131+
* @default 604800 (7 days)
132+
*/
133+
ttlSeconds?: number;
134+
/**
135+
* Grace period in seconds to add to blob deletion schedule beyond Redis TTL
136+
* This prevents race conditions where Redis expires but blob hasn't been pruned yet
137+
* @default 86400 (24 hours)
138+
*/
139+
blobGracePeriodSeconds?: number;
124140
/**
125141
* Produce and upload the asset, returning { url, key } and any metrics to attach
126142
*/
@@ -139,8 +155,14 @@ type CachedAssetOptions<TProduceMeta extends Record<string, unknown>> = {
139155
export async function getOrCreateCachedAsset<T extends Record<string, unknown>>(
140156
options: CachedAssetOptions<T>,
141157
): Promise<{ url: string | null }> {
142-
const { indexKey, lockKey, ttlSeconds, produceAndUpload, purgeQueue } =
143-
options;
158+
const {
159+
indexKey,
160+
lockKey,
161+
ttlSeconds = 604800, // 7 days default
162+
blobGracePeriodSeconds = 86400, // 24 hours default
163+
produceAndUpload,
164+
purgeQueue,
165+
} = options;
144166

145167
// 1) Check index
146168
try {
@@ -209,6 +231,8 @@ export async function getOrCreateCachedAsset<T extends Record<string, unknown>>(
209231
try {
210232
const produced = await produceAndUpload();
211233
const expiresAtMs = Date.now() + ttlSeconds * 1000;
234+
// Schedule blob deletion AFTER Redis TTL + grace period to prevent race conditions
235+
const blobDeleteAtMs = expiresAtMs + blobGracePeriodSeconds * 1000;
212236

213237
try {
214238
// Use pipeline to batch cache writes and lock release
@@ -220,7 +244,7 @@ export async function getOrCreateCachedAsset<T extends Record<string, unknown>>(
220244
);
221245
if (purgeQueue && produced.url) {
222246
pipeline.zadd(ns("purge", purgeQueue), {
223-
score: expiresAtMs,
247+
score: blobDeleteAtMs, // Use extended deadline for blob deletion
224248
member: produced.url,
225249
});
226250
}

0 commit comments

Comments
 (0)