Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
18 changes: 18 additions & 0 deletions packages/durabletask-js/src/task/completable-task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,22 @@ export class CompletableTask<T> extends Task<T> {
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);
}
}
}
5 changes: 2 additions & 3 deletions packages/durabletask-js/src/task/task.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

import { TaskFailedError } from "./exception/task-failed-error";
import { CompositeTask } from "./composite-task";

/**
* Abstract base class for asynchronous tasks in a durable orchestration.
*/
export class Task<T> {
_result: T | undefined;
_exception: TaskFailedError | undefined;
_exception: Error | undefined;
_parent: CompositeTask<T> | undefined;
_isComplete: boolean = false;

Expand Down Expand Up @@ -51,7 +50,7 @@ export class Task<T> {
/**
* Get the exception that caused the task to fail
*/
getException(): TaskFailedError {
getException(): Error {
if (!this._exception) {
throw new Error("Task did not fail");
}
Expand Down
26 changes: 12 additions & 14 deletions packages/durabletask-js/src/worker/orchestration-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
57 changes: 55 additions & 2 deletions packages/durabletask-js/test/entity-operation-events.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<Task<number>, string, number> {
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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<Task<number>, string, number> {
const entityId = new EntityInstanceId("counter", "my-counter");
yield ctx.entities.callEntity<number>(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", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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");
Expand Down
10 changes: 8 additions & 2 deletions packages/durabletask-js/test/task.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ function makeFailureDetails(
return details;
}

function getTaskFailedError(task: Task<unknown>): 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.
Expand Down Expand Up @@ -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");
Expand All @@ -207,7 +213,7 @@ describe("CompletableTask", () => {
const task = new CompletableTask<string>();
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("");
Expand Down
Loading