feat(producer): enforce planDir size cap with PLAN_TOO_LARGE

This commit is contained in:
James
2026-05-14 02:39:32 +00:00
committed by James Russo
parent 3468db6f37
commit fbbd41a797
2 changed files with 244 additions and 3 deletions
@@ -24,7 +24,7 @@
* never have to handle them.
*/
import { existsSync, mkdirSync, renameSync, rmSync } from "node:fs";
import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, statSync } from "node:fs";
import { join } from "node:path";
import { type CanvasResolution } from "@hyperframes/core";
import { type EngineConfig, resolveConfig } from "@hyperframes/engine";
@@ -102,6 +102,16 @@ export interface DistributedRenderConfig {
entryFile?: string;
/** Caller-supplied AbortSignal. Threaded through compile / probe / extract / audio stages. */
abortSignal?: AbortSignal;
/**
* Hard ceiling on `<planDir>/` size in bytes; trips a non-retryable
* `PLAN_TOO_LARGE` error after freeze. Defaults to
* {@link PLAN_DIR_SIZE_LIMIT_BYTES} (2 GB — fits inside AWS Lambda's
* 10 GB `/tmp` budget alongside the chunk worker's frame buffer +
* ffmpeg working set). Adapters that deploy onto storage with
* tighter ceilings can pass a smaller cap; tests pass a tiny cap to
* exercise the throw path.
*/
planDirSizeLimitBytes?: number;
}
/**
@@ -125,6 +135,81 @@ export interface PlanResult {
export const DEFAULT_CHUNK_SIZE = 240;
/** Default cap on parallel chunks for operational fairness across renders. */
export const DEFAULT_MAX_PARALLEL_CHUNKS = 16;
/**
* Default hard ceiling on `<planDir>/` size in bytes. 2 GB fits inside
* AWS Lambda's 10 GB `/tmp` alongside the chunk worker's captured frames
* and ffmpeg's temporary files. Compositions that exceed this have to
* fall back to the in-process renderer until per-chunk video-frame
* slicing lands.
*/
export const PLAN_DIR_SIZE_LIMIT_BYTES = 2 * 1024 * 1024 * 1024;
/**
* Non-retryable error code raised when `plan()` produces a planDir whose
* total size exceeds the configured limit. Workflow adapters key retry
* policies off `code` — the planDir would fail the same way on every
* retry, so the failure must not auto-retry.
*/
export const PLAN_TOO_LARGE = "PLAN_TOO_LARGE";
/** Typed error raised when the produced planDir exceeds {@link PLAN_DIR_SIZE_LIMIT_BYTES}. */
export class PlanTooLargeError extends Error {
readonly code: typeof PLAN_TOO_LARGE = PLAN_TOO_LARGE;
readonly sizeBytes: number;
readonly limitBytes: number;
constructor(sizeBytes: number, limitBytes: number) {
super(
`[plan] planDir size ${formatBytes(sizeBytes)} exceeds the configured ceiling ` +
`${formatBytes(limitBytes)} (PLAN_TOO_LARGE). The default 2 GB cap fits inside AWS ` +
`Lambda's 10 GB /tmp budget alongside the chunk worker's frame buffer and ffmpeg's ` +
`working set. To unblock: shorten the composition, lower the framerate, or use the ` +
`in-process renderer (\`executeRenderJob\`) — it has no planDir size cap.`,
);
this.name = "PlanTooLargeError";
this.sizeBytes = sizeBytes;
this.limitBytes = limitBytes;
}
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GiB`;
}
/**
* Walk `<planDir>/` depth-first and sum all regular file sizes. Symlinks
* are not traversed — they shouldn't appear inside a planDir to begin with
* (the extract stage materializes them), and following them could push the
* walker outside the planDir.
*/
export function measurePlanDirBytes(planDir: string): number {
let total = 0;
function walk(dir: string): void {
let entries;
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
walk(full);
} else if (entry.isFile()) {
try {
total += statSync(full).size;
} catch {
// Ignore — a file disappearing during the walk shouldn't crash
// the measurement.
}
}
}
}
walk(planDir);
return total;
}
/**
* Compute `(chunkCount, effectiveChunkSize)` from total frames and the
@@ -521,8 +606,8 @@ export async function plan(
// Clean up the temp work tree. `.plan-work/` holds intermediate
// compileStage artifacts that are now promoted into `planDir/`; leaving
// it would inflate the planDir-size check and confuse chunk workers' file
// walks.
// it would inflate the planDir-size check below and confuse chunk
// workers' file walks.
try {
rmSync(workDir, { recursive: true, force: true });
} catch (err) {
@@ -532,6 +617,16 @@ export async function plan(
});
}
// 2 GB hard cap so the planDir fits inside Lambda's 10 GB /tmp budget
// alongside the chunk worker's frame buffer + ffmpeg working set. The
// check runs AFTER cleanup so the workDir tree doesn't double-count.
// Non-retryable: the same planDir would trip the cap on every retry.
const sizeLimitBytes = config.planDirSizeLimitBytes ?? PLAN_DIR_SIZE_LIMIT_BYTES;
const planDirBytes = measurePlanDirBytes(planDir);
if (planDirBytes > sizeLimitBytes) {
throw new PlanTooLargeError(planDirBytes, sizeLimitBytes);
}
return {
planDir,
planHash,
@@ -0,0 +1,146 @@
/**
* Unit tests for the `PLAN_TOO_LARGE` size cap on `plan()`.
*
* `plan()` measures the produced planDir before returning, and throws a
* non-retryable `PlanTooLargeError` if it exceeds the configured ceiling.
* Defaults to {@link PLAN_DIR_SIZE_LIMIT_BYTES} (2 GB); a smaller ceiling
* can be passed via `DistributedRenderConfig.planDirSizeLimitBytes` so
* tests can exercise the throw path without filling 2 GB of /tmp.
*
* Two cases:
* 1. The standalone `measurePlanDirBytes` helper walks the tree and
* sums regular files.
* 2. `plan()` throws `PlanTooLargeError` with `code === PLAN_TOO_LARGE`
* when the produced planDir exceeds a tiny configured cap.
*/
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
measurePlanDirBytes,
PLAN_DIR_SIZE_LIMIT_BYTES,
PLAN_TOO_LARGE,
PlanTooLargeError,
plan,
} from "./plan.js";
const FIXTURE_HTML = `<!doctype html>
<html><body>
<div data-composition-id="root" data-width="320" data-height="240" data-duration="1">hi</div>
</body></html>`;
let runRoot: string;
beforeAll(() => {
runRoot = mkdtempSync(join(tmpdir(), "hf-plan-size-cap-"));
});
afterAll(() => {
rmSync(runRoot, { recursive: true, force: true });
});
describe("measurePlanDirBytes", () => {
it("returns 0 for an empty directory", () => {
const dir = mkdtempSync(join(runRoot, "empty-"));
expect(measurePlanDirBytes(dir)).toBe(0);
});
it("sums file sizes recursively", () => {
const dir = mkdtempSync(join(runRoot, "fixture-"));
mkdirSync(join(dir, "nested", "deeper"), { recursive: true });
writeFileSync(join(dir, "a.bin"), Buffer.alloc(100));
writeFileSync(join(dir, "nested", "b.bin"), Buffer.alloc(250));
writeFileSync(join(dir, "nested", "deeper", "c.bin"), Buffer.alloc(50));
expect(measurePlanDirBytes(dir)).toBe(400);
});
it("ignores symlinks (not traversed into)", () => {
const dir = mkdtempSync(join(runRoot, "symlinks-"));
writeFileSync(join(dir, "real.bin"), Buffer.alloc(128));
// We don't actually create a symlink here because the planDir
// materialization path strips them — but the function should still
// gracefully ignore broken entries if any slipped in. Confirm the
// baseline is correct (the real file's bytes).
expect(measurePlanDirBytes(dir)).toBe(128);
});
});
describe("PLAN_DIR_SIZE_LIMIT_BYTES constant", () => {
it("is the documented 2 GB ceiling", () => {
expect(PLAN_DIR_SIZE_LIMIT_BYTES).toBe(2 * 1024 * 1024 * 1024);
});
});
describe("PlanTooLargeError", () => {
it("carries the typed PLAN_TOO_LARGE code", () => {
const err = new PlanTooLargeError(3 * 1024 * 1024 * 1024, 2 * 1024 * 1024 * 1024);
expect(err.code).toBe(PLAN_TOO_LARGE);
expect(err.name).toBe("PlanTooLargeError");
expect(err.sizeBytes).toBe(3 * 1024 * 1024 * 1024);
expect(err.limitBytes).toBe(2 * 1024 * 1024 * 1024);
// Message should point callers at the in-process renderer as the
// escape hatch.
expect(err.message).toMatch(/PLAN_TOO_LARGE/);
expect(err.message).toMatch(/in-process/i);
});
});
describe("plan() PLAN_TOO_LARGE throw path", () => {
// Generous timeout — the actual plan() pass on a tiny fixture is ~250ms,
// but cold cache + font snapshot read can spike on slower CI hosts.
const TIMEOUT_MS = 30_000;
it(
"throws PlanTooLargeError when planDir exceeds the configured ceiling",
async () => {
const projectDir = mkdtempSync(join(runRoot, "project-"));
writeFileSync(join(projectDir, "index.html"), FIXTURE_HTML, "utf-8");
const planDir = mkdtempSync(join(runRoot, "plandir-too-large-"));
// 1024-byte ceiling — even an empty planDir's meta/{composition,
// encoder,chunks}.json + compiled/index.html easily exceeds this.
let caught: unknown;
try {
await plan(
projectDir,
{
fps: 30,
width: 320,
height: 240,
format: "mp4",
planDirSizeLimitBytes: 1024,
},
planDir,
);
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(PlanTooLargeError);
expect((caught as PlanTooLargeError).code).toBe(PLAN_TOO_LARGE);
expect((caught as PlanTooLargeError).sizeBytes).toBeGreaterThan(1024);
expect((caught as PlanTooLargeError).limitBytes).toBe(1024);
},
TIMEOUT_MS,
);
it(
"succeeds when the default ceiling is well above the produced planDir size",
async () => {
// No `planDirSizeLimitBytes` override → uses 2 GB default. The fixture
// produces a planDir well under that, so plan() must complete.
const projectDir = mkdtempSync(join(runRoot, "project-ok-"));
writeFileSync(join(projectDir, "index.html"), FIXTURE_HTML, "utf-8");
const planDir = mkdtempSync(join(runRoot, "plandir-ok-"));
const result = await plan(
projectDir,
{ fps: 30, width: 320, height: 240, format: "mp4" },
planDir,
);
expect(result.planHash).toMatch(/^[0-9a-f]{64}$/);
},
TIMEOUT_MS,
);
});