Skip to content

Commit e46a179

Browse files
committed
Fix: when-all buggy error handling
Signed-off-by: Albert Callarisa <albert@diagrid.io>
1 parent ab2a3f4 commit e46a179

3 files changed

Lines changed: 181 additions & 2 deletions

File tree

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -360,12 +360,15 @@ def pending_tasks(self) -> int:
360360

361361
def on_child_completed(self, task: Task[T]):
362362
if self.is_complete:
363-
raise ValueError('The task has already completed.')
363+
# Already completed (e.g. a previous child failed), ignore late arrivals
364+
return
364365
self._completed_tasks += 1
365366
if task.is_failed and self._exception is None:
366367
self._exception = task.get_exception()
367368
self._is_complete = True
368-
if self._completed_tasks == len(self._tasks):
369+
if self._parent is not None:
370+
self._parent.on_child_completed(self)
371+
elif self._completed_tasks == len(self._tasks):
369372
# The order of the result MUST match the order of the tasks provided to the constructor.
370373
self._result = [task.get_result() for task in self._tasks]
371374
self._is_complete = True

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

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1212,6 +1212,107 @@ def orchestrator(ctx: task.OrchestrationContext, _):
12121212
assert str(ex) in complete_action.failureDetails.errorMessage
12131213

12141214

1215+
def test_when_all_failure_after_success_bubbles_to_orchestrator():
1216+
"""Tests that when_all correctly surfaces a failure to the orchestrator
1217+
even when succeeding tasks complete before the failing one.
1218+
1219+
This is a regression test: previously the exception from the failed task
1220+
was swallowed when another task had already completed successfully.
1221+
"""
1222+
1223+
def dummy_activity(ctx, _):
1224+
pass
1225+
1226+
def orchestrator(ctx: task.OrchestrationContext, _):
1227+
t1 = ctx.call_activity(dummy_activity, input='will-succeed')
1228+
t2 = ctx.call_activity(dummy_activity, input='will-fail')
1229+
try:
1230+
yield task.when_all([t1, t2])
1231+
except task.TaskFailedError:
1232+
return 'caught'
1233+
return 'not caught'
1234+
1235+
registry = worker._Registry()
1236+
orchestrator_name = registry.add_orchestrator(orchestrator)
1237+
activity_name = registry.add_activity(dummy_activity)
1238+
1239+
old_events = [
1240+
helpers.new_workflow_started_event(),
1241+
helpers.new_execution_started_event(
1242+
orchestrator_name, TEST_INSTANCE_ID, encoded_input=None
1243+
),
1244+
helpers.new_task_scheduled_event(1, activity_name),
1245+
helpers.new_task_scheduled_event(2, activity_name),
1246+
]
1247+
1248+
# t1 succeeds FIRST, then t2 fails — this is the order that triggered the bug
1249+
ex = Exception('activity error')
1250+
new_events = [
1251+
helpers.new_task_completed_event(1, encoded_output=json.dumps('ok')),
1252+
helpers.new_task_failed_event(2, ex),
1253+
]
1254+
1255+
executor = worker._OrchestrationExecutor(registry, TEST_LOGGER)
1256+
result = executor.execute(TEST_INSTANCE_ID, old_events, new_events)
1257+
actions = result.actions
1258+
1259+
complete_action = get_and_validate_single_complete_workflow_action(actions)
1260+
# The orchestrator should have caught the exception and returned 'caught'
1261+
assert complete_action.workflowStatus == pb.ORCHESTRATION_STATUS_COMPLETED
1262+
assert complete_action.result.value == json.dumps('caught')
1263+
1264+
1265+
def test_when_all_success_after_failure_does_not_crash():
1266+
"""Tests that task completions arriving after when_all already failed
1267+
do not crash the orchestration.
1268+
1269+
This is a regression test: previously a ValueError was raised when
1270+
a successful task completed after the WhenAllTask was already marked
1271+
complete due to a prior child failure.
1272+
"""
1273+
1274+
def dummy_activity(ctx, _):
1275+
pass
1276+
1277+
def orchestrator(ctx: task.OrchestrationContext, _):
1278+
t1 = ctx.call_activity(dummy_activity, input='will-fail')
1279+
t2 = ctx.call_activity(dummy_activity, input='will-succeed')
1280+
try:
1281+
yield task.when_all([t1, t2])
1282+
except task.TaskFailedError:
1283+
return 'caught'
1284+
return 'not caught'
1285+
1286+
registry = worker._Registry()
1287+
orchestrator_name = registry.add_orchestrator(orchestrator)
1288+
activity_name = registry.add_activity(dummy_activity)
1289+
1290+
old_events = [
1291+
helpers.new_workflow_started_event(),
1292+
helpers.new_execution_started_event(
1293+
orchestrator_name, TEST_INSTANCE_ID, encoded_input=None
1294+
),
1295+
helpers.new_task_scheduled_event(1, activity_name),
1296+
helpers.new_task_scheduled_event(2, activity_name),
1297+
]
1298+
1299+
# t1 fails FIRST, then t2 succeeds — this would previously raise ValueError
1300+
ex = Exception('activity error')
1301+
new_events = [
1302+
helpers.new_task_failed_event(1, ex),
1303+
helpers.new_task_completed_event(2, encoded_output=json.dumps('ok')),
1304+
]
1305+
1306+
executor = worker._OrchestrationExecutor(registry, TEST_LOGGER)
1307+
result = executor.execute(TEST_INSTANCE_ID, old_events, new_events)
1308+
actions = result.actions
1309+
1310+
complete_action = get_and_validate_single_complete_workflow_action(actions)
1311+
# The orchestrator should have caught the exception and returned 'caught'
1312+
assert complete_action.workflowStatus == pb.ORCHESTRATION_STATUS_COMPLETED
1313+
assert complete_action.result.value == json.dumps('caught')
1314+
1315+
12151316
def test_when_any():
12161317
"""Tests that a when_any pattern works correctly"""
12171318

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

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,17 @@
1111

