Skip to content

Commit 0d04bbc

Browse files
dapr-botacrocayaron2
authored
when_all waits for all tasks before surfacing the first failure (#1166) (#1175)
Previously a WhenAllTask completed as soon as one child failed, while sibling tasks were still running and their later completions were dropped. Now the composite stages the first failure and keeps waiting; it completes only once every child has finished, then fails with the first recorded exception (or returns the ordered results if none failed). (cherry picked from commit 47f0f23) Signed-off-by: Albert Callarisa <albert@diagrid.io> Signed-off-by: dapr-bot <dapr-bot@users.noreply.github.com> Co-authored-by: Albert Callarisa <albert@diagrid.io> Co-authored-by: Yaron Schneider <schneider.yaron@live.com>
1 parent 9437ebc commit 0d04bbc

4 files changed

Lines changed: 100 additions & 36 deletions

File tree

ext/dapr-ext-workflow/dapr/ext/workflow/_durabletask/task.py

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -405,11 +405,17 @@ def on_child_completed(self, task: Task[T]):
405405

406406

407407
class WhenAllTask(CompositeTask[list[T]]):
408-
"""A task that completes when all of its child tasks complete."""
408+
"""A task that completes once all of its child tasks complete.
409+
410+
If any child fails, the composite still waits for every child to finish
411+
and then fails with the first failure.
412+
"""
409413

410414
def __init__(self, tasks: list[Task[T]]):
411-
# CompositeTask.__init__ already counted pre-completed children; do not reset
415+
# CompositeTask.__init__ replays pre-completed children into on_child_completed,
416+
# so _pending_exception must exist before it runs; likewise do not reset
412417
# _completed_tasks after it, or a deferred when_all over them hangs forever.
418+
self._pending_exception: Optional[Exception] = None
413419
super().__init__(tasks)
414420
# If there are no child tasks, this composite should complete immediately
415421
if len(self._tasks) == 0:
@@ -423,20 +429,22 @@ def pending_tasks(self) -> int:
423429

424430
def on_child_completed(self, task: Task[T]):
425431
if self.is_complete:
426-
# Already completed (e.g. a previous child failed), ignore late arrivals
427432
return
428433
self._completed_tasks += 1
429-
if task.is_failed and self._exception is None:
430-
self._exception = task.get_exception()
431-
self._is_complete = True
432-
if self._parent is not None:
433-
self._parent.on_child_completed(self)
434-
elif self._completed_tasks == len(self._tasks):
434+
if task.is_failed and self._pending_exception is None:
435+
# Stage the first failure without exposing it via _exception yet:
436+
# that would make is_failed True while is_complete is still False.
437+
self._pending_exception = task.get_exception()
438+
if self._completed_tasks < len(self._tasks):
439+
return
440+
self._is_complete = True
441+
if self._pending_exception is not None:
442+
self._exception = self._pending_exception
443+
else:
435444
# The order of the result MUST match the order of the tasks provided to the constructor.
436445
self._result = [task.get_result() for task in self._tasks]
437-
self._is_complete = True
438-
if self._parent is not None:
439-
self._parent.on_child_completed(self)
446+
if self._parent is not None:
447+
self._parent.on_child_completed(self)
440448

441449
def get_completed_tasks(self) -> int:
442450
return self._completed_tasks
@@ -629,7 +637,8 @@ def on_child_completed(self, completed_task: Task):
629637

630638

631639
def when_all(tasks: list[Task[T]]) -> WhenAllTask[T]:
632-
"""Returns a task that completes when all of the provided tasks complete or when one of the tasks fail."""
640+
"""Returns a task that completes once all of the provided tasks complete,
641+
surfacing the first failure (if any) only after every task has finished."""
633642
return WhenAllTask(tasks)
634643

635644

ext/dapr-ext-workflow/dapr/ext/workflow/dapr_workflow_context.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,8 +172,8 @@ def is_patched(self, patch_name: str) -> bool:
172172

173173

174174
def when_all(tasks: List[task.Task[T]]) -> task.WhenAllTask[T]:
175-
"""Returns a task that completes when all of the provided tasks complete or when one of the
176-
tasks fail."""
175+
"""Returns a task that completes once all of the provided tasks complete,
176+
surfacing the first failure (if any) only after every task has finished."""
177177
return task.when_all(tasks)
178178

179179

ext/dapr-ext-workflow/tests/durabletask/test_orchestration_executor.py

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1204,16 +1204,29 @@ def orchestrator(ctx: task.OrchestrationContext, _):
12041204
)
12051205

12061206
# 5 of the tasks complete successfully, 1 fails, and 4 are still running.
1207-
# The expectation is that the orchestration will fail immediately.
1208-
new_events = []
1207+
# when_all waits for every child task to complete before surfacing the
1208+
# failure, so the orchestration is expected to still be running with zero
1209+
# new actions.
1210+
ex = Exception('Kah-BOOOOM!!!')
1211+
partial_events = []
12091212
for i in range(5):
1213+
partial_events.append(
1214+
helpers.new_task_completed_event(i + 1, encoded_output=print_int(None, i))
1215+
)
1216+
partial_events.append(helpers.new_task_failed_event(6, ex))
1217+
1218+
executor = worker._OrchestrationExecutor(registry, TEST_LOGGER)
1219+
result = executor.execute(TEST_INSTANCE_ID, old_events, partial_events)
1220+
assert len(result.actions) == 0
1221+
1222+
# Once the remaining 4 tasks also complete, the orchestration fails and
1223+
# surfaces the first task failure.
1224+
new_events = list(partial_events)
1225+
for i in range(6, 10):
12101226
new_events.append(
12111227
helpers.new_task_completed_event(i + 1, encoded_output=print_int(None, i))
12121228
)
1213-
ex = Exception('Kah-BOOOOM!!!')
1214-
new_events.append(helpers.new_task_failed_event(6, ex))
12151229

