Skip to content

Commit 4e0e4a5

Browse files
authored
fix(testing-sdk): support PENDING status reinvocation and execution failure (#397)
*Issue #, if available:* #384 *Description of changes:* When the client (language SDK) has stale data, it should be reinvoked if it exited without knowing the latest state of the checkpoint API. This is how the actual service works, and so it should be implemented in the testing library as well. This also fixes an issue where the language SDK exits at the same time that a callback completes, and hangs forever/throws an error. It will now re-invoke in this case. Additionally, the testing SDK will now throw an error when the language SDK returns PENDING with no outstanding pending operations, timers, or stale data. This will fix the issue where it would hang forever when this happened. 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 8d49cff commit 4e0e4a5

File tree

17 files changed

+1032
-58
lines changed

17 files changed

+1032
-58
lines changed

packages/aws-durable-execution-sdk-js-examples/src/examples/wait-for-callback/multiple-invocations/wait-for-callback-multiple-invocations.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ import { createTests } from "../../../utils/test-helper";
88
createTests({
99
handler,
1010
invocationType: InvocationType.Event,
11+
localRunnerConfig: {
12+
skipTime: false,
13+
},
1114
tests: (runner) => {
1215
it("should handle multiple invocations tracking with waitForCallback operations", async () => {
1316
// Get operations for verification

packages/aws-durable-execution-sdk-js-testing/src/checkpoint-server/handlers/__tests__/execution-handlers.test.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,10 @@ describe("execution handlers", () => {
141141
Error: { Payload: undefined },
142142
},
143143
};
144-
completeInvocationSpy.mockReturnValue(mockEvent);
144+
completeInvocationSpy.mockReturnValue({
145+
event: mockEvent,
146+
hasDirtyOperations: false,
147+
});
145148

146149
const errorObject: ErrorObject = {
147150
ErrorType: "TestError",
@@ -160,7 +163,10 @@ describe("execution handlers", () => {
160163
invocationId,
161164
errorObject,
162165
);
163-
expect(result).toEqual(mockEvent);
166+
expect(result).toEqual({
167+
event: mockEvent,
168+
hasDirtyOperations: false,
169+
});
164170
});
165171
});
166172
});

packages/aws-durable-execution-sdk-js-testing/src/checkpoint-server/handlers/execution-handlers.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ErrorObject, Event } from "@aws-sdk/client-lambda";
1+
import { ErrorObject } from "@aws-sdk/client-lambda";
22
import {
33
createExecutionId,
44
ExecutionId,
@@ -12,6 +12,7 @@ import {
1212
StartDurableExecutionRequest,
1313
StartInvocationRequest,
1414
} from "../worker-api/worker-api-request";
15+
import { CompleteInvocationResponse } from "../worker-api/worker-api-response";
1516

1617
/**
1718
* Starts a durable execution. Returns the data needed for the handler invocation event.
@@ -42,6 +43,6 @@ export function processCompleteInvocation(
4243
invocationId: InvocationId,
4344
error: ErrorObject | undefined,
4445
executionManager: ExecutionManager,
45-
): Event {
46+
): CompleteInvocationResponse {
4647
return executionManager.completeInvocation(executionId, invocationId, error);
4748
}

packages/aws-durable-execution-sdk-js-testing/src/checkpoint-server/storage/__tests__/checkpoint-manager-dirty.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ describe("CheckpointManager dirty operation tracking", () => {
3636
const dirtyOperations = storage.getDirtyOperations();
3737

3838
expect(dirtyOperations).toEqual([]);
39+
expect(storage.hasDirtyOperations()).toBe(false);
3940
});
4041

4142
it("should return dirty operations and clear the dirty set", () => {
@@ -51,8 +52,11 @@ describe("CheckpointManager dirty operation tracking", () => {
5152
expect(firstCall).toHaveLength(1);
5253
expect(firstCall[0].Id).toBe("dirty-step");
5354

55+
expect(storage.hasDirtyOperations()).toBe(false);
56+
5457
const secondCall = storage.getDirtyOperations();
5558
expect(secondCall).toEqual([]);
59+
expect(storage.hasDirtyOperations()).toBe(false);
5660
});
5761

5862
it("should return multiple dirty operations in correct order", () => {
@@ -86,6 +90,7 @@ describe("CheckpointManager dirty operation tracking", () => {
8690
"second-op",
8791
"third-op",
8892
]);
93+
expect(storage.hasDirtyOperations()).toBe(false);
8994
});
9095
});
9196

@@ -100,16 +105,20 @@ describe("CheckpointManager dirty operation tracking", () => {
100105
storage.registerUpdate(stepUpdate);
101106

102107
// Verify operation is dirty before invocation
108+
expect(storage.hasDirtyOperations()).toBe(true);
103109
const dirtyBefore = storage.getDirtyOperations();
104110
expect(dirtyBefore).toHaveLength(1);
111+
expect(storage.hasDirtyOperations()).toBe(false);
105112

106113
// Start new invocation should clear dirty set
107114
const invocationId = createInvocationId("test-invocation");
108115
storage.startInvocation(invocationId);
116+
expect(storage.hasDirtyOperations()).toBe(false);
109117

110118
// Verify dirty set is cleared after invocation
111119
const dirtyAfter = storage.getDirtyOperations();
112120
expect(dirtyAfter).toEqual([]);
121+
expect(storage.hasDirtyOperations()).toBe(false);
113122
});
114123

115124
it("should return all operations from startInvocation", () => {

packages/aws-durable-execution-sdk-js-testing/src/checkpoint-server/storage/__tests__/execution-manager.test.ts

Lines changed: 94 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
OperationType,
55
EventType,
66
ErrorObject,
7+
OperationAction,
78
} from "@aws-sdk/client-lambda";
89
import { ExecutionManager, StartExecutionParams } from "../execution-manager";
910
import { CheckpointManager } from "../checkpoint-manager";
@@ -520,7 +521,10 @@ describe("execution-manager", () => {
520521
);
521522

522523
// Verify the result
523-
expect(result).toBe(mockHistoryEvent);
524+
expect(result).toEqual({
525+
event: mockHistoryEvent,
526+
hasDirtyOperations: false,
527+
});
524528

525529
// Verify CheckpointManager.completeInvocation was called
526530
expect(completeInvocationSpy).toHaveBeenCalledWith(invocationId);
@@ -593,7 +597,10 @@ describe("execution-manager", () => {
593597
);
594598

595599
// Verify the result
596-
expect(result).toBe(mockHistoryEvent);
600+
expect(result).toEqual({
601+
event: mockHistoryEvent,
602+
hasDirtyOperations: false,
603+
});
597604

598605
// Verify CheckpointManager.completeInvocation was called
599606
expect(completeInvocationSpy).toHaveBeenCalledWith(invocationId);
@@ -642,6 +649,91 @@ describe("execution-manager", () => {
642649
// Verify CheckpointManager.completeInvocation was called
643650
expect(completeInvocationSpy).toHaveBeenCalledWith(invocationId);
644651
});
652+
653+
it("should return hasDirtyOperations as true when checkpoint storage has dirty operations", () => {
654+
// Create an execution first
655+
const executionId = createExecutionId("test-execution-id");
656+
const invocationId = createInvocationId("test-invocation-id");
657+
executionManager.startExecution({ executionId, invocationId });
658+
659+
const storage = executionManager.getCheckpointsByExecution(executionId);
660+
expect(storage).toBeDefined();
661+
662+
// Register an operation to make the storage dirty
663+
const stepUpdate = {
664+
Id: "test-step-operation",
665+
Type: OperationType.STEP,
666+
Action: OperationAction.START,
667+
};
668+
storage!.registerUpdate(stepUpdate);
669+
670+
// Mock the completeInvocation method on CheckpointManager
671+
const mockTimestamps = {
672+
startTimestamp: new Date("2023-01-01T00:00:00.000Z"),
673+
endTimestamp: new Date("2023-01-01T00:01:00.000Z"),
674+
};
675+
676+
const completeInvocationSpy = jest
677+
.spyOn(storage!, "completeInvocation")
678+
.mockReturnValue(mockTimestamps);
679+
680+
// Mock the eventProcessor.createHistoryEvent method
681+
const mockHistoryEvent = {
682+
EventType: EventType.InvocationCompleted,
683+
Timestamp: new Date(),
684+
InvocationCompletedDetails: {
685+
StartTimestamp: mockTimestamps.startTimestamp,
686+
EndTimestamp: mockTimestamps.endTimestamp,
687+
Error: {
688+
Payload: undefined,
689+
},
690+
RequestId: invocationId,
691+
},
692+
};
693+
694+
const createHistoryEventSpy = jest
695+
.spyOn(storage!.eventProcessor, "createHistoryEvent")
696+
.mockReturnValue(mockHistoryEvent);
697+
698+
// Mock hasDirtyOperations to return true
699+
const hasDirtyOperationsSpy = jest
700+
.spyOn(storage!, "hasDirtyOperations")
701+
.mockReturnValue(true);
702+
703+
// Call the method
704+
const result = executionManager.completeInvocation(
705+
executionId,
706+
invocationId,
707+
undefined,
708+
);
709+
710+
// Verify the result shows dirty operations exist
711+
expect(result).toEqual({
712+
event: mockHistoryEvent,
713+
hasDirtyOperations: true,
714+
});
715+
716+
// Verify CheckpointManager.completeInvocation was called
717+
expect(completeInvocationSpy).toHaveBeenCalledWith(invocationId);
718+
719+
// Verify hasDirtyOperations was called
720+
expect(hasDirtyOperationsSpy).toHaveBeenCalled();
721+
722+
// Verify eventProcessor.createHistoryEvent was called with correct parameters
723+
expect(createHistoryEventSpy).toHaveBeenCalledWith(
724+
EventType.InvocationCompleted,
725+
undefined,
726+
"InvocationCompletedDetails",
727+
{
728+
StartTimestamp: mockTimestamps.startTimestamp,
729+
EndTimestamp: mockTimestamps.endTimestamp,
730+
Error: {
731+
Payload: undefined,
732+
},
733+
RequestId: invocationId,
734+
},
735+
);
736+
});
645737
});
646738
});
647739
});

packages/aws-durable-execution-sdk-js-testing/src/checkpoint-server/storage/checkpoint-manager.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ export class CheckpointManager {
5050
return this.operationDataMap;
5151
}
5252

53+
hasDirtyOperations(): boolean {
54+
return !!this.dirtyOperationIds.size;
55+
}
56+
5357
getDirtyOperations(): Operation[] {
5458
const dirtyOperations = Array.from(this.dirtyOperationIds).map((id) => {
5559
const operationEvents = this.operationDataMap.get(id);

packages/aws-durable-execution-sdk-js-testing/src/checkpoint-server/storage/execution-manager.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,12 @@ import { CallbackId, CheckpointToken } from "../utils/tagged-strings";
99
import { ExecutionId, InvocationId } from "../utils/tagged-strings";
1010
import { decodeCallbackId } from "../utils/callback-id";
1111
import { OperationEvents } from "../../test-runner/common/operations/operation-with-data";
12-
import { ErrorObject, EventType, Event } from "@aws-sdk/client-lambda";
12+
import { ErrorObject, EventType } from "@aws-sdk/client-lambda";
1313
import {
1414
StartDurableExecutionRequest,
1515
StartInvocationRequest,
1616
} from "../worker-api/worker-api-request";
17+
import { CompleteInvocationResponse } from "../worker-api/worker-api-response";
1718

1819
export interface InvocationResult {
1920
checkpointToken: CheckpointToken;
@@ -105,7 +106,7 @@ export class ExecutionManager {
105106
executionId: ExecutionId,
106107
invocationId: InvocationId,
107108
error: ErrorObject | undefined,
108-
): Event {
109+
): CompleteInvocationResponse {
109110
const checkpointStorage = this.executions.get(executionId);
110111

111112
if (!checkpointStorage) {
@@ -115,7 +116,7 @@ export class ExecutionManager {
115116
const { startTimestamp, endTimestamp } =
116117
checkpointStorage.completeInvocation(invocationId);
117118

118-
return checkpointStorage.eventProcessor.createHistoryEvent(
119+
const event = checkpointStorage.eventProcessor.createHistoryEvent(
119120
EventType.InvocationCompleted,
120121
undefined,
121122
"InvocationCompletedDetails",
@@ -128,6 +129,11 @@ export class ExecutionManager {
128129
RequestId: invocationId,
129130
},
130131
);
132+
133+
return {
134+
event,
135+
hasDirtyOperations: checkpointStorage.hasDirtyOperations(),
136+
};
131137
}
132138

133139
/**

packages/aws-durable-execution-sdk-js-testing/src/checkpoint-server/worker-api/worker-api-response.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,15 @@ export interface PollCheckpointDataResponse {
1616
operations: CheckpointOperation[];
1717
}
1818

19+
export interface CompleteInvocationResponse {
20+
event: Event;
21+
hasDirtyOperations: boolean;
22+
}
23+
1924
export interface WorkerApiResponseMapping {
2025
[ApiType.StartDurableExecution]: InvocationResult;
2126
[ApiType.StartInvocation]: InvocationResult;
22-
[ApiType.CompleteInvocation]: Event;
27+
[ApiType.CompleteInvocation]: CompleteInvocationResponse;
2328
[ApiType.UpdateCheckpointData]: Record<string, never>;
2429
[ApiType.PollCheckpointData]: Promise<PollCheckpointDataResponse>;
2530
[ApiType.GetDurableExecutionState]: GetDurableExecutionStateResponse;

packages/aws-durable-execution-sdk-js-testing/src/checkpoint-server/worker/__tests__/checkpoint-worker.test.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -306,7 +306,10 @@ describe("CheckpointWorker", () => {
306306
Timestamp: new Date(),
307307
Type: "InvocationComplete",
308308
};
309-
mockApiHandlerInstance.performApiCall.mockReturnValue(mockEvent);
309+
mockApiHandlerInstance.performApiCall.mockReturnValue({
310+
event: mockEvent,
311+
hasDirtyOperations: false,
312+
});
310313

311314
messageHandler(command);
312315

@@ -318,7 +321,10 @@ describe("CheckpointWorker", () => {
318321
data: {
319322
type: ApiType.CompleteInvocation,
320323
requestId: "complete-request-456",
321-
response: mockEvent,
324+
response: {
325+
event: mockEvent,
326+
hasDirtyOperations: false,
327+
},
322328
},
323329
});
324330
});

packages/aws-durable-execution-sdk-js-testing/src/test-runner/local/__tests__/test-execution-orchestrator-invocation-order.test.ts

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { ILocalDurableTestRunnerFactory } from "../interfaces/durable-test-runne
2525
import { DurableApiClient } from "../../common/create-durable-api-client";
2626
import { CheckpointApiClient } from "../api-client/checkpoint-api-client";
2727
import { InvocationResult } from "../../../checkpoint-server/storage/execution-manager";
28+
import { CompleteInvocationResponse } from "../../../checkpoint-server/worker-api/worker-api-response";
2829

2930
// Mock dependencies
3031
jest.mock("../operations/local-operation-storage");
@@ -75,15 +76,20 @@ describe("TestExecutionOrchestrator - Invocation History Ordering", () => {
7576
// Tracking arrays for call order verification
7677
let callOrder: string[];
7778
let addHistoryEventSpy: jest.SpyInstance;
78-
let completeInvocationSpy: jest.SpyInstance;
79-
80-
const mockInvocationCompletedEvent: Event = {
81-
EventType: EventType.InvocationCompleted,
82-
InvocationCompletedDetails: {
83-
RequestId: "invocation-request-id",
84-
StartTimestamp: new Date(),
85-
EndTimestamp: new Date(),
79+
let completeInvocationSpy: jest.SpyInstance<
80+
Promise<CompleteInvocationResponse>
81+
>;
82+
83+
const mockInvocationCompletedEvent: CompleteInvocationResponse = {
84+
event: {
85+
EventType: EventType.InvocationCompleted,
86+
InvocationCompletedDetails: {
87+
RequestId: "invocation-request-id",
88+
StartTimestamp: new Date(),
89+
EndTimestamp: new Date(),
90+
},
8691
},
92+
hasDirtyOperations: false,
8793
};
8894

8995
const nonResolvingPromise = new Promise<never>(() => {
@@ -135,7 +141,10 @@ describe("TestExecutionOrchestrator - Invocation History Ordering", () => {
135141
}),
136142
completeInvocation: jest.fn().mockImplementation(() => {
137143
callOrder.push("completeInvocation");
138-
return Promise.resolve(mockInvocationCompletedEvent);
144+
return Promise.resolve({
145+
event: mockInvocationCompletedEvent,
146+
hasDirtyOperations: false,
147+
});
139148
}),
140149
};
141150

@@ -509,8 +518,11 @@ describe("TestExecutionOrchestrator - Invocation History Ordering", () => {
509518
invocationCount++;
510519
callOrder.push(`completeInvocation${invocationCount}`);
511520
return Promise.resolve({
512-
...mockInvocationCompletedEvent,
513-
EventId: invocationCount,
521+
event: {
522+
...mockInvocationCompletedEvent,
523+
EventId: invocationCount,
524+
},
525+
hasDirtyOperations: false,
514526
});
515527
});
516528

0 commit comments

Comments
 (0)