Skip to content

Commit c236751

Browse files
vibeguiclaude
andcommitted
feat(cache): filesystem hardening with hex sharding, size limits, and warnings
- Add hex directory sharding ({key[0:2]}/{key[2:4]}/{key}) to avoid flat dirs with 10k+ files - Enforce CACHE_MAX_ENTRY_SIZE (2MB default) at filesystem and worker layers - Evict existing stale entries when oversized writes are rejected - Add write rate warning (CACHE_WRITE_RATE_WARN, default 500/min) - Add disk fill warning when LRU tracked bytes exceed LRU_DISK_WARN_RATIO (90%) - Add LRU eviction counter with reason dimension - Add observable gauges for LRU keys and bytes per cache Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f8d4b09 commit c236751

File tree

3 files changed

+131
-8
lines changed

3 files changed

+131
-8
lines changed

runtime/caches/cacheWriteWorker.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
// Worker thread for cache write operations.
22
// Offloads SHA1 hashing, buffer combining, and FS writes from the main event loop.
33

4+
const CACHE_MAX_ENTRY_SIZE = parseInt(
5+
Deno.env.get("CACHE_MAX_ENTRY_SIZE") ?? "2097152", // 2 MB
6+
) || 2097152;
7+
48
const textEncoder = new TextEncoder();
59

610
const initializedDirs = new Set<string>();
@@ -57,6 +61,12 @@ function generateCombinedBuffer(
5761
return buf;
5862
}
5963

64+
function shardedPath(cacheDir: string, key: string): string {
65+
const l1 = key.substring(0, 2);
66+
const l2 = key.substring(2, 4);
67+
return `${cacheDir}/${l1}/${l2}/${key}`;
68+
}
69+
6070
// --- Message handler ---
6171

