feat(studio-server): bound the proxy cache with LRU accounting (#2588)

Adds cache accounting and bounded cleanup for transcoded proxies, and keeps
.transcode-cache out of git. Standalone: the transcoder consumes it next.
This commit is contained in:
Miguel Ángel
2026-07-16 21:05:08 -04:00
committed by GitHub
parent 9ca1e17101
commit 1d3d20450d
3 changed files with 245 additions and 0 deletions
@@ -0,0 +1,96 @@
import { existsSync, mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { cleanupProxyCache } from "./proxyCache.js";
const tempDirs: string[] = [];
function cacheDir(): string {
const root = mkdtempSync(join(tmpdir(), "hf-proxy-cache-"));
tempDirs.push(root);
const cache = join(root, ".transcode-cache");
mkdirSync(cache);
return cache;
}
function writeEntry(path: string, bytes: number, modifiedAt: number): void {
writeFileSync(path, Buffer.alloc(bytes));
const date = new Date(modifiedAt);
utimesSync(path, date, date);
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});
describe("cleanupProxyCache", () => {
it("removes idle and oldest entries until the cache is within its byte budget", () => {
const cache = cacheDir();
const now = 1_800_000_000_000;
const expired = join(cache, "expired.mp4");
const oldest = join(cache, "oldest.mp4");
const newest = join(cache, "newest.mp4");
writeEntry(expired, 4, now - 31 * 24 * 60 * 60 * 1000);
writeEntry(oldest, 6, now - 3_000);
writeEntry(newest, 6, now - 1_000);
const result = cleanupProxyCache(cache, {
now,
maxBytes: 8,
maxIdleMs: 30 * 24 * 60 * 60 * 1000,
minSweepIntervalMs: 0,
});
expect(result.removed).toEqual([expired, oldest]);
expect(result.bytesAfter).toBe(6);
expect(existsSync(expired)).toBe(false);
expect(existsSync(oldest)).toBe(false);
expect(existsSync(newest)).toBe(true);
});
it("preserves in-flight entries and removes stale temporary files", () => {
const cache = cacheDir();
const now = 1_800_000_000_000;
const inFlight = join(cache, "active.mp4");
const staleTemp = join(cache, ".tmp-crashed-active.mp4");
writeEntry(inFlight, 12, now - 40 * 24 * 60 * 60 * 1000);
writeEntry(staleTemp, 3, now - 2 * 60 * 60 * 1000);
const result = cleanupProxyCache(cache, {
now,
maxBytes: 1,
maxIdleMs: 30 * 24 * 60 * 60 * 1000,
staleTempMs: 60 * 60 * 1000,
minSweepIntervalMs: 0,
protectedPaths: new Set([inFlight]),
});
expect(result.removed).toEqual([staleTemp]);
expect(result.bytesAfter).toBe(12);
expect(existsSync(inFlight)).toBe(true);
});
it("rate-limits repeated directory sweeps", () => {
const cache = cacheDir();
const path = join(cache, "entry.mp4");
writeEntry(path, 2, 1_000);
const first = cleanupProxyCache(cache, {
now: 2_000,
maxBytes: 10,
maxIdleMs: 10_000,
minSweepIntervalMs: 5_000,
});
const second = cleanupProxyCache(cache, {
now: 3_000,
maxBytes: 1,
maxIdleMs: 10_000,
minSweepIntervalMs: 5_000,
});
expect(first.skipped).toBe(false);
expect(second.skipped).toBe(true);
expect(existsSync(path)).toBe(true);
});
});
@@ -0,0 +1,147 @@
import { existsSync, readdirSync, statSync, unlinkSync } from "node:fs";
import { join } from "node:path";
const DEFAULT_MAX_BYTES = 10 * 1024 * 1024 * 1024;
const DEFAULT_STALE_TEMP_MS = 60 * 60 * 1000;
const DEFAULT_MIN_SWEEP_INTERVAL_MS = 5 * 60 * 1000;
export interface ProxyCacheCleanupOptions {
maxBytes?: number;
maxIdleMs?: number;
staleTempMs?: number;
minSweepIntervalMs?: number;
protectedPaths?: ReadonlySet<string>;
now?: number;
}
export interface ProxyCacheCleanupResult {
removed: string[];
bytesBefore: number;
bytesAfter: number;
skipped: boolean;
}
interface CacheEntry {
path: string;
size: number;
modifiedAt: number;
protected: boolean;
}
const lastSweepAt = new Map<string, number>();
function positiveEnvNumber(name: string, fallback: number): number {
const parsed = Number(process.env[name]);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function proxyCacheCleanupDefaults(): Required<
Pick<ProxyCacheCleanupOptions, "maxBytes" | "maxIdleMs" | "staleTempMs" | "minSweepIntervalMs">
> {
return {
maxBytes: positiveEnvNumber("HYPERFRAMES_PROXY_CACHE_MAX_BYTES", DEFAULT_MAX_BYTES),
maxIdleMs: positiveEnvNumber("HYPERFRAMES_PROXY_CACHE_MAX_IDLE_DAYS", 30) * 24 * 60 * 60 * 1000,
staleTempMs: positiveEnvNumber("HYPERFRAMES_PROXY_CACHE_STALE_TEMP_MS", DEFAULT_STALE_TEMP_MS),
minSweepIntervalMs: positiveEnvNumber(
"HYPERFRAMES_PROXY_CACHE_SWEEP_INTERVAL_MS",
DEFAULT_MIN_SWEEP_INTERVAL_MS,
),
};
}
function shouldSkipSweep(cacheDir: string, now: number, minSweepIntervalMs: number): boolean {
const previousSweep = lastSweepAt.get(cacheDir);
if (previousSweep !== undefined && now - previousSweep < minSweepIntervalMs) return true;
lastSweepAt.set(cacheDir, now);
return false;
}
function readCacheInventory(
cacheDir: string,
protectedPaths: ReadonlySet<string>,
now: number,
staleTempMs: number,
): { entries: CacheEntry[]; staleTemps: CacheEntry[] } {
const entries: CacheEntry[] = [];
const staleTemps: CacheEntry[] = [];
for (const dirent of readdirSync(cacheDir, { withFileTypes: true })) {
if (!dirent.isFile()) continue;
const path = join(cacheDir, dirent.name);
const stat = statSync(path);
const entry = {
path,
size: stat.size,
modifiedAt: stat.mtimeMs,
protected: protectedPaths.has(path),
};
if (dirent.name.startsWith(".tmp-")) {
if (now - stat.mtimeMs >= staleTempMs) staleTemps.push(entry);
} else if (dirent.name.endsWith(".mp4")) {
entries.push(entry);
}
}
const oldestFirst = (a: CacheEntry, b: CacheEntry): number =>
a.modifiedAt - b.modifiedAt || a.path.localeCompare(b.path);
entries.sort(oldestFirst);
staleTemps.sort(oldestFirst);
return { entries, staleTemps };
}
function evictCacheEntries(
entries: CacheEntry[],
staleTemps: CacheEntry[],
now: number,
maxIdleMs: number,
maxBytes: number,
): Omit<ProxyCacheCleanupResult, "skipped"> {
const bytesBefore = entries.reduce((total, entry) => total + entry.size, 0);
let bytesAfter = bytesBefore;
const removed: string[] = [];
const remove = (entry: CacheEntry, countsTowardBudget: boolean): void => {
unlinkSync(entry.path);
removed.push(entry.path);
if (countsTowardBudget) bytesAfter -= entry.size;
};
for (const entry of staleTemps) remove(entry, false);
for (const entry of entries) {
if (!entry.protected && now - entry.modifiedAt >= maxIdleMs) remove(entry, true);
}
for (const entry of entries) {
if (bytesAfter <= maxBytes) break;
if (!entry.protected && existsSync(entry.path)) remove(entry, true);
}
return { removed, bytesBefore, bytesAfter };
}
/**
* Opportunistically bounds a project's transparent-proxy cache. Cleanup is
* synchronous because callers already perform filesystem bookkeeping on the
* preview request path, but rate limiting keeps the directory scan off the
* hot path. Errors intentionally bubble so callers can warn without turning
* a cache-maintenance failure into a preview failure.
*/
export function cleanupProxyCache(
cacheDir: string,
options: ProxyCacheCleanupOptions = {},
): ProxyCacheCleanupResult {
const defaults = proxyCacheCleanupDefaults();
const now = options.now ?? Date.now();
const minSweepIntervalMs = options.minSweepIntervalMs ?? defaults.minSweepIntervalMs;
if (shouldSkipSweep(cacheDir, now, minSweepIntervalMs)) {
return { removed: [], bytesBefore: 0, bytesAfter: 0, skipped: true };
}
if (!existsSync(cacheDir)) {
return { removed: [], bytesBefore: 0, bytesAfter: 0, skipped: false };
}
const maxBytes = options.maxBytes ?? defaults.maxBytes;
const maxIdleMs = options.maxIdleMs ?? defaults.maxIdleMs;
const staleTempMs = options.staleTempMs ?? defaults.staleTempMs;
const protectedPaths = options.protectedPaths ?? new Set<string>();
const { entries, staleTemps } = readCacheInventory(cacheDir, protectedPaths, now, staleTempMs);
return {
...evictCacheEntries(entries, staleTemps, now, maxIdleMs, maxBytes),
skipped: false,
};
}