mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-12 23:29:50 +00:00
perf(engine): extraction cache on by default with atomic publish and LRU gc (#1901)
* perf(engine): extraction cache on by default with atomic publish and LRU gc Warm re-renders now skip source-video frame extraction entirely (video_extract 400ms -> 13ms on a 4-video composition; outputs are pixel-identical, PSNR inf). What made default-on safe: - Atomic entry publish: frames extract into a unique .partial-<pid>-<uuid> dir, the completion sentinel is written there, and the dir is renamed into the final key atomically. Concurrent renders sharing a cache can duplicate work but can never serve a torn entry (previously documented as single-writer only). - Size-capped LRU gc: best-effort sweep after extraction evicts oldest-used entries past a 2 GiB default budget (HYPERFRAMES_EXTRACT_CACHE_MAX_MB) and clears crashed writers' partials. Entries younger than 60 min are never evicted so live renders keep their frames. - Default cache dir: <tmpdir>/hyperframes-extract-cache-<uid>. Opt out with HYPERFRAMES_EXTRACT_CACHE_DIR=off (or none/false/0); a non-writable dir degrades to uncached with a single warning instead of failing the render. * fix(engine): harden extraction cache publish and surface cache ops signals Review hardening for the default-on extraction cache: - Bypass the cache for HDR-converted intermediates: the key snapshot describes the original source, so publishing converted frames under it would poison later plain-SDR renders of the same trim. (The follow-up transform-keyed change re-enables caching for these.) - publishCacheEntry TOCTOU: adopt a concurrent writer's completed entry both before removing an apparently-stale dir and after a failed retry rename, so a winner's publish is never destroyed or reported as a failure. - Observability for the failure paths: cachePublishFailures, cacheGcEvictions, cacheGcBytesFreed, and cacheAgedPartialsCleared on ExtractionPhaseBreakdown; gcExtractionCache now returns sweep stats. * fix(engine): sweep superseded cache generations in gc After a SCHEMA_PREFIX bump, old-generation entries (hfcache-v2-*) no longer matched the sweep's prefix filter and would orphan their disk forever. The gc now matches any hfcache-v* generation; superseded entries never receive sentinel touches, so the LRU evicts them first.
This commit is contained in:
@@ -1,5 +1,14 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
utimesSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
@@ -10,8 +19,11 @@ import {
|
||||
cacheEntryDirName,
|
||||
computeCacheKey,
|
||||
ensureCacheEntryDir,
|
||||
gcExtractionCache,
|
||||
lookupCacheEntry,
|
||||
markCacheEntryComplete,
|
||||
partialCacheEntryDir,
|
||||
publishCacheEntry,
|
||||
readKeyStat,
|
||||
type CacheKeyInput,
|
||||
} from "./extractionCache.js";
|
||||
@@ -31,6 +43,25 @@ const keyFor = (videoPath: string, overrides: Partial<CacheKeyInput> = {}): Cach
|
||||
};
|
||||
};
|
||||
|
||||
function makeCacheRoot(): { tmpRoot: string; sourceFile: string } {
|
||||
const tmpRoot = mkdtempSync(join(tmpdir(), "hf-extract-cache-test-"));
|
||||
const sourceFile = join(tmpRoot, "clip.mp4");
|
||||
writeFileSync(sourceFile, "fake-video-bytes", "utf-8");
|
||||
return { tmpRoot, sourceFile };
|
||||
}
|
||||
|
||||
function removeCacheRoot(tmpRoot: string): void {
|
||||
if (existsSync(tmpRoot)) rmSync(tmpRoot, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
/** Create and populate a partial dir for `entry` with one frame file. */
|
||||
function seedPartialDir(entry: { dir: string; keyHash: string }, frameContent: string): string {
|
||||
const partialDir = partialCacheEntryDir(entry);
|
||||
mkdirSync(partialDir, { recursive: true });
|
||||
writeFileSync(join(partialDir, "frame_00001.jpg"), frameContent, "utf-8");
|
||||
return partialDir;
|
||||
}
|
||||
|
||||
describe("extractionCache constants", () => {
|
||||
it("exposes the v2 schema prefix", () => {
|
||||
expect(SCHEMA_PREFIX).toBe("hfcache-v3-");
|
||||
@@ -50,13 +81,11 @@ describe("computeCacheKey", () => {
|
||||
let sourceFile: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpRoot = mkdtempSync(join(tmpdir(), "hf-extract-cache-test-"));
|
||||
sourceFile = join(tmpRoot, "clip.mp4");
|
||||
writeFileSync(sourceFile, "fake-video-bytes", "utf-8");
|
||||
({ tmpRoot, sourceFile } = makeCacheRoot());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (existsSync(tmpRoot)) rmSync(tmpRoot, { recursive: true, force: true });
|
||||
removeCacheRoot(tmpRoot);
|
||||
});
|
||||
|
||||
const base = (videoPath: string): CacheKeyInput => keyFor(videoPath);
|
||||
@@ -148,13 +177,11 @@ describe("lookupCacheEntry / markCacheEntryComplete", () => {
|
||||
let sourceFile: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpRoot = mkdtempSync(join(tmpdir(), "hf-extract-cache-test-"));
|
||||
sourceFile = join(tmpRoot, "clip.mp4");
|
||||
writeFileSync(sourceFile, "fake-video-bytes", "utf-8");
|
||||
({ tmpRoot, sourceFile } = makeCacheRoot());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (existsSync(tmpRoot)) rmSync(tmpRoot, { recursive: true, force: true });
|
||||
removeCacheRoot(tmpRoot);
|
||||
});
|
||||
|
||||
const base = (videoPath: string): CacheKeyInput => keyFor(videoPath);
|
||||
@@ -197,3 +224,144 @@ describe("lookupCacheEntry / markCacheEntryComplete", () => {
|
||||
expect(a.entry.dir).toBe(b.entry.dir);
|
||||
});
|
||||
});
|
||||
|
||||
describe("publishCacheEntry", () => {
|
||||
let tmpRoot: string;
|
||||
let sourceFile: string;
|
||||
|
||||
beforeEach(() => {
|
||||
({ tmpRoot, sourceFile } = makeCacheRoot());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
removeCacheRoot(tmpRoot);
|
||||
});
|
||||
|
||||
function entry() {
|
||||
return lookupCacheEntry(tmpRoot, keyFor(sourceFile)).entry;
|
||||
}
|
||||
|
||||
it("publishes a partial directory atomically with the complete sentinel inside", () => {
|
||||
const cacheEntry = entry();
|
||||
const partialDir = seedPartialDir(cacheEntry, "frame");
|
||||
|
||||
const result = publishCacheEntry(cacheEntry, partialDir);
|
||||
|
||||
expect(result).toEqual({ dir: cacheEntry.dir, published: true });
|
||||
expect(existsSync(partialDir)).toBe(false);
|
||||
expect(existsSync(join(cacheEntry.dir, "frame_00001.jpg"))).toBe(true);
|
||||
expect(existsSync(join(cacheEntry.dir, COMPLETE_SENTINEL))).toBe(true);
|
||||
});
|
||||
|
||||
it("serves a complete winner when another writer publishes the same entry first", () => {
|
||||
const cacheEntry = entry();
|
||||
mkdirSync(cacheEntry.dir, { recursive: true });
|
||||
writeFileSync(join(cacheEntry.dir, "frame_00001.jpg"), "winner", "utf-8");
|
||||
markCacheEntryComplete(cacheEntry);
|
||||
|
||||
const partialDir = seedPartialDir(cacheEntry, "loser");
|
||||
|
||||
const result = publishCacheEntry(cacheEntry, partialDir);
|
||||
|
||||
expect(result).toEqual({ dir: cacheEntry.dir, published: true });
|
||||
expect(existsSync(partialDir)).toBe(false);
|
||||
expect(existsSync(join(cacheEntry.dir, COMPLETE_SENTINEL))).toBe(true);
|
||||
expect(statSync(join(cacheEntry.dir, "frame_00001.jpg")).size).toBe("winner".length);
|
||||
});
|
||||
|
||||
it("replaces a stale unsentineled final directory and retries publish once", () => {
|
||||
const cacheEntry = entry();
|
||||
mkdirSync(cacheEntry.dir, { recursive: true });
|
||||
writeFileSync(join(cacheEntry.dir, "frame_00001.jpg"), "stale", "utf-8");
|
||||
|
||||
const partialDir = seedPartialDir(cacheEntry, "fresh");
|
||||
|
||||
const result = publishCacheEntry(cacheEntry, partialDir);
|
||||
|
||||
expect(result).toEqual({ dir: cacheEntry.dir, published: true });
|
||||
expect(existsSync(partialDir)).toBe(false);
|
||||
expect(statSync(join(cacheEntry.dir, "frame_00001.jpg")).size).toBe("fresh".length);
|
||||
expect(existsSync(join(cacheEntry.dir, COMPLETE_SENTINEL))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("gcExtractionCache", () => {
|
||||
let tmpRoot: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpRoot = mkdtempSync(join(tmpdir(), "hf-extract-cache-gc-test-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (existsSync(tmpRoot)) rmSync(tmpRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function makeEntry(name: string, bytes: number, ageMs: number): string {
|
||||
const dir = join(tmpRoot, `${SCHEMA_PREFIX}${name}`);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, "frame_00001.jpg"), "x".repeat(bytes), "utf-8");
|
||||
markCacheEntryComplete({ dir, keyHash: name.padEnd(64, "0") });
|
||||
const when = new Date(Date.now() - ageMs);
|
||||
utimesSync(join(dir, COMPLETE_SENTINEL), when, when);
|
||||
return dir;
|
||||
}
|
||||
|
||||
it("evicts superseded-generation entries (hfcache-v2-*) under the size cap", () => {
|
||||
const oldGen = join(tmpRoot, "hfcache-v2-0123456789abcdef");
|
||||
mkdirSync(oldGen, { recursive: true });
|
||||
writeFileSync(join(oldGen, "frame_00001.jpg"), "x".repeat(2048), "utf-8");
|
||||
writeFileSync(join(oldGen, ".hf-complete"), "", "utf-8");
|
||||
const aged = new Date(Date.now() - 2 * 60 * 60 * 1000);
|
||||
utimesSync(join(oldGen, ".hf-complete"), aged, aged);
|
||||
utimesSync(oldGen, aged, aged);
|
||||
|
||||
const stats = gcExtractionCache(tmpRoot, { maxBytes: 1024, minAgeMs: 60 * 60 * 1000 });
|
||||
|
||||
expect(existsSync(oldGen)).toBe(false);
|
||||
expect(stats.evictedEntries).toBe(1);
|
||||
});
|
||||
|
||||
it("evicts oldest complete entries first until under maxBytes while respecting minAge", () => {
|
||||
const oldest = makeEntry("oldest", 60, 120_000);
|
||||
const middle = makeEntry("middle", 60, 90_000);
|
||||
const young = makeEntry("young", 60, 1_000);
|
||||
|
||||
gcExtractionCache(tmpRoot, { maxBytes: 100, minAgeMs: 60_000 });
|
||||
|
||||
expect(existsSync(oldest)).toBe(false);
|
||||
expect(existsSync(middle)).toBe(false);
|
||||
expect(existsSync(young)).toBe(true);
|
||||
});
|
||||
|
||||
it("removes aged partial directories", () => {
|
||||
const agedPartial = join(tmpRoot, `${SCHEMA_PREFIX}abc.partial-1234-deadbeef`);
|
||||
const freshPartial = join(tmpRoot, `${SCHEMA_PREFIX}def.partial-1234-feedface`);
|
||||
mkdirSync(agedPartial, { recursive: true });
|
||||
mkdirSync(freshPartial, { recursive: true });
|
||||
const old = new Date(Date.now() - 120_000);
|
||||
utimesSync(agedPartial, old, old);
|
||||
|
||||
gcExtractionCache(tmpRoot, { maxBytes: 1_000_000, minAgeMs: 60_000 });
|
||||
|
||||
expect(existsSync(agedPartial)).toBe(false);
|
||||
expect(existsSync(freshPartial)).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores non-cache-prefix directories under the same root", () => {
|
||||
const animatedGif = join(tmpRoot, "animated-gif");
|
||||
mkdirSync(animatedGif, { recursive: true });
|
||||
writeFileSync(join(animatedGif, "frame.png"), "keep", "utf-8");
|
||||
makeEntry("old", 200, 120_000);
|
||||
|
||||
gcExtractionCache(tmpRoot, { maxBytes: 1, minAgeMs: 60_000 });
|
||||
|
||||
expect(existsSync(animatedGif)).toBe(true);
|
||||
expect(readdirSync(animatedGif)).toEqual(["frame.png"]);
|
||||
});
|
||||
|
||||
it("never throws when the cache root is missing", () => {
|
||||
expect(() =>
|
||||
gcExtractionCache(join(tmpRoot, "missing"), { maxBytes: 1, minAgeMs: 60_000 }),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user