Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
e6d5b28
feat(cache): add in-memory tier, size limits, hex sharding, and obser…
vibegui Mar 18, 2026
8c4e5d1
feat(cache): add comprehensive cache observability metrics
vibegui Mar 18, 2026
9e3c534
fix(cache): preserve response status, validate env vars, fix metric o…
vibegui Mar 18, 2026
7ba9241
fix(cache): address code review findings
vibegui Mar 18, 2026
0010914
fix(cache): address correctness issues in env parsing, shard init, LR…
vibegui Mar 18, 2026
23b5ce1
feat(cache): lazy re-index disk entries into LRU on pod restart
vibegui Mar 18, 2026
b3696ff
perf(cache): eliminate hot-path allocations and fix correctness bugs
vibegui Mar 18, 2026
60d5f50
fix(cache): async mkdir and single-tier cache-tier header
vibegui Mar 18, 2026
ad86a9b
fix(cache): reject oversized entries before writing to disk
vibegui Mar 18, 2026
7b2e290
test(cache): add tests for tiered cache, in-memory cache, and lazy re…
vibegui Mar 18, 2026
9ed8346
fix(cache): evict stale disk entry when oversized write is rejected
vibegui Mar 18, 2026
464e1f0
fix(cache): NaN guard, oversized check in L1, memoize LRU open()
vibegui Mar 18, 2026
093066e
perf(cache): skip body read in L1 put when Content-Length exceeds limit
vibegui Mar 18, 2026
ff09a48
feat(cache): extend default stale window to 1h, add STALE_WINDOW_S en…
vibegui Mar 18, 2026
84560bf
fix(cache): bump STALE_TTL_PERIOD default from 30s to 1h
vibegui Mar 18, 2026
2c062bd
feat(cache): bot write guard, L1 admission filter, eviction logging
vibegui Mar 18, 2026
ac7edcd
fix(cache): update inMemoryCache tests for admission filter
vibegui Mar 18, 2026
7f6bbac
feat(cache): write rate warning and disk fill warning
vibegui Mar 18, 2026
9f84eee
fix(cache): separate singleFlight key for bot requests
vibegui Mar 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 98 additions & 14 deletions blocks/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
} from "../observability/otel/metrics.ts";
import { caches, ENABLE_LOADER_CACHE } from "../runtime/caches/mod.ts";
import { inFuture } from "../runtime/caches/utils.ts";
import { isBot } from "../utils/userAgent.ts";
import type { DebugProperties } from "../utils/vary.ts";
import type { HttpContext } from "./handler.ts";
import {
Expand Down Expand Up @@ -146,6 +147,16 @@ const stats = {
unit: "ms",
valueType: ValueType.DOUBLE,
}),
cacheEntrySize: meter.createHistogram("loader_cache_entry_size", {
description: "size of cached loader responses in bytes",
unit: "bytes",
valueType: ValueType.DOUBLE,
}),
bgRevalidation: meter.createHistogram("loader_bg_revalidation", {
description: "duration of background stale-while-revalidate calls",
unit: "ms",
valueType: ValueType.DOUBLE,
}),
};

let maybeCache: Cache | undefined;
Expand All @@ -155,6 +166,9 @@ caches?.open("loader")
.catch(() => maybeCache = undefined);

const MAX_AGE_S = parseInt(Deno.env.get("CACHE_MAX_AGE_S") ?? "60"); // 60 seconds
const CACHE_MAX_ENTRY_SIZE = parseInt(
Deno.env.get("CACHE_MAX_ENTRY_SIZE") ?? "2097152", // 2 MB
) || 2097152;

