Skip to content

Commit f1bde06

Browse files
authored
fix(sdk): fix wait-for-condition incorrect attempt counting (#357)
*Issue #, if available:* *Description of changes:* Fix off-by-one error in wait-for-condition. Before, the `attempt` variable in waitStrategy would be `1` on the first attempt, then `1` again on the second attempt, and `2` on the third attempt. We should count the current attempt in waitStrategy based on the actual attempt. The current attempt is equal to the last attempt from the API + 1, not the result directly from the API. By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.
1 parent 64878b8 commit f1bde06

File tree

5 files changed

+187
-6
lines changed

5 files changed

+187
-6
lines changed

packages/aws-durable-execution-sdk-js-examples/src/examples/force-checkpointing/callback/force-checkpointing-callback.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,6 @@ createTests({
5858
// Verify operations were tracked
5959
const operations = execution.getOperations();
6060
expect(operations.length).toBeGreaterThan(0);
61-
}, 20000); // 20 second timeout
61+
}, 50000);
6262
},
6363
});
Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
import { handler } from "./wait-for-condition";
22
import { createTests } from "../../utils/test-helper";
33

4-
// TODO: enable test when waitForCondition SDK implementation is fixed
5-
64
createTests({
75
name: "wait-for-condition test",
86
functionName: "wait-for-condition",
@@ -11,7 +9,6 @@ createTests({
119
it("should invoke step three times before succeeding", async () => {
1210
const execution = await runner.run();
1311
expect(execution.getResult()).toStrictEqual(3);
14-
// expect(execution.getInvocations()).toHaveLength(3);
1512
});
1613
},
1714
});

packages/aws-durable-execution-sdk-js-examples/src/examples/wait-for-condition/wait-for-condition.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ export const handler = withDurableExecution(
1717
},
1818
{
1919
waitStrategy: (state: number, attempt: number) => {
20+
if (state !== attempt) {
21+
throw new Error("State does not match attempt");
22+
}
23+
2024
if (state >= 3) {
2125
return { shouldContinue: false };
2226
}

packages/aws-durable-execution-sdk-js/src/handlers/wait-for-condition-handler/wait-for-condition-handler.test.ts

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,23 @@ import { Checkpoint } from "../../utils/checkpoint/checkpoint-helper";
1414

1515
jest.mock("../../utils/logger/logger");
1616
jest.mock("../../errors/serdes-errors/serdes-errors");
17+
jest.mock("../../utils/context-tracker/context-tracker");
1718

1819
import {
1920
safeSerialize,
2021
safeDeserialize,
2122
} from "../../errors/serdes-errors/serdes-errors";
23+
import { runWithContext } from "../../utils/context-tracker/context-tracker";
2224

2325
const mockSafeSerialize = safeSerialize as jest.MockedFunction<
2426
typeof safeSerialize
2527
>;
2628
const mockSafeDeserialize = safeDeserialize as jest.MockedFunction<
2729
typeof safeDeserialize
2830
>;
31+
const mockRunWithContext = runWithContext as jest.MockedFunction<
32+
typeof runWithContext
33+
>;
2934

3035
describe("WaitForCondition Handler", () => {
3136
let mockContext: ExecutionContext;
@@ -59,6 +64,11 @@ describe("WaitForCondition Handler", () => {
5964
mockSafeDeserialize.mockImplementation(async (_serdes, value) =>
6065
value ? JSON.parse(value) : undefined,
6166
);
67+
68+
// Set up mockRunWithContext to execute the provided function
69+
mockRunWithContext.mockImplementation(async (_stepId, _parentId, fn) => {
70+
return await fn();
71+
});
6272
});
6373

6474
describe("Parameter parsing", () => {
@@ -313,4 +323,174 @@ describe("WaitForCondition Handler", () => {
313323
await handler(checkFunc, config);
314324
});
315325
});
326+
327+
describe("currentAttempt parameter", () => {
328+
it("should pass currentAttempt 1 to both runWithContext and waitStrategy on first execution", async () => {
329+
const handler = createWaitForConditionHandler(
330+
mockContext,
331+
mockCheckpoint,
332+
createStepId,
333+
createDefaultLogger(),
334+
"parent-123",
335+
);
336+
337+
const checkFunc: WaitForConditionCheckFunc<string, DurableLogger> = jest
338+
.fn()
339+
.mockResolvedValue("result");
340+
const waitStrategy = jest.fn().mockReturnValue({ shouldContinue: false });
341+
const config: WaitForConditionConfig<string> = {
342+
waitStrategy,
343+
initialState: "initial",
344+
};
345+
346+
await handler(checkFunc, config);
347+
348+
// Verify currentAttempt is passed correctly to both functions
349+
expect(mockRunWithContext).toHaveBeenCalledWith(
350+
"step-1",
351+
"parent-123",
352+
expect.any(Function),
353+
1, // currentAttempt should be 1 on first execution
354+
expect.any(String), // DurableExecutionMode.ExecutionMode
355+
);
356+
expect(waitStrategy).toHaveBeenCalledWith("result", 1);
357+
});
358+
359+
it("should calculate currentAttempt correctly based on step data attempt count", async () => {
360+
const stepId = "step-1";
361+
const hashedStepId = hashId(stepId);
362+
363+
// Mock step data with attempt = 2 (so currentAttempt should be 3)
364+
(mockContext as any)._stepData[hashedStepId] = {
365+
Id: hashedStepId,
366+
Status: OperationStatus.READY,
367+
StepDetails: {
368+
Attempt: 2,
369+
Result: JSON.stringify("previous-state"),
370+
},
371+
};
372+
373+
(mockContext.getStepData as jest.Mock).mockReturnValue(
374+
(mockContext as any)._stepData[hashedStepId],
375+
);
376+
377+
const handler = createWaitForConditionHandler(
378+
mockContext,
379+
mockCheckpoint,
380+
createStepId,
381+
createDefaultLogger(),
382+
"parent-123",
383+
);
384+
385+
const checkFunc: WaitForConditionCheckFunc<string, DurableLogger> = jest
386+
.fn()
387+
.mockResolvedValue("result");
388+
const waitStrategy = jest.fn().mockReturnValue({ shouldContinue: false });
389+
const config: WaitForConditionConfig<string> = {
390+
waitStrategy,
391+
initialState: "initial",
392+
};
393+
394+
await handler(checkFunc, config);
395+
396+
// Verify currentAttempt = Attempt + 1 for both functions
397+
expect(mockRunWithContext).toHaveBeenCalledWith(
398+
"step-1",
399+
"parent-123",
400+
expect.any(Function),
401+
3, // currentAttempt should be Attempt + 1 = 2 + 1 = 3
402+
expect.any(String),
403+
);
404+
expect(waitStrategy).toHaveBeenCalledWith("result", 3);
405+
});
406+
407+
it("should pass incrementing currentAttempt through multiple retries", async () => {
408+
const stepId = "step-1";
409+
const hashedStepId = hashId(stepId);
410+
411+
// Track the retry cycle - first call has no step data, then subsequent calls have increasing attempt numbers
412+
let getStepDataCallCount = 0;
413+
(mockContext.getStepData as jest.Mock).mockImplementation(() => {
414+
getStepDataCallCount++;
415+
416+
// First call - no step data (fresh execution)
417+
if (getStepDataCallCount <= 2) {
418+
return null;
419+
}
420+
// Subsequent calls during retry cycles - simulate step data with increasing attempt number
421+
const attemptNumber = Math.floor((getStepDataCallCount - 2) / 2);
422+
return {
423+
Id: hashedStepId,
424+
Status: OperationStatus.READY,
425+
StepDetails: {
426+
Attempt: attemptNumber,
427+
Result: JSON.stringify(10),
428+
},
429+
};
430+
});
431+
432+
const handler = createWaitForConditionHandler(
433+
mockContext,
434+
mockCheckpoint,
435+
createStepId,
436+
createDefaultLogger(),
437+
undefined,
438+
);
439+
440+
const checkFunc: WaitForConditionCheckFunc<number, DurableLogger> = jest
441+
.fn()
442+
.mockResolvedValue(10);
443+
444+
const waitStrategy = jest
445+
.fn()
446+
.mockReturnValueOnce({
447+
shouldContinue: true,
448+
delay: { milliseconds: 1 },
449+
})
450+
.mockReturnValueOnce({
451+
shouldContinue: true,
452+
delay: { milliseconds: 1 },
453+
})
454+
.mockReturnValue({ shouldContinue: false });
455+
456+
const config: WaitForConditionConfig<number> = {
457+
waitStrategy,
458+
initialState: 0,
459+
};
460+
461+
await handler(checkFunc, config);
462+
463+
// Check that waitStrategy was called with incrementing currentAttempt values
464+
expect(waitStrategy).toHaveBeenNthCalledWith(1, 10, 1); // first attempt
465+
expect(waitStrategy).toHaveBeenNthCalledWith(2, 10, 2); // second attempt
466+
expect(waitStrategy).toHaveBeenNthCalledWith(3, 10, 3); // third attempt
467+
468+
// Verify runWithContext was also called with correct attempts
469+
expect(mockRunWithContext).toHaveBeenCalledTimes(3);
470+
expect(mockRunWithContext).toHaveBeenNthCalledWith(
471+
1,
472+
"step-1",
473+
undefined,
474+
expect.any(Function),
475+
1,
476+
expect.any(String),
477+
);
478+
expect(mockRunWithContext).toHaveBeenNthCalledWith(
479+
2,
480+
"step-1",
481+
undefined,
482+
expect.any(Function),
483+
2,
484+
expect.any(String),
485+
);
486+
expect(mockRunWithContext).toHaveBeenNthCalledWith(
487+
3,
488+
"step-1",
489+
undefined,
490+
expect.any(Function),
491+
3,
492+
expect.any(String),
493+
);
494+
});
495+
});
316496
});

packages/aws-durable-execution-sdk-js/src/handlers/wait-for-condition-handler/wait-for-condition-handler.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ export const createWaitForConditionHandler = <Logger extends DurableLogger>(
173173
currentState = config.initialState;
174174
}
175175

176-
const currentAttempt = stepData?.StepDetails?.Attempt || 1;
176+
const currentAttempt = (stepData?.StepDetails?.Attempt ?? 0) + 1;
177177

178178
// Checkpoint START if not already started
179179
if (stepData?.Status !== OperationStatus.STARTED) {
@@ -211,7 +211,7 @@ export const createWaitForConditionHandler = <Logger extends DurableLogger>(
211211
stepId,
212212
parentId,
213213
() => check(currentState, waitForConditionContext),
214-
currentAttempt + 1,
214+
currentAttempt,
215215
DurableExecutionMode.ExecutionMode,
216216
);
217217

0 commit comments

Comments
 (0)