6272
export interface CacheWriteMessage {
@@ -85,8 +95,12 @@ self.onmessage = async (e: MessageEvent<CacheWriteMessage>) => {
8595
// Combine into single buffer
8696
const buffer = generateCombinedBuffer(body, headersBytes);
8797

88-
// Write to filesystem
89-
const filePath = `${cacheDir}/${cacheKey}`;
98+
if (buffer.length > CACHE_MAX_ENTRY_SIZE) return;
99+
100+
// Write to filesystem (with hex sharding for directory distribution)
101+
const filePath = shardedPath(cacheDir, cacheKey);
102+
const dir = filePath.substring(0, filePath.lastIndexOf("/"));
103+
ensureCacheDir(dir);
90104
await Deno.writeFile(filePath, buffer);
91105
} catch (err) {
92106
console.error("[cache-write-worker] error:", err);

runtime/caches/fileSystem.ts

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,45 @@ import {
1111
const FILE_SYSTEM_CACHE_DIRECTORY =
1212
Deno.env.get("FILE_SYSTEM_CACHE_DIRECTORY") ?? "/tmp/deco_cache";
1313

14+
const CACHE_MAX_ENTRY_SIZE = parseInt(
15+
Deno.env.get("CACHE_MAX_ENTRY_SIZE") ?? "2097152", // 2 MB
16+
) || 2097152;
17+
18+
// Warn when write rate exceeds this many writes per minute.
19+
// High write rates usually indicate bots, missing cache keys, or very short TTLs.
20+
const CACHE_WRITE_RATE_WARN = parseInt(
21+
Deno.env.get("CACHE_WRITE_RATE_WARN") ?? "500",
22+
) || 500;
23+
24+
// --- Write rate tracking ---
25+
let writeCount = 0;
26+
let writeWindowStart = Date.now();
27+
28+
function trackWriteRate(key: string) {
29+
const now = Date.now();
30+
if (now - writeWindowStart > 60_000) {
31+
writeWindowStart = now;
32+
writeCount = 0;
33+
}
34+
writeCount++;
35+
if (writeCount === CACHE_WRITE_RATE_WARN) {
36+
logger.warn(
37+
`fs_cache: high write rate — ${writeCount} writes in the last minute. ` +
38+
`Latest key: ${key}. ` +
39+
`Consider increasing CACHE_MAX_AGE_S or reviewing loader cacheKey functions. ` +
40+
`Adjust threshold with CACHE_WRITE_RATE_WARN (current: ${CACHE_WRITE_RATE_WARN}/min).`,
41+
);
42+
}
43+
}
44+
45+
const initializedShardDirs = new Set<string>();
46+
47+
function shardedPath(cacheDir: string, key: string): string {
48+
const l1 = key.substring(0, 2);
49+
const l2 = key.substring(2, 4);
50+
return `${cacheDir}/${l1}/${l2}/${key}`;
51+
}
52+
1453
// Reuse TextEncoder instance to avoid repeated instantiation
1554
const textEncoder = new TextEncoder();
1655

@@ -106,7 +145,7 @@ function createFileSystemCache(): CacheStorage {
106145
if (
107146
FILE_SYSTEM_CACHE_DIRECTORY && !existsSync(FILE_SYSTEM_CACHE_DIRECTORY)
108147
) {
109-
await Deno.mkdirSync(FILE_SYSTEM_CACHE_DIRECTORY, { recursive: true });
148+
await Deno.mkdir(FILE_SYSTEM_CACHE_DIRECTORY, { recursive: true });
110149
}
111150
isCacheInitialized = true;
112151
} catch (err) {
@@ -118,11 +157,25 @@ function createFileSystemCache(): CacheStorage {
118157
key: string,
119158
responseArray: Uint8Array,
120159
) {
160+
if (responseArray.length > CACHE_MAX_ENTRY_SIZE) {
161+
// Evict any existing entry so stale data doesn't stay pinned on disk.
162+
deleteFile(key).catch(() => {});
163+
return;
164+
}
121165
if (!isCacheInitialized) {
122166
await assertCacheDirectory();
123167
}
124-
const filePath = `${FILE_SYSTEM_CACHE_DIRECTORY}/${key}`;
125-
168+
trackWriteRate(key);
169+
const filePath = shardedPath(FILE_SYSTEM_CACHE_DIRECTORY, key);
170+
const dir = filePath.substring(0, filePath.lastIndexOf("/"));
171+
if (!initializedShardDirs.has(dir)) {
172+
try {
173+
await Deno.mkdir(dir, { recursive: true });
174+
initializedShardDirs.add(dir);
175+
} catch {
176+
// transient failure — don't mark initialized so next write retries mkdir
177+
}
178+
}
126179
await Deno.writeFile(filePath, responseArray);
127180
return;
128181
}
@@ -132,8 +185,12 @@ function createFileSystemCache(): CacheStorage {
132185
await assertCacheDirectory();
133186
}
134187
try {
135-
const filePath = `${FILE_SYSTEM_CACHE_DIRECTORY}/${key}`;
188+
const filePath = shardedPath(FILE_SYSTEM_CACHE_DIRECTORY, key);
136189
const fileContent = await Deno.readFile(filePath);
190+
if (fileContent.length > CACHE_MAX_ENTRY_SIZE) {
191+
Deno.remove(filePath).catch(() => {});
192+
return null;
193+
}
137194
return fileContent;
138195
} catch (_err) {
139196
const err = _err as { code?: string };
@@ -151,7 +208,7 @@ function createFileSystemCache(): CacheStorage {
151208
await assertCacheDirectory();
152209
}
153210
try {
154-
const filePath = `${FILE_SYSTEM_CACHE_DIRECTORY}/${key}`;
211+
const filePath = shardedPath(FILE_SYSTEM_CACHE_DIRECTORY, key);
155212
await Deno.remove(filePath);
156213
return true;
157214
} catch (err) {

runtime/caches/lrucache.ts

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,19 @@
11
import { LRUCache } from "npm:lru-cache@10.2.0";
2+
import { ValueType } from "../../deps.ts";
3+
import { logger } from "../../observability/otel/config.ts";
4+
import { meter } from "../../observability/otel/metrics.ts";
25
import {
36
assertCanBeCached,
47
assertNoOptions,
58
baseCache,
69
createBaseCacheStorage,
710
} from "./utils.ts";
811

12+
const lruEvictionCounter = meter.createCounter("lru_cache_eviction", {
13+
unit: "1",
14+
valueType: ValueType.DOUBLE,
15+
});
16+
917
// keep compatible with old variable name
1018
const CACHE_MAX_SIZE = parseInt(
1119
Deno.env.get("CACHE_MAX_SIZE") ?? Deno.env.get("MAX_CACHE_SIZE") ??
@@ -30,17 +38,61 @@ const cacheOptions = (cache: Cache) => (
3038
maxSize: CACHE_MAX_SIZE,
3139
ttlAutopurge: CACHE_TTL_AUTOPURGE,
3240
ttlResolution: CACHE_TTL_RESOLUTION,
33-
dispose: async (_value: boolean, key: string) => {
41+
dispose: async (_value: boolean, key: string, reason: string) => {
42+
lruEvictionCounter.add(1, { reason });
3443
await cache.delete(key);
3544
},
3645
}
3746
);
3847

48+
const lruSizeGauge = meter.createObservableGauge("lru_cache_keys", {
49+
description: "number of keys in the LRU cache",
50+
unit: "1",
51+
valueType: ValueType.DOUBLE,
52+
});
53+
54+
const lruBytesGauge = meter.createObservableGauge("lru_cache_bytes", {
55+
description: "total bytes tracked by the LRU cache",
56+
unit: "bytes",
57+
valueType: ValueType.DOUBLE,
58+
});
59+
60+
// deno-lint-ignore no-explicit-any
61+
const activeCaches = new Map<string, LRUCache<string, any>>();
62+
63+
lruSizeGauge.addCallback((observer) => {
64+
for (const [name, lru] of activeCaches) {
65+
observer.observe(lru.size, { cache: name });
66+
}
67+
});
68+
69+
// Warn when LRU disk usage exceeds this fraction of CACHE_MAX_SIZE.
70+
// At this point the LRU is evicting aggressively and disk is nearly full.
71+
const LRU_DISK_WARN_RATIO = parseFloat(
72+
Deno.env.get("LRU_DISK_WARN_RATIO") ?? "0.9",
73+
);
74+
75+
lruBytesGauge.addCallback((observer) => {
76+
for (const [name, lru] of activeCaches) {
77+
observer.observe(lru.calculatedSize, { cache: name });
78+
const ratio = lru.calculatedSize / CACHE_MAX_SIZE;
79+
if (ratio >= LRU_DISK_WARN_RATIO) {
80+
logger.warn(
81+
`lru_cache: disk usage for cache "${name}" is at ` +
82+
`${Math.round(lru.calculatedSize / 1024 / 1024)}MB / ` +
83+
`${Math.round(CACHE_MAX_SIZE / 1024 / 1024)}MB (${Math.round(ratio * 100)}%). ` +
84+
`LRU is evicting aggressively. Consider increasing CACHE_MAX_SIZE or reducing CACHE_MAX_AGE_S.`,
85+
);
86+
}
87+
}
88+
});
89+
3990
function createLruCacheStorage(cacheStorageInner: CacheStorage): CacheStorage {
4091
const caches = createBaseCacheStorage(
4192
cacheStorageInner,
4293
(_cacheName, cacheInner, requestURLSHA1) => {
4394
const fileCache = new LRUCache(cacheOptions(cacheInner));
95+
activeCaches.set(_cacheName, fileCache);
4496
return Promise.resolve({
4597
...baseCache,
4698
delete: async (

0 commit comments

Comments
 (0)