Skip to content

Commit e23ed70

Browse files
committed
Constructor ordering to prevent AttributeError
1 parent 3a890a9 commit e23ed70

2 files changed

Lines changed: 36 additions & 3 deletions

File tree

durabletask/task.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -526,6 +526,8 @@ def get_exception(self) -> TaskFailedError:
526526
class CompositeTask(Task[T]):
527527
"""A task that is composed of other tasks."""
528528
_tasks: list[Task[Any]]
529+
_completed_tasks: int
530+
_failed_tasks: int
529531

530532
def __init__(self, tasks: list[Task[Any]]):
531533
super().__init__()
@@ -549,10 +551,14 @@ class WhenAllTask(CompositeTask[list[T]]):
549551
"""A task that completes when all of its child tasks complete."""
550552

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

557563
@property
558564
def pending_tasks(self) -> int:

tests/durabletask/test_orchestration_executor.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1680,6 +1680,33 @@ def test_when_all_defers_failure_until_all_children_complete():
16801680
assert when_all.is_failed
16811681

16821682

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+
16831710
def test_when_any():
16841711
"""Tests that a when_any pattern works correctly"""
16851712
def hello(_, name: str):

0 commit comments

Comments
 (0)