Repository: Azure/azure-functions-durable-js
Pull request #405 (2022-11-12 00:45:55)

Patch isReplaying on WhenAll
Resolves: https://github.com/Azure/azure-functions-durable-js/issues/404

We currently have a bug where in WhenAll where ithe value of `isPlayed` for the `WhenAll` task will be set to `True` if any of its sub-tasks has a `isPlayed` value of `True`. The correct behavior is that the value of `WhenAll's` `IsPlayed` should be `True` when *all* of its sub-tasks are `True`. 

This PR addresses this.

Note: for `WhenAny` it suffices that only one of its Tasks have `isPlayed` as `True` for it's `isPlayed` to be `True` as well

davidmrdavid: @hossam-nasr: a friendly ping here. Would appreciate a review to get this hotfix out :) Thanks!

Patch:
diff --git a/azure-pipelines-release.yml b/azure-pipelines-release.yml
index 89f8921..232c98e 100644
--- a/azure-pipelines-release.yml
+++ b/azure-pipelines-release.yml
@@ -1,3 +1,13 @@
+parameters:
+    - name: NpmPublishTag
+      displayName: "Tag"
+      type: string
+      default: "latest"
+    - name: NpmPublishDryRun
+      displayName: "Dry Run"
+      type: boolean
+      default: true
+
 trigger: none
 pr: none
 
@@ -29,5 +39,5 @@ jobs:
                 command: custom
                 workingDir: "$(Pipeline.Workspace)/DurableJSCI/drop"
                 verbose: true
