Skip to content

Commit 3a890a9

Browse files
committed
Fix is_failed guard by storing exception
1 parent 64b688a commit 3a890a9

2 files changed

Lines changed: 44 additions & 3 deletions

File tree

durabletask/task.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -552,6 +552,7 @@ def __init__(self, tasks: list[Task[T]]):
552552
super().__init__(cast(list[Task[Any]], tasks))
553553
self._completed_tasks = 0
554554
self._failed_tasks = 0
555+
self._pending_exception: TaskFailedError | None = None
555556

556557
@property
557558
def pending_tasks(self) -> int:
@@ -564,15 +565,21 @@ def on_child_completed(self, task: Task[Any]) -> None:
564565
self._completed_tasks += 1
565566
if task.is_failed:
566567
self._failed_tasks += 1
567-
if self._exception is None:
568-
self._exception = task.get_exception()
568+
if self._pending_exception is None:
569+
# Stage the first failure but do NOT expose it via `_exception`
570+
# yet. Exposing it now would make `is_failed` return True while
571+
# `is_complete` is still False, diverging from .NET's
572+
# Task.WhenAll (which does not fault until all children finish).
573+
self._pending_exception = task.get_exception()
569574
if self._completed_tasks == len(self._tasks):
570575
# Only complete once every child task has completed. This matches the
571576
# semantics of .NET's Task.WhenAll: the composite task waits for all
572577
# children to finish and, if any failed, surfaces the first failure
573578
# rather than failing fast on the first error.
574579
self._is_complete = True
575-
if self._exception is None:
580+
if self._pending_exception is not None:
581+
self._exception = self._pending_exception
582+
else:
576583
# The order of the result MUST match the order of the tasks
577584
# provided to the constructor.
578585
self._result = [child.get_result() for child in self._tasks]

tests/durabletask/test_orchestration_executor.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1646,6 +1646,40 @@ def orchestrator(ctx: task.OrchestrationContext, _):
16461646
assert str(ex) in complete_action.failureDetails.errorMessage
16471647

16481648

1649+
def test_when_all_defers_failure_until_all_children_complete():
1650+
"""when_all must not report is_failed until every child task completes.
1651+
1652+
This mirrors .NET's Task.WhenAll, where the returned task does not fault
1653+
(failed => complete) until all children have finished. A composite that
1654+
exposed is_failed while still incomplete would surprise consumers that
1655+
assume failed implies complete.
1656+
"""
1657+
t1 = task.CompletableTask()
1658+
t2 = task.CompletableTask()
1659+
t3 = task.CompletableTask()
1660+
when_all = task.when_all([t1, t2, t3])
1661+
1662+
assert not when_all.is_complete
1663+
assert not when_all.is_failed
1664+
1665+
# First child fails: the composite must stay pending and must NOT report
1666+
# failure yet.
1667+
t1.fail("boom", Exception("boom"))
1668+
assert not when_all.is_complete
1669+
assert not when_all.is_failed
1670+
1671+
# A later child completes successfully: still pending, still not failed.
1672+
t2.complete("ok")
1673+
assert not when_all.is_complete
1674+
assert not when_all.is_failed
1675+
1676+
# Once the final child completes, the composite completes and only then
1677+
# surfaces the first failure.
1678+
t3.complete("ok")
1679+
assert when_all.is_complete
1680+
assert when_all.is_failed
1681+
1682+
16491683
def test_when_any():
16501684
"""Tests that a when_any pattern works correctly"""
16511685
def hello(_, name: str):

0 commit comments

Comments
 (0)