Skip to content

Commit 28253b9

Browse files
authored
Fix #301: WhenAll wait-all + aggregate-exception semantics (root cause of rewind deadlock) (#302)
1 parent 486ad4e commit 28253b9

5 files changed

Lines changed: 327 additions & 49 deletions

File tree

packages/durabletask-js/src/task/when-all-task.ts

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -28,24 +28,39 @@ export class WhenAllTask<T> extends CompositeTask<T[]> {
2828
return this._tasks.length - this._completedTasks;
2929
}
3030

31-
onChildCompleted(task: Task<any>): void {
31+
onChildCompleted(_task: Task<any>): void {
3232
if (this._isComplete) {
33-
// Already completed (fail-fast or all children done). Ignore subsequent child completions.
33+
// Already completed (all children done). Ignore subsequent child completions.
3434
return;
3535
}
3636

3737
this._completedTasks++;
3838

39-
if (task.isFailed && !this._exception) {
40-
this._exception = task.getException();
41-
this._isComplete = true;
42-
this._parent?.onChildCompleted(this);
43-
return;
44-
}
45-
39+
// Wait-all: a failing child does not complete the whenAll early; we wait until every child
40+
// is terminal. This prevents a later failing sibling's TaskFailed from being dropped against
41+
// an already-terminal instance (issue #301).
42+
//
43+
// Set _exception only at completion so isFailed and isComplete flip together — otherwise
44+
// resume() (which checks isFailed before isComplete) would throw into the generator before
45+
// the other siblings finish, re-introducing fail-fast.
4646
if (this._completedTasks == this._tasks.length) {
47-
this._result = this._tasks.map((task) => task.getResult());
4847
this._isComplete = true;
48+
49+
const failures = this._tasks.filter((child) => child.isFailed).map((child) => child.getException());
50+
51+
if (failures.length > 0) {
52+
// Aggregate all child failures into an AggregateError. The message inlines every child
53+
// message because newFailureDetails() serializes only e.message (not .errors), so an
54+
// uncaught whenAll failure would otherwise lose per-child detail.
55+
const inlined = failures.map((f) => (f instanceof Error ? f.message : String(f))).join("; ");
56+
this._exception = new AggregateError(
57+
failures,
58+
`${failures.length} of ${this._tasks.length} tasks in the whenAll failed: ${inlined}`,
59+
);
60+
} else {
61+
this._result = this._tasks.map((child) => child.getResult());
62+
}
63+
4964
this._parent?.onChildCompleted(this);
5065
}
5166
}

packages/durabletask-js/test/orchestration_executor.spec.ts

Lines changed: 114 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1035,7 +1035,7 @@ describe("Orchestration Executor", () => {
10351035
expect(completeAction?.getResult()?.getValue()).toEqual("[0,1,2,3,4,5,6,7,8,9]");
10361036
});
10371037

1038-
it("should test that a fan-in works correctly when one of the tasks fails", async () => {
1038+
it("should test that a fan-in completes as failed only after all tasks finish when one fails", async () => {
10391039
const printInt = (_: any, value: number) => {
10401040
return value.toString();
10411041
};
@@ -1061,25 +1061,42 @@ describe("Orchestration Executor", () => {
10611061
oldEvents.push(newTaskScheduledEvent(i + 1, activityName, i.toString()));
10621062
}
10631063

1064-
// Chaos test with 5 tasks completing successfully, 1 failing and 4 still running
1065-
// we exect that the orchestration fails immediately now and does not wait for the 4 that are running
1066-
const newEvents: any[] = [];
1067-
1064+
// Wait-all (issue #301): 5 tasks complete successfully, 1 fails, and 4 are still running.
1065+
// The whenAll must NOT go terminal yet — it waits for the 4 outstanding tasks rather than
1066+
// failing immediately (which used to happen under fail-fast).
1067+
const ex = new Error("Kah-BOOOOM!!!");
1068+
const partialEvents: any[] = [];
10681069
for (let i = 0; i < 5; i++) {
1069-
newEvents.push(newTaskCompletedEvent(i + 1, printInt(null, i)));
1070+
partialEvents.push(newTaskCompletedEvent(i + 1, printInt(null, i)));
10701071
}
1072+
partialEvents.push(newTaskFailedEvent(6, ex));
10711073

1072-
const ex = new Error("Kah-BOOOOM!!!");
1073-
newEvents.push(newTaskFailedEvent(6, ex));
1074+
const waitingResult = await new OrchestrationExecutor(registry, testLogger).execute(
1075+
TEST_INSTANCE_ID,
1076+
oldEvents,
1077+
partialEvents,
1078+
);
1079+
// Still waiting on tasks 7-10 — no complete action is emitted.
1080+
expect(waitingResult.actions.some((a) => a.hasCompleteorchestration())).toBe(false);
1081+
expect(waitingResult.actions.length).toEqual(0);
1082+
1083+
// The remaining 4 tasks now finish (delivered in a later batch). Only now does the whenAll
1084+
// complete — as failed, aggregating the single failure into an AggregateError.
1085+
const remainingEvents: any[] = [];
1086+
for (let i = 6; i < 10; i++) {
1087+
remainingEvents.push(newTaskCompletedEvent(i + 1, printInt(null, i)));
1088+
}
10741089

1075-
// Now test with the full set of new events
1076-
// We expect the orchestration to complete
1077-
const executor = new OrchestrationExecutor(registry, testLogger);
1078-
const result = await executor.execute(TEST_INSTANCE_ID, oldEvents, newEvents);
1090+
const result = await new OrchestrationExecutor(registry, testLogger).execute(
1091+
TEST_INSTANCE_ID,
1092+
[...oldEvents, ...partialEvents],
1093+
remainingEvents,
1094+
);
10791095

10801096
const completeAction = getAndValidateSingleCompleteOrchestrationAction(result);
10811097
expect(completeAction?.getOrchestrationstatus()).toEqual(pb.OrchestrationStatus.ORCHESTRATION_STATUS_FAILED);
1082-
expect(completeAction?.getFailuredetails()?.getErrortype()).toEqual("TaskFailedError");
1098+
// whenAll failures are aggregated — even a single failure is wrapped in an AggregateError.
1099+
expect(completeAction?.getFailuredetails()?.getErrortype()).toEqual("AggregateError");
10831100
expect(completeAction?.getFailuredetails()?.getErrormessage()).toContain(ex.message);
10841101
});
10851102

@@ -1772,11 +1789,12 @@ describe("Orchestration Executor", () => {
17721789

17731790
const completeAction = getAndValidateSingleCompleteOrchestrationAction(result);
17741791
expect(completeAction?.getOrchestrationstatus()).toEqual(pb.OrchestrationStatus.ORCHESTRATION_STATUS_FAILED);
1775-
expect(completeAction?.getFailuredetails()?.getErrortype()).toEqual("TaskFailedError");
1792+
// whenAll aggregates failures into an AggregateError (always, even for one failure).
1793+
expect(completeAction?.getFailuredetails()?.getErrortype()).toEqual("AggregateError");
17761794
expect(completeAction?.getFailuredetails()?.getErrormessage()).toContain(ex.message);
17771795
});
17781796

1779-
it("should not crash when additional tasks complete after whenAll fails fast", async () => {
1797+
it("should complete whenAll as failed after all tasks finish when one task fails", async () => {
17801798
const printInt = (_: any, value: number) => {
17811799
return value.toString();
17821800
};
@@ -1802,7 +1820,8 @@ describe("Orchestration Executor", () => {
18021820
oldEvents.push(newTaskScheduledEvent(i + 1, activityName, i.toString()));
18031821
}
18041822

1805-
// First task fails, then remaining tasks complete in the same batch
1823+
// First task fails; the remaining tasks complete in the same batch. Under wait-all the
1824+
// whenAll completes as failed only once every task is terminal, aggregating the failure.
18061825
const ex = new Error("First task failed");
18071826
const newEvents: any[] = [
18081827
newTaskFailedEvent(1, ex),
@@ -1815,7 +1834,7 @@ describe("Orchestration Executor", () => {
18151834

18161835
const completeAction = getAndValidateSingleCompleteOrchestrationAction(result);
18171836
expect(completeAction?.getOrchestrationstatus()).toEqual(pb.OrchestrationStatus.ORCHESTRATION_STATUS_FAILED);
1818-
expect(completeAction?.getFailuredetails()?.getErrortype()).toEqual("TaskFailedError");
1837+
expect(completeAction?.getFailuredetails()?.getErrortype()).toEqual("AggregateError");
18191838
expect(completeAction?.getFailuredetails()?.getErrormessage()).toContain(ex.message);
18201839
});
18211840

@@ -1849,7 +1868,8 @@ describe("Orchestration Executor", () => {
18491868
oldEvents.push(newTaskScheduledEvent(i + 1, activityName, i.toString()));
18501869
}
18511870

1852-
// First task fails, then remaining tasks complete in the same batch
1871+
// First task fails; the remaining tasks complete in the same batch. The failure is caught
1872+
// by the orchestrator once the whenAll completes (as failed) after all tasks are terminal.
18531873
const ex = new Error("One task failed");
18541874
const newEvents: any[] = [
18551875
newTaskFailedEvent(1, ex),
@@ -1865,6 +1885,62 @@ describe("Orchestration Executor", () => {
18651885
expect(completeAction?.getResult()?.getValue()).toEqual(JSON.stringify("handled"));
18661886
});
18671887

1888+
it("should keep waiting until all whenAll siblings fail across separate event batches (issue #301)", async () => {
1889+
// Regression for issue #301: when a whenAll fan-out has multiple failing siblings whose
1890+
// TaskFailed events arrive in separate batches, the whenAll must NOT go terminal on the
1891+
// first failure. If it did, the orchestration would go terminal early and the later
1892+
// sibling's TaskFailed would be dropped against a terminal instance, leaving a bare
1893+
// TaskScheduled with no completion — which is what deadlocks on rewind.
1894+
const printInt = (_: any, value: number) => value.toString();
1895+
1896+
const orchestrator: TOrchestrator = async function* (ctx: OrchestrationContext): any {
1897+
const a = ctx.callActivity(printInt, 0);
1898+
const b = ctx.callActivity(printInt, 1);
1899+
return yield whenAll([a, b]);
1900+
};
1901+
1902+
const registry = new Registry();
1903+
const orchestratorName = registry.addOrchestrator(orchestrator);
1904+
const activityName = registry.addActivity(printInt);
1905+
1906+
const scheduledHistory = [
1907+
newOrchestratorStartedEvent(),
1908+
newExecutionStartedEvent(orchestratorName, TEST_INSTANCE_ID),
1909+
newTaskScheduledEvent(1, activityName, "0"),
1910+
newTaskScheduledEvent(2, activityName, "1"),
1911+
];
1912+
1913+
const firstFailure = new Error("first activity failed");
1914+
const secondFailure = new Error("second activity failed");
1915+
1916+
// Batch 1: only the first sibling fails. The whenAll is still waiting on the second
1917+
// sibling, so the orchestration must NOT emit any complete action.
1918+
const batch1 = await new OrchestrationExecutor(registry, testLogger).execute(
1919+
TEST_INSTANCE_ID,
1920+
scheduledHistory,
1921+
[newTaskFailedEvent(1, firstFailure)],
1922+
);
1923+
expect(batch1.actions.some((a) => a.hasCompleteorchestration())).toBe(false);
1924+
expect(batch1.actions.length).toEqual(0);
1925+
1926+
// Batch 2: the second sibling fails too (delivered in a later batch, with batch 1 now part
1927+
// of the replayed history). Only now does the whenAll complete — as failed, aggregating BOTH
1928+
// sibling failures into an AggregateError whose message inlines every child message.
1929+
const batch2 = await new OrchestrationExecutor(registry, testLogger).execute(
1930+
TEST_INSTANCE_ID,
1931+
[...scheduledHistory, newTaskFailedEvent(1, firstFailure)],
1932+
[newTaskFailedEvent(2, secondFailure)],
1933+
);
1934+
1935+
const completeAction = getAndValidateSingleCompleteOrchestrationAction(batch2);
1936+
expect(completeAction?.getOrchestrationstatus()).toEqual(pb.OrchestrationStatus.ORCHESTRATION_STATUS_FAILED);
1937+
expect(completeAction?.getFailuredetails()?.getErrortype()).toEqual("AggregateError");
1938+
// BOTH siblings' failures are captured (proving neither TaskFailed was dropped): the
1939+
// aggregate message inlines every child message.
1940+
expect(completeAction?.getFailuredetails()?.getErrormessage()).toContain("first activity failed");
1941+
expect(completeAction?.getFailuredetails()?.getErrormessage()).toContain("second activity failed");
1942+
});
1943+
18681944
it("should complete nested whenAny(whenAll, whenAll) when first inner group finishes", async () => {
18691945
const hello = (_: any, name: string) => {
18701946
return `Hello ${name}!`;
@@ -2036,14 +2112,15 @@ describe("Orchestration Executor", () => {
20362112
expect(completeAction?.getFailuredetails()?.getErrormessage()).toContain("non-Task");
20372113
});
20382114

2039-
it("should propagate inner whenAll failure to outer whenAny in nested composites", async () => {
2115+
it("should propagate inner whenAll failure to outer whenAny only after all whenAll children finish", async () => {
20402116
const hello = (_: any, name: string) => {
20412117
return `Hello ${name}!`;
20422118
};
20432119

20442120
// Orchestrator: yield whenAny([whenAll([a, b])])
2045-
// If an inner task fails, the whenAll should fail-fast and notify the outer whenAny.
2046-
// WhenAny completes with the failed task — the orchestrator inspects the winner.
2121+
// Under wait-all, the inner whenAll does NOT fail-fast: it completes (as failed) only once
2122+
// BOTH children are terminal, and only then notifies the outer whenAny. So the outer whenAny
2123+
// completes strictly later than it would have under fail-fast.
20472124
const orchestrator: TOrchestrator = async function* (ctx: OrchestrationContext): any {
20482125
const group = [ctx.callActivity(hello, "a"), ctx.callActivity(hello, "b")];
20492126
const winner: Task<string[]> = yield whenAny([whenAll(group)]);
@@ -2061,12 +2138,24 @@ describe("Orchestration Executor", () => {
20612138
newTaskScheduledEvent(2, activityName, JSON.stringify("b")),
20622139
];
20632140

2064-
// Task 1 fails — whenAll should fail-fast, and outer whenAny should complete
20652141
const ex = new Error("task a failed");
2066-
const completionEvents = [newTaskFailedEvent(1, ex)];
20672142

2068-
const executor = new OrchestrationExecutor(registry, testLogger);
2069-
const result = await executor.execute(TEST_INSTANCE_ID, replayEvents, completionEvents);
2143+
// Task 1 fails but task 2 is still outstanding: the inner whenAll must keep waiting, so the
2144+
// outer whenAny — and therefore the orchestration — does NOT complete yet (no complete action).
2145+
const waitingResult = await new OrchestrationExecutor(registry, testLogger).execute(
2146+
TEST_INSTANCE_ID,
2147+
replayEvents,
2148+
[newTaskFailedEvent(1, ex)],
2149+
);
2150+
expect(waitingResult.actions.length).toEqual(0);
2151+
2152+
// Task 2 now completes: the inner whenAll completes as failed and notifies the outer whenAny,
2153+
// which resolves with the failed whenAll as its winner. The orchestrator inspects the winner.
2154+
const result = await new OrchestrationExecutor(registry, testLogger).execute(
2155+
TEST_INSTANCE_ID,
2156+
[...replayEvents, newTaskFailedEvent(1, ex)],
2157+
[newTaskCompletedEvent(2, JSON.stringify(hello(null, "b")))],
2158+
);
20702159

20712160
const completeAction = getAndValidateSingleCompleteOrchestrationAction(result);
20722161
expect(completeAction?.getOrchestrationstatus()).toEqual(pb.OrchestrationStatus.ORCHESTRATION_STATUS_COMPLETED);

packages/durabletask-js/test/task.spec.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -313,9 +313,15 @@ describe("CompletableTask", () => {
313313

314314
child.fail("child failed");
315315

316-
// WhenAllTask fails fast on first child failure
316+
// WhenAll with a single child: once that child fails, all children are terminal, so the
317+
// WhenAll completes (as failed) and notifies its parent. The single failure is still wrapped
318+
// in an AggregateError (whenAll always aggregates), carrying the child's TaskFailedError.
317319
expect(parent.isComplete).toBe(true);
318320
expect(parent.isFailed).toBe(true);
321+
const err = parent.getException();
322+
expect(err).toBeInstanceOf(AggregateError);
323+
expect((err as AggregateError).errors).toHaveLength(1);
324+
expect((err as AggregateError).errors[0]).toBeInstanceOf(TaskFailedError);
319325
});
320326

321327
it("should fail without error when no parent is set", () => {

0 commit comments

Comments
 (0)