Skip to content

Commit d8851cf

Browse files
jrusso1020claude
andcommitted
fix(aws-lambda): account for TaskScheduled/TaskSucceeded in cost
The CDK construct compiles tasks.LambdaInvoke to the optimized arn:aws:states:::lambda:invoke integration, which emits Task* history events with the Lambda response wrapped in .Payload. getRenderProgress was only listening for the older LambdaFunction* events, so every CDK- deployed stack reported $0 total cost and zero invocations on success — a high-visibility regression that only surfaced when we manually walked SFN history during a cost-analysis sweep. Add cases for TaskScheduled (count invocation), TaskSucceeded (parse Payload + accumulate billed duration / frame counts), and TaskFailed (record error). Keep the LambdaFunction* paths so anyone wiring the raw lambda:invokeFunction.sync task type still works. Factor out the shared FramesEncoded-attribution logic so both branches agree on the "only RenderChunk frames count" rule. Tests pin a real-shape regression: replay the inspector-launch 1080p/30fps history (1 Plan + 16 RenderChunks + 1 Assemble) and assert lambdaUsd lands at ~$0.582 — matching the cost-analysis script's direct read against SFN history. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c1fd891 commit d8851cf

2 files changed

Lines changed: 267 additions & 1 deletion

File tree

packages/aws-lambda/src/sdk/getRenderProgress.test.ts

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,60 @@ function stateExited(name: string, output?: unknown): HistoryEvent {
6767
} as HistoryEvent;
6868
}
6969

