Skip to content

Commit 3b5d5e3

Browse files
authored
Promote dev to main for 1.1.1 release (#321)
1 parent 5a5ba25 commit 3b5d5e3

File tree

4 files changed

+268
-12
lines changed

4 files changed

+268
-12
lines changed

azure/durable_functions/models/Task.py

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,9 @@ def __init__(self, tasks: List[TaskBase], compound_action_constructor=None):
159159
self.completed_tasks: List[TaskBase] = []
160160
self.children = tasks
161161

162+
if len(self.children) == 0:
163+
self.state = TaskState.SUCCEEDED
164+
162165
def handle_completion(self, child: TaskBase):
163166
"""Manage sub-task completion events.
164167
@@ -238,7 +241,7 @@ def try_set_value(self, child: TaskBase):
238241
# A WhenAll Task only completes when it has no pending tasks
239242
# i.e _when all_ of its children have completed
240243
if len(self.pending_tasks) == 0:
241-
results = list(map(lambda x: x.result, self.completed_tasks))
244+
results = list(map(lambda x: x.result, self.children))
242245
self.set_value(is_error=False, value=results)
243246
else: # child.state is TaskState.FAILED:
244247
# a single error is sufficient to fail this task
@@ -287,14 +290,28 @@ class RetryAbleTask(WhenAllTask):
287290
"""
288291

289292
def __init__(self, child: TaskBase, retry_options: RetryOptions, context):
290-
self.id_ = str(child.id) + "_retryable_proxy"
291293
tasks = [child]
292294
super().__init__(tasks, context._replay_schema)
293295

294296
self.retry_options = retry_options
295297
self.num_attempts = 1
296298
self.context = context
297299
self.actions = child.action_repr
300+
self.is_waiting_on_timer = False
301+
302+
@property
303+
def id_(self):
304+
"""Obtain the task's ID.
305+
306+
Since this is an internal-only abstraction, the task ID is represented
307+
by the ID of its inner/wrapped task _plus_ a suffix: "_retryable_proxy"
308+
309+
Returns
310+
-------
311+
[type]
312+
[description]
313+
"""
314+
return str(list(map(lambda x: x.id, self.children))) + "_retryable_proxy"
298315

299316
def try_set_value(self, child: TaskBase):
300317
"""Transition a Retryable Task to a terminal state and set its value.
@@ -304,6 +321,14 @@ def try_set_value(self, child: TaskBase):
304321
child : TaskBase
305322
A sub-task that just completed
306323
"""
324+
if self.is_waiting_on_timer:
325+
# timer fired, re-scheduling original task
326+
self.is_waiting_on_timer = False
327+
rescheduled_task = self.context._generate_task(
328+
action=NoOpAction("rescheduled task"), parent=self)
329+
self.pending_tasks.add(rescheduled_task)
330+
self.context._add_to_open_tasks(rescheduled_task)
331+
return
307332
if child.state is TaskState.SUCCEEDED:
308333
if len(self.pending_tasks) == 0:
309334
# if all pending tasks have completed,
@@ -318,11 +343,11 @@ def try_set_value(self, child: TaskBase):
318343
else:
319344
# still have some retries left.
320345
# increase size of pending tasks by adding a timer task
321-
# and then re-scheduling the current task after that
322-
timer_task = self.context._generate_task(action=NoOpAction(), parent=self)
346+
# when it completes, we'll retry the original task
347+
timer_task = self.context._generate_task(
348+
action=NoOpAction("-WithRetry timer"), parent=self)
323349
self.pending_tasks.add(timer_task)
324350
self.context._add_to_open_tasks(timer_task)
325-
rescheduled_task = self.context._generate_task(action=NoOpAction(), parent=self)
326-
self.pending_tasks.add(rescheduled_task)
327-
self.context._add_to_open_tasks(rescheduled_task)
351+
self.is_waiting_on_timer = True
352+
328353
self.num_attempts += 1

azure/durable_functions/models/actions/NoOpAction.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,25 @@
11
from azure.durable_functions.models.actions.Action import Action
2-
from typing import Any, Dict
2+
from typing import Any, Dict, Optional
33

44

55
class NoOpAction(Action):
66
"""A no-op action, for anonymous tasks only."""
77

8+
def __init__(self, metadata: Optional[str] = None):
9+
"""Create a NoOpAction object.
10+
11+
This is an internal-only action class used to represent cases when intermediate
12+
tasks are used to implement some API. For example, in -WithRetry APIs, intermediate
13+
timers are created. We create this NoOp action to track those the backing actions
14+
of those tasks, which is necessary because we mimic the DF-internal replay algorithm.
15+
16+
Parameters
17+
----------
18+
metadata : Optional[str]
19+
Used for internal debugging: metadata about the action being represented.
20+
"""
21+
self.metadata = metadata
22+
823
def action_type(self) -> int:
924
"""Get the type of action this class represents."""
1025
raise Exception("Attempted to get action type of an anonymous Action")

tests/orchestrator/test_sequential_orchestrator_with_retry.py

Lines changed: 207 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from typing import List, Union
12
from azure.durable_functions.models.ReplaySchema import ReplaySchema
23
from .orchestrator_test_utils \
34
import get_orchestration_state_result, assert_orchestration_state_equals, assert_valid_schema
@@ -28,16 +29,63 @@ def generator_function(context):
2829

2930
return outputs
3031

32+
def generator_function_concurrent_retries(context):
33+
outputs = []
34+
35+
retry_options = RETRY_OPTIONS
36+
task1 = context.call_activity_with_retry(
37+
"Hello", retry_options, "Tokyo")
38+
task2 = context.call_activity_with_retry(
39+
"Hello", retry_options, "Seattle")
40+
task3 = context.call_activity_with_retry(
41+
"Hello", retry_options, "London")
42+
43+
outputs = yield context.task_all([task1, task2, task3])
44+
45+
return outputs
46+
47+
def generator_function_two_concurrent_retries_when_all(context):
48+
outputs = []
49+
50+
retry_options = RETRY_OPTIONS
51+
task1 = context.call_activity_with_retry(
52+
"Hello", retry_options, "Tokyo")
53+
task2 = context.call_activity_with_retry(
54+
"Hello", retry_options, "Seattle")
55+
56+
outputs = yield context.task_all([task1, task2])
57+
58+
return outputs
59+
60+
def generator_function_two_concurrent_retries_when_any(context):
61+
outputs = []
62+
63+
retry_options = RETRY_OPTIONS
64+
task1 = context.call_activity_with_retry(
65+
"Hello", retry_options, "Tokyo")
66+
task2 = context.call_activity_with_retry(
67+
"Hello", retry_options, "Seattle")
68+
69+
outputs = yield context.task_any([task1, task2])
70+
71+
return outputs.result
72+
3173

3274
def base_expected_state(output=None, replay_schema: ReplaySchema = ReplaySchema.V1) -> OrchestratorState:
3375
return OrchestratorState(is_done=False, actions=[], output=output, replay_schema=replay_schema.value)
3476

3577

36-
def add_hello_action(state: OrchestratorState, input_: str):
78+
def add_hello_action(state: OrchestratorState, input_: Union[List[str], str]):
3779
retry_options = RETRY_OPTIONS
38-
action = CallActivityWithRetryAction(
39-
function_name='Hello', retry_options=retry_options, input_=input_)
40-
state._actions.append([action])
80+
actions = []
81+
inputs = input_
82+
if not isinstance(input_, list):
83+
inputs = [input_]
84+
for input_ in inputs:
85+
action = CallActivityWithRetryAction(
86+
function_name='Hello', retry_options=retry_options, input_=input_)
87+
actions.append(action)
88+
state._actions.append(actions)
4189

4290

4391
def add_hello_failed_events(
@@ -63,6 +111,45 @@ def add_retry_timer_events(context_builder: ContextBuilder, id_: int):
63111
context_builder.add_orchestrator_started_event()
64112
context_builder.add_timer_fired_event(id_=id_, fire_at=fire_at)
65113

114+
def add_two_retriable_events_completing_out_of_order(context_builder: ContextBuilder,
115+
failed_reason, failed_details):
116+
## Schedule tasks
117+
context_builder.add_task_scheduled_event(name='Hello', id_=0) # Tokyo task
118+
context_builder.add_task_scheduled_event(name='Hello', id_=1) # Seattle task
119+
120+
context_builder.add_orchestrator_completed_event()
121+
context_builder.add_orchestrator_started_event()
122+
123+
## Task failures and timer-scheduling
124+
125+
# tasks fail "out of order"
126+
context_builder.add_task_failed_event(
127+
id_=1, reason=failed_reason, details=failed_details) # Seattle task
128+
fire_at_1 = context_builder.add_timer_created_event(2) # Seattle timer
129+
130+
context_builder.add_orchestrator_completed_event()
131+
context_builder.add_orchestrator_started_event()
132+
133+
context_builder.add_task_failed_event(
134+
id_=0, reason=failed_reason, details=failed_details) # Tokyo task
135+
fire_at_2 = context_builder.add_timer_created_event(3) # Tokyo timer
136+
137+
context_builder.add_orchestrator_completed_event()
138+
context_builder.add_orchestrator_started_event()
139+
140+
## fire timers
141+
context_builder.add_timer_fired_event(id_=2, fire_at=fire_at_1) # Seattle timer
142+
context_builder.add_timer_fired_event(id_=3, fire_at=fire_at_2) # Tokyo timer
143+
144+
## Complete events
145+
context_builder.add_task_scheduled_event(name='Hello', id_=4) # Seattle task
146+
context_builder.add_task_scheduled_event(name='Hello', id_=5) # Tokyo task
147+
148+
context_builder.add_orchestrator_completed_event()
149+
context_builder.add_orchestrator_started_event()
150+
context_builder.add_task_completed_event(id_=4, result="\"Hello Seattle!\"")
151+
context_builder.add_task_completed_event(id_=5, result="\"Hello Tokyo!\"")
152+
66153

67154
def test_initial_orchestration_state():
68155
context_builder = ContextBuilder('test_simple_function')
@@ -217,3 +304,119 @@ def test_failed_tokyo_hit_max_attempts():
217304

218305
expected_error_str = f"{error_msg}{error_label}{state_str}"
219306
assert expected_error_str == error_str
307+
308+
def test_concurrent_retriable_results():
309+
failed_reason = 'Reasons'
310+
failed_details = 'Stuff and Things'
311+
context_builder = ContextBuilder('test_concurrent_retriable')
312+
add_hello_failed_events(context_builder, 0, failed_reason, failed_details)
313+
add_hello_failed_events(context_builder, 1, failed_reason, failed_details)
314+
add_hello_failed_events(context_builder, 2, failed_reason, failed_details)
315+
add_retry_timer_events(context_builder, 3)
316+
add_retry_timer_events(context_builder, 4)
317+
add_retry_timer_events(context_builder, 5)
318+
add_hello_completed_events(context_builder, 6, "\"Hello Tokyo!\"")
319+
add_hello_completed_events(context_builder, 7, "\"Hello Seattle!\"")
320+
add_hello_completed_events(context_builder, 8, "\"Hello London!\"")
321+
322+
result = get_orchestration_state_result(
323+
context_builder, generator_function_concurrent_retries)
324+
325+
expected_state = base_expected_state()
326+
add_hello_action(expected_state, ['Tokyo', 'Seattle', 'London'])
327+
expected_state._output = ["Hello Tokyo!", "Hello Seattle!", "Hello London!"]
328+
expected_state._is_done = True
329+
expected = expected_state.to_json()
330+
331+
assert_valid_schema(result)
332+
assert_orchestration_state_equals(expected, result)
333+
334+
def test_concurrent_retriable_results_unordered_arrival():
335+
failed_reason = 'Reasons'
336+
failed_details = 'Stuff and Things'
337+
context_builder = ContextBuilder('test_concurrent_retriable_unordered_results')
338+
add_hello_failed_events(context_builder, 0, failed_reason, failed_details)
339+
add_hello_failed_events(context_builder, 1, failed_reason, failed_details)
340+
add_hello_failed_events(context_builder, 2, failed_reason, failed_details)
341+
add_retry_timer_events(context_builder, 3)
342+
add_retry_timer_events(context_builder, 4)
343+
add_retry_timer_events(context_builder, 5)
344+
# events arrive in non-sequential different order
345+
add_hello_completed_events(context_builder, 8, "\"Hello London!\"")
346+
add_hello_completed_events(context_builder, 6, "\"Hello Tokyo!\"")
347+
add_hello_completed_events(context_builder, 7, "\"Hello Seattle!\"")
348+
349+
result = get_orchestration_state_result(
350+
context_builder, generator_function_concurrent_retries)
351+
352+
expected_state = base_expected_state()
353+
add_hello_action(expected_state, ['Tokyo', 'Seattle', 'London'])
354+
expected_state._output = ["Hello Tokyo!", "Hello Seattle!", "Hello London!"]
355+
expected_state._is_done = True
356+
expected = expected_state.to_json()
357+
358+
assert_valid_schema(result)
359+
assert_orchestration_state_equals(expected, result)
360+
361+
def test_concurrent_retriable_results_mixed_arrival():
362+
failed_reason = 'Reasons'
363+
failed_details = 'Stuff and Things'
364+
context_builder = ContextBuilder('test_concurrent_retriable_unordered_results')
365+
# one task succeeds, the other two fail at first, and succeed on retry
366+
add_hello_failed_events(context_builder, 1, failed_reason, failed_details)
367+
add_hello_completed_events(context_builder, 0, "\"Hello Tokyo!\"")
368+
add_hello_failed_events(context_builder, 2, failed_reason, failed_details)
369+
add_retry_timer_events(context_builder, 3)
370+
add_retry_timer_events(context_builder, 4)
371+
add_hello_completed_events(context_builder, 6, "\"Hello London!\"")
372+
add_hello_completed_events(context_builder, 5, "\"Hello Seattle!\"")
373+
374+
result = get_orchestration_state_result(
375+
context_builder, generator_function_concurrent_retries)
376+
377+
expected_state = base_expected_state()
378+
add_hello_action(expected_state, ['Tokyo', 'Seattle', 'London'])
379+
expected_state._output = ["Hello Tokyo!", "Hello Seattle!", "Hello London!"]
380+
expected_state._is_done = True
381+
expected = expected_state.to_json()
382+
383+
assert_valid_schema(result)
384+
assert_orchestration_state_equals(expected, result)
385+
386+
def test_concurrent_retriable_results_alternating_taskIDs_when_all():
387+
failed_reason = 'Reasons'
388+
failed_details = 'Stuff and Things'
389+
context_builder = ContextBuilder('test_concurrent_retriable_unordered_results')
390+
391+
add_two_retriable_events_completing_out_of_order(context_builder, failed_reason, failed_details)
392+
393+
result = get_orchestration_state_result(
394+
context_builder, generator_function_two_concurrent_retries_when_all)
395+
396+
expected_state = base_expected_state()
397+
add_hello_action(expected_state, ['Tokyo', 'Seattle'])
398+
expected_state._output = ["Hello Tokyo!", "Hello Seattle!"]
399+
expected_state._is_done = True
400+
expected = expected_state.to_json()
401+
402+
assert_valid_schema(result)
403+
assert_orchestration_state_equals(expected, result)
404+
405+
def test_concurrent_retriable_results_alternating_taskIDs_when_any():
406+
failed_reason = 'Reasons'
407+
failed_details = 'Stuff and Things'
408+
context_builder = ContextBuilder('test_concurrent_retriable_unordered_results')
409+
410+
add_two_retriable_events_completing_out_of_order(context_builder, failed_reason, failed_details)
411+
412+
result = get_orchestration_state_result(
413+
context_builder, generator_function_two_concurrent_retries_when_any)
414+
415+
expected_state = base_expected_state()
416+
add_hello_action(expected_state, ['Tokyo', 'Seattle'])
417+
expected_state._output = "Hello Seattle!"
418+
expected_state._is_done = True
419+
expected = expected_state.to_json()
420+
421+
assert_valid_schema(result)
422+
assert_orchestration_state_equals(expected, result)

tests/orchestrator/test_task_any.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,19 @@ def generator_function(context):
1414
except:
1515
return "exception"
1616

17+
def generator_function_no_activity(context):
18+
yield context.task_any([])
19+
return "Done!"
20+
21+
def test_continues_on_zero_inner_tasks():
22+
context_builder = ContextBuilder()
23+
result = get_orchestration_state_result(
24+
context_builder, generator_function_no_activity)
25+
expected_state = base_expected_state("Done!")
26+
expected_state._is_done = True
27+
expected = expected_state.to_json()
28+
assert_orchestration_state_equals(expected, result)
29+
1730
def test_continues_on_zero_results():
1831
context_builder = ContextBuilder()
1932
result = get_orchestration_state_result(

0 commit comments

Comments
 (0)