1212
"""Unit tests for durabletask.task primitives."""
1313

14+
import pytest
15+
16+
import dapr.ext.workflow._durabletask.internal.helpers as pbh
1417
from dapr.ext.workflow._durabletask import task
1518

1619

20+
def _make_failure_details(message: str = 'test error', error_type: str = 'TestError'):
21+
"""Create a TaskFailureDetails proto for testing."""
22+
return pbh.new_failure_details(Exception(message))
23+
24+
1725
def test_when_all_empty_returns_successfully():
1826
"""task.when_all([]) should complete immediately and return an empty list."""
1927
when_all_task = task.when_all([])
@@ -121,3 +129,70 @@ def test_when_any_happy_path_returns_winner_task_and_completes_on_first():
121129
a.complete('A')
122130

123131
assert any_task.get_result() is b
132+
133+
134+
def test_when_all_failure_after_success_still_reports_failure():
135+
"""When a child fails after another child has already succeeded,
136+
the WhenAllTask must still complete with the failure — not swallow it."""
137+
c1 = task.CompletableTask()
138+
c2 = task.CompletableTask()
139+
140+
all_task = task.when_all([c1, c2])
141+
142+
# c1 succeeds first
143+
c1.complete('one')
144+
assert not all_task.is_complete
145+
146+
# c2 fails second — this is the order that used to swallow the exception
147+
c2.fail('activity failed', _make_failure_details('activity failed'))
148+
149+
assert all_task.is_complete
150+
assert all_task.is_failed
151+
with pytest.raises(task.TaskFailedError):
152+
all_task.get_result()
153+
154+
155+
def test_when_all_failure_before_success_still_reports_failure():
156+
"""When a child fails before the other children succeed,
157+
the WhenAllTask must complete with the failure immediately."""
158+
c1 = task.CompletableTask()
159+
c2 = task.CompletableTask()
160+
161+
all_task = task.when_all([c1, c2])
162+
163+
# c1 fails first
164+
c1.fail('activity failed', _make_failure_details('activity failed'))
165+
166+
assert all_task.is_complete
167+
assert all_task.is_failed
168+
with pytest.raises(task.TaskFailedError):
169+
all_task.get_result()
170+
171+
# c2 succeeds after — must not raise ValueError
172+
c2.complete('two')
173+
174+
# WhenAllTask should still be in the same failed state
175+
assert all_task.is_complete
176+
assert all_task.is_failed
177+
with pytest.raises(task.TaskFailedError):
178+
all_task.get_result()
179+
180+
181+
def test_when_all_failure_propagates_to_parent():
182+
"""When a WhenAllTask fails due to a child failure,
183+
it should notify its parent composite task."""
184+
c1 = task.CompletableTask()
185+
c2 = task.CompletableTask()
186+
187+
all_task = task.when_all([c1, c2])
188+
any_task = task.when_any([all_task])
189+
190+
assert not any_task.is_complete
191+
192+
c1.fail('activity failed', _make_failure_details('activity failed'))
193+
194+
assert all_task.is_complete
195+
assert all_task.is_failed
196+
# The parent WhenAnyTask should also have completed
197+
assert any_task.is_complete
198+
assert any_task.get_result() is all_task

0 commit comments

Comments
 (0)