Skip to content

Commit 3053ef4

Browse files
authored
Fix when_all to wait for all tasks before surfacing failures (#173)
* Fix when_all to wait for all tasks before surfacing failures when_all previously failed fast on the first child task failure. It now waits for every child task to complete before surfacing the first failure, matching the semantics of .NET's Task.WhenAll. * Fix is_failed guard by storing exception * Constructor ordering to prevent AttributeError * Update CHANGELOG.md
1 parent 5722cd4 commit 3053ef4

3 files changed

Lines changed: 106 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ FIXED
1919
- Fixed the orchestration version being set from an unset `executionStarted.version` field. Presence is now checked with `HasField`, so `OrchestrationContext` no longer reports a version when none was provided.
2020
- Fixed orchestrations failing with `OrchestrationStateError` when a `genericEvent` history event was replayed (for example, the marker the Durable Functions extension appends when rewinding an orchestration). Such informational events are now ignored during replay, matching the .NET worker.
2121
- Fixed a lock-granted entity response over the legacy entity protocol raising while trying to deserialize an empty operation result. Lock-granted events no longer attempt result deserialization.
22+
- Fixed `task.when_all()` failing fast when one of its child tasks failed. It now waits for every child task to complete before surfacing the first failure, matching the semantics of .NET's `Task.WhenAll`.
2223

2324
## v1.7.2
2425

durabletask/task.py

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -531,6 +531,8 @@ def get_exception(self) -> TaskFailedError:
531531
class CompositeTask(Task[T]):
532532
"""A task that is composed of other tasks."""
533533
_tasks: list[Task[Any]]
534+
_completed_tasks: int
535+
_failed_tasks: int
534536

535537
def __init__(self, tasks: list[Task[Any]]):
536538
super().__init__()
@@ -554,9 +556,14 @@ class WhenAllTask(CompositeTask[list[T]]):
554556
"""A task that completes when all of its child tasks complete."""
555557

556558
def __init__(self, tasks: list[Task[T]]):
559+
# Initialize state that on_child_completed() reads BEFORE invoking the
560+
# base constructor: CompositeTask.__init__ calls on_child_completed()
561+
# for any children that are already complete, so `_pending_exception`
562+
# must exist first. The base constructor also initializes
563+
# `_completed_tasks`/`_failed_tasks` to 0 and then accounts for
564+
# pre-completed children, so they must not be reset afterwards.
565+
self._pending_exception: TaskFailedError | None = None
557566
super().__init__(cast(list[Task[Any]], tasks))
558-
self._completed_tasks = 0
559-
self._failed_tasks = 0
560567

561568
@property
562569
def pending_tasks(self) -> int:
@@ -567,13 +574,26 @@ def on_child_completed(self, task: Task[Any]) -> None:
567574
if self.is_complete:
568575
raise ValueError('The task has already completed.')
569576
self._completed_tasks += 1
570-
if task.is_failed and self._exception is None:
571-
self._exception = task.get_exception()
572-
self._is_complete = True
577+
if task.is_failed:
578+
self._failed_tasks += 1
579+
if self._pending_exception is None:
580+
# Stage the first failure but do NOT expose it via `_exception`
581+
# yet. Exposing it now would make `is_failed` return True while
582+
# `is_complete` is still False, diverging from .NET's
583+
# Task.WhenAll (which does not fault until all children finish).
584+
self._pending_exception = task.get_exception()
573585
if self._completed_tasks == len(self._tasks):
574-
# The order of the result MUST match the order of the tasks provided to the constructor.
575-
self._result = [child.get_result() for child in self._tasks]
586+
# Only complete once every child task has completed. This matches the
587+
# semantics of .NET's Task.WhenAll: the composite task waits for all
588+
# children to finish and, if any failed, surfaces the first failure
589+
# rather than failing fast on the first error.
576590
self._is_complete = True
591+
if self._pending_exception is not None:
592+
self._exception = self._pending_exception
593+
else:
594+
# The order of the result MUST match the order of the tasks
595+
# provided to the constructor.
596+
self._result = [child.get_result() for child in self._tasks]
577597

578598
def get_completed_tasks(self) -> int:
579599
return self._completed_tasks

tests/durabletask/test_orchestration_executor.py

Lines changed: 78 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1615,15 +1615,27 @@ def orchestrator(ctx: task.OrchestrationContext, _):
16151615
i + 1, activity_name, encoded_input=str(i)))
16161616

16171617
# 5 of the tasks complete successfully, 1 fails, and 4 are still running.
1618-
# The expectation is that the orchestration will fail immediately.
1619-
new_events = []
1618+
# when_all must NOT fail fast: it waits for every child task to complete
1619+
# before surfacing a failure (matching .NET's Task.WhenAll semantics), so
1620+
# the orchestration is expected to still be running with zero new actions.
1621+
ex = Exception("Kah-BOOOOM!!!")
1622+
partial_events = []
16201623
for i in range(5):
1624+
partial_events.append(helpers.new_task_completed_event(
1625+
i + 1, encoded_output=print_int(None, i)))
1626+
partial_events.append(helpers.new_task_failed_event(6, ex))
1627+
1628+
executor = worker._OrchestrationExecutor(registry, TEST_LOGGER, JsonDataConverter())
1629+
result = executor.execute(TEST_INSTANCE_ID, old_events, partial_events)
1630+
assert len(result.actions) == 0
1631+
1632+
# Once the remaining 4 tasks also complete, the orchestration fails and
1633+
# surfaces the first task failure.
1634+
new_events = list(partial_events)
1635+
for i in range(6, 10):
16211636
new_events.append(helpers.new_task_completed_event(
16221637
i + 1, encoded_output=print_int(None, i)))
1623-
ex = Exception("Kah-BOOOOM!!!")
1624-
new_events.append(helpers.new_task_failed_event(6, ex))
16251638

1626-
# Now test with the full set of new events. We expect the orchestration to complete.
16271639
executor = worker._OrchestrationExecutor(registry, TEST_LOGGER, JsonDataConverter())
16281640
result = executor.execute(TEST_INSTANCE_ID, old_events, new_events)
16291641
actions = result.actions
@@ -1634,6 +1646,67 @@ def orchestrator(ctx: task.OrchestrationContext, _):
16341646
assert str(ex) in complete_action.failureDetails.errorMessage
16351647

16361648

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+
1683+
def test_when_all_handles_pre_completed_children():
1684+
"""when_all must account for children that are already complete/failed.
1685+
1686+
CompositeTask.__init__ invokes on_child_completed() for children that are
1687+
already complete, so when_all must be constructible from an already-failed
1688+
child without raising (regression test for AttributeError on
1689+
`_pending_exception`).
1690+
"""
1691+
# An already-failed child plus a still-pending child.
1692+
failed = task.CompletableTask()
1693+
failed.fail("boom", Exception("boom"))
1694+
pending = task.CompletableTask()
1695+
1696+
when_all = task.when_all([failed, pending])
1697+
1698+
# The pre-completed failure is accounted for but not yet surfaced.
1699+
assert when_all.get_completed_tasks() == 1
1700+
assert not when_all.is_complete
1701+
assert not when_all.is_failed
1702+
1703+
# Completing the remaining child finishes the composite and surfaces the
1704+
# first failure.
1705+
pending.complete("ok")
1706+
assert when_all.is_complete
1707+
assert when_all.is_failed
1708+
1709+
16371710
def test_when_any():
16381711
"""Tests that a when_any pattern works correctly"""
16391712
def hello(_, name: str):

0 commit comments

Comments
 (0)