fix(engine): skip caching oversized frames + add eviction telemetry

This commit is contained in:
James
2026-05-07 06:10:26 +00:00
committed by James Russo
parent 8203005488
commit 8355b39ffc
2 changed files with 95 additions and 3 deletions
@@ -4,9 +4,12 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { __testing } from "./videoFrameInjector.js";
import { DEFAULT_CONFIG } from "../config.js";
const { createFrameSourceCache } = __testing;
const SHARED_STATS = { evictions: 0, oversizedRejections: 0 };
describe("frame source cache eviction", () => {
let dir: string;
@@ -39,6 +42,16 @@ describe("frame source cache eviction", () => {
await cache.get(c);
expect(cache.stats().entries).toBe(2);
expect(cache.stats().evictions).toBe(1);
// Verify the *oldest* entry (a) was the victim — the LRU contract.
// A later get(a) is a miss-then-insert, which would also evict whichever
// entry is now oldest. We instrument the eviction counter to detect it.
const evictionsBefore = cache.stats().evictions;
await cache.get(a);
expect(cache.stats().evictions).toBe(evictionsBefore + 1);
// After re-inserting `a`, `b` is the next oldest. `c` is now newest.
// Touch `b` (move-to-front) → next eviction would be `c`, not `b`.
});
it("evicts oldest entry when byte budget is exceeded", async () => {
@@ -70,7 +83,7 @@ describe("frame source cache eviction", () => {
expect(await cache.get(file)).toBe("/served/frame.png");
// Cache stays empty because the resolver short-circuits the read.
expect(cache.stats()).toEqual({ entries: 0, bytes: 0 });
expect(cache.stats()).toMatchObject({ entries: 0, bytes: 0 });
served = null;
const dataUri = await cache.get(file);
@@ -87,4 +100,46 @@ describe("frame source cache eviction", () => {
expect(second).toBe(first);
expect(cache.stats().entries).toBe(1);
});
it("skips caching an entry that alone exceeds the byte budget (no self-eviction)", async () => {
// 64 KB raw → ~88 KB base64 + prefix. Budget of 32 KB rejects this entry.
// The contract: caller still gets the data URI; cache stays empty so
// future inserts aren't blocked by the rejected entry's bookkeeping.
const cache = createFrameSourceCache(100, 32 * 1024);
const big = writeFrame("big.png", 64 * 1024);
const dataUri = await cache.get(big);
expect(dataUri.startsWith("data:image/png;base64,")).toBe(true);
expect(cache.stats().entries).toBe(0);
expect(cache.stats().bytes).toBe(0);
expect(cache.stats().oversizedRejections).toBe(1);
expect(cache.stats().evictions).toBe(0);
// A subsequent normal-sized entry must cache cleanly — the rejection
// path didn't pollute internal state.
const small = writeFrame("small.png", 1024);
await cache.get(small);
expect(cache.stats().entries).toBe(1);
});
it("at the production default (1500 MB), 1080p frames stay cached", async () => {
// Regression for the post-PR-#662 default: previously the cache held up
// to 256 entries × ~8 MB ≈ 2 GB at 1080p. The new byte-budget default of
// 1500 MB caps it tighter (~187 entries at 1080p ≈ 6s @ 30fps). This
// test pins the math so a future tweak to the default is visible.
const oneEightyP_jpegSize = 8 * 1024 * 1024; // ~8 MB JPEG (data URI)
const defaultBytesLimit = DEFAULT_CONFIG.frameDataUriCacheBytesLimitMb * 1024 * 1024;
const expectedMaxEntries = Math.floor(defaultBytesLimit / oneEightyP_jpegSize);
expect(expectedMaxEntries).toBeGreaterThanOrEqual(180);
expect(expectedMaxEntries).toBeLessThanOrEqual(200);
// At 30fps that's at least 6 seconds of look-ahead. Sequential access is
// strictly cheaper, so the cache helps any seek-back ≤ 6s.
expect(expectedMaxEntries / 30).toBeGreaterThanOrEqual(6);
});
// Suppress unused-import warning when the SHARED_STATS sentinel is dropped.
it("stats() exposes counters used by telemetry", async () => {
const cache = createFrameSourceCache(1, Number.MAX_SAFE_INTEGER);
expect(cache.stats()).toMatchObject({ ...SHARED_STATS, entries: 0, bytes: 0 });
});
});