diff --git a/runtime/caches/cacheWriteWorker.ts b/runtime/caches/cacheWriteWorker.ts index ba92708c5..31ee9e84e 100644 --- a/runtime/caches/cacheWriteWorker.ts +++ b/runtime/caches/cacheWriteWorker.ts @@ -1,6 +1,10 @@ // Worker thread for cache write operations. // Offloads SHA1 hashing, buffer combining, and FS writes from the main event loop. +const CACHE_MAX_ENTRY_SIZE = parseInt( + Deno.env.get("CACHE_MAX_ENTRY_SIZE") ?? "2097152", // 2 MB +) || 2097152; + const textEncoder = new TextEncoder(); const initializedDirs = new Set(); @@ -57,6 +61,12 @@ function generateCombinedBuffer( return buf; } +function shardedPath(cacheDir: string, key: string): string { + const l1 = key.substring(0, 2); + const l2 = key.substring(2, 4); + return `${cacheDir}/${l1}/${l2}/${key}`; +} + // --- Message handler --- export interface CacheWriteMessage { @@ -85,8 +95,12 @@ self.onmessage = async (e: MessageEvent) => { // Combine into single buffer const buffer = generateCombinedBuffer(body, headersBytes); - // Write to filesystem - const filePath = `${cacheDir}/${cacheKey}`; + if (buffer.length > CACHE_MAX_ENTRY_SIZE) return; + + // Write to filesystem (with hex sharding for directory distribution) + const filePath = shardedPath(cacheDir, cacheKey); + const dir = filePath.substring(0, filePath.lastIndexOf("/")); + ensureCacheDir(dir); await Deno.writeFile(filePath, buffer); } catch (err) { console.error("[cache-write-worker] error:", err); diff --git a/runtime/caches/fileSystem.ts b/runtime/caches/fileSystem.ts index 036ae93cd..840db6a9a 100644 --- a/runtime/caches/fileSystem.ts +++ b/runtime/caches/fileSystem.ts @@ -11,6 +11,45 @@ import { const FILE_SYSTEM_CACHE_DIRECTORY = Deno.env.get("FILE_SYSTEM_CACHE_DIRECTORY") ?? "/tmp/deco_cache"; +const CACHE_MAX_ENTRY_SIZE = parseInt( + Deno.env.get("CACHE_MAX_ENTRY_SIZE") ?? "2097152", // 2 MB +) || 2097152; + +// Warn when write rate exceeds this many writes per minute. +// High write rates usually indicate bots, missing cache keys, or very short TTLs. +const CACHE_WRITE_RATE_WARN = parseInt( + Deno.env.get("CACHE_WRITE_RATE_WARN") ?? "500", +) || 500; + +// --- Write rate tracking --- +let writeCount = 0; +let writeWindowStart = Date.now(); + +function trackWriteRate(key: string) { + const now = Date.now(); + if (now - writeWindowStart > 60_000) { + writeWindowStart = now; + writeCount = 0; + } + writeCount++; + if (writeCount === CACHE_WRITE_RATE_WARN) { + logger.warn( + `fs_cache: high write rate — ${writeCount} writes in the last minute. ` + + `Latest key: ${key}. ` + + `Consider increasing CACHE_MAX_AGE_S or reviewing loader cacheKey functions. ` + + `Adjust threshold with CACHE_WRITE_RATE_WARN (current: ${CACHE_WRITE_RATE_WARN}/min).`, + ); + } +} + +const initializedShardDirs = new Set(); + +function shardedPath(cacheDir: string, key: string): string { + const l1 = key.substring(0, 2); + const l2 = key.substring(2, 4); + return `${cacheDir}/${l1}/${l2}/${key}`; +} + // Reuse TextEncoder instance to avoid repeated instantiation const textEncoder = new TextEncoder(); @@ -106,7 +145,7 @@ function createFileSystemCache(): CacheStorage { if ( FILE_SYSTEM_CACHE_DIRECTORY && !existsSync(FILE_SYSTEM_CACHE_DIRECTORY) ) { - await Deno.mkdirSync(FILE_SYSTEM_CACHE_DIRECTORY, { recursive: true }); + await Deno.mkdir(FILE_SYSTEM_CACHE_DIRECTORY, { recursive: true }); } isCacheInitialized = true; } catch (err) { @@ -118,11 +157,25 @@ function createFileSystemCache(): CacheStorage { key: string, responseArray: Uint8Array, ) { + if (responseArray.length > CACHE_MAX_ENTRY_SIZE) { + // Evict any existing entry so stale data doesn't stay pinned on disk. + deleteFile(key).catch(() => {}); + return; + } if (!isCacheInitialized) { await assertCacheDirectory(); } - const filePath = `${FILE_SYSTEM_CACHE_DIRECTORY}/${key}`; - + trackWriteRate(key); + const filePath = shardedPath(FILE_SYSTEM_CACHE_DIRECTORY, key); + const dir = filePath.substring(0, filePath.lastIndexOf("/")); + if (!initializedShardDirs.has(dir)) { + try { + await Deno.mkdir(dir, { recursive: true }); + initializedShardDirs.add(dir); + } catch { + // transient failure — don't mark initialized so next write retries mkdir + } + } await Deno.writeFile(filePath, responseArray); return; } @@ -132,8 +185,12 @@ function createFileSystemCache(): CacheStorage { await assertCacheDirectory(); } try { - const filePath = `${FILE_SYSTEM_CACHE_DIRECTORY}/${key}`; + const filePath = shardedPath(FILE_SYSTEM_CACHE_DIRECTORY, key); const fileContent = await Deno.readFile(filePath); + if (fileContent.length > CACHE_MAX_ENTRY_SIZE) { + Deno.remove(filePath).catch(() => {}); + return null; + } return fileContent; } catch (_err) { const err = _err as { code?: string }; @@ -151,7 +208,7 @@ function createFileSystemCache(): CacheStorage { await assertCacheDirectory(); } try { - const filePath = `${FILE_SYSTEM_CACHE_DIRECTORY}/${key}`; + const filePath = shardedPath(FILE_SYSTEM_CACHE_DIRECTORY, key); await Deno.remove(filePath); return true; } catch (err) {