Fix #301: WhenAll wait-all + aggregate-exception semantics (root cause of rewind deadlock)#302
Merged
Merged
Conversation
WhenAllTask completed on the FIRST child failure (fail-fast). On Azure Storage, rewind is driven by TaskFailed history events. With 2+ failing siblings in a whenAll fan-out, the orchestration went terminal after only the first TaskFailed; later failing siblings' TaskFailed events hit the now-terminal instance and were dropped as late messages, so they were never committed to history. Those siblings kept a bare TaskScheduled with no completion. On rewind, the worker's handleTaskScheduled pops the pending action expecting a completion that never arrives -> the whenAll waits forever -> 30s deadlock (signature: Waiting for 2 -> Returning 1). Fix: change WhenAllTask.onChildCompleted to wait-all-then-fail, matching .NET Task.WhenAll and Java CompletableFuture.allOf. The WhenAll now completes only after ALL children reach a terminal state. If any child failed it completes as failed, surfacing the FIRST failure (by task order, mirroring what await Task.WhenAll rethrows); otherwise it builds the result array. The parent is notified only at completion. Implementation note: _exception is intentionally not set until the task is complete. isFailed is (_exception != undefined), and resume() checks isFailed before isComplete; setting it early would throw the failure into the generator before the other siblings finish, re-introducing fail-fast at the executor level. Behavior change (user-visible): a whenAll failure now surfaces only after all siblings finish. Existing tests that assumed fail-fast were updated. Root-cause replacement for the now-closed band-aid PR #300. Non-goals / follow-ups: no AggregateException (first-exception behavior is preserved, as in C#); durabletask-python has the same fail-fast divergence and needs the same fix separately. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Contributor
There was a problem hiding this comment.
Pull request overview
This PR changes the core WhenAllTask semantics in the durabletask-js orchestration task model from fail-fast (complete on first child failure) to wait-all-then-fail (complete only after all children are terminal, surfacing the first failure if any). This aligns behavior with .NET/Java WhenAll and addresses the rewind deadlock described in issue #301 by preventing later sibling failure events from being dropped against a terminal orchestration.
Changes:
- Update
WhenAllTask.onChildCompletedto wait for all child tasks to reach a terminal state before completing, and to surface the first failure by task order (if any). - Update and expand unit tests to validate wait-all semantics, parent notification behavior, and the multi-batch failure regression scenario for #301.
- Adjust test comments/documentation to reflect the new semantics (including the single-child WhenAll case).
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
| packages/durabletask-js/src/task/when-all-task.ts | Implements wait-all-then-fail completion semantics and surfaces first failure only at completion time. |
| packages/durabletask-js/test/when-all-task.spec.ts | Updates existing tests and adds new cases to assert wait-all behavior and first-failure surfacing. |
| packages/durabletask-js/test/orchestration_executor.spec.ts | Updates executor-level tests to validate orchestration behavior across event batches under wait-all semantics (issue #301 regression). |
| packages/durabletask-js/test/task.spec.ts | Updates commentary to correctly describe single-child WhenAll behavior under wait-all semantics. |
Follow-up to the wait-all fix (#301): now that WhenAll waits for every child to reach a terminal state, surface ALL child failures rather than only the first. WhenAllTask.onChildCompleted collects every failed child in task-array order and completes with a native JS AggregateError whose .errors carries each child exception (each a TaskFailedError with its .details). Design decisions: - Native AggregateError (ES2021; repo targets ES2022 + node>=22) — no custom exception class. - Always wrap, even for a single failure — matching .NET AggregateException and Java CompositeTaskFailedException (durabletask-java allOf unconditionally throws CompositeTaskFailedException even for one failure). - Inline every child message into the AggregateError's top-level message. newFailureDetails() only serializes e.message (not .errors), so an UNCAUGHT whenAll failure would otherwise lose per-child detail; inlining ensures every child's message is recorded. .errors still carries the structured exceptions for the caught case. - Order .errors by the task array (not arrival order) so the aggregate is deterministic across replay. Invariant preserved: _exception is still set ONLY at completion, so isFailed and isComplete flip together and resume() cannot throw into the generator before all siblings finish (the whole reason the #301 fix works). Behavior change: whenAll failures are now AggregateError-wrapped (previously surfaced a single exception). Updated all tests that assert a whenAll failure's type/message accordingly, and added a determinism test proving .errors follows task-array order regardless of failure arrival order. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Trim the onChildCompleted comments in when-all-task.ts to remove cross-SDK references from code comments (logic byte-identical). Add a real azure-managed/DTS-emulator E2E test proving whenAll aggregation survives the full round trip (orchestrator throw -> newFailureDetails serialization -> gRPC -> backend -> client state.failureDetails): both child messages and errorType 'AggregateError' appear end-to-end. De-stale the existing 'fail-fast' whenAll E2E test wording to wait-all and assert the aggregate errorType on the uncaught single-failure test. Refs #301 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
kaibocai
approved these changes
Jul 14, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
WhenAllTaskcompleted on the first child failure (fail-fast). This changes it to wait-all-then-aggregate — the WhenAll now completes only after all children reach a terminal state, and if any child failed it surfaces a nativeAggregateErrorcarrying every child failure. This matches .NETTask.WhenAll/AggregateExceptionand JavaCompletableFuture.allOf/CompositeTaskFailedException, and is the root-cause fix for #301.Fixes #301
Root cause
On Azure Storage,
rewindis server-side and driven byTaskFailedhistory events. When awhenAllfan-out has 2+ failing siblings:WhenAllTaskcompleted on the first child failure (fail-fast), so the orchestration went terminal after only the firstTaskFailedwas processed.TaskFailedevents then arrived at the now-terminal instance and were dropped as late messages — so they were never committed to history.TaskScheduledwith no completion.handleTaskScheduledpops the pending action (assuming a completion will arrive), but after rewind none ever does → thewhenAllwaits forever → 30s deadlock / timeout.Worker signature:
Waiting for 2 -> Returning 1.The fix (wait-all-then-aggregate)
packages/durabletask-js/src/task/when-all-task.ts→onChildCompleted:_completedTasks == _tasks.length) mark the WhenAll complete.Completing only when all siblings are terminal is exactly what prevents later failing siblings'
TaskFailedevents from being dropped against an already-terminal instance.Exception aggregation
When one or more children fail, the WhenAll now completes with a native JS
AggregateError:.errorscarries every failed child's exception, in task-array order (deterministic across replay — independent of the order failures arrive). Each entry is the child'sTaskFailedError(with its.details).AggregateExceptionand JavaCompositeTaskFailedException(durabletask-java'sTaskOrchestrationExecutor.allOfunconditionally throwsCompositeTaskFailedExceptioneven for a single failure)."{n} of {m} tasks in the whenAll failed: msg1; msg2; …"). Rationale: the serializerpb-helper.util.ts::newFailureDetailsonly readse.name/e.message/e.stack— it does not walk.errors. So for an uncaught whenAll failure, onlye.messagesurvives into the orchestrationFailureDetails. Inlining ensures an uncaught failure still records every child's message;.errorsstill carries the structured exceptions for the caught case.AggregateError(ES2021) is used directly — the repo targets ES2022 + node>=22, so it's available and typed. No custom exception class.Implementation note (subtle but important)
_exceptionis intentionally not set until the task is complete.Task.isFailedis(_exception != undefined), andruntime-orchestration-context.ts::resume()checksisFailedbeforeisComplete. Setting_exceptionon the first failure would make the WhenAll reportisFailed === truewhileisComplete === false, andresume()would throw the failure into the generator before the other siblings finish — re-introducing fail-fast at the executor level and breaking the two-batch regression test. Instead the aggregate is built at completion, soisFailedandisCompleteflip together. This keeps the entire fix in one source file with no new class field (which would also be wiped byuseDefineForClassFieldsaftersuper()).Production-code safety
The WhenAll
_exceptionis consumed only byresume()→generator.throw()→ user code. A grep ofsrc/found noinstanceof TaskFailedErrorand no code that assumes a whenAll surfaces a single exception. Retry logic (tryHandleRetry→RetryableTask/RetryHandlerTask) operates on the individual childCompletableTaskinhandleFailedTaskbefore the whenAll aggregates;WhenAllTaskis aCompositeTaskand never enters retry. Blast radius is user catch blocks + tests only.Cross-SDK parity evidence
Task.WhenAll/AggregateExceptionCompletableFuture.allOf/CompositeTaskFailedException.NET and Java both wait for all siblings before surfacing a failure and aggregate all failures, so every sibling's completion is committed to history and rewind has no orphaned
TaskScheduled. This PR aligns durabletask-js with that behavior.Relationship to PR #300
This is the root-cause replacement for the now-closed band-aid PR #300. The fix stands alone in
when-all-task.ts— no archive/revive/isRewindRevival/_handledTaskScheduledIdsmachinery is introduced.Behavior change (user-visible)
whenAllfailure now surfaces only after all siblings finish (previously on the first failure).AggregateErrorwrapping all child failures (previously a single exception). Even a single failure is wrapped.Existing tests updated
when-all-task.spec.ts— "should fail fast when any child fails" → "should wait for all children before surfacing a failure"AggregateError.when-all-task.spec.ts— "should fail immediately when a pre-completed child is failed" → "should not surface a pre-failed child while a sibling is still pending"AggregateError.when-all-task.spec.ts— "should not double-complete when child completes after fail-fast" → "should notify the parent exactly once, only after all children complete"AggregateError.when-all-task.spec.ts— "...surfaced exception is the FIRST (object-identity)" → "should aggregate a single failure (always wrapped)..."AggregateError,.errors.length === 1,.errors[0]is the failed child's exception (object identity).when-all-task.spec.ts— "should surface the first failure (not an aggregate)..." → "should aggregate ALL failures in task-array order when multiple children fail"AggregateError,.errors.length === 2,.errors[0]/[1]are the two failures in task-array order (object identity), message contains both.orchestration_executor.spec.ts— "fan-in completes as failed only after all tasks finish when one fails"AggregateError(message still contains the single failure).orchestration_executor.spec.ts— "should fail whenAll correctly when the failing task is the last to complete"AggregateError.orchestration_executor.spec.ts— "should complete whenAll as failed after all tasks finish when one task fails"AggregateError.orchestration_executor.spec.ts— "should keep waiting until all whenAll siblings fail across separate event batches (#301)"AggregateError; the message must now contain both "first activity failed" and "second activity failed" (proving neitherTaskFailedwas dropped).task.spec.ts— "should notify parent via onChildCompleted when parent is set"AggregateErrorwith oneTaskFailedErrorin.errors.New tests added
when-all-task.spec.ts: single-failure-always-wraps (.errors.length === 1); multi-failure aggregates all in task-array order; determinism test — children fail out of array order (child[2]thenchild[0]) and.errorsstill follows task-array order (child[0]beforechild[2]), proving order is deterministic w.r.t. the task array, not arrival/wall-clock.orchestration_executor.spec.ts— "should keep waiting until all whenAll siblings fail across separate event batches (Rewind after a multi-failed-sibling WhenAll deadlocks the UNSIGNALED sibling subtree (emit-once replay can't reconcile a re-driven orphan with no rewind signal) #301)":whenAll([a, b])where both fail across two batches. After only the firstTaskFailed: no complete action (still waiting). After the second: completes as failed with anAggregateErrorwhose message contains both failures. This is the crux that prevents the dropped-sibling → rewind orphan.Non-goals / follow-ups
Validation
npm run build:core✅npm run lint✅npm test✅ — 63 suites, 1124 tests pass