mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +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:
@@ -25,11 +25,13 @@ import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
|
||||
import { unwrapTemplate } from "../utils/htmlTemplate.js";
|
||||
import {
|
||||
FRAME_FILENAME_PREFIX,
|
||||
ensureCacheEntryDir,
|
||||
gcExtractionCache,
|
||||
lookupCacheEntry,
|
||||
markCacheEntryComplete,
|
||||
partialCacheEntryDir,
|
||||
publishCacheEntry,
|
||||
readKeyStat,
|
||||
rehydrateCacheEntry,
|
||||
touchCacheEntry,
|
||||
type CacheFrameFormat,
|
||||
} from "./extractionCache.js";
|
||||
|
||||
@@ -82,6 +84,8 @@ export interface ExtractionOptions {
|
||||
format?: VideoFrameFormat;
|
||||
}
|
||||
|
||||
const EXTRACT_CACHE_MIN_AGE_MS = 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Per-phase timings and counters emitted by `extractAllVideoFrames`.
|
||||
*
|
||||
@@ -106,6 +110,16 @@ export interface ExtractionOptions {
|
||||
*/
|
||||
export interface ExtractionPhaseBreakdown {
|
||||
resolveMs: number;
|
||||
/** Publishes that could not land atomically — the render still succeeded
|
||||
* from the partial dir, but future renders re-extract. A rising rate is
|
||||
* the first signal that warm renders are silently going cold. */
|
||||
cachePublishFailures: number;
|
||||
/** Entries evicted by the post-extraction LRU sweep. */
|
||||
cacheGcEvictions: number;
|
||||
/** Bytes reclaimed by the LRU sweep. */
|
||||
cacheGcBytesFreed: number;
|
||||
/** Aged .partial-* dirs (crashed writers) removed by the sweep. */
|
||||
cacheAgedPartialsCleared: number;
|
||||
hdrProbeMs: number;
|
||||
hdrPreflightMs: number;
|
||||
hdrPreflightCount: number;
|
||||
@@ -529,7 +543,9 @@ export async function extractAllVideoFrames(
|
||||
baseDir: string,
|
||||
options: ExtractionOptions,
|
||||
signal?: AbortSignal,
|
||||
config?: Partial<Pick<EngineConfig, "ffmpegProcessTimeout" | "extractCacheDir">>,
|
||||
config?: Partial<
|
||||
Pick<EngineConfig, "ffmpegProcessTimeout" | "extractCacheDir" | "extractCacheMaxBytes">
|
||||
>,
|
||||
compiledDir?: string,
|
||||
): Promise<ExtractionResult> {
|
||||
const startTime = Date.now();
|
||||
@@ -538,6 +554,10 @@ export async function extractAllVideoFrames(
|
||||
let totalFramesExtracted = 0;
|
||||
const breakdown: ExtractionPhaseBreakdown = {
|
||||
resolveMs: 0,
|
||||
cachePublishFailures: 0,
|
||||
cacheGcEvictions: 0,
|
||||
cacheGcBytesFreed: 0,
|
||||
cacheAgedPartialsCleared: 0,
|
||||
hdrProbeMs: 0,
|
||||
hdrPreflightMs: 0,
|
||||
hdrPreflightCount: 0,
|
||||
@@ -691,6 +711,13 @@ export async function extractAllVideoFrames(
|
||||
config,
|
||||
);
|
||||
entry.videoPath = convertedPath;
|
||||
// The converted intermediate carries BT.2020-mapped pixels but the
|
||||
// cache key snapshot above still describes the ORIGINAL source.
|
||||
// Publishing converted frames under that key would poison later
|
||||
// plain-SDR renders of the same trim, so bypass the cache for
|
||||
// converted entries. (The follow-up transform-keyed cache change
|
||||
// re-enables caching for these with a discriminated key.)
|
||||
cacheKeyInputs[i] = null;
|
||||
// Segment-scoped re-encode starts the new file at t=0, so downstream
|
||||
// extraction must seek from 0, not the original mediaStart. Shallow-copy
|
||||
// to avoid mutating the caller's VideoElement.
|
||||
@@ -739,7 +766,18 @@ export async function extractAllVideoFrames(
|
||||
breakdown.vfrPreflightMs = Date.now() - vfrPreflightStart;
|
||||
|
||||
const phase3Start = Date.now();
|
||||
const cacheRootDir = config?.extractCacheDir;
|
||||
const configuredCacheRootDir = config?.extractCacheDir;
|
||||
let cacheRootDir: string | undefined;
|
||||
if (configuredCacheRootDir) {
|
||||
try {
|
||||
mkdirSync(configuredCacheRootDir, { recursive: true });
|
||||
cacheRootDir = configuredCacheRootDir;
|
||||
} catch {
|
||||
process.stderr.write(
|
||||
`[hyperframes:render] WARNING: extraction cache dir ${configuredCacheRootDir} is not writable; caching disabled for this render\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function tryCachedExtract(
|
||||
video: VideoElement,
|
||||
@@ -770,6 +808,7 @@ export async function extractAllVideoFrames(
|
||||
|
||||
if (lookup.hit) {
|
||||
breakdown.cacheHits += 1;
|
||||
touchCacheEntry(lookup.entry);
|
||||
const rehydrated = rehydrateCacheEntry(lookup.entry, {
|
||||
videoId: video.id,
|
||||
srcPath: keyInput.videoPath,
|
||||
@@ -781,7 +820,8 @@ export async function extractAllVideoFrames(
|
||||
}
|
||||
|
||||
breakdown.cacheMisses += 1;
|
||||
ensureCacheEntryDir(lookup.entry);
|
||||
const partialDir = partialCacheEntryDir(lookup.entry);
|
||||
mkdirSync(partialDir, { recursive: true });
|
||||
const result = await extractVideoFramesRange(
|
||||
videoPath,
|
||||
video.id,
|
||||
@@ -790,12 +830,22 @@ export async function extractAllVideoFrames(
|
||||
{ ...options, format: cacheFormat },
|
||||
signal,
|
||||
config,
|
||||
lookup.entry.dir,
|
||||
partialDir,
|
||||
);
|
||||
// Mark complete only AFTER frames are on disk — a crash mid-extract
|
||||
// leaves the entry un-sentineled so the next lookup re-extracts over it.
|
||||
markCacheEntryComplete(lookup.entry);
|
||||
return { ...result, ownedByLookup: true };
|
||||
const published = publishCacheEntry(lookup.entry, partialDir);
|
||||
if (!published.published) {
|
||||
breakdown.cachePublishFailures += 1;
|
||||
return { ...result, ownedByLookup: false };
|
||||
}
|
||||
|
||||
const rehydrated = rehydrateCacheEntry(lookup.entry, {
|
||||
videoId: video.id,
|
||||
srcPath: keyInput.videoPath,
|
||||
fps: options.fps,
|
||||
format: cacheFormat,
|
||||
metadata,
|
||||
});
|
||||
return { ...rehydrated, ownedByLookup: true };
|
||||
}
|
||||
|
||||
function extractionError(videoId: string, err: unknown): { videoId: string; error: string } {
|
||||
@@ -926,6 +976,16 @@ export async function extractAllVideoFrames(
|
||||
}
|
||||
}
|
||||
|
||||
if (cacheRootDir) {
|
||||
const gcStats = gcExtractionCache(cacheRootDir, {
|
||||
maxBytes: config?.extractCacheMaxBytes ?? DEFAULT_CONFIG.extractCacheMaxBytes,
|
||||
minAgeMs: EXTRACT_CACHE_MIN_AGE_MS,
|
||||
});
|
||||
breakdown.cacheGcEvictions = gcStats.evictedEntries;
|
||||
breakdown.cacheGcBytesFreed = gcStats.evictedBytes;
|
||||
breakdown.cacheAgedPartialsCleared = gcStats.agedPartialsRemoved;
|
||||
}
|
||||
|
||||
return {
|
||||
success: errors.length === 0,
|
||||
extracted,
|
||||
|
||||
Reference in New Issue
Block a user