diff --git a/dex-internal-sdk/.gitignore b/dex-internal-sdk/.gitignore index 0feec4f1..f8fcf05c 100644 --- a/dex-internal-sdk/.gitignore +++ b/dex-internal-sdk/.gitignore @@ -1,7 +1,6 @@ /node_modules /build /dist -/dist-cjs /coverage .husky *.iml diff --git a/dex-internal-sdk/dist-types/commands/CheckpointDurableExecutionCommand.d.ts b/dex-internal-sdk/dist-types/commands/CheckpointDurableExecutionCommand.d.ts index 4015d1a3..6626ceae 100644 --- a/dex-internal-sdk/dist-types/commands/CheckpointDurableExecutionCommand.d.ts +++ b/dex-internal-sdk/dist-types/commands/CheckpointDurableExecutionCommand.d.ts @@ -78,6 +78,9 @@ declare const CheckpointDurableExecutionCommand_base: { * "STRING_VALUE", * ], * }, + * ContextOptions: { // ContextOptions + * ReplayChildren: true || false, + * }, * StepOptions: { // StepOptions * NextAttemptDelaySeconds: Number("int"), * }, @@ -116,6 +119,7 @@ declare const CheckpointDurableExecutionCommand_base: { * // InputPayload: "STRING_VALUE", * // }, * // ContextDetails: { // ContextDetails + * // ReplayChildren: true || false, * // Result: "STRING_VALUE", * // Error: { // ErrorObject * // ErrorMessage: "STRING_VALUE", diff --git a/dex-internal-sdk/dist-types/commands/GetDurableExecutionStateCommand.d.ts b/dex-internal-sdk/dist-types/commands/GetDurableExecutionStateCommand.d.ts index 6af16b4d..143940bb 100644 --- a/dex-internal-sdk/dist-types/commands/GetDurableExecutionStateCommand.d.ts +++ b/dex-internal-sdk/dist-types/commands/GetDurableExecutionStateCommand.d.ts @@ -81,6 +81,7 @@ declare const GetDurableExecutionStateCommand_base: { * // InputPayload: "STRING_VALUE", * // }, * // ContextDetails: { // ContextDetails + * // ReplayChildren: true || false, * // Result: "STRING_VALUE", * // Error: { // ErrorObject * // ErrorMessage: "STRING_VALUE", diff --git a/dex-internal-sdk/dist-types/models/models_0.d.ts b/dex-internal-sdk/dist-types/models/models_0.d.ts index 65d47771..6907d005 100644 --- a/dex-internal-sdk/dist-types/models/models_0.d.ts +++ b/dex-internal-sdk/dist-types/models/models_0.d.ts @@ -157,6 +157,12 @@ export declare const OperationAction: { */ export type OperationAction = (typeof OperationAction)[keyof typeof OperationAction]; +/** + * @public + */ +export interface ContextOptions { + ReplayChildren?: boolean | undefined; +} /** * @public */ @@ -205,6 +211,7 @@ export interface OperationUpdate { Action?: OperationAction | undefined; Payload?: string | undefined; Error?: ErrorObject | undefined; + ContextOptions?: ContextOptions | undefined; StepOptions?: StepOptions | undefined; WaitOptions?: WaitOptions | undefined; CallbackOptions?: CallbackOptions | undefined; @@ -234,6 +241,7 @@ export declare const CheckpointDurableExecutionRequestFilterSensitiveLog: ( * @public */ export interface ContextDetails { + ReplayChildren?: boolean | undefined; Result?: string | undefined; Error?: ErrorObject | undefined; } diff --git a/dex-internal-sdk/package.json b/dex-internal-sdk/package.json index 44c5573f..7fe7e710 100644 --- a/dex-internal-sdk/package.json +++ b/dex-internal-sdk/package.json @@ -8,12 +8,12 @@ "build:es": "tsc -p tsconfig.es.json", "build:types": "tsc -p tsconfig.types.json", "build:types:downlevel": "downlevel-dts dist-types dist-types/ts3.4", - "clean": "node_modules/.bin/rimraf ./dist-* && rimraf *.tsbuildinfo || exit 0", + "clean": "rimraf ./dist-* && rimraf *.tsbuildinfo || exit 0", "prepack": "npm run clean && npm run build" }, - "main": "dist-cjs/index.js", + "main": "./dist-cjs/index.js", "types": "./dist-types/index.d.ts", - "module": "dist-es/index.js", + "module": "./dist-es/index.js", "sideEffects": false, "dependencies": { "tslib": "^2.6.2", diff --git a/lambda-durable-functions-testing-sdk-js/src/checkpoint-server/__tests__/checkpoint-server.test.ts b/lambda-durable-functions-testing-sdk-js/src/checkpoint-server/__tests__/checkpoint-server.test.ts index 409bb748..8f7a5724 100644 --- a/lambda-durable-functions-testing-sdk-js/src/checkpoint-server/__tests__/checkpoint-server.test.ts +++ b/lambda-durable-functions-testing-sdk-js/src/checkpoint-server/__tests__/checkpoint-server.test.ts @@ -349,10 +349,7 @@ describe("checkpoint-server", () => { ]; const mockStorage = { - operationDataMap: new Map([ - ["op1", { operation: mockOperations[0] }], - ["op2", { operation: mockOperations[1] }], - ]), + getState: jest.fn().mockReturnValue(mockOperations), }; mockExecutionManager.getCheckpointsByToken.mockReturnValueOnce({ diff --git a/lambda-durable-functions-testing-sdk-js/src/checkpoint-server/checkpoint-server.ts b/lambda-durable-functions-testing-sdk-js/src/checkpoint-server/checkpoint-server.ts index 294d2def..21118ce0 100644 --- a/lambda-durable-functions-testing-sdk-js/src/checkpoint-server/checkpoint-server.ts +++ b/lambda-durable-functions-testing-sdk-js/src/checkpoint-server/checkpoint-server.ts @@ -189,12 +189,8 @@ export async function startCheckpointServer(port: number) { return; } - const operations = Array.from( - executionData.storage.operationDataMap.values() - ).map((operationData) => operationData.operation); - const output: GetDurableExecutionStateResponse = { - Operations: operations, + Operations: executionData.storage.getState(), NextMarker: undefined, }; diff --git a/lambda-durable-functions-testing-sdk-js/src/checkpoint-server/storage/__tests__/checkpoint-manager.test.ts b/lambda-durable-functions-testing-sdk-js/src/checkpoint-server/storage/__tests__/checkpoint-manager.test.ts index bf8cb245..3e0e0d01 100644 --- a/lambda-durable-functions-testing-sdk-js/src/checkpoint-server/storage/__tests__/checkpoint-manager.test.ts +++ b/lambda-durable-functions-testing-sdk-js/src/checkpoint-server/storage/__tests__/checkpoint-manager.test.ts @@ -226,6 +226,37 @@ describe("CheckpointManager", () => { }) ).toThrow("Could not find operation"); }); + + it("should update operation with new context details", () => { + // Initialize storage + storage.initialize(); + + // Register a step operation + storage.registerUpdate( + { + Id: "new-id", + Action: OperationAction.START, + Type: OperationType.CONTEXT, + ContextOptions: { + ReplayChildren: true, + }, + }, + mockInvocationId + ); + + // Complete the operation + const { operation } = storage.completeOperation({ + Id: "new-id", + Action: OperationAction.SUCCEED, + Payload: "new payload", + }); + + expect(operation).toBeDefined(); + expect(operation.Status).toBe(OperationStatus.SUCCEEDED); + expect(operation.EndTimestamp).toBeInstanceOf(Date); + expect(operation.ContextDetails?.ReplayChildren).toBe(true); + expect(operation.ContextDetails?.Result).toEqual("new payload"); + }); }); describe("registerUpdate", () => { @@ -259,6 +290,30 @@ describe("CheckpointManager", () => { expect(storage.operationDataMap.get("step-id")).toBe(result); }); + it("should create and register a new CONTEXT operation", () => { + const update: OperationUpdate = { + Id: "CONTEXT-id", + Name: "test-step", + Type: OperationType.CONTEXT, + ContextOptions: { + ReplayChildren: true, + }, + }; + + const result = storage.registerUpdate(update, mockInvocationId); + + expect(result).toBeDefined(); + expect(result.operation.Id).toBe("CONTEXT-id"); + expect(result.operation.Name).toBe("test-step"); + expect(result.operation.Type).toBe(OperationType.CONTEXT); + expect(result.operation.Status).toBe(OperationStatus.STARTED); + expect(result.operation.StartTimestamp).toBeInstanceOf(Date); + expect(result.operation.StepDetails).toBeUndefined(); + expect(result.operation.ContextDetails?.ReplayChildren).toBe(true); + + expect(storage.operationDataMap.get("CONTEXT-id")).toBe(result); + }); + it("should update operation with new step details", () => { // Initialize storage storage.initialize(); @@ -717,8 +772,10 @@ describe("CheckpointManager", () => { expect(result.operation.Type).toBe(OperationType.CONTEXT); expect(result.operation.Status).toBe(OperationStatus.STARTED); expect(result.operation.StartTimestamp).toBeInstanceOf(Date); - // ContextDetails should not be populated in registerUpdate - expect(result.operation.ContextDetails).toBeUndefined(); + // ContextDetails should be populated in registerUpdate for CONTEXT operations + expect(result.operation.ContextDetails).toEqual({ + ReplayChildren: undefined, + }); expect(storage.operationDataMap.get("context-id")).toBe(result); }); @@ -739,8 +796,10 @@ describe("CheckpointManager", () => { expect(result).toBeDefined(); expect(result.operation.Type).toBe(OperationType.CONTEXT); - // ContextDetails should not be populated in registerUpdate - expect(result.operation.ContextDetails).toBeUndefined(); + // ContextDetails should be populated in registerUpdate for CONTEXT operations + expect(result.operation.ContextDetails).toEqual({ + ReplayChildren: undefined, + }); }); it("should create CONTEXT operation with both result and error", () => { @@ -760,8 +819,10 @@ describe("CheckpointManager", () => { expect(result).toBeDefined(); expect(result.operation.Type).toBe(OperationType.CONTEXT); - // ContextDetails should not be populated in registerUpdate - expect(result.operation.ContextDetails).toBeUndefined(); + // ContextDetails should be populated in registerUpdate + expect(result.operation.ContextDetails).toEqual({ + ReplayChildren: undefined, + }); }); it("should handle CONTEXT operation with undefined payload and error", () => { @@ -778,8 +839,10 @@ describe("CheckpointManager", () => { expect(result).toBeDefined(); expect(result.operation.Type).toBe(OperationType.CONTEXT); - // ContextDetails should not be populated in registerUpdate - expect(result.operation.ContextDetails).toBeUndefined(); + // ContextDetails should be populated in registerUpdate + expect(result.operation.ContextDetails).toEqual({ + ReplayChildren: undefined, + }); }); it.each([OperationAction.SUCCEED, OperationAction.FAIL])( @@ -1225,7 +1288,7 @@ describe("CheckpointManager", () => { Payload: "result1", }, { - Id: "batch-op-2", + Id: "batch-op-2", Type: OperationType.WAIT, WaitOptions: { WaitSeconds: 5 }, }, @@ -1287,7 +1350,11 @@ describe("CheckpointManager", () => { const updates: OperationUpdate[] = [ { Id: "op-z", Type: OperationType.STEP }, - { Id: "op-a", Type: OperationType.WAIT, WaitOptions: { WaitSeconds: 1 } }, + { + Id: "op-a", + Type: OperationType.WAIT, + WaitOptions: { WaitSeconds: 1 }, + }, { Id: "op-m", Type: OperationType.STEP }, ]; @@ -1308,18 +1375,25 @@ describe("CheckpointManager", () => { EndTimestamp: new Date(), }; - const result = storage.updateOperation(initialOperation.Id, newOperationData); + const result = storage.updateOperation( + initialOperation.Id, + newOperationData + ); // Should return the updated CheckpointOperation expect(result.operation.Id).toBe(initialOperation.Id); expect(result.operation.Status).toBe(OperationStatus.SUCCEEDED); // Updated status - expect(result.operation.EndTimestamp).toEqual(newOperationData.EndTimestamp); + expect(result.operation.EndTimestamp).toEqual( + newOperationData.EndTimestamp + ); // And the stored operation should be the same as returned const storedOperation = storage.operationDataMap.get(initialOperation.Id); expect(storedOperation).toBe(result); expect(storedOperation?.operation.Status).toBe(OperationStatus.SUCCEEDED); - expect(storedOperation?.operation.EndTimestamp).toEqual(newOperationData.EndTimestamp); + expect(storedOperation?.operation.EndTimestamp).toEqual( + newOperationData.EndTimestamp + ); }); it("should preserve existing operation properties when updating", () => { @@ -1336,9 +1410,13 @@ describe("CheckpointManager", () => { const storedOperation = storage.operationDataMap.get(initialOperation.Id); expect(storedOperation?.operation.Type).toBe(originalType); - expect(storedOperation?.operation.ExecutionDetails).toBe(originalExecutionDetails); + expect(storedOperation?.operation.ExecutionDetails).toBe( + originalExecutionDetails + ); expect(storedOperation?.operation.Status).toBe(OperationStatus.FAILED); - expect((storedOperation?.operation as Record).SomeNewField).toBe("new-value"); + expect( + (storedOperation?.operation as Record).SomeNewField + ).toBe("new-value"); }); it("should handle partial operation updates", () => { @@ -1373,7 +1451,9 @@ describe("CheckpointManager", () => { storage.initialize(); expect(() => { - storage.updateOperation("non-existent-id", { Status: OperationStatus.SUCCEEDED }); + storage.updateOperation("non-existent-id", { + Status: OperationStatus.SUCCEEDED, + }); }).toThrow("Could not find operation"); }); @@ -1416,7 +1496,9 @@ describe("CheckpointManager", () => { Result: "final-result", Attempt: 1, }); - expect(storedOperation?.operation.EndTimestamp).toEqual(updateData.EndTimestamp); + expect(storedOperation?.operation.EndTimestamp).toEqual( + updateData.EndTimestamp + ); }); it("should handle updates with undefined or null values", () => { @@ -1432,8 +1514,181 @@ describe("CheckpointManager", () => { const storedOperation = storage.operationDataMap.get(initialOperation.Id); expect(storedOperation?.operation.Status).toBe(OperationStatus.SUCCEEDED); - expect((storedOperation?.operation as Record).SomeField).toBeUndefined(); - expect((storedOperation?.operation as Record).AnotherField).toBeNull(); + expect( + (storedOperation?.operation as Record).SomeField + ).toBeUndefined(); + expect( + (storedOperation?.operation as Record).AnotherField + ).toBeNull(); + }); + }); + + describe("getState", () => { + it("should return empty array when no operations exist", () => { + const state = storage.getState(); + expect(state).toEqual([]); + }); + + it("should return single operation when only one exists", () => { + const initialOperation = storage.initialize(); + + const state = storage.getState(); + + expect(state).toHaveLength(1); + expect(state[0]).toEqual(initialOperation); + }); + + it("should return all operations when no parent-child relationships exist", () => { + storage.initialize(); + + // Add some operations without parent relationships + const update1: OperationUpdate = { + Id: "op1", + Type: OperationType.STEP, + }; + const update2: OperationUpdate = { + Id: "op2", + Type: OperationType.WAIT, + WaitOptions: { WaitSeconds: 1 }, + }; + + storage.registerUpdate(update1, mockInvocationId); + storage.registerUpdate(update2, mockInvocationId); + + const state = storage.getState(); + + expect(state).toHaveLength(3); // initial + 2 registered + expect(state.map((op) => op.Id)).toContain("op1"); + expect(state.map((op) => op.Id)).toContain("op2"); + }); + + it("should handle comprehensive operation tree with all pruning scenarios", () => { + storage.initialize(); + + const updates: OperationUpdate[] = [ + // Step with no parent + { + Id: "1-step", + Type: OperationType.STEP, + Action: OperationAction.SUCCEED, + }, + // In-progress context + { + Id: "2-context", + Type: OperationType.CONTEXT, + }, + // In-progress step in in-progress context + { + Id: "2.1-step", + ParentId: "2-context", + Type: OperationType.STEP, + }, + // Step with non-existent parent (shouldn't happen but we handle it) + { + Id: "3-step", + ParentId: "does-not-exist", + Type: OperationType.STEP, + Action: OperationAction.SUCCEED, + }, + // Failed context, no explicit ReplayChildren=true (defaults to false/undefined) + { + Id: "4-context", + Type: OperationType.CONTEXT, + Action: OperationAction.FAIL, + }, + // Failed context, explicit ReplayChildren=true, but inside one that's already completed so should be pruned + { + Id: "4.1-context", + ParentId: "4-context", + Type: OperationType.CONTEXT, + ContextOptions: { + ReplayChildren: true, + }, + Action: OperationAction.FAIL, + }, + // Failed step, should get pruned as its top ancestor has ReplayChildren=false + { + Id: "4.1.1-step", + ParentId: "4.1-context", + Type: OperationType.STEP, + Action: OperationAction.FAIL, + }, + // Failed step, should get pruned as its top ancestor has ReplayChildren=false + { + Id: "4.1.2-step", + ParentId: "4.1-context", + Type: OperationType.STEP, + Action: OperationAction.FAIL, + }, + // Succeeded context with explicit ReplayChildren=true + { + Id: "5-context", + Type: OperationType.CONTEXT, + ContextOptions: { + ReplayChildren: true, + }, + Action: OperationAction.SUCCEED, + }, + // Should not get pruned + { + Id: "5.1-context", + ParentId: "5-context", + Type: OperationType.CONTEXT, + ContextOptions: { + ReplayChildren: false, + }, + Action: OperationAction.SUCCEED, + }, + // Should get pruned (inside completed context with ReplayChildren=false) + { + Id: "5.1.1-step", + ParentId: "5.1-context", + Type: OperationType.STEP, + Action: OperationAction.SUCCEED, + }, + // Should not get pruned + { + Id: "5.1-step", + ParentId: "5-context", + Type: OperationType.STEP, + Action: OperationAction.SUCCEED, + }, + ]; + + storage.registerUpdates(updates, mockInvocationId); + + const state = storage.getState(); + + const expectedOperations = [ + "mocked-uuid", // initial operation + "1-step", + "2-context", + "2.1-step", + "3-step", // orphan with non-existent parent gets included + "4-context", + "5-context", + "5.1-context", + "5.1-step", + ]; + + expect(state).toHaveLength(expectedOperations.length); + + const stateIds = state.map((op) => op.Id); + // Verify expected operations are present + expectedOperations.forEach((expectedId) => { + expect(stateIds).toContain(expectedId); + }); + + // Verify pruned operations are not present + const prunedOperations = [ + "4.1-context", + "4.1.1-step", + "4.1.2-step", + "5.1.1-step", + ]; + prunedOperations.forEach((prunedId) => { + expect(state.find((op) => op.Id === prunedId)).toBeUndefined(); + }); }); }); diff --git a/lambda-durable-functions-testing-sdk-js/src/checkpoint-server/storage/checkpoint-manager.ts b/lambda-durable-functions-testing-sdk-js/src/checkpoint-server/storage/checkpoint-manager.ts index 72af47a5..229a81e5 100644 --- a/lambda-durable-functions-testing-sdk-js/src/checkpoint-server/storage/checkpoint-manager.ts +++ b/lambda-durable-functions-testing-sdk-js/src/checkpoint-server/storage/checkpoint-manager.ts @@ -32,6 +32,39 @@ export class CheckpointManager { this.callbackManager = new CallbackManager(executionId, this); } + getState(): Operation[] { + const excludedOperations = new Set(); + const operations: Operation[] = []; + for (const [id, { operation }] of this.operationDataMap.entries()) { + const parentId = operation.ParentId; + if (!parentId) { + operations.push(operation); + continue; + } + + const parent = this.operationDataMap.get(parentId); + if ( + // Parent type is valid + parent?.operation.Type === OperationType.CONTEXT && + // Parent is completed + (parent.operation.Status === OperationStatus.SUCCEEDED || + parent.operation.Status === OperationStatus.FAILED) && + // The parent is set to not replay children + (!parent.operation.ContextDetails?.ReplayChildren || + // If the parent was excluded, then ReplayChildren must be false for an ancestor, + // so this operation should also be excluded. + excludedOperations.has(parentId)) + ) { + excludedOperations.add(id); + continue; + } + + operations.push(operation); + } + + return operations; + } + /** * Initialize the checkpoint manager with the first operation * @returns the initial operation for an execution @@ -186,6 +219,7 @@ export class CheckpointManager { break; case OperationType.CONTEXT: copied.operation.ContextDetails = { + ...copied.operation.ContextDetails, Result: inputUpdate.Payload, Error: inputUpdate.Error, }; @@ -207,7 +241,7 @@ export class CheckpointManager { /** * Registers multiple operation updates at once for a given invocation. * This is a batch operation that calls registerUpdate for each update. - * + * * @param updates Array of operation updates to register * @param invocationId The invocation ID these updates belong to * @returns Array of checkpoint operations with their associated updates @@ -223,7 +257,7 @@ export class CheckpointManager { * Updates an existing operation with new operation data. * This method merges the new operation properties with the existing operation * while preserving the original update information. - * + * * @param id The operation ID to update * @param newOperation Partial operation data to merge with existing operation * @returns The updated checkpoint operation data @@ -333,6 +367,12 @@ export class CheckpointManager { }; break; } + case OperationType.CONTEXT: { + operation.ContextDetails = { + ReplayChildren: update.ContextOptions?.ReplayChildren, + }; + break; + } } const result: CheckpointOperation = {