-
Notifications
You must be signed in to change notification settings - Fork 55
feat(cache): add L1 in-memory cache tier with admission filter #1127
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
vibegui
wants to merge
5
commits into
main
Choose a base branch
from
feat/cache-l1-inmemory-tier
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
dc80293
feat(cache): add L1 in-memory cache tier with admission filter
vibegui 31c98fd
fix(cache): address review — eviction metric, test assertion, json_pa…
vibegui 4e3bb7b
fix(cache): update inMemoryCache tests for min_hits=3
vibegui eb941af
refactor(cache): move loader observability to PR #1124
vibegui 460a9b4
fix(cache): skip admission gate for keys already in L1
vibegui File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,113 @@ | ||
| import { assertEquals, assertNotEquals } from "@std/assert"; | ||
| import { caches } from "./inMemoryCache.ts"; | ||
|
|
||
| // Use unique cache names per test to avoid cross-test contamination | ||
| // from the shared singleton LRU store. | ||
| let seq = 0; | ||
| const nextCache = () => `inMemoryTest_${seq++}_${Date.now()}`; | ||
|
|
||
| const REQ = new Request("https://example.com/item"); | ||
|
|
||
| // The admission filter requires MEMORY_CACHE_MIN_HITS puts before a key is | ||
| // stored in RAM (default 3). Tests that expect a hit must use this helper. | ||
| async function putUntilAdmitted( | ||
| cache: Cache, | ||
| req: RequestInfo | URL, | ||
| makeResponse: () => Response, | ||
| ) { | ||
| await cache.put(req, makeResponse()); | ||
| await cache.put(req, makeResponse()); | ||
| await cache.put(req, makeResponse()); | ||
| } | ||
|
|
||
| Deno.test("inMemoryCache: preserves response body", async () => { | ||
| const cache = await caches.open(nextCache()); | ||
| await putUntilAdmitted(cache, REQ, () => new Response("hello world")); | ||
| const result = await cache.match(REQ); | ||
| assertNotEquals(result, undefined); | ||
| assertEquals(await result!.text(), "hello world"); | ||
| }); | ||
|
|
||
| Deno.test("inMemoryCache: preserves response status", async () => { | ||
| const cache = await caches.open(nextCache()); | ||
| await putUntilAdmitted( | ||
| cache, | ||
| REQ, | ||
| () => new Response("not found", { status: 404 }), | ||
| ); | ||
| const result = await cache.match(REQ); | ||
| assertEquals(result?.status, 404); | ||
| }); | ||
|
|
||
| Deno.test("inMemoryCache: preserves non-standard status codes", async () => { | ||
| const cache = await caches.open(nextCache()); | ||
| await putUntilAdmitted( | ||
| cache, | ||
| REQ, | ||
| () => new Response("gone", { status: 410 }), | ||
| ); | ||
| assertEquals((await cache.match(REQ))?.status, 410); | ||
| }); | ||
|
|
||
| Deno.test("inMemoryCache: preserves response headers", async () => { | ||
| const cache = await caches.open(nextCache()); | ||
| await putUntilAdmitted( | ||
| cache, | ||
| REQ, | ||
| () => | ||
| new Response("data", { | ||
| headers: { "content-type": "application/json", "x-custom": "value" }, | ||
| }), | ||
| ); | ||
| const result = await cache.match(REQ); | ||
| assertEquals(result?.headers.get("content-type"), "application/json"); | ||
| assertEquals(result?.headers.get("x-custom"), "value"); | ||
| }); | ||
|
|
||
| Deno.test("inMemoryCache: miss returns undefined", async () => { | ||
| const cache = await caches.open(nextCache()); | ||
| assertEquals(await cache.match(REQ), undefined); | ||
| }); | ||
|
|
||
| Deno.test("inMemoryCache: single put does not admit to L1 (admission filter)", async () => { | ||
| const cache = await caches.open(nextCache()); | ||
| await cache.put(REQ, new Response("data")); | ||
| // First put should not be stored — key has not earned admission yet. | ||
| assertEquals(await cache.match(REQ), undefined); | ||
| }); | ||
|
|
||
| Deno.test("inMemoryCache: two puts do not admit to L1 (admission filter)", async () => { | ||
| const cache = await caches.open(nextCache()); | ||
| await cache.put(REQ, new Response("data")); | ||
| await cache.put(REQ, new Response("data")); | ||
| // Two puts should not be stored — key needs 3 to earn admission. | ||
| assertEquals(await cache.match(REQ), undefined); | ||
| }); | ||
|
|
||
| Deno.test("inMemoryCache: delete removes entry", async () => { | ||
| const cache = await caches.open(nextCache()); | ||
| await putUntilAdmitted(cache, REQ, () => new Response("data")); | ||
| assertNotEquals(await cache.match(REQ), undefined); | ||
| await cache.delete(REQ); | ||
| assertEquals(await cache.match(REQ), undefined); | ||
| }); | ||
|
|
||
| Deno.test("inMemoryCache: different requests are independent", async () => { | ||
| const cache = await caches.open(nextCache()); | ||
| const req1 = new Request("https://example.com/1"); | ||
| const req2 = new Request("https://example.com/2"); | ||
|
|
||
| await putUntilAdmitted( | ||
| cache, | ||
| req1, | ||
| () => new Response("one", { status: 200 }), | ||
| ); | ||
| await putUntilAdmitted( | ||
| cache, | ||
| req2, | ||
| () => new Response("two", { status: 201 }), | ||
| ); | ||
|
|
||
| assertEquals((await cache.match(req1))?.status, 200); | ||
| assertEquals((await cache.match(req2))?.status, 201); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,146 @@ | ||
| import { LRUCache } from "npm:lru-cache@10.2.0"; | ||
| import { ValueType } from "../../deps.ts"; | ||
| import { logger } from "../../observability/otel/config.ts"; | ||
| import { meter } from "../../observability/otel/metrics.ts"; | ||
| import { | ||
| assertCanBeCached, | ||
| assertNoOptions, | ||
| baseCache, | ||
| withCacheNamespace, | ||
| } from "./utils.ts"; | ||
|
|
||
| const MEMORY_CACHE_MAX_SIZE = parseInt( | ||
| Deno.env.get("MEMORY_CACHE_MAX_SIZE") ?? "268435456", // 256 MB | ||
| ) || 268435456; | ||
| const MEMORY_CACHE_MAX_ITEMS = parseInt( | ||
| Deno.env.get("MEMORY_CACHE_MAX_ITEMS") ?? "2048", | ||
| ) || 2048; | ||
| const CACHE_MAX_ENTRY_SIZE = parseInt( | ||
| Deno.env.get("CACHE_MAX_ENTRY_SIZE") ?? "2097152", // 2 MB | ||
| ) || 2097152; | ||
| // Minimum number of cache writes before a key is admitted to L1 (in-memory). | ||
| // Prevents one-hit wonders (bot traffic, rare URLs) from displacing hot keys. | ||
| // Default 3: a key must be written three times (i.e. accessed from L2 on separate requests) | ||
| // before it earns a spot in RAM. | ||
| const MEMORY_CACHE_MIN_HITS = parseInt( | ||
| Deno.env.get("MEMORY_CACHE_MIN_HITS") ?? "3", | ||
| ) || 3; | ||
|
|
||
| const l1EvictionCounter = meter.createCounter("l1_cache_eviction", { | ||
| description: "number of entries evicted from the L1 in-memory cache", | ||
| unit: "1", | ||
| valueType: ValueType.DOUBLE, | ||
| }); | ||
|
|
||
| interface CacheEntry { | ||
| body: Uint8Array; | ||
| headers: [string, string][]; | ||
| status: number; | ||
| } | ||
|
|
||
| function createInMemoryCache(): CacheStorage { | ||
| let totalEvictions = 0; | ||
|
|
||
| const store = new LRUCache<string, CacheEntry>({ | ||
| max: MEMORY_CACHE_MAX_ITEMS, | ||
| maxSize: MEMORY_CACHE_MAX_SIZE, | ||
| sizeCalculation: (entry) => entry.body.length, | ||
| dispose: (_value, _key, reason) => { | ||
| if (reason === "evict") { | ||
| l1EvictionCounter.add(1); | ||
| totalEvictions++; | ||
| if (totalEvictions === 1 || totalEvictions % 100 === 0) { | ||
| logger.warn( | ||
| `l1_cache: ${totalEvictions} total evictions — L1 is full and dropping entries. ` + | ||
| `Consider increasing MEMORY_CACHE_MAX_SIZE (current: ${MEMORY_CACHE_MAX_SIZE}) ` + | ||
| `or MEMORY_CACHE_MAX_ITEMS (current: ${MEMORY_CACHE_MAX_ITEMS}).`, | ||
| ); | ||
| } | ||
| } | ||
| }, | ||
| }); | ||
|
|
||
| // Admission filter: tracks how many times each key has been presented for storage. | ||
| // A key must be seen MEMORY_CACHE_MIN_HITS times before it's actually stored in RAM. | ||
| // The admission LRU is sized generously (4x items) since it holds only counters. | ||
| const admissionCounts = new LRUCache<string, number>({ | ||
| max: MEMORY_CACHE_MAX_ITEMS * 4, | ||
| }); | ||
|
|
||
| const caches: CacheStorage = { | ||
| delete: () => { | ||
| throw new Error("Not Implemented"); | ||
| }, | ||
| has: () => { | ||
| throw new Error("Not Implemented"); | ||
| }, | ||
| keys: () => { | ||
| throw new Error("Not Implemented"); | ||
| }, | ||
| match: () => { | ||
| throw new Error("Not Implemented"); | ||
| }, | ||
| open: (cacheName: string): Promise<Cache> => { | ||
| const requestURLSHA1 = withCacheNamespace(cacheName); | ||
| return Promise.resolve({ | ||
| ...baseCache, | ||
| delete: async ( | ||
| request: RequestInfo | URL, | ||
| _options?: CacheQueryOptions, | ||
| ): Promise<boolean> => { | ||
| const cacheKey = await requestURLSHA1(request); | ||
| admissionCounts.delete(cacheKey); | ||
| return store.delete(cacheKey); | ||
| }, | ||
| match: async ( | ||
| request: RequestInfo | URL, | ||
| options?: CacheQueryOptions, | ||
| ): Promise<Response | undefined> => { | ||
| assertNoOptions(options); | ||
| const cacheKey = await requestURLSHA1(request); | ||
| const entry = store.get(cacheKey); | ||
| if (!entry) return undefined; | ||
| return new Response(entry.body as unknown as BodyInit, { | ||
| headers: new Headers(entry.headers), | ||
| status: entry.status, | ||
| }); | ||
| }, | ||
| put: async ( | ||
| request: RequestInfo | URL, | ||
| response: Response, | ||
| ): Promise<void> => { | ||
| const req = new Request(request); | ||
| assertCanBeCached(req, response); | ||
| if (!response.body) return; | ||
| // Fast path: skip the body read entirely if Content-Length already tells us | ||
| // the entry is too large. The loader always sets this header. | ||
| const cl = parseInt(response.headers.get("content-length") ?? "0"); | ||
| if (cl > CACHE_MAX_ENTRY_SIZE) return; | ||
| const cacheKey = await requestURLSHA1(request); | ||
|
|
||
| // Skip admission gate for keys already in L1 — allow immediate refresh | ||
| // so background revalidation updates don't get silently dropped. | ||
| if (!store.has(cacheKey)) { | ||
| // Admission filter: only promote to L1 after MEMORY_CACHE_MIN_HITS writes. | ||
| const hits = (admissionCounts.get(cacheKey) ?? 0) + 1; | ||
| if (hits < MEMORY_CACHE_MIN_HITS) { | ||
| admissionCounts.set(cacheKey, hits); | ||
| return; | ||
| } | ||
| // Key has earned its place — remove from admission tracker. | ||
| admissionCounts.delete(cacheKey); | ||
| } | ||
|
|
||
| const body = new Uint8Array(await response.arrayBuffer()); | ||
| if (body.length > CACHE_MAX_ENTRY_SIZE) return; | ||
| const headers: [string, string][] = [...response.headers.entries()]; | ||
| store.set(cacheKey, { body, headers, status: response.status }); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| }, | ||
| }); | ||
| }, | ||
| }; | ||
|
|
||
| return caches; | ||
| } | ||
|
|
||
| export const caches = createInMemoryCache(); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.