-                customCommand: "publish package.tgz"
-                publishEndpoint: "NPM Durable Functions JS Publish"
+                customCommand: "publish package.tgz --tag ${{ parameters.NpmPublishTag }} --dry-run ${{ lower(parameters.NpmPublishDryRun) }}"
+                customEndpoint: "NPM Durable Functions JS Publish"
diff --git a/src/task.ts b/src/task.ts
index 00c680d..2794470 100644
--- a/src/task.ts
+++ b/src/task.ts
@@ -125,6 +125,7 @@ export abstract class TaskBase {
      */
     constructor(public id: TaskID, protected action: BackingAction) {
         this.state = TaskState.Running;
+        this.isPlayed = false;
     }
 
     /** Get this task's backing action */
@@ -261,12 +262,17 @@ export abstract class CompoundTask extends DFTask {
      *  A sub-task of this task.
      */
     public handleCompletion(child: TaskBase): void {
-        if (!this.isPlayed) {
-            this.isPlayed = child.isPlayed;
-        }
+        this.trySetIsPlayed();
         this.trySetValue(child);
     }
 
+    /**
+     * @hidden
+     *
+     * Task-internal logic for setting this task's isPlayed flag
+     */
+    abstract trySetIsPlayed(): void;
+
     /**
      * @hidden
      *
@@ -336,6 +342,10 @@ export class WhenAllTask extends CompoundTask {
         super(children, action);
     }
 
+    trySetIsPlayed(): void {
+        this.isPlayed = this.children.every((c) => c.isPlayed);
+    }
+
     /**
      * @hidden
      * Attempts to set a value to this task, given a completed sub-task
@@ -367,6 +377,9 @@ export class WhenAllTask extends CompoundTask {
  * A WhenAny task.
  */
 export class WhenAnyTask extends CompoundTask {
+    trySetIsPlayed(): void {
+        this.isPlayed = this.children.some((c) => c.isPlayed);
+    }
     /**
      * @hidden
      * Attempts to set a value to this task, given a completed sub-task
@@ -382,6 +395,7 @@ export class WhenAnyTask extends CompoundTask {
         // in the result as a value.
         if (this.state === TaskState.Running) {
             this.setValue(false, child);
+            this.isPlayed = child.isPlayed;
         }
     }
 }
@@ -421,6 +435,11 @@ export class CallHttpWithPollingTask extends CompoundTask {
         );
     }
 
+    trySetIsPlayed(): void {
+        const isPlayed = this.children.every((c) => c.isPlayed);
+        this.isPlayed = isPlayed;
+    }
+
     public trySetValue(child: TaskBase): void {
         if (child.stateObj === TaskState.Completed) {
             if (child.actionObj instanceof CallHttpAction) {


Test patch:
diff --git a/test/integration/orchestrator-spec.ts b/test/integration/orchestrator-spec.ts
index b5a1df7..56ef875 100644
--- a/test/integration/orchestrator-spec.ts
+++ b/test/integration/orchestrator-spec.ts
@@ -224,6 +224,20 @@ describe("Orchestrator", () => {
             expect(mockContext.doneValue?.output).to.be.deep.equal([true, false]);
         });
 
+        it("can assign isReplaying to False in WhenAll", async () => {
+            const orchestrator = TestOrchestrations.FanningIsReplaying;
+
+            const mockHistory = TestHistories.GetSimpleFanOut(moment.utc().toDate());
+
+            const mockContext = new MockContext({
+                context: new DurableOrchestrationBindingInfo(mockHistory, "SimpleFanOut"),
+            });
+
+            orchestrator(mockContext);
+
+            expect(mockContext.doneValue?.output).to.be.deep.equal([true, false]);
+        });
+
         it("handles creating a composite task out of an already completed task", async () => {
             const orchestrator = TestOrchestrations.MultiYieldWhenAll;
             const name = "World";
diff --git a/test/testobjects/TestOrchestrations.ts b/test/testobjects/TestOrchestrations.ts
index 6768ba9..e801d8f 100644
--- a/test/testobjects/TestOrchestrations.ts
+++ b/test/testobjects/TestOrchestrations.ts
@@ -332,6 +332,19 @@ export class TestOrchestrations {
         return output;
     });
 
+    public static FanningIsReplaying: any = df.orchestrator(function* (context: any) {
+        const output = [];
+
+        output.push(context.df.isReplaying);
+        const tasks = [
+            context.df.callActivity("Hello", "Tokyo"),
+            context.df.callActivity("Hello", "Seattle"),
+        ];
+        yield context.df.Task.all(tasks);
+        output.push(context.df.isReplaying);
+        return output;
+    });
+
     public static MultiYieldWhenAll: any = df.orchestrator(function* (context: any) {
         const output = [];
 
diff --git a/test/testobjects/testhistories.ts b/test/testobjects/testhistories.ts
index 58e77f0..356c437 100644
--- a/test/testobjects/testhistories.ts
+++ b/test/testobjects/testhistories.ts
@@ -1434,6 +1434,88 @@ export class TestHistories {
         return history;
     }
 
+    public static GetSimpleFanOut(firstTimestamp: Date) {
+        const historyEvents: HistoryEvent[] = [];
+
+        historyEvents.push(
+            new OrchestratorStartedEvent({
+                eventId: -1,
+                timestamp: firstTimestamp,
+                isPlayed: true,
+            })
+        );
+        historyEvents.push(
+            new ExecutionStartedEvent({
+                eventId: -1,
+                timestamp: firstTimestamp,
+                isPlayed: true,
+                name: "SayHelloWithActivityRetryFanout",
+                input: undefined,
+            })
+        );
+        historyEvents.push(
+            new TaskScheduledEvent({
+                eventId: 0,
+                timestamp: firstTimestamp,
+                isPlayed: true,
+                name: "Hello",
+                input: "Tokyo",
+            })
+        );
+        historyEvents.push(
+            new TaskScheduledEvent({
+                eventId: 1,
+                timestamp: firstTimestamp,
+                isPlayed: true,
+                name: "Hello",
+                input: "Seattle",
+            })
+        );
+        historyEvents.push(
+            new OrchestratorCompletedEvent({
+                eventId: -1,
+                timestamp: firstTimestamp,
+                isPlayed: true,
+            })
+        );
+
+        historyEvents.push(
+            new OrchestratorStartedEvent({
+                eventId: -1,
+                timestamp: moment(firstTimestamp).add(100, "s").toDate(),
+                isPlayed: true,
+            })
+        );
+        historyEvents.push(
+            new TaskCompletedEvent({
+                eventId: -1,
+                timestamp: moment(firstTimestamp).add(100, "s").toDate(),
+                isPlayed: true,
+                result: JSON.stringify(`Hello, Tokyo!`),
+                taskScheduledId: 0,
+            })
+        );
+
+        historyEvents.push(
+            new OrchestratorStartedEvent({
+                eventId: -1,
+                timestamp: moment(firstTimestamp).add(100, "s").toDate(),
+                isPlayed: false,
+            })
+        );
+        historyEvents.push(
+            new TaskCompletedEvent({
+                eventId: -1,
+                timestamp: moment(firstTimestamp).add(100, "s").toDate(),
+                isPlayed: false,
+                result: JSON.stringify(`Hello, Seattle!`),
+                taskScheduledId: 1,
+            })
+        );
+
+        return historyEvents;
+    }
+
     public static GetSayHelloWithActivityRetryFanout(
         firstTimestamp: Date,
         retryInterval: number,