Skip to content

Commit 36ddd48

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 6d1236a commit 36ddd48

2 files changed

Lines changed: 227 additions & 12 deletions

File tree

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

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

70+
// Optimized `lambda:invoke` integration's wire shape: Task* events with
71+
// the handler payload at `.Payload`. Helpers below fabricate these so
72+
// tests cover the same events a real CDK-deployed render produces.
73+
function taskScheduled(): HistoryEvent {
74+
return {
75+
type: "TaskScheduled",
76+
id: 1,
77+
timestamp: new Date(),
78+
taskScheduledEventDetails: {
79+
resource: "invoke",
80+
resourceType: "lambda",
81+
region: "us-east-1",
82+
parameters: "{}",
83+
},
84+
} as HistoryEvent;
85+
}
86+
87+
function taskSucceeded(payload: unknown): HistoryEvent {
88+
return {
89+
type: "TaskSucceeded",
90+
id: 1,
91+
timestamp: new Date(),
92+
taskSucceededEventDetails: {
93+
resource: "invoke",
94+
resourceType: "lambda",
95+
output: JSON.stringify({
96+
ExecutedVersion: "$LATEST",
97+
Payload: payload,
98+
StatusCode: 200,
99+
}),
100+
},
101+
} as HistoryEvent;
102+
}
103+
104+
function taskFailed(error: string, cause: string): HistoryEvent {
105+
return {
106+
type: "TaskFailed",
107+
id: 1,
108+
timestamp: new Date(),
109+
taskFailedEventDetails: {
110+
resource: "invoke",
111+
resourceType: "lambda",
112+
error,
113+
cause,
114+
},
115+
} as HistoryEvent;
116+
}
117+
70118
describe("getRenderProgress", () => {
71119
it("reports 0 progress before Plan completes", async () => {
72120
const sfn = new FakeSFN();
@@ -222,6 +270,116 @@ describe("getRenderProgress", () => {
222270
it("requires executionArn", async () => {
223271
await expect(getRenderProgress({ executionArn: "" })).rejects.toThrow(/executionArn/);
224272
});
273+
274+
describe("optimized lambda:invoke integration", () => {
275+
it("counts a single TaskSucceeded as one Lambda invocation", async () => {
276+
const sfn = new FakeSFN();
277+
sfn.historyPages = [
278+
[
279+
stateEntered("Plan"),
280+
taskScheduled(),
281+
taskSucceeded({ Action: "plan", TotalFrames: 240, DurationMs: 1_000 }),
282+
],
283+
];
284+
const progress = await getRenderProgress({
285+
executionArn: "arn",
286+
defaultMemorySizeMb: 10_240,
287+
sfn: sfn as unknown as SFNClient,
288+
});
289+
expect(progress.lambdasInvoked).toBe(1);
290+
expect(progress.totalFrames).toBe(240);
291+
// computeRenderCost rounds to 4 decimals; precision=4 not 6.
292+
expect(progress.costs.breakdown.lambdaUsd).toBeCloseTo(0.0002, 4);
293+
expect(progress.costs.breakdown.lambdaUsd).toBeGreaterThan(0);
294+
});
295+
296+
it("attributes RenderChunk FramesEncoded but ignores Plan/Assemble FramesEncoded", async () => {
297+
const sfn = new FakeSFN();
298+
sfn.historyPages = [
299+
[
300+
stateEntered("Plan"),
301+
taskScheduled(),
302+
taskSucceeded({ Action: "plan", TotalFrames: 100, DurationMs: 1_000 }),
303+
stateEntered("RenderChunk"),
304+
taskScheduled(),
305+
taskSucceeded({ Action: "renderChunk", FramesEncoded: 50, DurationMs: 2_000 }),
306+
stateEntered("Assemble"),
307+
taskScheduled(),
308+
taskSucceeded({
309+
Action: "assemble",
310+
FramesEncoded: 100, // would double-count if Assemble's count bled in
311+
FileSize: 9_000_000,
312+
OutputS3Uri: "s3://b/k.mp4",
313+
DurationMs: 1_500,
314+
}),
315+
],
316+
];
317+
const progress = await getRenderProgress({
318+
executionArn: "arn",
319+
sfn: sfn as unknown as SFNClient,
320+
});
321+
expect(progress.framesRendered).toBe(50);
322+
expect(progress.lambdasInvoked).toBe(3);
323+
});
324+
325+
it("captures TaskFailed errors with the enclosing state name", async () => {
326+
const sfn = new FakeSFN();
327+
sfn.historyPages = [
328+
[
329+
stateEntered("RenderChunk"),
330+
taskScheduled(),
331+
taskFailed("Sandbox.Timedout", "Task timed out after 900.00 seconds"),
332+
],
333+
];
334+
const progress = await getRenderProgress({
335+
executionArn: "arn",
336+
sfn: sfn as unknown as SFNClient,
337+
});
338+
expect(progress.errors).toEqual([
339+
{
340+
state: "RenderChunk",
341+
error: "Sandbox.Timedout",
342+
cause: "Task timed out after 900.00 seconds",
343+
},
344+
]);
345+
});
346+
347+
it("sums billed seconds across plan + chunks + assemble", async () => {
348+
const sfn = new FakeSFN();
349+
const renderChunkSucceeded = (frames: number, ms: number) => [
350+
stateEntered("RenderChunk"),
351+
taskScheduled(),
352+
taskSucceeded({ Action: "renderChunk", FramesEncoded: frames, DurationMs: ms }),
353+
];
354+
// 1 plan + 16 chunks @ 217s + 1 assemble = 3492.7s billed.
355+
sfn.historyPages = [
356+
[
357+
stateEntered("Plan"),
358+
taskScheduled(),
359+
taskSucceeded({ Action: "plan", TotalFrames: 1349, DurationMs: 13_000 }),
360+
...Array.from({ length: 16 }, () => renderChunkSucceeded(84, 217_000)).flat(),
361+
stateEntered("Assemble"),
362+
taskScheduled(),
363+
taskSucceeded({
364+
Action: "assemble",
365+
FileSize: 81_000_000,
366+
OutputS3Uri: "s3://b/k.mp4",
367+
DurationMs: 7_700,
368+
}),
369+
],
370+
];
371+
sfn.describe.status = "SUCCEEDED";
372+
const progress = await getRenderProgress({
373+
executionArn: "arn",
374+
defaultMemorySizeMb: 10_240,
375+
sfn: sfn as unknown as SFNClient,
376+
});
377+
// 3492.7s × 10GB × $0.0000166667/GB-s ≈ $0.582.
378+
expect(progress.costs.breakdown.lambdaUsd).toBeCloseTo(0.582, 2);
379+
expect(progress.framesRendered).toBe(84 * 16);
380+
expect(progress.lambdasInvoked).toBe(18);
381+
});
382+
});
225383
});
226384

227385
void DescribeExecutionCommand;

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

Lines changed: 69 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ 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+
/** Total Lambda invocations scheduled so far (both optimized + raw task integrations). */
7777
lambdasInvoked: number;
7878
costs: RenderCost;
7979
/** Final output object if Assemble succeeded; `null` otherwise. */
@@ -198,9 +198,35 @@ function summarizeHistory(events: HistoryEvent[], memoryMb: number): HistorySumm
198198
stateTransitions++;
199199
currentLambdaState = ev.stateEnteredEventDetails?.name ?? currentLambdaState;
200200
break;
201+
// Optimized `lambda:invoke` task emits Task* events; raw
202+
// `lambda:invokeFunction.sync` emits LambdaFunction*. Handle both.
203+
case "TaskScheduled":
204+
if (ev.taskScheduledEventDetails?.resourceType === "lambda") {
205+
lambdasInvoked++;
206+
}
207+
break;
201208
case "LambdaFunctionScheduled":
202209
lambdasInvoked++;
203210
break;
211+
case "TaskSucceeded": {
212+
if (ev.taskSucceededEventDetails?.resourceType !== "lambda") break;
213+
const wrapped = parseJson(ev.taskSucceededEventDetails?.output);
214+
const payload = unwrapLambdaPayload(wrapped);
215+
const billedDurationMs = inferBilledMs(payload);
216+
lambdaInvocations.push({
217+
billedDurationMs,
218+
memorySizeMb: memoryMb,
219+
estimated: billedDurationMs === 0,
220+
});
221+
applyPayloadFrameCounts(payload, currentLambdaState, (delta) => {
222+
framesRendered += delta;
223+
});
224+
if (payload && typeof payload === "object") {
225+
const obj = payload as Record<string, unknown>;
226+
if (typeof obj.TotalFrames === "number") totalFrames = obj.TotalFrames;
227+
}
228+
break;
229+
}
204230
case "LambdaFunctionSucceeded": {
205231
const payload = parseJson(ev.lambdaFunctionSucceededEventDetails?.output);
206232
const billedDurationMs = inferBilledMs(payload);
@@ -209,20 +235,12 @@ function summarizeHistory(events: HistoryEvent[], memoryMb: number): HistorySumm
209235
memorySizeMb: memoryMb,
210236
estimated: billedDurationMs === 0,
211237
});
238+
applyPayloadFrameCounts(payload, currentLambdaState, (delta) => {
239+
framesRendered += delta;
240+
});
212241
if (payload && typeof payload === "object") {
213242
const obj = payload as Record<string, unknown>;
214243
if (typeof obj.TotalFrames === "number") totalFrames = obj.TotalFrames;
215-
if (typeof obj.FramesEncoded === "number") {
216-
// Plan and Assemble also return FramesEncoded; count framesRendered
217-
// only inside the RenderChunk state so we don't double-count
218-
// it on the Assemble pass. Keyed off the enclosing state name
219-
// (set by the matching StateEntered) rather than the payload's
220-
// `Action` field — `Action` is part of the Lambda event
221-
// contract and not load-bearing for state-machine identity.
222-
if (currentLambdaState === "RenderChunk") {
223-
framesRendered += obj.FramesEncoded;
224-
}
225-
}
226244
}
227245
break;
228246
}
@@ -245,6 +263,14 @@ function summarizeHistory(events: HistoryEvent[], memoryMb: number): HistorySumm
245263
}
246264
}
247265
break;
266+
case "TaskFailed":
267+
if (ev.taskFailedEventDetails?.resourceType !== "lambda") break;
268+
errors.push({
269+
state: currentLambdaState ?? "<unknown>",
270+
error: ev.taskFailedEventDetails?.error ?? "UNKNOWN",
271+
cause: ev.taskFailedEventDetails?.cause ?? "",
272+
});
273+
break;
248274
case "LambdaFunctionFailed":
249275
errors.push({
250276
state: currentLambdaState ?? "<unknown>",
@@ -299,6 +325,37 @@ function parseJson(s: string | undefined): unknown {
299325
}
300326
}
301327

328+
/**
329+
* Optimized `lambda:invoke` wraps the Lambda response as
330+
* `{ ExecutedVersion, Payload: {…handler payload…}, StatusCode }`. Raw
331+
* `lambda:invokeFunction.sync` puts the handler payload at the root.
332+
* Return the inner `Payload` when present so callers read the same fields
333+
* either way.
334+
*/
335+
function unwrapLambdaPayload(payload: unknown): unknown {
336+
if (payload && typeof payload === "object" && "Payload" in payload) {
337+
const inner = (payload as { Payload: unknown }).Payload;
338+
if (inner && typeof inner === "object") return inner;
339+
}
340+
return payload;
341+
}
342+
343+
/**
344+
* Bump `framesRendered` only inside the `RenderChunk` state. Plan and
345+
* Assemble also report `FramesEncoded`, so a state-blind add would
346+
* double-count once Assemble runs.
347+
*/
348+
function applyPayloadFrameCounts(
349+
payload: unknown,
350+
currentLambdaState: string | null,
351+
bump: (delta: number) => void,
352+
): void {
353+
if (currentLambdaState !== "RenderChunk") return;
354+
if (!payload || typeof payload !== "object") return;
355+
const obj = payload as Record<string, unknown>;
356+
if (typeof obj.FramesEncoded === "number") bump(obj.FramesEncoded);
357+
}
358+
302359
/**
303360
* Lambda success payloads from our handler include `DurationMs` — the
304361
* wall-clock the handler observed. We use it as a best-effort proxy

0 commit comments

Comments
 (0)