70+
// The CDK construct compiles `tasks.LambdaInvoke` to the optimized
71+
// `lambda:invoke` integration, which emits Task* events with the Lambda
72+
// response wrapped in `.Payload`. These helpers fabricate the matching
73+
// shapes so the test suite covers the same event stream a real render
74+
// produces — without them, the cost/progress code regressed silently to
75+
// $0 against production stacks.
76+
function taskScheduled(): HistoryEvent {
77+
return {
78+
type: "TaskScheduled",
79+
id: 1,
80+
timestamp: new Date(),
81+
taskScheduledEventDetails: {
82+
resource: "invoke",
83+
resourceType: "lambda",
84+
region: "us-east-1",
85+
parameters: "{}",
86+
},
87+
} as HistoryEvent;
88+
}
89+
90+
function taskSucceeded(payload: unknown): HistoryEvent {
91+
// Match the optimized integration's wire shape — handler payload at
92+
// `.Payload`, plus the ExecutedVersion / StatusCode envelope SFN
93+
// surfaces verbatim from Lambda.
94+
return {
95+
type: "TaskSucceeded",
96+
id: 1,
97+
timestamp: new Date(),
98+
taskSucceededEventDetails: {
99+
resource: "invoke",
100+
resourceType: "lambda",
101+
output: JSON.stringify({
102+
ExecutedVersion: "$LATEST",
103+
Payload: payload,
104+
StatusCode: 200,
105+
}),
106+
},
107+
} as HistoryEvent;
108+
}
109+
110+
function taskFailed(error: string, cause: string): HistoryEvent {
111+
return {
112+
type: "TaskFailed",
113+
id: 1,
114+
timestamp: new Date(),
115+
taskFailedEventDetails: {
116+
resource: "invoke",
117+
resourceType: "lambda",
118+
error,
119+
cause,
120+
},
121+
} as HistoryEvent;
122+
}
123+
70124
describe("getRenderProgress", () => {
71125
it("reports 0 progress before Plan completes", async () => {
72126
const sfn = new FakeSFN();
@@ -222,6 +276,130 @@ describe("getRenderProgress", () => {
222276
it("requires executionArn", async () => {
223277
await expect(getRenderProgress({ executionArn: "" })).rejects.toThrow(/executionArn/);
224278
});
279+
280+
// Real-world production renders emit Task* events (optimized
281+
// lambda:invoke integration), not LambdaFunction* events. Before this
282+
// block the SDK counted zero invocations and reported $0 cost on
283+
// every CDK-deployed stack — see the in-tree cost analysis doc that
284+
// hand-walked SFN history to confirm the bug.
285+
describe("optimized lambda:invoke integration", () => {
286+
it("counts a single TaskSucceeded as one Lambda invocation", async () => {
287+
const sfn = new FakeSFN();
288+
sfn.historyPages = [
289+
[
290+
stateEntered("Plan"),
291+
taskScheduled(),
292+
taskSucceeded({ Action: "plan", TotalFrames: 240, DurationMs: 1_000 }),
293+
],
294+
];
295+
const progress = await getRenderProgress({
296+
executionArn: "arn",
297+
defaultMemorySizeMb: 10_240,
298+
sfn: sfn as unknown as SFNClient,
299+
});
300+
expect(progress.lambdasInvoked).toBe(1);
301+
expect(progress.totalFrames).toBe(240);
302+
// 1 invocation × 1s × 10GB × $0.0000166667/GB-s ≈ $0.000167; the
303+
// cost code rounds to 4 decimals (≈ tenth-of-a-cent), so a precise
304+
// assertion needs precision=4 not precision=6. Pre-fix this read 0.
305+
expect(progress.costs.breakdown.lambdaUsd).toBeCloseTo(0.0002, 4);
306+
expect(progress.costs.breakdown.lambdaUsd).toBeGreaterThan(0);
307+
});
308+
309+
it("attributes RenderChunk FramesEncoded but ignores Plan/Assemble FramesEncoded", async () => {
310+
const sfn = new FakeSFN();
311+
sfn.historyPages = [
312+
[
313+
stateEntered("Plan"),
314+
taskScheduled(),
315+
taskSucceeded({ Action: "plan", TotalFrames: 100, DurationMs: 1_000 }),
316+
stateEntered("RenderChunk"),
317+
taskScheduled(),
318+
taskSucceeded({ Action: "renderChunk", FramesEncoded: 50, DurationMs: 2_000 }),
319+
stateEntered("Assemble"),
320+
taskScheduled(),
321+
taskSucceeded({
322+
Action: "assemble",
323+
FramesEncoded: 100, // would double-count if Assemble's count bled in
324+
FileSize: 9_000_000,
325+
OutputS3Uri: "s3://b/k.mp4",
326+
DurationMs: 1_500,
327+
}),
328+
],
329+
];
330+
const progress = await getRenderProgress({
331+
executionArn: "arn",
332+
sfn: sfn as unknown as SFNClient,
333+
});
334+
expect(progress.framesRendered).toBe(50);
335+
expect(progress.lambdasInvoked).toBe(3);
336+
});
337+
338+
it("captures TaskFailed errors with the enclosing state name", async () => {
339+
const sfn = new FakeSFN();
340+
sfn.historyPages = [
341+
[
342+
stateEntered("RenderChunk"),
343+
taskScheduled(),
344+
taskFailed("Sandbox.Timedout", "Task timed out after 900.00 seconds"),
345+
],
346+
];
347+
const progress = await getRenderProgress({
348+
executionArn: "arn",
349+
sfn: sfn as unknown as SFNClient,
350+
});
351+
expect(progress.errors).toEqual([
352+
{
353+
state: "RenderChunk",
354+
error: "Sandbox.Timedout",
355+
cause: "Task timed out after 900.00 seconds",
356+
},
357+
]);
358+
});
359+
360+
it("billed-seconds sum matches what the cost-analysis script computes", async () => {
361+
// Real-shape regression: cost script summed `DurationMs` across plan
362+
// + renderChunk + assemble TaskSucceeded events and pinned the
363+
// inspector-launch 1080p/30fps run at ~$0.58. Replay the same
364+
// shape and verify the SDK lands on the same number.
365+
const sfn = new FakeSFN();
366+
const renderChunkSucceeded = (frames: number, ms: number) => [
367+
stateEntered("RenderChunk"),
368+
taskScheduled(),
369+
taskSucceeded({ Action: "renderChunk", FramesEncoded: frames, DurationMs: ms }),
370+
];
371+
sfn.historyPages = [
372+
[
373+
stateEntered("Plan"),
374+
taskScheduled(),
375+
taskSucceeded({ Action: "plan", TotalFrames: 1349, DurationMs: 13_000 }),
376+
// 16 chunks averaging ~217 s each; the actual sweep saw
377+
// 3492.7 s billed total.
378+
...Array.from({ length: 16 }, () => renderChunkSucceeded(84, 217_000)).flat(),
379+
stateEntered("Assemble"),
380+
taskScheduled(),
381+
taskSucceeded({
382+
Action: "assemble",
383+
FileSize: 81_000_000,
384+
OutputS3Uri: "s3://b/k.mp4",
385+
DurationMs: 7_700,
386+
}),
387+
],
388+
];
389+
sfn.describe.status = "SUCCEEDED";
390+
const progress = await getRenderProgress({
391+
executionArn: "arn",
392+
defaultMemorySizeMb: 10_240,
393+
sfn: sfn as unknown as SFNClient,
394+
});
395+
// 13s + 16×217s + 7.7s = 3492.7 s × 10GB × $0.0000166667/GB-s
396+
// ≈ $0.582 — the inspector-launch 1080p/30fps real run cost was
397+
// $0.5826 (Lambda only). The pre-fix SDK reported $0.
398+
expect(progress.costs.breakdown.lambdaUsd).toBeCloseTo(0.582, 2);
399+
expect(progress.framesRendered).toBe(84 * 16);
400+
expect(progress.lambdasInvoked).toBe(18); // 1 plan + 16 chunks + 1 assemble
401+
});
402+
});
225403
});
226404

227405
void DescribeExecutionCommand;

packages/aws-lambda/src/sdk/getRenderProgress.ts

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,12 @@ export interface RenderProgress {
7373
framesRendered: number;
7474
/** `null` until Plan completes. */
7575
totalFrames: number | null;
76-
/** Count of `LambdaFunctionScheduled` events seen in the history so far. */
76+
/**
77+
* Count of Lambda invocations scheduled so far. Counts both the optimized
78+
* `lambda:invoke` task type's `TaskScheduled` events and the older raw
79+
* `LambdaFunctionScheduled` events, so this is the total whichever
80+
* integration the state machine uses.
81+
*/
7782
lambdasInvoked: number;
7883
costs: RenderCost;
7984
/** Final output object if Assemble succeeded; `null` otherwise. */
@@ -198,9 +203,45 @@ function summarizeHistory(events: HistoryEvent[], memoryMb: number): HistorySumm
198203
stateTransitions++;
199204
currentLambdaState = ev.stateEnteredEventDetails?.name ?? currentLambdaState;
200205
break;
206+
// The CDK construct uses `tasks.LambdaInvoke`, which compiles to the
207+
// optimized `arn:aws:states:::lambda:invoke` integration. That
208+
// integration emits `TaskScheduled` / `TaskSucceeded` / `TaskFailed`
209+
// events (with the Lambda response wrapped in `.Payload`), NOT the
210+
// older `LambdaFunctionScheduled` / `LambdaFunctionSucceeded` /
211+
// `LambdaFunctionFailed` events the raw `lambda:invokeFunction.sync`
212+
// task type would emit. Both paths are handled so the SDK works
213+
// against either flavor of the construct.
214+
case "TaskScheduled":
215+
if (ev.taskScheduledEventDetails?.resourceType === "lambda") {
216+
lambdasInvoked++;
217+
}
218+
break;
201219
case "LambdaFunctionScheduled":
202220
lambdasInvoked++;
203221
break;
222+
case "TaskSucceeded": {
223+
if (ev.taskSucceededEventDetails?.resourceType !== "lambda") break;
224+
// Optimized integration wraps the Lambda response: the actual
225+
// handler payload is at `.Payload`. Anywhere we used to read
226+
// `payload.X` on a LambdaFunctionSucceeded output, we now read
227+
// `payload.Payload.X` here.
228+
const wrapped = parseJson(ev.taskSucceededEventDetails?.output);
229+
const payload = unwrapLambdaPayload(wrapped);
230+
const billedDurationMs = inferBilledMs(payload);
231+
lambdaInvocations.push({
232+
billedDurationMs,
233+
memorySizeMb: memoryMb,
234+
estimated: billedDurationMs === 0,
235+
});
236+
applyPayloadFrameCounts(payload, currentLambdaState, (delta) => {
237+
framesRendered += delta;
238+
});
239+
if (payload && typeof payload === "object") {
240+
const obj = payload as Record<string, unknown>;
241+
if (typeof obj.TotalFrames === "number") totalFrames = obj.TotalFrames;
242+
}
243+
break;
244+
}
204245
case "LambdaFunctionSucceeded": {
205246
const payload = parseJson(ev.lambdaFunctionSucceededEventDetails?.output);
206247
const billedDurationMs = inferBilledMs(payload);
@@ -245,6 +286,14 @@ function summarizeHistory(events: HistoryEvent[], memoryMb: number): HistorySumm
245286
}
246287
}
247288
break;
289+
case "TaskFailed":
290+
if (ev.taskFailedEventDetails?.resourceType !== "lambda") break;
291+
errors.push({
292+
state: currentLambdaState ?? "<unknown>",
293+
error: ev.taskFailedEventDetails?.error ?? "UNKNOWN",
294+
cause: ev.taskFailedEventDetails?.cause ?? "",
295+
});
296+
break;
248297
case "LambdaFunctionFailed":
249298
errors.push({
250299
state: currentLambdaState ?? "<unknown>",
@@ -299,6 +348,45 @@ function parseJson(s: string | undefined): unknown {
299348
}
300349
}
301350

351+
/**
352+
* The optimized `lambda:invoke` task wraps the Lambda response so the
353+
* `TaskSucceeded.output` JSON looks like
354+
*
355+
* { ExecutedVersion, Payload: { …handler payload… }, StatusCode, … }
356+
*
357+
* The raw `lambda:invokeFunction.sync` integration (older style) puts the
358+
* handler payload at the root. Return the inner `Payload` object when
359+
* present, otherwise the input — so downstream extraction code reads
360+
* `.TotalFrames`/`.FramesEncoded`/`.DurationMs` the same way regardless of
361+
* which integration is wired up. A non-object input passes through
362+
* unchanged so the type-narrowing checks below still work.
363+
*/
364+
function unwrapLambdaPayload(payload: unknown): unknown {
365+
if (payload && typeof payload === "object" && "Payload" in payload) {
366+
const inner = (payload as { Payload: unknown }).Payload;
367+
if (inner && typeof inner === "object") return inner;
368+
}
369+
return payload;
370+
}
371+
372+
/**
373+
* Apply `FramesEncoded` from a Lambda success payload to the running
374+
* counter, but only when the enclosing state is the chunk-render step.
375+
* Plan and Assemble payloads also carry `FramesEncoded` — counting them
376+
* would double-count once Assemble runs. Shared between the
377+
* `TaskSucceeded` and `LambdaFunctionSucceeded` branches.
378+
*/
379+
function applyPayloadFrameCounts(
380+
payload: unknown,
381+
currentLambdaState: string | null,
382+
bump: (delta: number) => void,
383+
): void {
384+
if (currentLambdaState !== "RenderChunk") return;
385+
if (!payload || typeof payload !== "object") return;
386+
const obj = payload as Record<string, unknown>;
387+
if (typeof obj.FramesEncoded === "number") bump(obj.FramesEncoded);
388+
}
389+
302390
/**
303391
* Lambda success payloads from our handler include `DurationMs` — the
304392
* wall-clock the handler observed. We use it as a best-effort proxy

0 commit comments

Comments
 (0)