Repository: Azure/azure-functions-durable-js (ts)

Issue:
Support SignalEntity API
This allows orchestrations to send messages to Entities without needing to `yield` / `await` on an API. The implementation should be similar to `ContinueAsNew`, which is also a fire-and-forget operation.

Pull request description:
Add signalEntity API to orchestrations
Resolves #327 . Allows orchestrations to have a "fire-and-forget" `signalEntity` operation, in addition to the existing blocking `callEntity` API.

## Design decisions/questions

Some design decisions made in this PR which could require additional discussions:

### An error encountered after a `signalEntity` call
The entity is still signaled if an error is encountered after the `signalEntity` call.

Code like the below:

```TS
const orchestrator = df.orchestrator(function* (context) {
    const id:string = context.df.getInput()
    const entityId = new df.EntityId("Counter", id);
    context.df.signalEntity(entityId, "add", 1)
    throw new Error("test");
});
```

Will result in:

* The orchestrator fails with an error message "test"
* The entity `Counter` is signaled once


### Yielding a `signalEntity` call
`signalEntity` is a fire-and-forget operation, and therefore it doesn't return a `Task` object and cannot be `yield`ed. Attempting to yield `signalEntity` call will cause your orchestration to fail with an error, but the signal call will still go through.

Code as the following:

```TS
const orchestrator = df.orchestrator(function* (context) {
    const id:string = context.df.getInput()
    const entityId = new df.EntityId("Counter", id);
    yield context.df.signalEntity(entityId, "add", 1)
});
```

Will result in:
* An error thrown in Core Tools (if running locally) with a message like the below:

```
Orchestration yielded data of type undefined. Only Task types can be yielded.Please refactor your orchestration to yield only Tasks.
```

* Orchestration status failed with the output message similar to the one above
* The `Counter` entity is still signaled once



Patch:
diff --git a/src/actions/actiontype.ts b/src/actions/actiontype.ts
index 88d6d12..3afbe8c 100644
--- a/src/actions/actiontype.ts
+++ b/src/actions/actiontype.ts
@@ -17,8 +17,8 @@ export enum ActionType {
     WaitForExternalEvent = 6,
     CallEntity = 7,
     CallHttp = 8,
-    // ActionType 9 and 10 correspond to SignalEntity and ScheduledSignalEntity
-    // Those two are not supported yet.
+    SignalEntity = 9,
+    // ActionType 10 corresponds to ScheduledSignalEntity, which is not supported yet
     WhenAny = 11,
     WhenAll = 12,
 }
diff --git a/src/actions/signalentityaction.ts b/src/actions/signalentityaction.ts
new file mode 100644
index 0000000..2e19abe
--- /dev/null
+++ b/src/actions/signalentityaction.ts
@@ -0,0 +1,17 @@
+import { ActionType, EntityId, IAction, Utils } from "../classes";
+
+/** @hidden */
+export class SignalEntityAction implements IAction {
+    public readonly actionType: ActionType = ActionType.SignalEntity;
+    public readonly instanceId: string;
+    public readonly input: unknown;
+
+    constructor(entityId: EntityId, public readonly operation: string, input?: unknown) {
+        if (!entityId) {
+            throw new Error("Must provide EntityId to SignalEntityAction constructor");
+        }
+        this.input = Utils.processInput(input);
+        Utils.throwIfEmpty(operation, "operation");
+        this.instanceId = EntityId.getSchedulerIdFromEntityId(entityId);
+    }
+}
diff --git a/src/durableorchestrationcontext.ts b/src/durableorchestrationcontext.ts
index 05c95e4..63da6d4 100644
--- a/src/durableorchestrationcontext.ts
+++ b/src/durableorchestrationcontext.ts
@@ -33,6 +33,7 @@ import {
 } from "./task";
 import moment = require("moment");
 import { ReplaySchema } from "./replaySchema";
