feat(aws-lambda): support plan protocol v2 (#2789)

* feat(aws-lambda): support plan protocol v2

* fix(aws-lambda): align SAM v2 terminal errors
This commit is contained in:
James Russo
2026-07-25 23:42:51 -04:00
committed by GitHub
parent c6fdd9c015
commit 5bf61d6df0
46 changed files with 4687 additions and 114 deletions
@@ -20,11 +20,12 @@
*/
import { beforeAll, describe, expect, it } from "bun:test";
import { mkdtempSync, writeFileSync } from "node:fs";
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { App, Stack } from "aws-cdk-lib";
import { Template } from "aws-cdk-lib/assertions";
import { parse as parseYaml } from "yaml";
import { HyperframesRenderStack } from "./HyperframesRenderStack.js";
// CDK synth + Template.fromStack is slow on cold start in CI (~5-8s on
@@ -50,22 +51,33 @@ const EXPECTED_RESOURCE_COUNTS: Record<string, number> = {
// `RenderChunk` task lives nested under `RenderChunks.Iterator.States`,
// not at this level — we cover it separately in the contract test.
const EXPECTED_STATE_NAMES = [
"SelectPlanProtocol",
"Plan",
"PlanV2",
"BuildChunkList",
"AssertChunkCount",
"SelectWorkerProtocol",
"RenderChunks",
"RenderChunksV2",
"Assemble",
"AssembleV2",
"PlanProducedZeroChunks",
"UnsupportedPlanProtocol",
];
const EXPECTED_NON_RETRYABLE_ERRORS = new Set([
"FFMPEG_VERSION_MISMATCH",
"PLAN_HASH_MISMATCH",
"S3_URI_NOT_ALLOWED",
"BROWSER_GPU_NOT_SOFTWARE",
"FONT_FETCH_FAILED",
"PLAN_TOO_LARGE",
"PlanTooLargeError",
"PLAN_PROTOCOL_UNSUPPORTED",
"PlanProtocolUnsupportedError",
"PLAN_V2_INTEGRITY_UNRECOVERABLE",
"PlanV2IntegrityError",
"PLAN_ARTIFACT_DIGEST_MISMATCH",
"FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED",
"ChromeBinaryUnavailableError",
]);
@@ -130,7 +142,7 @@ describe("HyperframesRenderStack — snapshot", () => {
it("declares the state machine with the expected state names", () => {
const { definition } = SYNTHED;
expect(definition.StartAt).toBe("Plan");
expect(definition.StartAt).toBe("SelectPlanProtocol");
const actualStates = Object.keys(definition.States);
expect(actualStates.sort()).toEqual([...EXPECTED_STATE_NAMES].sort());
});
@@ -140,7 +152,7 @@ describe("HyperframesRenderStack — snapshot", () => {
const collected = new Set<string>();
// Plan + Assemble are top-level states; RenderChunk is nested inside
// the Map's Iterator definition.
const topLevelStates = ["Plan", "Assemble"] as const;
const topLevelStates = ["Plan", "PlanV2", "Assemble", "AssembleV2"] as const;
for (const stateName of topLevelStates) {
collectNonRetryableErrors(definition.States[stateName], collected);
}
@@ -152,6 +164,15 @@ describe("HyperframesRenderStack — snapshot", () => {
| undefined;
const innerStates = renderChunks?.Iterator?.States ?? renderChunks?.ItemProcessor?.States ?? {};
collectNonRetryableErrors(innerStates.RenderChunk, collected);
const renderChunksV2 = definition.States.RenderChunksV2 as
| {
Iterator?: { States?: Record<string, unknown> };
ItemProcessor?: { States?: Record<string, unknown> };
}
| undefined;
const innerStatesV2 =
renderChunksV2?.Iterator?.States ?? renderChunksV2?.ItemProcessor?.States ?? {};
collectNonRetryableErrors(innerStatesV2.RenderChunkV2, collected);
for (const expected of EXPECTED_NON_RETRYABLE_ERRORS) {
expect({ error: expected, present: collected.has(expected) }).toEqual({
@@ -160,6 +181,52 @@ describe("HyperframesRenderStack — snapshot", () => {
});
}
});
it("classifies plan v2 integrity failures as terminal in every v2 Lambda task", () => {
const v2TaskStates = Object.values(getV2TaskStates(SYNTHED.definition));
for (const state of v2TaskStates) {
const errors = new Set<string>();
collectNonRetryableErrors(state, errors);
expect(errors.has("PLAN_V2_INTEGRITY_UNRECOVERABLE")).toBe(true);
expect(errors.has("PlanV2IntegrityError")).toBe(true);
}
});
it("keeps SAM and CDK terminal classifiers identical for every v2 Lambda task", () => {
const cdkTasks = getV2TaskStates(SYNTHED.definition);
const samTasks = getV2TaskStates(readSamDefinition());
for (const taskName of ["PlanV2", "RenderChunkV2", "AssembleV2"] as const) {
const cdkErrors = new Set<string>();
const samErrors = new Set<string>();
collectNonRetryableErrors(cdkTasks[taskName], cdkErrors);
collectNonRetryableErrors(samTasks[taskName], samErrors);
expect({ taskName, errors: [...samErrors].sort() }).toEqual({
taskName,
errors: [...cdkErrors].sort(),
});
}
});
it("keeps v1 and v2 locators disjoint across orchestration branches", () => {
const { definition } = SYNTHED;
const v1 = JSON.stringify({
plan: definition.States.Plan,
chunks: definition.States.RenderChunks,
assemble: definition.States.Assemble,
});
const v2 = JSON.stringify({
plan: definition.States.PlanV2,
chunks: definition.States.RenderChunksV2,
assemble: definition.States.AssembleV2,
});
expect(v1).toContain("PlanS3Uri");
expect(v1).not.toContain("PlanV2ManifestS3Uri");
expect(v2).toContain("PlanV2ManifestS3Uri");
expect(v2).toContain("PlanV2ArtifactS3Prefix");
expect(v2).not.toContain("PlanS3Uri");
});
});
function collectNonRetryableErrors(state: unknown, out: Set<string>): void {
@@ -171,3 +238,66 @@ function collectNonRetryableErrors(state: unknown, out: Set<string>): void {
}
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function requireRecord(value: unknown, label: string): Record<string, unknown> {
if (!isRecord(value)) throw new Error(`${label} must be an object`);
return value;
}
function requireRecordProperty(
record: Record<string, unknown>,
property: string,
label: string,
): Record<string, unknown> {
return requireRecord(record[property], label);
}
function getV2TaskStates(definition: {
States: Record<string, unknown>;
}): Record<"PlanV2" | "RenderChunkV2" | "AssembleV2", unknown> {
const renderChunksV2 = requireRecord(definition.States.RenderChunksV2, "RenderChunksV2 state");
const processor = isRecord(renderChunksV2.Iterator)
? renderChunksV2.Iterator
: requireRecord(renderChunksV2.ItemProcessor, "RenderChunksV2 processor");
const innerStates = requireRecord(processor.States, "RenderChunksV2 processor states");
return {
PlanV2: definition.States.PlanV2,
RenderChunkV2: innerStates.RenderChunkV2,
AssembleV2: definition.States.AssembleV2,
};
}
function readSamDefinition(): { States: Record<string, unknown> } {
const source = readFileSync(
new URL("../../../../examples/aws-lambda/template.yaml", import.meta.url),
"utf8",
);
// CloudFormation intrinsic tags are irrelevant to classifier parity. The
// YAML parser preserves their scalar values while this option suppresses
// warnings for the intentionally unresolved `!Ref`/`!GetAtt` tags.
const parsed: unknown = parseYaml(source, { logLevel: "silent" });
const root = requireRecord(parsed, "SAM template");
const resources = requireRecordProperty(root, "Resources", "SAM resources");
const stateMachine = requireRecordProperty(
resources,
"RenderStateMachine",
"SAM RenderStateMachine",
);
const properties = requireRecordProperty(
stateMachine,
"Properties",
"SAM state-machine properties",
);
const definition = requireRecordProperty(
properties,
"Definition",
"SAM state-machine definition",
);
return {
States: requireRecordProperty(definition, "States", "SAM state-machine states"),
};
}
@@ -200,8 +200,12 @@ export class HyperframesRenderStack extends Construct {
"BROWSER_GPU_NOT_SOFTWARE",
"FONT_FETCH_FAILED",
"PLAN_TOO_LARGE",
"PlanTooLargeError",
"PLAN_PROTOCOL_UNSUPPORTED",
"PlanProtocolUnsupportedError",
"PLAN_V2_INTEGRITY_UNRECOVERABLE",
"PlanV2IntegrityError",
"PLAN_ARTIFACT_DIGEST_MISMATCH",
"FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED",
"ChromeBinaryUnavailableError",
];
@@ -210,8 +214,13 @@ export class HyperframesRenderStack extends Construct {
"PLAN_HASH_MISMATCH",
"S3_URI_NOT_ALLOWED",
"BROWSER_GPU_NOT_SOFTWARE",
"PLAN_TOO_LARGE",
"PlanTooLargeError",
"PLAN_PROTOCOL_UNSUPPORTED",
"PlanProtocolUnsupportedError",
"PLAN_V2_INTEGRITY_UNRECOVERABLE",
"PlanV2IntegrityError",
"PLAN_ARTIFACT_DIGEST_MISMATCH",
"ChromeBinaryUnavailableError",
];
const NON_RETRYABLE_ASSEMBLE = [
@@ -220,7 +229,12 @@ export class HyperframesRenderStack extends Construct {
"S3_URI_NOT_ALLOWED",
"PLAN_PROTOCOL_UNSUPPORTED",
"PlanProtocolUnsupportedError",
"PLAN_V2_INTEGRITY_UNRECOVERABLE",
"PlanV2IntegrityError",
"FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED",
"PLAN_TOO_LARGE",
"PlanTooLargeError",
"PLAN_ARTIFACT_DIGEST_MISMATCH",
"ChromeBinaryUnavailableError",
];
@@ -233,6 +247,7 @@ export class HyperframesRenderStack extends Construct {
"Config.$": "$.Config",
}),
resultSelector: {
PlanProtocol: "v1",
"PlanS3Uri.$": "$.Payload.PlanS3Uri",
"PlanHash.$": "$.Payload.PlanHash",
"ChunkCount.$": "$.Payload.ChunkCount",
@@ -254,6 +269,35 @@ export class HyperframesRenderStack extends Construct {
maxDelay: Duration.seconds(60),
});
const planV2 = new tasks.LambdaInvoke(this, "PlanV2", {
lambdaFunction: this.renderFunction,
payload: sfn.TaskInput.fromObject({
Action: "plan",
PlanProtocol: "v2",
"ProjectS3Uri.$": "$.ProjectS3Uri",
"PlanOutputS3Prefix.$": "$.PlanOutputS3Prefix",
"Config.$": "$.Config",
}),
resultSelector: {
PlanProtocol: "v2",
"PlanV2ManifestS3Uri.$": "$.Payload.PlanV2ManifestS3Uri",
"PlanV2ArtifactS3Prefix.$": "$.Payload.PlanV2ArtifactS3Prefix",
"PlanHash.$": "$.Payload.PlanHash",
"ChunkCount.$": "$.Payload.ChunkCount",
"Format.$": "$.Payload.Format",
"HasAudio.$": "$.Payload.HasAudio",
},
resultPath: "$.Plan",
});
planV2.addRetry({ errors: NON_RETRYABLE_PLAN, maxAttempts: 0 });
planV2.addRetry({
errors: ["States.ALL"],
interval: Duration.seconds(2),
maxAttempts: 4,
backoffRate: 2,
maxDelay: Duration.seconds(60),
});
const buildChunkList = new sfn.Pass(this, "BuildChunkList", {
parameters: {
"ChunkIndexes.$": "States.ArrayRange(0, States.MathAdd($.Plan.ChunkCount, -1), 1)",
@@ -337,11 +381,100 @@ export class HyperframesRenderStack extends Construct {
maxDelay: Duration.seconds(60),
});
const renderChunkV2Task = new tasks.LambdaInvoke(this, "RenderChunkV2", {
lambdaFunction: this.renderFunction,
payload: sfn.TaskInput.fromObject({
Action: "renderChunk",
PlanProtocol: "v2",
"ChunkIndex.$": "$.ChunkIndex",
"PlanV2ManifestS3Uri.$": "$.PlanV2ManifestS3Uri",
"PlanV2ArtifactS3Prefix.$": "$.PlanV2ArtifactS3Prefix",
"PlanHash.$": "$.PlanHash",
"ChunkOutputS3Prefix.$": "$.ChunkOutputS3Prefix",
"Format.$": "$.Format",
}),
resultSelector: {
"ChunkS3Uri.$": "$.Payload.ChunkS3Uri",
"ChunkIndex.$": "$.Payload.ChunkIndex",
"Sha256.$": "$.Payload.Sha256",
},
});
renderChunkV2Task.addRetry({ errors: NON_RETRYABLE_CHUNK, maxAttempts: 0 });
renderChunkV2Task.addRetry({
errors: ["States.ALL"],
interval: Duration.seconds(2),
maxAttempts: 4,
backoffRate: 2,
maxDelay: Duration.seconds(60),
});
const renderChunksV2 = new sfn.Map(this, "RenderChunksV2", {
itemsPath: "$.Iterator.ChunkIndexes",
itemSelector: {
"ChunkIndex.$": "$$.Map.Item.Value",
"PlanV2ManifestS3Uri.$": "$.Plan.PlanV2ManifestS3Uri",
"PlanV2ArtifactS3Prefix.$": "$.Plan.PlanV2ArtifactS3Prefix",
"PlanHash.$": "$.Plan.PlanHash",
"ChunkOutputS3Prefix.$": "$.PlanOutputS3Prefix",
"Format.$": "$.Plan.Format",
},
maxConcurrencyPath: "$.Plan.ChunkCount",
resultPath: "$.Chunks",
});
renderChunksV2.itemProcessor(renderChunkV2Task);
const assembleV2 = new tasks.LambdaInvoke(this, "AssembleV2", {
lambdaFunction: this.renderFunction,
payload: sfn.TaskInput.fromObject({
Action: "assemble",
PlanProtocol: "v2",
"PlanV2ManifestS3Uri.$": "$.Plan.PlanV2ManifestS3Uri",
"PlanV2ArtifactS3Prefix.$": "$.Plan.PlanV2ArtifactS3Prefix",
"PlanHash.$": "$.Plan.PlanHash",
"ChunkS3Uris.$": "$.Chunks[*].ChunkS3Uri",
AudioS3Uri: null,
"OutputS3Uri.$": "$.OutputS3Uri",
"Format.$": "$.Plan.Format",
}),
resultSelector: {
"OutputS3Uri.$": "$.Payload.OutputS3Uri",
"FramesEncoded.$": "$.Payload.FramesEncoded",
"FileSize.$": "$.Payload.FileSize",
},
resultPath: "$.Output",
});
assembleV2.addRetry({ errors: NON_RETRYABLE_ASSEMBLE, maxAttempts: 0 });
assembleV2.addRetry({
errors: ["States.ALL"],
interval: Duration.seconds(2),
maxAttempts: 4,
backoffRate: 2,
maxDelay: Duration.seconds(60),
});
const selectWorkerProtocol = new sfn.Choice(this, "SelectWorkerProtocol")
.when(
sfn.Condition.stringEquals("$.Plan.PlanProtocol", "v2"),
renderChunksV2.next(assembleV2),
)
.otherwise(renderChunks.next(assemble));
const assertChunkCount = new sfn.Choice(this, "AssertChunkCount")
.when(sfn.Condition.numberGreaterThan("$.Plan.ChunkCount", 0), renderChunks.next(assemble))
.when(sfn.Condition.numberGreaterThan("$.Plan.ChunkCount", 0), selectWorkerProtocol)
.otherwise(planProducedZero);
return plan.next(buildChunkList).next(assertChunkCount);
plan.next(buildChunkList);
planV2.next(buildChunkList);
buildChunkList.next(assertChunkCount);
const unsupportedPlanProtocol = new sfn.Fail(this, "UnsupportedPlanProtocol", {
error: "PLAN_PROTOCOL_UNSUPPORTED",
cause: 'PlanProtocol must be "v1", "v2", or absent (defaults to v1).',
});
return new sfn.Choice(this, "SelectPlanProtocol")
.when(sfn.Condition.stringEquals("$.PlanProtocol", "v2"), planV2)
.when(sfn.Condition.stringEquals("$.PlanProtocol", "v1"), plan)
.when(sfn.Condition.isPresent("$.PlanProtocol"), unsupportedPlanProtocol)
.otherwise(plan);
}
}