Skip to content

Commit 09fd4ff

Browse files
committed
fix(producer): document read-only plan hashing
1 parent f9f00b0 commit 09fd4ff

14 files changed

Lines changed: 1602 additions & 31 deletions

packages/producer/src/distributed.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,26 @@ export {
5454
PlanTooLargeError,
5555
} from "./services/distributed/plan.js";
5656

57+
// ── Plan v2 content-addressed transport ────────────────────────────────────
58+
export {
59+
createPlanV2FromV1,
60+
listPlanV2ArtifactsForTarget,
61+
materializePlanV2Target,
62+
planV2,
63+
readPlanV2Manifest,
64+
validatePlanV2MaterializedTarget,
65+
PLAN_V2_INTEGRITY_UNRECOVERABLE,
66+
PLAN_V2_MATERIALIZATION_MARKER,
67+
PlanV2IntegrityError,
68+
type PlanV2Artifact,
69+
type PlanV2Limitations,
70+
type PlanV2Manifest,
71+
type PlanV2MaterializationResult,
72+
type PlanV2MaterializationTarget,
73+
type PlanV2Result,
74+
} from "./services/distributed/planV2.js";
75+
export { assembleV2, renderChunkV2 } from "./services/distributed/planV2Execution.js";
76+
5777
// ── RenderChunk (Activity B) ────────────────────────────────────────────────
5878
export {
5979
applyRuntimeEnvSnapshot,
@@ -91,14 +111,21 @@ export {
91111
getDistributedRenderCapabilities,
92112
PLAN_ARTIFACT_LAYOUT,
93113
PLAN_HASH_SCHEMA,
114+
PLAN_PROTOCOL_V2,
94115
PLAN_PROTOCOL_UNSUPPORTED,
95116
PLAN_SCHEMA_VERSION,
117+
PLAN_V2_ARTIFACT_LAYOUT,
118+
PLAN_V2_HASH_SCHEMA,
119+
PLAN_V2_SCHEMA_VERSION,
96120
PlanProtocolUnsupportedError,
97121
readPlanProtocol,
122+
readPlanProtocolV1,
98123
type DistributedRenderCapabilities,
99124
type PlanProtocolConsumerCapabilities,
100125
type PlanProtocolDescriptor,
101126
type PlanProtocolV1Descriptor,
127+
type PlanProtocolV2Descriptor,
128+
type SupportedPlanProtocolDescriptor,
102129
} from "./services/distributed/planProtocol.js";
103130

104131
// ── Format union ────────────────────────────────────────────────────────────

packages/producer/src/index.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,23 +133,47 @@ export {
133133
// separate subpath import.
134134
export {
135135
assemble,
136+
assembleV2,
136137
CURRENT_PLAN_PROTOCOL,
137138
DISTRIBUTED_RENDER_CAPABILITIES,
138139
getDistributedRenderCapabilities,
139140
PLAN_ARTIFACT_LAYOUT,
140141
PLAN_HASH_SCHEMA,
142+
PLAN_PROTOCOL_V2,
141143
PLAN_PROTOCOL_UNSUPPORTED,
142144
PLAN_SCHEMA_VERSION,
145+
PLAN_V2_ARTIFACT_LAYOUT,
146+
PLAN_V2_HASH_SCHEMA,
147+
PLAN_V2_INTEGRITY_UNRECOVERABLE,
148+
PLAN_V2_MATERIALIZATION_MARKER,
149+
PLAN_V2_SCHEMA_VERSION,
150+
createPlanV2FromV1,
151+
listPlanV2ArtifactsForTarget,
152+
materializePlanV2Target,
143153
plan,
154+
planV2,
155+
PlanV2IntegrityError,
144156
PlanProtocolUnsupportedError,
145157
readPlanProtocol,
158+
readPlanProtocolV1,
159+
readPlanV2Manifest,
146160
renderChunk,
161+
renderChunkV2,
162+
validatePlanV2MaterializedTarget,
147163
type AssembleResult,
148164
type ChunkResult,
149165
type DistributedRenderCapabilities,
150166
type DistributedRenderConfig,
151167
type PlanProtocolConsumerCapabilities,
152168
type PlanProtocolDescriptor,
153169
type PlanProtocolV1Descriptor,
170+
type PlanProtocolV2Descriptor,
154171
type PlanResult,
172+
type PlanV2Artifact,
173+
type PlanV2Limitations,
174+
type PlanV2Manifest,
175+
type PlanV2MaterializationResult,
176+
type PlanV2MaterializationTarget,
177+
type PlanV2Result,
178+
type SupportedPlanProtocolDescriptor,
155179
} from "./distributed.js";

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ 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";
43+
import { DISTRIBUTED_RENDER_CAPABILITIES, readPlanProtocolV1 } from "./planProtocol.js";
44+
import { validatePlanV2MaterializedTarget } from "./planV2.js";
4445
import type { DistributedFormat } from "./shared.js";
4546

4647
/**
@@ -121,7 +122,8 @@ export async function assemble(
121122
throw new Error(`[assemble] planDir missing plan.json: ${planJsonPath}`);
122123
}
123124
const plan = JSON.parse(readFileSync(planJsonPath, "utf-8")) as PlanJsonForAssemble;
124-
readPlanProtocol(plan, DISTRIBUTED_RENDER_CAPABILITIES.roles.assembler);
125+
readPlanProtocolV1(plan, DISTRIBUTED_RENDER_CAPABILITIES.roles.assembler);
126+
validatePlanV2MaterializedTarget(planDir, { role: "assembler" });
125127
if (!existsSync(chunksJsonPath)) {
126128
throw new Error(`[assemble] planDir missing meta/chunks.json: ${chunksJsonPath}`);
127129
}

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

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ import { snapshotRuntimeEnv } from "../render/runtimeEnvSnapshot.js";
7575
import {
7676
buildSyntheticRenderJob,
7777
type DistributedFormat,
78+
PLAN_AUDIO_RELATIVE_PATH,
7879
PLAN_VIDEOS_META_RELATIVE_PATH,
7980
type PlanVideosJson,
8081
readFfmpegVersion,
@@ -225,7 +226,8 @@ export interface DistributedRenderConfig {
225226
* 10 GB `/tmp` budget alongside the chunk worker's frame buffer +
226227
* ffmpeg working set). Adapters that deploy onto storage with
227228
* tighter ceilings can pass a smaller cap; tests pass a tiny cap to
228-
* exercise the throw path.
229+
* exercise the throw path. This applies to the monolithic v1 transport;
230+
* `planV2()` emits content-addressed role dependencies and bypasses it.
229231
*/
230232
planDirSizeLimitBytes?: number;
231233

@@ -340,9 +342,8 @@ export const MIN_CHUNK_SIZE = 10;
340342
/**
341343
* Default hard ceiling on `<planDir>/` size in bytes. 2 GB fits inside
342344
* AWS Lambda's 10 GB `/tmp` alongside the chunk worker's captured frames
343-
* and ffmpeg's temporary files. Compositions that exceed this have to
344-
* fall back to the in-process renderer until per-chunk video-frame
345-
* slicing lands.
345+
* and ffmpeg's temporary files. Compositions that exceed this can opt into
346+
* `planV2()` or fall back to the in-process renderer.
346347
*/
347348
export const PLAN_DIR_SIZE_LIMIT_BYTES = 2 * 1024 * 1024 * 1024;
348349

@@ -364,8 +365,9 @@ export class PlanTooLargeError extends Error {
364365
`[plan] planDir size ${formatBytes(sizeBytes)} exceeds the configured ceiling ` +
365366
`${formatBytes(limitBytes)} (PLAN_TOO_LARGE). The default 2 GB cap fits inside AWS ` +
366367
`Lambda's 10 GB /tmp budget alongside the chunk worker's frame buffer and ffmpeg's ` +
367-
`working set. To unblock: shorten the composition, lower the framerate, or use the ` +
368-
`in-process renderer (\`executeRenderJob\`) — it has no planDir size cap.`,
368+
`working set. To unblock: use the content-addressed \`planV2()\` transport, shorten ` +
369+
`the composition, lower the framerate, or use the in-process renderer ` +
370+
`(\`executeRenderJob\`) — it has no planDir size cap.`,
369371
);
370372
this.name = "PlanTooLargeError";
371373
this.sizeBytes = sizeBytes;
@@ -1006,7 +1008,7 @@ export async function plan(
10061008
"utf-8",
10071009
);
10081010

1009-
const planAudioPath = join(planDir, "audio.aac");
1011+
const planAudioPath = join(planDir, PLAN_AUDIO_RELATIVE_PATH);
10101012
if (audioResult.hasAudio && existsSync(audioResult.audioOutputPath)) {
10111013
renameSync(audioResult.audioOutputPath, planAudioPath);
10121014
}

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

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
// These protocol rejection cases intentionally repeat the arrange/assert shape
2+
// so each malformed wire descriptor remains independently readable.
3+
// fallow-ignore-file code-duplication
4+
15
import { afterEach, describe, expect, it } from "bun:test";
26
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
37
import { tmpdir } from "node:os";
@@ -9,6 +13,7 @@ import {
913
getDistributedRenderCapabilities,
1014
PLAN_ARTIFACT_LAYOUT,
1115
PLAN_HASH_SCHEMA,
16+
PLAN_PROTOCOL_V2,
1217
PLAN_PROTOCOL_UNSUPPORTED,
1318
PLAN_SCHEMA_VERSION,
1419
PlanProtocolUnsupportedError,
@@ -126,6 +131,10 @@ describe("readPlanProtocol()", () => {
126131
).toBe(CURRENT_PLAN_PROTOCOL);
127132
});
128133

134+
it("accepts the explicit v2 descriptor", () => {
135+
expect(readPlanProtocol({ protocol: PLAN_PROTOCOL_V2 })).toBe(PLAN_PROTOCOL_V2);
136+
});
137+
129138
it("rejects malformed and partial descriptors", () => {
130139
for (const protocol of [
131140
null,
@@ -166,19 +175,19 @@ describe("readPlanProtocol()", () => {
166175
});
167176

168177
describe("getDistributedRenderCapabilities()", () => {
169-
it("reports explicit v1 support for every distributed role", () => {
178+
it("reports explicit v1 and v2 support for every distributed role", () => {
170179
expect(getDistributedRenderCapabilities()).toBe(DISTRIBUTED_RENDER_CAPABILITIES);
171180
expect(DISTRIBUTED_RENDER_CAPABILITIES).toEqual({
172181
roles: {
173182
planner: {
174-
produces: [CURRENT_PLAN_PROTOCOL],
183+
produces: [CURRENT_PLAN_PROTOCOL, PLAN_PROTOCOL_V2],
175184
},
176185
chunk: {
177-
accepts: [CURRENT_PLAN_PROTOCOL],
186+
accepts: [CURRENT_PLAN_PROTOCOL, PLAN_PROTOCOL_V2],
178187
acceptsLegacyV1WithoutDescriptor: true,
179188
},
180189
assembler: {
181-
accepts: [CURRENT_PLAN_PROTOCOL],
190+
accepts: [CURRENT_PLAN_PROTOCOL, PLAN_PROTOCOL_V2],
182191
acceptsLegacyV1WithoutDescriptor: true,
183192
},
184193
},
@@ -273,6 +282,21 @@ describe("distributed plan protocol readers", () => {
273282
expect((caught as PlanProtocolUnsupportedError).code).toBe(PLAN_PROTOCOL_UNSUPPORTED);
274283
});
275284

285+
it("legacy activities reject a recognized v2 root before v1 layout access", async () => {
286+
const planDir = createReaderPlan({
287+
includeProtocol: true,
288+
protocol: PLAN_PROTOCOL_V2,
289+
omitDownstreamArtifacts: true,
290+
});
291+
292+
await expect(renderChunk(planDir, 0, join(planDir, "unused-output"))).rejects.toThrow(
293+
"must be materialized before v1 layout access",
294+
);
295+
await expect(assemble(planDir, [], null, join(planDir, "unused-output"))).rejects.toThrow(
296+
"must be materialized before v1 layout access",
297+
);
298+
});
299+
276300
it("assemble rejects a partial protocol before parsing chunks", async () => {
277301
const planDir = createReaderPlan({
278302
includeProtocol: true,

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

Lines changed: 48 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111
export const PLAN_SCHEMA_VERSION = 1 as const;
1212
export const PLAN_ARTIFACT_LAYOUT = "plan-dir-v1" as const;
1313
export const PLAN_HASH_SCHEMA = "hyperframes-plan-hash-v1" as const;
14+
export const PLAN_V2_SCHEMA_VERSION = 2 as const;
15+
export const PLAN_V2_ARTIFACT_LAYOUT = "content-addressed-plan-v2" as const;
16+
export const PLAN_V2_HASH_SCHEMA = "hyperframes-plan-manifest-hash-v2" as const;
1417
export const PLAN_PROTOCOL_UNSUPPORTED = "PLAN_PROTOCOL_UNSUPPORTED" as const;
1518

1619
export interface PlanProtocolDescriptor {
@@ -25,13 +28,28 @@ export interface PlanProtocolV1Descriptor extends PlanProtocolDescriptor {
2528
readonly hashSchema: typeof PLAN_HASH_SCHEMA;
2629
}
2730

31+
export interface PlanProtocolV2Descriptor extends PlanProtocolDescriptor {
32+
readonly schemaVersion: typeof PLAN_V2_SCHEMA_VERSION;
33+
readonly artifactLayout: typeof PLAN_V2_ARTIFACT_LAYOUT;
34+
readonly hashSchema: typeof PLAN_V2_HASH_SCHEMA;
35+
}
36+
37+
export type SupportedPlanProtocolDescriptor = PlanProtocolV1Descriptor | PlanProtocolV2Descriptor;
38+
2839
/** Descriptor written by the current producer and accepted by v1 workers. */
2940
export const CURRENT_PLAN_PROTOCOL: Readonly<PlanProtocolV1Descriptor> = Object.freeze({
3041
schemaVersion: PLAN_SCHEMA_VERSION,
3142
artifactLayout: PLAN_ARTIFACT_LAYOUT,
3243
hashSchema: PLAN_HASH_SCHEMA,
3344
});
3445

46+
/** Explicit opt-in descriptor for the content-addressed v2 transport layout. */
47+
export const PLAN_PROTOCOL_V2: Readonly<PlanProtocolV2Descriptor> = Object.freeze({
48+
schemaVersion: PLAN_V2_SCHEMA_VERSION,
49+
artifactLayout: PLAN_V2_ARTIFACT_LAYOUT,
50+
hashSchema: PLAN_V2_HASH_SCHEMA,
51+
});
52+
3553
export interface PlanProtocolConsumerCapabilities {
3654
readonly accepts: readonly Readonly<PlanProtocolDescriptor>[];
3755
readonly acceptsLegacyV1WithoutDescriptor: boolean;
@@ -52,14 +70,14 @@ export const DISTRIBUTED_RENDER_CAPABILITIES: Readonly<DistributedRenderCapabili
5270
Object.freeze({
5371
roles: Object.freeze({
5472
planner: Object.freeze({
55-
produces: Object.freeze([CURRENT_PLAN_PROTOCOL]),
73+
produces: Object.freeze([CURRENT_PLAN_PROTOCOL, PLAN_PROTOCOL_V2]),
5674
}),
5775
chunk: Object.freeze({
58-
accepts: Object.freeze([CURRENT_PLAN_PROTOCOL]),
76+
accepts: Object.freeze([CURRENT_PLAN_PROTOCOL, PLAN_PROTOCOL_V2]),
5977
acceptsLegacyV1WithoutDescriptor: true,
6078
}),
6179
assembler: Object.freeze({
62-
accepts: Object.freeze([CURRENT_PLAN_PROTOCOL]),
80+
accepts: Object.freeze([CURRENT_PLAN_PROTOCOL, PLAN_PROTOCOL_V2]),
6381
acceptsLegacyV1WithoutDescriptor: true,
6482
}),
6583
}),
@@ -87,7 +105,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
87105

88106
function protocolMatches(
89107
descriptor: Record<string, unknown>,
90-
expected: PlanProtocolDescriptor,
108+
expected: SupportedPlanProtocolDescriptor,
91109
): boolean {
92110
return (
93111
descriptor.schemaVersion === expected.schemaVersion &&
@@ -117,7 +135,7 @@ export function readPlanProtocol(
117135
planJson: unknown,
118136
capabilities: Readonly<PlanProtocolConsumerCapabilities> = DISTRIBUTED_RENDER_CAPABILITIES.roles
119137
.chunk,
120-
): Readonly<PlanProtocolV1Descriptor> {
138+
): Readonly<SupportedPlanProtocolDescriptor> {
121139
if (!isRecord(planJson)) {
122140
throw new PlanProtocolUnsupportedError("plan.json must contain a JSON object");
123141
}
@@ -145,11 +163,32 @@ export function readPlanProtocol(
145163
}
146164
}
147165

148-
if (
149-
!protocolMatches(descriptor, CURRENT_PLAN_PROTOCOL) ||
150-
!capabilitiesAccept(capabilities, CURRENT_PLAN_PROTOCOL)
151-
) {
166+
const protocol = protocolMatches(descriptor, CURRENT_PLAN_PROTOCOL)
167+
? CURRENT_PLAN_PROTOCOL
168+
: protocolMatches(descriptor, PLAN_PROTOCOL_V2)
169+
? PLAN_PROTOCOL_V2
170+
: null;
171+
if (protocol === null || !capabilitiesAccept(capabilities, protocol)) {
152172
throw new PlanProtocolUnsupportedError("unsupported plan.json protocol descriptor");
153173
}
174+
return protocol;
175+
}
176+
177+
/**
178+
* Validate that a directory is directly consumable by the legacy execution
179+
* functions. A v2 transport must be materialized first; rejecting it here
180+
* prevents readers from probing paths that have different meanings in v2.
181+
*/
182+
export function readPlanProtocolV1(
183+
planJson: unknown,
184+
capabilities: Readonly<PlanProtocolConsumerCapabilities> = DISTRIBUTED_RENDER_CAPABILITIES.roles
185+
.chunk,
186+
): Readonly<PlanProtocolV1Descriptor> {
187+
const protocol = readPlanProtocol(planJson, capabilities);
188+
if (protocol !== CURRENT_PLAN_PROTOCOL) {
189+
throw new PlanProtocolUnsupportedError(
190+
"content-addressed v2 plan must be materialized before v1 layout access",
191+
);
192+
}
154193
return CURRENT_PLAN_PROTOCOL;
155194
}

0 commit comments

Comments
 (0)