+import { SignalEntityAction } from "./actions/signalentityaction";
 
 /**
  * Parameter data for orchestration bindings that can be used to schedule
@@ -241,6 +242,19 @@ export class DurableOrchestrationContext {
         return task;
     }
 
+    /**
+     * Send a signal operation to a Durable Entity, passing an argument, without
+     * waiting for a response. A fire-and-forget operation.
+     *
+     * @param entityId ID of the target entity.
+     * @param operationName The name of the operation.
+     * @param operationInput (optional) input for the operation.
+     */
+    public signalEntity(entityId: EntityId, operationName: string, operationInput?: unknown): void {
+        const action = new SignalEntityAction(entityId, operationName, operationInput);
+        this.taskOrchestratorExecutor.recordFireAndForgetAction(action);
+    }
+
     /**
      * Schedules an orchestration function named `name` for execution.
      *
diff --git a/src/taskorchestrationexecutor.ts b/src/taskorchestrationexecutor.ts
index ca2b2e8..df8f53e 100644
--- a/src/taskorchestrationexecutor.ts
+++ b/src/taskorchestrationexecutor.ts
@@ -400,10 +400,17 @@ export class TaskOrchestrationExecutor {
                 newTask = generatorResult.value;
             } else {
                 // non-task was yielded. This isn't supported
-                throw Error(
-                    `Orchestration yielded data of type ${typeof generatorResult.value}. Only Task types can be yielded.` +
-                        "Please refactor your orchestration to yield only Tasks."
-                );
+                let errorMsg = `Durable Functions programming constraint violation: Orchestration yielded data of type ${typeof generatorResult.value}.`;
+                errorMsg +=
+                    typeof generatorResult.value === "undefined"
+                        ? ' This is likely a result of yielding a "fire-and-forget API" such as signalEntity or continueAsNew.' +
+                          " These APIs should not be yielded as they are not blocking operations. Please remove the yield statement preceding those invocations." +
+                          " If you are not calling those APIs, p"
+                        : " Only Task types can be yielded. P";
+                errorMsg +=
+                    "lease check your yield statements to make sure you only yield Task types resulting from calling Durable Functions APIs.";
+
+                throw Error(errorMsg);
             }
         } catch (exception) {
             // The generator threw an exception
@@ -443,6 +450,13 @@ export class TaskOrchestrationExecutor {
         }
     }
 
+    public recordFireAndForgetAction(action: IAction): void {
+        if (!this.willContinueAsNew) {
+            this.addToActions(action);
+            this.sequenceNumber++;
+        }
+    }
+
     /**
      * @hidden
      * Tracks this task as waiting for completion.


Test patch:
diff --git a/test/integration/orchestrator-spec.ts b/test/integration/orchestrator-spec.ts
index c48d014..b5a1df7 100644
--- a/test/integration/orchestrator-spec.ts
+++ b/test/integration/orchestrator-spec.ts
@@ -13,6 +13,7 @@ import "mocha";
 import * as moment from "moment";
 import * as uuidv1 from "uuid/v1";
 import { DummyOrchestrationContext, ManagedIdentityTokenSource } from "../../src";
+import { SignalEntityAction } from "../../src/actions/signalentityaction";
 import {
     ActionType,
     CallActivityAction,
@@ -68,6 +69,38 @@ describe("Orchestrator", () => {
         );
     });
 
+    it("doesn't allow yielding non-Task types", async () => {
+        const orchestrator = TestOrchestrations.YieldInteger;
+        const mockContext = new MockContext({
+            context: new DurableOrchestrationBindingInfo(
+                TestHistories.StarterHistory(moment.utc().toDate())
+            ),
+        });
+
+        orchestrator(mockContext);
+
+        const errorMsg =
+            `Durable Functions programming constraint violation: Orchestration yielded data of type number.` +
+            " Only Task types can be yielded. Please check your yield statements to make sure you only yield Task types resulting from calling Durable Functions APIs.";
+
+        const expectedErr = new OrchestrationFailureError(
+            false,
+            new OrchestratorState(
+                {
+                    isDone: false,
+                    actions: [],
+                    schemaVersion: ReplaySchema.V1,
+                    error: errorMsg,
+                    output: undefined,
+                },
+                true
+            )
+        );
+
+        expect(mockContext.doneValue).to.be.undefined;
+        expect(mockContext.err?.toString()).to.equal(expectedErr.toString());
+    });
+
     it("handles a simple orchestration function (no activity functions)", async () => {
         const orchestrator = TestOrchestrations.SayHelloInline;
         const name = "World";
@@ -1339,6 +1372,103 @@ describe("Orchestrator", () => {
         });
     });
 
+    describe("signalEntity()", () => {
+        it("scheduled a SignalEntity action", () => {
+            const orchestrator = TestOrchestrations.signalEntity;
+            const entityName = "Counter";
+            const id = "1234";
+            const expectedEntity = new EntityId(entityName, id);
+            const operationName = "add";
+            const operationArgument = 1;
+            const mockContext = new MockContext({
+                context: new DurableOrchestrationBindingInfo(
+                    TestHistories.GetOrchestratorStart("signalEntity", new Date()),
+                    {
+                        id,
+                        entityName,
+                        operationName,
+                        operationArgument,
+                    }
+                ),
+            });
+
+            orchestrator(mockContext);
+
+            expect(mockContext.doneValue).to.deep.equal(
+                new OrchestratorState(
+                    {
+                        isDone: true,
+                        output: undefined,
+                        actions: [
+                            [
+                                new SignalEntityAction(
+                                    expectedEntity,
+                                    operationName,
+                                    operationArgument
+                                ),
+                            ],
+                        ],
+                        schemaVersion: ReplaySchema.V1,
+                    },
+                    true
+                )
+            );
+        });
+
+        it("doesn't allow signalEntity() to be yielded", () => {
+            const orchestrator = TestOrchestrations.signalEntityYield;
+            const entityName = "Counter";
+            const id = "1234";
+            const expectedEntity = new EntityId(entityName, id);
+            const operationName = "add";
+            const operationArgument = 1;
+            const mockContext = new MockContext({
+                context: new DurableOrchestrationBindingInfo(
+                    TestHistories.GetOrchestratorStart("signalEntity", new Date()),
+                    {
+                        id,
+                        entityName,
+                        operationName,
+                        operationArgument,
+                    }
+                ),
+            });
+
+            orchestrator(mockContext);
+
+            const errorMsg =
+                `Durable Functions programming constraint violation: Orchestration yielded data of type undefined.` +
+                ' This is likely a result of yielding a "fire-and-forget API" such as signalEntity or continueAsNew.' +
+                " These APIs should not be yielded as they are not blocking operations. Please remove the yield statement preceding those invocations." +
+                " If you are not calling those APIs, please check your yield statements to make sure you only yield Task types resulting from calling Durable Functions APIs.";
+
+            const expectedErr = new OrchestrationFailureError(
+                false,
+                new OrchestratorState(
+                    {
+                        isDone: false,
+                        actions: [
+                            [
+                                new SignalEntityAction(
+                                    expectedEntity,
+                                    operationName,
+                                    operationArgument
+                                ),
+                            ],
+                        ],
+                        schemaVersion: ReplaySchema.V1,
+                        error: errorMsg,
+                        output: undefined,
+                    },
+                    true
+                )
+            );
+
+            expect(mockContext.doneValue).to.be.undefined;
+            expect(mockContext.err?.toString()).to.equal(expectedErr.toString());
+        });
+    });
+
     describe("callSubOrchestrator()", () => {
         it("schedules a suborchestrator function", async () => {
             const orchestrator = TestOrchestrations.SayHelloWithSubOrchestrator;
diff --git a/test/testobjects/TestOrchestrations.ts b/test/testobjects/TestOrchestrations.ts
index a7a25e0..6768ba9 100644
--- a/test/testobjects/TestOrchestrations.ts
+++ b/test/testobjects/TestOrchestrations.ts
@@ -5,6 +5,10 @@ export class TestOrchestrations {
         return "Hello";
     });
 
+    public static YieldInteger: any = df.orchestrator(function* () {
+        yield 4;
+    });
+
     public static AnyAOrB: any = df.orchestrator(function* (context: any) {
         const completeInOrder = context.df.getInput();
 
@@ -103,6 +107,20 @@ export class TestOrchestrations {
         return currentValue;
     });
 
+    public static signalEntity: any = df.orchestrator(function* (context: any) {
+        const { id, entityName, operationName, operationArgument } = context.df.getInput();
+        const entityId = new df.EntityId(entityName, id);
+        context.df.signalEntity(entityId, operationName, operationArgument);
+        return;
+    });
+
+    public static signalEntityYield: any = df.orchestrator(function* (context: any) {
+        const { id, entityName, operationName, operationArgument } = context.df.getInput();
+        const entityId = new df.EntityId(entityName, id);
+        yield context.df.signalEntity(entityId, operationName, operationArgument);
+        return;
+    });
+
     public static FanOutFanInDiskUsage: any = df.orchestrator(function* (context: any) {
         const directory = context.df.getInput();
         const files = yield context.df.callActivity("GetFileList", directory);