mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user