Skip to content

Commit f9f00b0

Browse files
authored
feat(producer): version distributed plan protocol (#2777)
1 parent 5ac3a7a commit f9f00b0

15 files changed

Lines changed: 609 additions & 5 deletions

File tree

packages/aws-lambda/src/cdk/HyperframesRenderStack.snapshot.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ const EXPECTED_NON_RETRYABLE_ERRORS = new Set([
6464
"BROWSER_GPU_NOT_SOFTWARE",
6565
"FONT_FETCH_FAILED",
6666
"PLAN_TOO_LARGE",
67+
"PLAN_PROTOCOL_UNSUPPORTED",
68+
"PlanProtocolUnsupportedError",
6769
"FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED",
6870
"ChromeBinaryUnavailableError",
6971
]);

packages/aws-lambda/src/cdk/HyperframesRenderStack.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,8 @@ export class HyperframesRenderStack extends Construct {
200200
"BROWSER_GPU_NOT_SOFTWARE",
201201
"FONT_FETCH_FAILED",
202202
"PLAN_TOO_LARGE",
203+
"PLAN_PROTOCOL_UNSUPPORTED",
204+
"PlanProtocolUnsupportedError",
203205
"FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED",
204206
"ChromeBinaryUnavailableError",
205207
];
@@ -208,12 +210,16 @@ export class HyperframesRenderStack extends Construct {
208210
"PLAN_HASH_MISMATCH",
209211
"S3_URI_NOT_ALLOWED",
210212
"BROWSER_GPU_NOT_SOFTWARE",
213+
"PLAN_PROTOCOL_UNSUPPORTED",
214+
"PlanProtocolUnsupportedError",
211215
"ChromeBinaryUnavailableError",
212216
];
213217
const NON_RETRYABLE_ASSEMBLE = [
214218
"FFMPEG_VERSION_MISMATCH",
215219
"PLAN_HASH_MISMATCH",
216220
"S3_URI_NOT_ALLOWED",
221+
"PLAN_PROTOCOL_UNSUPPORTED",
222+
"PlanProtocolUnsupportedError",
217223
"FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED",
218224
"ChromeBinaryUnavailableError",
219225
];

packages/aws-lambda/src/handler.test.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,12 @@ import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
1818
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
1919
import { tmpdir } from "node:os";
2020
import { join } from "node:path";
21-
import type { AssembleResult, ChunkResult, PlanResult } from "@hyperframes/producer/distributed";
21+
import {
22+
CURRENT_PLAN_PROTOCOL,
23+
type AssembleResult,
24+
type ChunkResult,
25+
type PlanResult,
26+
} from "@hyperframes/producer/distributed";
2227
import type { AssembleEvent, LambdaEvent, PlanEvent, RenderChunkEvent } from "./events.js";
2328
import { handler, unwrapEvent } from "./handler.js";
2429

@@ -157,6 +162,7 @@ describe("handler dispatch", () => {
157162
writeFileSync(join(planDir, "meta", "chunks.json"), "[]");
158163
return {
159164
planDir,
165+
planProtocol: CURRENT_PLAN_PROTOCOL,
160166
planHash: "fakehash",
161167
chunkCount: 4,
162168
totalFrames: 720,
@@ -224,6 +230,7 @@ describe("handler dispatch", () => {
224230
writeFileSync(join(planDir, "meta", "chunks.json"), "[]");
225231
return {
226232
planDir,
233+
planProtocol: CURRENT_PLAN_PROTOCOL,
227234
planHash: "fakehash",
228235
chunkCount: 1,
229236
totalFrames: 30,

packages/gcp-cloud-run/src/server.test.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,13 @@ import { afterEach, describe, expect, it } from "bun:test";
1818
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
1919
import { tmpdir } from "node:os";
2020
import { join } from "node:path";
21-
import type { AssembleResult, ChunkResult, PlanResult } from "@hyperframes/producer/distributed";
21+
import {
22+
CURRENT_PLAN_PROTOCOL,
23+
PlanProtocolUnsupportedError,
24+
type AssembleResult,
25+
type ChunkResult,
26+
type PlanResult,
27+
} from "@hyperframes/producer/distributed";
2228
import { asStorage, FakeGcs } from "./__fixtures__/fakeGcs.js";
2329
import type { AssembleEvent, CloudRunEvent, PlanEvent, RenderChunkEvent } from "./events.js";
2430
import { createApp, dispatch, type HandlerDeps, unwrapEvent } from "./server.js";
@@ -56,6 +62,7 @@ async function seedPlanTar(gcs: FakeGcs, uri: string, planHash: string): Promise
5662

5763
const planResult: PlanResult = {
5864
planDir: "(set at call time)",
65+
planProtocol: CURRENT_PLAN_PROTOCOL,
5966
planHash: PLAN_HASH,
6067
chunkCount: 3,
6168
totalFrames: 90,
@@ -295,6 +302,34 @@ describe("createApp HTTP mapping", () => {
295302
expect(body.error).toBe("PLAN_HASH_MISMATCH");
296303
});
297304

305+
it("returns 400 for an unsupported plan protocol", async () => {
306+
const gcs = new FakeGcs();
307+
await seedPlanTar(gcs, "gs://b/renders/r1/plan.tar.gz", PLAN_HASH);
308+
const app = createApp(
309+
depsWith(gcs, {
310+
renderChunk: async () => {
311+
throw new PlanProtocolUnsupportedError("unsupported test protocol");
312+
},
313+
}),
314+
);
315+
const res = await app.request("/", {
316+
method: "POST",
317+
headers: { "content-type": "application/json" },
318+
body: JSON.stringify({
319+
Action: "renderChunk",
320+
PlanGcsUri: "gs://b/renders/r1/plan.tar.gz",
321+
PlanHash: PLAN_HASH,
322+
ChunkIndex: 0,
323+
ChunkOutputGcsPrefix: "gs://b/renders/r1/",
324+
Format: "mp4",
325+
}),
326+
});
327+
328+
expect(res.status).toBe(400);
329+
const body = (await res.json()) as { error: string };
330+
expect(body.error).toBe("PlanProtocolUnsupportedError");
331+
});
332+
298333
it("returns 500 for a retryable/unknown error", async () => {
299334
const gcs = new FakeGcs(); // plan tar NOT seeded → download fails (retryable)
300335
const app = createApp(depsWith(gcs));

packages/gcp-cloud-run/src/server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -584,10 +584,12 @@ const NON_RETRYABLE_ERROR_NAMES = new Set([
584584
// non-retryable list.
585585
"FormatNotSupportedInDistributedError",
586586
"PlanTooLargeError",
587+
"PlanProtocolUnsupportedError",
587588
"RenderChunkValidationError",
588589
"FFMPEG_VERSION_MISMATCH",
589590
"FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED",
590591
"PLAN_TOO_LARGE",
592+
"PLAN_PROTOCOL_UNSUPPORTED",
591593
"BROWSER_GPU_NOT_SOFTWARE",
592594
"FONT_FETCH_FAILED",
593595
"ChromeBinaryUnavailableError",

packages/producer/src/distributed.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,25 @@ export {
8282
} from "./services/distributed/renderConfigValidation.js";
8383
export { hashProjectDir } from "./services/distributed/projectHash.js";
8484

85+
// ── Plan protocol compatibility ────────────────────────────────────────────
86+
// Workers validate this descriptor before consuming layout-specific
87+
// artifacts. Missing descriptors remain compatible with legacy v1 plans.
88+
export {
89+
CURRENT_PLAN_PROTOCOL,
90+
DISTRIBUTED_RENDER_CAPABILITIES,
91+
getDistributedRenderCapabilities,
92+
PLAN_ARTIFACT_LAYOUT,
93+
PLAN_HASH_SCHEMA,
94+
PLAN_PROTOCOL_UNSUPPORTED,
95+
PLAN_SCHEMA_VERSION,
96+
PlanProtocolUnsupportedError,
97+
readPlanProtocol,
98+
type DistributedRenderCapabilities,
99+
type PlanProtocolConsumerCapabilities,
100+
type PlanProtocolDescriptor,
101+
type PlanProtocolV1Descriptor,
102+
} from "./services/distributed/planProtocol.js";
103+
85104
// ── Format union ────────────────────────────────────────────────────────────
86105
// Canonical output-format type. The aws-lambda package re-exports it so
87106
// CLI / adopter SDKs can derive runtime allowlists from one source.

packages/producer/src/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,10 +133,23 @@ export {
133133
// separate subpath import.
134134
export {
135135
assemble,
136+
CURRENT_PLAN_PROTOCOL,
137+
DISTRIBUTED_RENDER_CAPABILITIES,
138+
getDistributedRenderCapabilities,
139+
PLAN_ARTIFACT_LAYOUT,
140+
PLAN_HASH_SCHEMA,
141+
PLAN_PROTOCOL_UNSUPPORTED,
142+
PLAN_SCHEMA_VERSION,
136143
plan,
144+
PlanProtocolUnsupportedError,
145+
readPlanProtocol,
137146
renderChunk,
138147
type AssembleResult,
139148
type ChunkResult,
149+
type DistributedRenderCapabilities,
140150
type DistributedRenderConfig,
151+
type PlanProtocolConsumerCapabilities,
152+
type PlanProtocolDescriptor,
153+
type PlanProtocolV1Descriptor,
141154
type PlanResult,
142155
} from "./distributed.js";

packages/producer/src/services/distributed/assemble.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import { defaultLogger, type ProducerLogger } from "../../logger.js";
4040
import { formatExportFrameName } from "../../utils/paths.js";
4141
import { padOrTrimAudioToVideoFrameCount } from "../render/audioPadTrim.js";
4242
import type { ChunkSliceJson } from "../render/stages/freezePlan.js";
43+
import { DISTRIBUTED_RENDER_CAPABILITIES, readPlanProtocol } from "./planProtocol.js";
4344
import type { DistributedFormat } from "./shared.js";
4445

4546
/**
@@ -56,6 +57,7 @@ export interface AssembleResult {
5657

5758
/** Shape of the planDir's top-level `plan.json` — only the fields `assemble` needs. */
5859
interface PlanJsonForAssemble {
60+
protocol?: unknown;
5961
planHash: string;
6062
totalFrames: number;
6163
hasAudio: boolean;
@@ -118,10 +120,11 @@ export async function assemble(
118120
if (!existsSync(planJsonPath)) {
119121
throw new Error(`[assemble] planDir missing plan.json: ${planJsonPath}`);
120122
}
123+
const plan = JSON.parse(readFileSync(planJsonPath, "utf-8")) as PlanJsonForAssemble;
124+
readPlanProtocol(plan, DISTRIBUTED_RENDER_CAPABILITIES.roles.assembler);
121125
if (!existsSync(chunksJsonPath)) {
122126
throw new Error(`[assemble] planDir missing meta/chunks.json: ${chunksJsonPath}`);
123127
}
124-
const plan = JSON.parse(readFileSync(planJsonPath, "utf-8")) as PlanJsonForAssemble;
125128
const chunks = JSON.parse(readFileSync(chunksJsonPath, "utf-8")) as ChunkSliceJson[];
126129
if (chunkPaths.length !== chunks.length) {
127130
throw new Error(

packages/producer/src/services/distributed/plan.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { tmpdir } from "node:os";
2121
import { join } from "node:path";
2222
import { recomputePlanHashFromPlanDir } from "../render/stages/freezePlan.js";
2323
import { RenderQualityError } from "../renderOrchestrator.js";
24+
import { CURRENT_PLAN_PROTOCOL } from "./planProtocol.js";
2425
import {
2526
applyDistributedAudioWarningPolicy,
2627
buildChunkSlices,
@@ -401,6 +402,7 @@ describe("plan() — golden planDir + planHash determinism", () => {
401402

402403
// ── PlanResult contract ─────────────────────────────────────────────
403404
expect(result.planDir).toBe(planDir);
405+
expect(result.planProtocol).toEqual(CURRENT_PLAN_PROTOCOL);
404406
expect(result.planHash).toMatch(/^[0-9a-f]{64}$/);
405407
expect(result.chunkCount).toBe(1);
406408
expect(result.totalFrames).toBe(30); // 1s @ 30fps
@@ -429,6 +431,7 @@ describe("plan() — golden planDir + planHash determinism", () => {
429431
unknown
430432
>;
431433
expect(planJson.planHash).toBe(result.planHash);
434+
expect(planJson.protocol).toEqual(CURRENT_PLAN_PROTOCOL);
432435
expect(planJson.hasAudio).toBe(false);
433436
expect(planJson.totalFrames).toBe(result.totalFrames);
434437
},
@@ -516,8 +519,18 @@ describe("plan() — golden planDir + planHash determinism", () => {
516519
expect(recomputed).toBe(result.planHash);
517520
const planJson = JSON.parse(readFileSync(join(planDir, "plan.json"), "utf-8")) as {
518521
planHash: string;
522+
protocol?: unknown;
519523
};
520524
expect(planJson.planHash).toBe(result.planHash);
525+
expect(planJson.protocol).toEqual(CURRENT_PLAN_PROTOCOL);
526+
527+
delete planJson.protocol;
528+
writeFileSync(join(planDir, "plan.json"), `${JSON.stringify(planJson, null, 2)}\n`, "utf-8");
529+
expect(recomputePlanHashFromPlanDir(planDir)).toBe(result.planHash);
530+
531+
planJson.protocol = CURRENT_PLAN_PROTOCOL;
532+
writeFileSync(join(planDir, "plan.json"), `${JSON.stringify(planJson, null, 2)}\n`, "utf-8");
533+
expect(recomputePlanHashFromPlanDir(planDir)).toBe(result.planHash);
521534
},
522535
TIMEOUT_MS,
523536
);

packages/producer/src/services/distributed/plan.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ import {
8080
readFfmpegVersion,
8181
readProducerVersion,
8282
} from "./shared.js";
83+
import { CURRENT_PLAN_PROTOCOL, type PlanProtocolV1Descriptor } from "./planProtocol.js";
8384

8485
/**
8586
* Caller-supplied configuration for a distributed render. `fps`, `width`,
@@ -255,6 +256,7 @@ export interface DistributedRenderConfig {
255256
*/
256257
export interface PlanResult {
257258
planDir: string;
259+
planProtocol: Readonly<PlanProtocolV1Descriptor>;
258260
planHash: string;
259261
chunkCount: number;
260262
totalFrames: number;
@@ -1100,6 +1102,7 @@ export async function plan(
11001102

11011103
return {
11021104
planDir,
1105+
planProtocol: CURRENT_PLAN_PROTOCOL,
11031106
planHash,
11041107
chunkCount,
11051108
totalFrames,

0 commit comments

Comments
 (0)