// Reuse TextEncoder instance to avoid repeated instantiation
const textEncoder = new TextEncoder();
Expand Down Expand Up @@ -207,6 +221,10 @@ const wrapLoader = (
const loader = ctx.resolverId || "unknown";
const start = performance.now();
let status: "bypass" | "miss" | "stale" | "hit" | undefined;
// Bots can read from cache but must not write to it or trigger background
// revalidation — they often hit arbitrary URLs with many query params and
// would pollute the cache with one-hit entries.
const isBotRequest = isBot(req);

const isCacheEngineDefined = isCache(maybeCache);
const isCacheDisabled = !ENABLE_LOADER_CACHE ||
Expand Down Expand Up @@ -248,7 +266,14 @@ const wrapLoader = (
!shouldNotCache && ctx.vary?.push(cacheKeyValue);

status = "bypass";
stats.cache.add(1, { status, loader });
const bypassReason = isCacheNoStore
? "no-store"
: isCacheNoCache
? "no-cache"
: isCacheKeyNull
? "null-key"
: "disabled";
stats.cache.add(1, { status, loader, reason: bypassReason });

RequestContext?.signal?.throwIfAborted();
return await handler(props, req, ctx);
Expand Down Expand Up @@ -297,6 +322,19 @@ const wrapLoader = (
// Serialize and encode once on the main thread.
const jsonStringEncoded = textEncoder.encode(JSON.stringify(json));

// Skip caching oversized entries to protect disk and memory.
// Also evict any existing stale entry so it doesn't stay pinned forever.
if (jsonStringEncoded.length > CACHE_MAX_ENTRY_SIZE) {
cache.delete(request).catch((error) =>
logger.error(`loader error ${error}`)
);
return json;
}

if (OTEL_ENABLE_EXTRA_METRICS) {
stats.cacheEntrySize.record(jsonStringEncoded.length, { loader });
}

const expires = new Date(Date.now() + (cacheMaxAge * 1e3))
.toUTCString();
const headerPairs: [string, string][] = [
Expand All @@ -305,16 +343,20 @@ const wrapLoader = (
["Content-Length", "" + jsonStringEncoded.length],
];

// Cache write goes through the full chain (LRU → filesystem)
// so the LRU registers the key for fast match lookups.
// The filesystem layer offloads the actual I/O to a worker thread
// when DECO_CACHE_WRITE_WORKER=true.
cache.put(
request,
new Response(jsonStringEncoded, {
headers: Object.fromEntries(headerPairs),
}),
).catch((error) => logger.error(`loader error ${error}`));
// Bots must not write to cache — they hit arbitrary URLs and would
// pollute all cache tiers with one-hit entries.
if (!isBotRequest) {
// Cache write goes through the full chain (LRU → in-memory → filesystem)
// so the LRU registers the key for fast match lookups.
// The filesystem layer offloads the actual I/O to a worker thread
// when DECO_CACHE_WRITE_WORKER=true.
cache.put(
request,
new Response(jsonStringEncoded, {
headers: Object.fromEntries(headerPairs),
}),
).catch((error) => logger.error(`loader error ${error}`));
}

return json;
};
Expand All @@ -336,17 +378,59 @@ const wrapLoader = (
status = "stale";
stats.cache.add(1, { status, loader });

bgFlights.do(request.url, callHandlerAndCache)
.catch((error) => logger.error(`loader error ${error}`));
// Bots get the stale response but must not trigger revalidation —
// running the handler for a bot request would waste CPU and still
// not write to cache.
if (!isBotRequest) {
// Timer lives inside the singleFlight fn so it records exactly once
// per revalidation, not once per concurrent waiter on the same key.
bgFlights.do(request.url, async () => {
const bgStart = performance.now();
try {
return await callHandlerAndCache();
} finally {
if (OTEL_ENABLE_EXTRA_METRICS) {
stats.bgRevalidation.record(
performance.now() - bgStart,
{ loader },
);
}
}
}).catch((error) => logger.error(`loader error ${error}`));
}
} else {
status = "hit";
stats.cache.add(1, { status, loader });
}

if (OTEL_ENABLE_EXTRA_METRICS) {
const cl = parseInt(
matched.headers.get("Content-Length") ?? "0",
);
if (cl > 0) {
stats.cacheEntrySize.record(cl, { loader, status });
}
}

if (OTEL_ENABLE_EXTRA_METRICS) {
const parseStart = performance.now();
const result = await matched.json();
stats.latency.record(performance.now() - parseStart, {
loader,
status: "json_parse",
});
return result;
}
return await matched.json();
};

return await flights.do(request.url, staleWhileRevalidate);
// Separate flight key for bots so a bot can never become the leader
// for a non-bot request — bot leaders skip cache.put(), which would
// leave all concurrent non-bot waiters with an uncached result.
const flightKey = isBotRequest
? `bot:${request.url}`
: request.url;
return await flights.do(flightKey, staleWhileRevalidate);
} finally {
const dimension = { loader, status };
if (OTEL_ENABLE_EXTRA_METRICS) {
Expand Down
18 changes: 16 additions & 2 deletions runtime/caches/cacheWriteWorker.ts
Original file line number Diff line number Diff line change
@@ -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;
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The || 2097152 fallback makes an explicit CACHE_MAX_ENTRY_SIZE=0 impossible, because 0 is coerced to the default 2MB.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At runtime/caches/cacheWriteWorker.ts, line 6:

<comment>The `|| 2097152` fallback makes an explicit `CACHE_MAX_ENTRY_SIZE=0` impossible, because `0` is coerced to the default 2MB.</comment>

<file context>
@@ -1,6 +1,10 @@
 
+const CACHE_MAX_ENTRY_SIZE = parseInt(
+  Deno.env.get("CACHE_MAX_ENTRY_SIZE") ?? "2097152", // 2 MB
+) || 2097152;
+
 const textEncoder = new TextEncoder();
</file context>
Fix with Cubic


const textEncoder = new TextEncoder();

const initializedDirs = new Set<string>();
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -85,8 +95,12 @@ self.onmessage = async (e: MessageEvent<CacheWriteMessage>) => {
// 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);
Expand Down
9 changes: 9 additions & 0 deletions runtime/caches/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,15 @@ export const withInstrumentation = (
const result = getCacheStatus(isMatch);

span.setAttribute("cache_status", result);
if (isMatch) {
const cl = isMatch.headers.get("Content-Length");
if (cl) span.setAttribute("content_length", parseInt(cl));
const tier = isMatch.headers.get("X-Cache-Tier");
if (tier) {
span.setAttribute("cache_tier", parseInt(tier));
isMatch.headers.delete("X-Cache-Tier");
}
}
cacheHit.add(1, {
result,
engine,
Expand Down
67 changes: 62 additions & 5 deletions runtime/caches/fileSystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Using || 2097152 breaks the explicit CACHE_MAX_ENTRY_SIZE=0 case by restoring the default 2 MB limit.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At runtime/caches/fileSystem.ts, line 16:

<comment>Using `|| 2097152` breaks the explicit `CACHE_MAX_ENTRY_SIZE=0` case by restoring the default 2 MB limit.</comment>

<file context>
@@ -13,7 +13,7 @@ const FILE_SYSTEM_CACHE_DIRECTORY =
 const CACHE_MAX_ENTRY_SIZE = parseInt(
   Deno.env.get("CACHE_MAX_ENTRY_SIZE") ?? "2097152", // 2 MB
-);
+) || 2097152;
 
 const initializedShardDirs = new Set<string>();
</file context>
Fix with Cubic


// 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<string>();

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();

Expand Down Expand Up @@ -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) {
Expand All @@ -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);
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Write-rate warnings are skipped whenever cache writes use cacheWriteWorker, so this new alert silently does nothing in worker-enabled deployments.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At runtime/caches/fileSystem.ts, line 168:

<comment>Write-rate warnings are skipped whenever cache writes use `cacheWriteWorker`, so this new alert silently does nothing in worker-enabled deployments.</comment>

<file context>
@@ -138,6 +165,7 @@ function createFileSystemCache(): CacheStorage {
     if (!isCacheInitialized) {
       await assertCacheDirectory();
     }
+    trackWriteRate(key);
     const filePath = shardedPath(FILE_SYSTEM_CACHE_DIRECTORY, key);
     const dir = filePath.substring(0, filePath.lastIndexOf("/"));
</file context>
Fix with Cubic

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;
}
Expand All @@ -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 };
Expand All @@ -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) {
Expand Down
Loading
Loading