diff --git a/packages/durabletask-js/src/entities/entity-operation-failed-exception.ts b/packages/durabletask-js/src/entities/entity-operation-failed-exception.ts index 35c7308..46b3dea 100644 --- a/packages/durabletask-js/src/entities/entity-operation-failed-exception.ts +++ b/packages/durabletask-js/src/entities/entity-operation-failed-exception.ts @@ -82,8 +82,13 @@ export class EntityOperationFailedException extends Error { * @param operationName - The operation name. * @param failureDetails - The failure details. */ - constructor(entityId: EntityInstanceId, operationName: string, failureDetails: TaskFailureDetails) { - super(EntityOperationFailedException.getExceptionMessage(operationName, entityId, failureDetails)); + constructor( + entityId: EntityInstanceId, + operationName: string, + failureDetails: TaskFailureDetails, + ) { + const message = EntityOperationFailedException.getExceptionMessage(operationName, entityId, failureDetails); + super(message); this.name = "EntityOperationFailedException"; this.entityId = entityId; this.operationName = operationName; diff --git a/packages/durabletask-js/src/task/completable-task.ts b/packages/durabletask-js/src/task/completable-task.ts index 2171a32..4d82d3d 100644 --- a/packages/durabletask-js/src/task/completable-task.ts +++ b/packages/durabletask-js/src/task/completable-task.ts @@ -37,4 +37,22 @@ export class CompletableTask extends Task { this._parent.onChildCompleted(this); } } + + /** + * Fails the task with a pre-constructed error. + * Use this when a more specific error type (e.g., EntityOperationFailedException) + * should be preserved as the task's exception rather than wrapping in a generic TaskFailedError. + */ + failWithError(error: Error): void { + if (this._isComplete) { + throw new Error("Task is already completed"); + } + + this._exception = error; + this._isComplete = true; + + if (this._parent) { + this._parent.onChildCompleted(this); + } + } } diff --git a/packages/durabletask-js/src/task/task.ts b/packages/durabletask-js/src/task/task.ts index c214e7a..4a99ca3 100644 --- a/packages/durabletask-js/src/task/task.ts +++ b/packages/durabletask-js/src/task/task.ts @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import { TaskFailedError } from "./exception/task-failed-error"; import { CompositeTask } from "./composite-task"; /** @@ -9,7 +8,7 @@ import { CompositeTask } from "./composite-task"; */ export class Task { _result: T | undefined; - _exception: TaskFailedError | undefined; + _exception: Error | undefined; _parent: CompositeTask | undefined; _isComplete: boolean = false; @@ -51,7 +50,7 @@ export class Task { /** * Get the exception that caused the task to fail */ - getException(): TaskFailedError { + getException(): Error { if (!this._exception) { throw new Error("Task did not fail"); } diff --git a/packages/durabletask-js/src/worker/orchestration-executor.ts b/packages/durabletask-js/src/worker/orchestration-executor.ts index e8e237c..b43a939 100644 --- a/packages/durabletask-js/src/worker/orchestration-executor.ts +++ b/packages/durabletask-js/src/worker/orchestration-executor.ts @@ -641,20 +641,18 @@ export class OrchestrationExecutor { // If in a critical section, recover the lock for this entity ctx._entityFeature.recoverLockAfterCall(pendingCall.entityId); - // Convert failure details and throw EntityOperationFailedException - const failureDetails = createTaskFailureDetails(failedEvent?.getFailuredetails()); - if (!failureDetails) { - pendingCall.task.fail( - `Entity operation '${pendingCall.operationName}' failed with unknown error`, - ); - } else { - const exception = new EntityOperationFailedException( - pendingCall.entityId, - pendingCall.operationName, - failureDetails, - ); - pendingCall.task.fail(exception.message, failedEvent?.getFailuredetails()); - } + const failureDetails = + createTaskFailureDetails(failedEvent?.getFailuredetails()) ?? + { + errorType: "UnknownError", + errorMessage: `Entity operation '${pendingCall.operationName}' failed with unknown error`, + }; + const exception = new EntityOperationFailedException( + pendingCall.entityId, + pendingCall.operationName, + failureDetails, + ); + pendingCall.task.failWithError(exception); await ctx.resume(); } diff --git a/packages/durabletask-js/test/entity-operation-events.spec.ts b/packages/durabletask-js/test/entity-operation-events.spec.ts index 2766dd0..6c2f2f4 100644 --- a/packages/durabletask-js/test/entity-operation-events.spec.ts +++ b/packages/durabletask-js/test/entity-operation-events.spec.ts @@ -5,6 +5,7 @@ import { OrchestrationExecutor } from "../src/worker/orchestration-executor"; import { Registry } from "../src/worker/registry"; import { OrchestrationContext } from "../src/task/context/orchestration-context"; import { EntityInstanceId } from "../src/entities/entity-instance-id"; +import { EntityOperationFailedException } from "../src/entities/entity-operation-failed-exception"; import * as pb from "../src/proto/orchestrator_service_pb"; import * as ph from "../src/utils/pb-helper.util"; import { StringValue } from "google-protobuf/google/protobuf/wrappers_pb"; @@ -190,7 +191,7 @@ describe("OrchestrationExecutor Entity Operation Events", () => { }); describe("ENTITYOPERATIONFAILED", () => { - it("should fail entity call task with error details", async () => { + it("should fail entity call task with EntityOperationFailedException", async () => { // Arrange let caughtError: Error | undefined; const orchestrator = async function* (ctx: OrchestrationContext): AsyncGenerator, string, number> { @@ -225,10 +226,19 @@ describe("OrchestrationExecutor Entity Operation Events", () => { await executor.execute("test-instance", oldEvents2, newEvents2); - // Assert + // Assert - error should be EntityOperationFailedException expect(caughtError).toBeDefined(); + expect(caughtError).toBeInstanceOf(EntityOperationFailedException); expect(caughtError!.message).toContain("badOperation"); expect(caughtError!.message).toContain("Operation not supported"); + + // Verify entity-specific context is preserved + const entityError = caughtError as EntityOperationFailedException; + expect(entityError.entityId.name).toBe("counter"); + expect(entityError.entityId.key).toBe("my-counter"); + expect(entityError.operationName).toBe("badOperation"); + expect(entityError.failureDetails.errorType).toBe("InvalidOperationError"); + expect(entityError.failureDetails.errorMessage).toBe("Operation not supported"); }); it("should propagate failure to orchestration if not caught", async () => { @@ -268,6 +278,49 @@ describe("OrchestrationExecutor Entity Operation Events", () => { const completeAction = completeActionWrapper!.getCompleteorchestration()!; expect(completeAction.getOrchestrationstatus()).toBe(pb.OrchestrationStatus.ORCHESTRATION_STATUS_FAILED); }); + + it("should throw EntityOperationFailedException details when uncaught", async () => { + // Arrange — verify the uncaught path produces an EntityOperationFailedException in the + // orchestration failure details message, matching the documented API contract + const orchestrator = async function* (ctx: OrchestrationContext): AsyncGenerator, string, number> { + const entityId = new EntityInstanceId("counter", "my-counter"); + yield ctx.entities.callEntity(entityId, "badOperation"); + return "should not reach here"; + }; + + registry.addNamedOrchestrator("TestOrchestrator", orchestrator); + + const executor = new OrchestrationExecutor(registry); + + const oldEvents: pb.HistoryEvent[] = []; + const newEvents: pb.HistoryEvent[] = [ + ph.newOrchestratorStartedEvent(new Date()), + ph.newExecutionStartedEvent("TestOrchestrator", "test-instance", undefined), + ]; + + const result1 = await executor.execute("test-instance", oldEvents, newEvents); + const requestId = result1.actions[0].getSendentitymessage()!.getEntityoperationcalled()!.getRequestid(); + + // Fail the operation + const oldEvents2 = [...newEvents]; + const newEvents2 = [ + ph.newOrchestratorStartedEvent(new Date()), + newEntityOperationFailedEvent(100, requestId, "ValidationError", "Invalid input"), + ]; + + const result2 = await executor.execute("test-instance", oldEvents2, newEvents2); + + // Assert - orchestration should fail with the EntityOperationFailedException message + const completeActionWrapper = result2.actions.find((a) => a.hasCompleteorchestration()); + expect(completeActionWrapper).toBeDefined(); + const completeAction = completeActionWrapper!.getCompleteorchestration()!; + expect(completeAction.getOrchestrationstatus()).toBe(pb.OrchestrationStatus.ORCHESTRATION_STATUS_FAILED); + const failureDetails = completeAction.getFailuredetails(); + expect(failureDetails).toBeDefined(); + expect(failureDetails!.getErrortype()).toBe("EntityOperationFailedException"); + expect(failureDetails!.getErrormessage()).toContain("badOperation"); + expect(failureDetails!.getErrormessage()).toContain("Invalid input"); + }); }); describe("Multiple entity calls", () => { diff --git a/packages/durabletask-js/test/entity-operation-failed-exception.spec.ts b/packages/durabletask-js/test/entity-operation-failed-exception.spec.ts index 9721df9..c36ea06 100644 --- a/packages/durabletask-js/test/entity-operation-failed-exception.spec.ts +++ b/packages/durabletask-js/test/entity-operation-failed-exception.spec.ts @@ -7,6 +7,7 @@ import { TaskFailureDetails, createTaskFailureDetails, } from "../src/entities/entity-operation-failed-exception"; +import { TaskFailedError } from "../src/task/exception/task-failed-error"; import * as pb from "../src/proto/orchestrator_service_pb"; import { StringValue } from "google-protobuf/google/protobuf/wrappers_pb"; @@ -65,6 +66,21 @@ describe("EntityOperationFailedException", () => { expect(exception instanceof EntityOperationFailedException).toBe(true); }); + it("should not be instanceof TaskFailedError", () => { + // Arrange + const entityId = new EntityInstanceId("counter", "my-counter"); + const failureDetails: TaskFailureDetails = { + errorType: "Error", + errorMessage: "Something went wrong", + }; + + // Act + const exception = new EntityOperationFailedException(entityId, "op", failureDetails); + + // Assert + expect(exception instanceof TaskFailedError).toBe(false); + }); + it("should include stack trace", () => { // Arrange const entityId = new EntityInstanceId("counter", "my-counter"); diff --git a/packages/durabletask-js/test/task.spec.ts b/packages/durabletask-js/test/task.spec.ts index 1975746..af4e7d5 100644 --- a/packages/durabletask-js/test/task.spec.ts +++ b/packages/durabletask-js/test/task.spec.ts @@ -27,6 +27,12 @@ function makeFailureDetails( return details; } +function getTaskFailedError(task: Task): TaskFailedError { + const exception = task.getException(); + expect(exception).toBeInstanceOf(TaskFailedError); + return exception as TaskFailedError; +} + describe("Task (base class)", () => { // Task is not abstract, so we can instantiate it directly for testing // its base-class behavior. @@ -197,7 +203,7 @@ describe("CompletableTask", () => { const details = makeFailureDetails("detailed error", "CustomError", "at line 42"); task.fail("detailed error", details); - const exception = task.getException(); + const exception = getTaskFailedError(task); expect(exception.details.message).toBe("detailed error"); expect(exception.details.errorType).toBe("CustomError"); expect(exception.details.stackTrace).toBe("at line 42"); @@ -207,7 +213,7 @@ describe("CompletableTask", () => { const task = new CompletableTask(); task.fail("no details"); - const exception = task.getException(); + const exception = getTaskFailedError(task); // Default TaskFailureDetails has empty strings for message and errorType expect(exception.details.message).toBe(""); expect(exception.details.errorType).toBe("");