Skip to content

Commit 4702eb6

Browse files
committed
perf(pdf-server): cap total cache bytes with LRU eviction
The module-level sharedPdfCache could grow unbounded within the 60s lifetime window under a burst of distinct URLs. Track running total bytes and evict least-recently-used entries on insert when it would exceed CACHE_MAX_TOTAL_BYTES (256MB). getCacheEntry now bumps the accessed entry to the end of insertion order so eviction targets the LRU entry rather than the oldest insert. createPdfCache takes an optional maxTotalBytes for testability.
1 parent e33fc46 commit 4702eb6

2 files changed

Lines changed: 85 additions & 1 deletion

File tree

examples/pdf-server/server.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,72 @@ describe("PDF Cache with Timeouts", () => {
7171
});
7272
});
7373

74+
describe("byte-cap LRU eviction", () => {
75+
const tenBytes = new Uint8Array(10);
76+
77+
async function fill(cache: PdfCache, url: string): Promise<void> {
78+
const m = spyOn(globalThis, "fetch").mockResolvedValueOnce(
79+
new Response(tenBytes, { status: 200 }),
80+
);
81+
try {
82+
await cache.readPdfRange(url, 0, 1024);
83+
} finally {
84+
m.mockRestore();
85+
}
86+
}
87+
88+
it("evicts least-recently-used entry when total exceeds cap", async () => {
89+
const cache = createPdfCache(25);
90+
try {
91+
await fill(cache, "https://arxiv.org/pdf/a");
92+
await fill(cache, "https://arxiv.org/pdf/b");
93+
expect(cache.getCacheSize()).toBe(2);
94+
95+
// Touch A so B becomes least-recently-used
96+
await cache.readPdfRange("https://arxiv.org/pdf/a", 0, 1);
97+
98+
// Inserting C (10B) pushes total to 30 > 25 → evict LRU (B)
99+
await fill(cache, "https://arxiv.org/pdf/c");
100+
expect(cache.getCacheSize()).toBe(2);
101+
102+
// A and C still served from cache; B re-fetches
103+
const m = spyOn(globalThis, "fetch").mockResolvedValue(
104+
new Response(tenBytes, { status: 200 }),
105+
);
106+
try {
107+
await cache.readPdfRange("https://arxiv.org/pdf/a", 0, 1);
108+
await cache.readPdfRange("https://arxiv.org/pdf/c", 0, 1);
109+
expect(m).toHaveBeenCalledTimes(0);
110+
await cache.readPdfRange("https://arxiv.org/pdf/b", 0, 1);
111+
expect(m).toHaveBeenCalledTimes(1);
112+
} finally {
113+
m.mockRestore();
114+
}
115+
} finally {
116+
cache.clearCache();
117+
}
118+
});
119+
120+
it("evicts multiple entries if a single insert exceeds the cap", async () => {
121+
const cache = createPdfCache(25);
122+
try {
123+
await fill(cache, "https://arxiv.org/pdf/a");
124+
await fill(cache, "https://arxiv.org/pdf/b");
125+
const big = spyOn(globalThis, "fetch").mockResolvedValueOnce(
126+
new Response(new Uint8Array(20), { status: 200 }),
127+
);
128+
try {
129+
await cache.readPdfRange("https://arxiv.org/pdf/big", 0, 1024);
130+
} finally {
131+
big.mockRestore();
132+
}
133+
expect(cache.getCacheSize()).toBe(1);
134+
} finally {
135+
cache.clearCache();
136+
}
137+
});
138+
});
139+
74140
describe("readPdfRange caching behavior", () => {
75141
const testUrl = "https://arxiv.org/pdf/test-pdf";
76142
const testData = new Uint8Array([0x25, 0x50, 0x44, 0x46]); // %PDF header

examples/pdf-server/server.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,9 @@ export const CACHE_MAX_LIFETIME_MS = 60_000; // 60 seconds
8484
/** Max size for cached PDFs (defensive limit to prevent memory exhaustion) */
8585
export const CACHE_MAX_PDF_SIZE_BYTES = 50 * 1024 * 1024; // 50MB
8686

87+
/** Max total bytes across all cache entries; oldest evicted first when exceeded. */
88+
export const CACHE_MAX_TOTAL_BYTES = 256 * 1024 * 1024; // 256MB
89+
8790
/** Allowed local file paths (CLI args + file roots — read access). */
8891
export const allowedLocalFiles = new Set<string>();
8992

@@ -696,15 +699,19 @@ export interface PdfCache {
696699
* - CACHE_INACTIVITY_TIMEOUT_MS of no access (resets on each access)
697700
* - CACHE_MAX_LIFETIME_MS from creation (absolute timeout)
698701
*/
699-
export function createPdfCache(): PdfCache {
702+
export function createPdfCache(
703+
maxTotalBytes: number = CACHE_MAX_TOTAL_BYTES,
704+
): PdfCache {
700705
const cache = new Map<string, CacheEntry>();
706+
let totalBytes = 0;
701707

702708
/** Delete a cache entry and clear its timers */
703709
function deleteCacheEntry(url: string): void {
704710
const entry = cache.get(url);
705711
if (entry) {
706712
clearTimeout(entry.inactivityTimer);
707713
clearTimeout(entry.maxLifetimeTimer);
714+
totalBytes -= entry.data.length;
708715
cache.delete(url);
709716
}
710717
}
@@ -720,6 +727,10 @@ export function createPdfCache(): PdfCache {
720727
deleteCacheEntry(url);
721728
}, CACHE_INACTIVITY_TIMEOUT_MS);
722729

730+
// Move to end of insertion order so size-cap eviction is LRU.
731+
cache.delete(url);
732+
cache.set(url, entry);
733+
723734
return entry.data;
724735
}
725736

@@ -728,6 +739,12 @@ export function createPdfCache(): PdfCache {
728739
// Clear any existing entry first
729740
deleteCacheEntry(url);
730741

742+
// Evict least-recently-used entries until under the byte cap.
743+
for (const oldest of cache.keys()) {
744+
if (totalBytes + data.length <= maxTotalBytes) break;
745+
deleteCacheEntry(oldest);
746+
}
747+
731748
const entry: CacheEntry = {
732749
data,
733750
createdAt: Date.now(),
@@ -740,6 +757,7 @@ export function createPdfCache(): PdfCache {
740757
};
741758

742759
cache.set(url, entry);
760+
totalBytes += data.length;
743761
}
744762

745763
/** Slice a cached or freshly-fetched full body to the requested range. */

0 commit comments

Comments
 (0)