1216-
# Now test with the full set of new events. We expect the orchestration to complete.
12171230
executor = worker._OrchestrationExecutor(registry, TEST_LOGGER)
12181231
result = executor.execute(TEST_INSTANCE_ID, old_events, new_events)
12191232
actions = result.actions
@@ -1277,13 +1290,8 @@ def orchestrator(ctx: task.OrchestrationContext, _):
12771290

12781291

12791292
def test_when_all_success_after_failure_does_not_crash():
1280-
"""Tests that task completions arriving after when_all already failed
1281-
do not crash the orchestration.
1282-
1283-
This is a regression test: previously a ValueError was raised when
1284-
a successful task completed after the WhenAllTask was already marked
1285-
complete due to a prior child failure.
1286-
"""
1293+
"""Tests that a success arriving after a failure completes the set and
1294+
surfaces the failure to the orchestrator where it can be caught."""
12871295

12881296
def dummy_activity(ctx, _):
12891297
pass

ext/dapr-ext-workflow/tests/durabletask/test_task.py

Lines changed: 56 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -267,34 +267,75 @@ def test_when_all_failure_after_success_still_reports_failure():
267267
all_task.get_result()
268268

269269

270-
def test_when_all_failure_before_success_still_reports_failure():
271-
"""When a child fails before the other children succeed,
272-
the WhenAllTask must complete with the failure immediately."""
270+
def test_when_all_defers_failure_until_all_children_complete():
271+
"""After a child failure the WhenAllTask keeps waiting for the remaining
272+
children; the first failure is surfaced once every child has completed."""
273273
c1 = task.CompletableTask()
274274
c2 = task.CompletableTask()
275+
c3 = task.CompletableTask()
275276

276-
all_task = task.when_all([c1, c2])
277+
all_task = task.when_all([c1, c2, c3])
277278

278-
# c1 fails first
279279
c1.fail('activity failed', _make_failure_details('activity failed'))
280280

281+
assert not all_task.is_complete
282+
assert not all_task.is_failed
283+
assert all_task.get_completed_tasks() == 1
284+
285+
c2.complete('two')
286+
287+
assert not all_task.is_complete
288+
assert not all_task.is_failed
289+
assert all_task.get_completed_tasks() == 2
290+
291+
c3.complete('three')
292+
281293
assert all_task.is_complete
282294
assert all_task.is_failed
295+
assert all_task.get_completed_tasks() == 3
283296
with pytest.raises(task.TaskFailedError):
284297
all_task.get_result()
285298

286-
# c2 succeeds after — must not raise ValueError
287-
c2.complete('two')
288299

289-
# WhenAllTask should still be in the same failed state
300+
def test_when_all_surfaces_first_failure_when_multiple_children_fail():
301+
"""When several children fail, the WhenAllTask reports the first failure."""
302+
c1 = task.CompletableTask()
303+
c2 = task.CompletableTask()
304+
305+
all_task = task.when_all([c1, c2])
306+
307+
c1.fail('first error', _make_failure_details('first error'))
308+
c2.fail('second error', _make_failure_details('second error'))
309+
310+
assert all_task.is_complete
311+
assert all_task.is_failed
312+
with pytest.raises(task.TaskFailedError, match='first error'):
313+
all_task.get_result()
314+
315+
316+
def test_when_all_with_pre_failed_child_waits_for_remaining():
317+
"""A child that already failed before construction is staged, not surfaced,
318+
until the remaining children complete."""
319+
failed_child = task.CompletableTask()
320+
failed_child.fail('activity failed', _make_failure_details('activity failed'))
321+
pending_child = task.CompletableTask()
322+
323+
all_task = task.when_all([failed_child, pending_child])
324+
325+
assert not all_task.is_complete
326+
assert not all_task.is_failed
327+
assert all_task.get_completed_tasks() == 1
328+
329+
pending_child.complete('ok')
330+
290331
assert all_task.is_complete
291332
assert all_task.is_failed
292333
with pytest.raises(task.TaskFailedError):
293334
all_task.get_result()
294335

295336

296337
def test_when_all_failure_propagates_to_parent():
297-
"""When a WhenAllTask fails due to a child failure,
338+
"""When a WhenAllTask fails after all children complete,
298339
it should notify its parent composite task."""
299340
c1 = task.CompletableTask()
300341
c2 = task.CompletableTask()
@@ -306,6 +347,12 @@ def test_when_all_failure_propagates_to_parent():
306347

307348
c1.fail('activity failed', _make_failure_details('activity failed'))
308349

350+
# The failure is staged until c2 completes, so neither composite is done yet
351+
assert not all_task.is_complete
352+
assert not any_task.is_complete
353+
354+
c2.complete('two')
355+
309356
assert all_task.is_complete
310357
assert all_task.is_failed
311358
# The parent WhenAnyTask should also have completed

0 commit comments

Comments
 (0)