diff --git a/runtime/caches/lrucache.ts b/runtime/caches/lrucache.ts index f564cb84c..23ca6f580 100644 --- a/runtime/caches/lrucache.ts +++ b/runtime/caches/lrucache.ts @@ -58,9 +58,27 @@ function createLruCacheStorage(cacheStorageInner: CacheStorage): CacheStorage { assertNoOptions(options); const cacheKey = await requestURLSHA1(request); if (fileCache.has(cacheKey)) { - const result = cacheInner.match(cacheKey); - return result; + return cacheInner.match(cacheKey); } + // Lazy re-index: on a cold LRU (e.g. after pod restart), check if the file + // exists on disk. If still valid, re-index into the LRU so eviction is managed + // normally from this point on. If expired, delete the orphaned file and miss. + // TODO: add a background sweep at startup that deletes expired orphaned files + // that are never re-accessed — they accumulate on disk across restarts and are + // never evicted without this sweep. + const response = await cacheInner.match(cacheKey); + if (!response) return undefined; + const expires = response.headers.get("expires"); + const length = response.headers.get("content-length"); + if (expires && length) { + const ttl = Date.parse(expires) - Date.now() + STALE_TTL_PERIOD; + if (ttl > 0) { + fileCache.set(cacheKey, true, { size: parseInt(length, 10), ttl }); + return response; + } + } + // Expired or missing metadata — delete the orphaned file and treat as a miss. + cacheInner.delete(cacheKey).catch(() => {}); return undefined; }, put: async ( @@ -90,7 +108,7 @@ function createLruCacheStorage(cacheStorageInner: CacheStorage): CacheStorage { return; } fileCache.set(cacheKey, true, { - size: parseInt(length), + size: parseInt(length, 10), ttl, }); return cacheInner.put(cacheKey, response); diff --git a/runtime/caches/mod.test.ts b/runtime/caches/mod.test.ts index f3720d571..d8e57e345 100644 --- a/runtime/caches/mod.test.ts +++ b/runtime/caches/mod.test.ts @@ -137,3 +137,119 @@ Deno.test({ }); // TODO TESTAR O CENARIO ONDE O RESPONSE N TEM LENGTH + +// --------------------------------------------------------------------------- +// Lazy re-index tests +// --------------------------------------------------------------------------- + +const STALE_TTL_PERIOD_MS = parseInt( + Deno.env.get("STALE_TTL_PERIOD") ?? "30000", +); + +Deno.test({ + name: "lru_cache_lazy_reindex: valid entry is served after LRU restart", + sanitizeResources: false, + sanitizeOps: false, +}, async () => { + const disk = new Map(); + const storage1 = testCacheStorage(disk); + + // --- first LRU lifetime: put an entry --- + const cache1 = await headersCache(lruCache(storage1)).open(CACHE_NAME); + const futureExpires = new Date(Date.now() + 60_000).toUTCString(); + await cache1.put( + createRequest(100), + new Response("cached-body", { + headers: { + "content-length": "11", + expires: futureExpires, + }, + }), + ); + + // --- simulate pod restart: new LRU over the *same* disk map --- + const storage2 = testCacheStorage(disk); + const cache2 = await headersCache(lruCache(storage2)).open(CACHE_NAME); + + const response = await cache2.match(createRequest(100)); + assertNotEquals(response, undefined); +}); + +Deno.test({ + name: "lru_cache_lazy_reindex: truly expired entry is evicted on access", + sanitizeResources: false, + sanitizeOps: false, +}, async () => { + const disk = new Map(); + const storage1 = testCacheStorage(disk); + + const cache1 = await headersCache(lruCache(storage1)).open(CACHE_NAME); + // Expired well before now, even accounting for STALE_TTL_PERIOD + const pastExpires = new Date( + Date.now() - STALE_TTL_PERIOD_MS - 60_000, + ).toUTCString(); + await cache1.put( + createRequest(200), + new Response("old-body", { + headers: { + "content-length": "8", + expires: pastExpires, + }, + }), + ); + + // --- simulate pod restart --- + const storage2 = testCacheStorage(disk); + const cache2 = await headersCache(lruCache(storage2)).open(CACHE_NAME); + + const response = await cache2.match(createRequest(200)); + assertEquals(response, undefined); +}); + +Deno.test({ + name: "lru_cache_lazy_reindex: entry missing from disk is a miss", + sanitizeResources: false, + sanitizeOps: false, +}, async () => { + // Empty disk — nothing was ever written + const disk = new Map(); + const storage = testCacheStorage(disk); + const cache = await headersCache(lruCache(storage)).open(CACHE_NAME); + + const response = await cache.match(createRequest(300)); + assertEquals(response, undefined); +}); + +Deno.test({ + name: + "lru_cache_lazy_reindex: re-indexed entry stays accessible on subsequent accesses", + sanitizeResources: false, + sanitizeOps: false, +}, async () => { + const disk = new Map(); + const storage1 = testCacheStorage(disk); + + const cache1 = await headersCache(lruCache(storage1)).open(CACHE_NAME); + const futureExpires = new Date(Date.now() + 60_000).toUTCString(); + await cache1.put( + createRequest(400), + new Response("repeat-body", { + headers: { + "content-length": "11", + expires: futureExpires, + }, + }), + ); + + // --- simulate pod restart --- + const storage2 = testCacheStorage(disk); + const cache2 = await headersCache(lruCache(storage2)).open(CACHE_NAME); + + // First access triggers lazy re-index + const first = await cache2.match(createRequest(400)); + assertNotEquals(first, undefined); + + // Second access should hit the LRU directly (no re-index needed) + const second = await cache2.match(createRequest(400)); + assertNotEquals(second, undefined); +});