azure-functions-durable 1.2.9__py3-none-any.whl → 1.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. azure/durable_functions/__init__.py +81 -81
  2. azure/durable_functions/constants.py +9 -9
  3. azure/durable_functions/decorators/__init__.py +3 -3
  4. azure/durable_functions/decorators/durable_app.py +260 -249
  5. azure/durable_functions/decorators/metadata.py +109 -109
  6. azure/durable_functions/entity.py +129 -125
  7. azure/durable_functions/models/DurableEntityContext.py +201 -201
  8. azure/durable_functions/models/DurableHttpRequest.py +58 -58
  9. azure/durable_functions/models/DurableOrchestrationBindings.py +66 -66
  10. azure/durable_functions/models/DurableOrchestrationClient.py +812 -781
  11. azure/durable_functions/models/DurableOrchestrationContext.py +761 -707
  12. azure/durable_functions/models/DurableOrchestrationStatus.py +156 -156
  13. azure/durable_functions/models/EntityStateResponse.py +23 -23
  14. azure/durable_functions/models/FunctionContext.py +7 -7
  15. azure/durable_functions/models/OrchestrationRuntimeStatus.py +32 -32
  16. azure/durable_functions/models/OrchestratorState.py +117 -116
  17. azure/durable_functions/models/PurgeHistoryResult.py +33 -33
  18. azure/durable_functions/models/ReplaySchema.py +9 -8
  19. azure/durable_functions/models/RetryOptions.py +69 -69
  20. azure/durable_functions/models/RpcManagementOptions.py +86 -86
  21. azure/durable_functions/models/Task.py +540 -426
  22. azure/durable_functions/models/TaskOrchestrationExecutor.py +352 -336
  23. azure/durable_functions/models/TokenSource.py +56 -56
  24. azure/durable_functions/models/__init__.py +26 -24
  25. azure/durable_functions/models/actions/Action.py +23 -23
  26. azure/durable_functions/models/actions/ActionType.py +18 -18
  27. azure/durable_functions/models/actions/CallActivityAction.py +41 -41
  28. azure/durable_functions/models/actions/CallActivityWithRetryAction.py +45 -45
  29. azure/durable_functions/models/actions/CallEntityAction.py +46 -46
  30. azure/durable_functions/models/actions/CallHttpAction.py +35 -35
  31. azure/durable_functions/models/actions/CallSubOrchestratorAction.py +40 -40
  32. azure/durable_functions/models/actions/CallSubOrchestratorWithRetryAction.py +44 -44
  33. azure/durable_functions/models/actions/CompoundAction.py +35 -35
  34. azure/durable_functions/models/actions/ContinueAsNewAction.py +36 -36
  35. azure/durable_functions/models/actions/CreateTimerAction.py +48 -48
  36. azure/durable_functions/models/actions/NoOpAction.py +35 -35
  37. azure/durable_functions/models/actions/SignalEntityAction.py +47 -47
  38. azure/durable_functions/models/actions/WaitForExternalEventAction.py +63 -63
  39. azure/durable_functions/models/actions/WhenAllAction.py +14 -14
  40. azure/durable_functions/models/actions/WhenAnyAction.py +14 -14
  41. azure/durable_functions/models/actions/__init__.py +24 -24
  42. azure/durable_functions/models/entities/EntityState.py +74 -74
  43. azure/durable_functions/models/entities/OperationResult.py +94 -76
  44. azure/durable_functions/models/entities/RequestMessage.py +53 -53
  45. azure/durable_functions/models/entities/ResponseMessage.py +48 -48
  46. azure/durable_functions/models/entities/Signal.py +62 -62
  47. azure/durable_functions/models/entities/__init__.py +17 -17
  48. azure/durable_functions/models/history/HistoryEvent.py +92 -92
  49. azure/durable_functions/models/history/HistoryEventType.py +27 -27
  50. azure/durable_functions/models/history/__init__.py +8 -8
  51. azure/durable_functions/models/utils/__init__.py +7 -7
  52. azure/durable_functions/models/utils/entity_utils.py +103 -91
  53. azure/durable_functions/models/utils/http_utils.py +80 -69
  54. azure/durable_functions/models/utils/json_utils.py +96 -56
  55. azure/durable_functions/orchestrator.py +73 -71
  56. azure/durable_functions/testing/OrchestratorGeneratorWrapper.py +42 -0
  57. azure/durable_functions/testing/__init__.py +6 -0
  58. {azure_functions_durable-1.2.9.dist-info → azure_functions_durable-1.3.0.dist-info}/LICENSE +21 -21
  59. {azure_functions_durable-1.2.9.dist-info → azure_functions_durable-1.3.0.dist-info}/METADATA +59 -58
  60. azure_functions_durable-1.3.0.dist-info/RECORD +103 -0
  61. {azure_functions_durable-1.2.9.dist-info → azure_functions_durable-1.3.0.dist-info}/WHEEL +1 -1
  62. tests/models/test_DecoratorMetadata.py +135 -135
  63. tests/models/test_Decorators.py +107 -107
  64. tests/models/test_DurableOrchestrationBindings.py +68 -68
  65. tests/models/test_DurableOrchestrationClient.py +730 -730
  66. tests/models/test_DurableOrchestrationContext.py +102 -102
  67. tests/models/test_DurableOrchestrationStatus.py +59 -59
  68. tests/models/test_OrchestrationState.py +28 -28
  69. tests/models/test_RpcManagementOptions.py +79 -79
  70. tests/models/test_TokenSource.py +10 -10
  71. tests/orchestrator/models/OrchestrationInstance.py +18 -18
  72. tests/orchestrator/orchestrator_test_utils.py +130 -130
  73. tests/orchestrator/schemas/OrchetrationStateSchema.py +66 -66
  74. tests/orchestrator/test_call_http.py +235 -176
  75. tests/orchestrator/test_continue_as_new.py +67 -67
  76. tests/orchestrator/test_create_timer.py +126 -126
  77. tests/orchestrator/test_entity.py +397 -395
  78. tests/orchestrator/test_external_event.py +53 -53
  79. tests/orchestrator/test_fan_out_fan_in.py +175 -175
  80. tests/orchestrator/test_is_replaying_flag.py +101 -101
  81. tests/orchestrator/test_retries.py +308 -308
  82. tests/orchestrator/test_sequential_orchestrator.py +841 -841
  83. tests/orchestrator/test_sequential_orchestrator_custom_status.py +119 -119
  84. tests/orchestrator/test_sequential_orchestrator_with_retry.py +465 -465
  85. tests/orchestrator/test_serialization.py +30 -30
  86. tests/orchestrator/test_sub_orchestrator.py +95 -95
  87. tests/orchestrator/test_sub_orchestrator_with_retry.py +129 -129
  88. tests/orchestrator/test_task_any.py +60 -60
  89. tests/tasks/tasks_test_utils.py +17 -17
  90. tests/tasks/test_long_timers.py +70 -0
  91. tests/tasks/test_new_uuid.py +34 -34
  92. tests/test_utils/ContextBuilder.py +174 -174
  93. tests/test_utils/EntityContextBuilder.py +56 -56
  94. tests/test_utils/constants.py +1 -1
  95. tests/test_utils/json_utils.py +30 -30
  96. tests/test_utils/testClasses.py +56 -56
  97. tests/utils/__init__.py +1 -0
  98. tests/utils/test_entity_utils.py +24 -0
  99. azure_functions_durable-1.2.9.data/data/_manifest/bsi.json +0 -1
  100. azure_functions_durable-1.2.9.data/data/_manifest/manifest.cat +0 -0
  101. azure_functions_durable-1.2.9.data/data/_manifest/manifest.spdx.json +0 -11985
  102. azure_functions_durable-1.2.9.data/data/_manifest/manifest.spdx.json.sha256 +0 -1
  103. azure_functions_durable-1.2.9.dist-info/RECORD +0 -102
  104. {azure_functions_durable-1.2.9.dist-info → azure_functions_durable-1.3.0.dist-info}/top_level.txt +0 -0
@@ -1,426 +1,540 @@
1
- from azure.durable_functions.models.actions.NoOpAction import NoOpAction
2
- from azure.durable_functions.models.actions.CompoundAction import CompoundAction
3
- from azure.durable_functions.models.RetryOptions import RetryOptions
4
- from azure.durable_functions.models.ReplaySchema import ReplaySchema
5
- from azure.durable_functions.models.actions.Action import Action
6
- from azure.durable_functions.models.actions.WhenAnyAction import WhenAnyAction
7
- from azure.durable_functions.models.actions.WhenAllAction import WhenAllAction
8
- from azure.durable_functions.models.actions.CreateTimerAction import CreateTimerAction
9
-
10
- import enum
11
- from typing import Any, List, Optional, Set, Type, Union
12
-
13
-
14
- class TaskState(enum.Enum):
15
- """The possible states that a Task can be in."""
16
-
17
- RUNNING = 0
18
- SUCCEEDED = 1
19
- FAILED = 2
20
-
21
-
22
- class TaskBase:
23
- """The base class of all Tasks.
24
-
25
- Contains shared logic that drives all of its sub-classes. Should never be
26
- instantiated on its own.
27
- """
28
-
29
- def __init__(self, id_: Union[int, str], actions: Union[List[Action], Action]):
30
- """Initialize the TaskBase.
31
-
32
- Parameters
33
- ----------
34
- id_ : int
35
- An ID for the task
36
- actions : List[Any]
37
- The list of DF actions representing this Task.
38
- Needed for reconstruction in the extension.
39
- """
40
- self.id: Union[int, str] = id_
41
- self.state = TaskState.RUNNING
42
- self.parent: Optional[CompoundTask] = None
43
- self._api_name: str
44
-
45
- api_action: Union[Action, Type[CompoundAction]]
46
- if isinstance(actions, list):
47
- if len(actions) == 1:
48
- api_action = actions[0]
49
- else:
50
- api_action = CompoundAction
51
- else:
52
- api_action = actions
53
-
54
- self._api_name = api_action.__class__.__name__
55
-
56
- self.result: Any = None
57
- self.action_repr: Union[List[Action], Action] = actions
58
- self.is_played = False
59
- self._is_scheduled_flag = False
60
-
61
- @property
62
- def _is_scheduled(self) -> bool:
63
- return self._is_scheduled_flag
64
-
65
- def _set_is_scheduled(self, is_scheduled: bool):
66
- self._is_scheduled_flag = is_scheduled
67
-
68
- @property
69
- def is_completed(self) -> bool:
70
- """Get indicator of whether the task completed.
71
-
72
- Note that completion is not equivalent to success.
73
- """
74
- return not (self.state is TaskState.RUNNING)
75
-
76
- def set_is_played(self, is_played: bool):
77
- """Set the is_played flag for the Task.
78
-
79
- Needed for updating the orchestrator's is_replaying flag.
80
-
81
- Parameters
82
- ----------
83
- is_played : bool
84
- Whether the latest event for this Task has been played before.
85
- """
86
- self.is_played = is_played
87
-
88
- def change_state(self, state: TaskState):
89
- """Transition a running Task to a terminal state: success or failure.
90
-
91
- Parameters
92
- ----------
93
- state : TaskState
94
- The terminal state to assign to this Task
95
-
96
- Raises
97
- ------
98
- Exception
99
- When the input state is RUNNING
100
- """
101
- if state is TaskState.RUNNING:
102
- raise Exception("Cannot change Task to the RUNNING state.")
103
- self.state = state
104
-
105
- def set_value(self, is_error: bool, value: Any):
106
- """Set the value of this Task: either an exception of a result.
107
-
108
- Parameters
109
- ----------
110
- is_error : bool
111
- Whether the value represents an exception of a result.
112
- value : Any
113
- The value of this Task
114
-
115
- Raises
116
- ------
117
- Exception
118
- When the Task failed but its value was not an Exception
119
- """
120
- new_state = self.state
121
- if is_error:
122
- if not isinstance(value, Exception):
123
- if not (isinstance(value, TaskBase) and isinstance(value.result, Exception)):
124
- err_message = f"Task ID {self.id} failed but it's value was not an Exception"
125
- raise Exception(err_message)
126
- new_state = TaskState.FAILED
127
- else:
128
- new_state = TaskState.SUCCEEDED
129
- self.change_state(new_state)
130
- self.result = value
131
- self.propagate()
132
-
133
- def propagate(self):
134
- """Notify parent Task of this Task's state change."""
135
- has_completed = not (self.state is TaskState.RUNNING)
136
- has_parent = not (self.parent is None)
137
- if has_completed and has_parent:
138
- self.parent.handle_completion(self)
139
-
140
-
141
- class CompoundTask(TaskBase):
142
- """A Task of Tasks.
143
-
144
- Contains shared logic that drives all of its sub-classes.
145
- Should never be instantiated on its own.
146
- """
147
-
148
- def __init__(self, tasks: List[TaskBase], compound_action_constructor=None):
149
- """Instantiate CompoundTask attributes.
150
-
151
- Parameters
152
- ----------
153
- tasks : List[Task]
154
- The children/sub-tasks of this Task
155
- compound_action_constructor : Union[WhenAllAction, WhenAnyAction, None]
156
- Either None or, a WhenAllAction or WhenAnyAction constructor.
157
- It is None when using the V1 replay protocol, where no Compound Action
158
- objects size and compound actions are represented as arrays of actions.
159
- It is not None when using the V2 replay protocol.
160
- """
161
- super().__init__(-1, [])
162
- child_actions = []
163
- for task in tasks:
164
- task.parent = self
165
- action_repr = task.action_repr
166
- if isinstance(action_repr, list):
167
- child_actions.extend(action_repr)
168
- else:
169
- if not task._is_scheduled:
170
- child_actions.append(action_repr)
171
- if compound_action_constructor is None:
172
- self.action_repr = child_actions
173
- else: # replay_schema is ReplaySchema.V2
174
- self.action_repr = compound_action_constructor(child_actions)
175
- self._first_error: Optional[Exception] = None
176
- self.pending_tasks: Set[TaskBase] = set(tasks)
177
- self.completed_tasks: List[TaskBase] = []
178
- self.children = tasks
179
-
180
- if len(self.children) == 0:
181
- self.state = TaskState.SUCCEEDED
182
-
183
- # Sub-tasks may have already completed, so we process them
184
- for child in self.children:
185
- if not (child.state is TaskState.RUNNING):
186
- self.handle_completion(child)
187
-
188
- @property
189
- def _is_scheduled(self) -> bool:
190
- return all([child._is_scheduled for child in self.children])
191
-
192
- def handle_completion(self, child: TaskBase):
193
- """Manage sub-task completion events.
194
-
195
- Parameters
196
- ----------
197
- child : TaskBase
198
- The sub-task that completed
199
-
200
- Raises
201
- ------
202
- Exception
203
- When the calling sub-task was not registered
204
- with this Task's pending sub-tasks.
205
- """
206
- try:
207
- self.pending_tasks.remove(child)
208
- except KeyError:
209
- raise Exception(
210
- f"Parent Task {self.id} does not have pending sub-task with ID {child.id}."
211
- f"This most likely means that Task {child.id} completed twice.")
212
-
213
- self.completed_tasks.append(child)
214
- self.set_is_played(child.is_played)
215
- self.try_set_value(child)
216
-
217
- def try_set_value(self, child: TaskBase):
218
- """Transition a CompoundTask to a terminal state and set its value.
219
-
220
- Should be implemented by sub-classes.
221
-
222
- Parameters
223
- ----------
224
- child : TaskBase
225
- A sub-task that just completed
226
-
227
- Raises
228
- ------
229
- NotImplementedError
230
- This method needs to be implemented by each subclass.
231
- """
232
- raise NotImplementedError
233
-
234
-
235
- class AtomicTask(TaskBase):
236
- """A Task with no subtasks."""
237
-
238
- def _get_action(self) -> Action:
239
- action: Action
240
- if isinstance(self.action_repr, list):
241
- action = self.action_repr[0]
242
- else:
243
- action = self.action_repr
244
- return action
245
-
246
-
247
- class TimerTask(AtomicTask):
248
- """A Timer Task."""
249
-
250
- def __init__(self, id_: Union[int, str], action: CreateTimerAction):
251
- super().__init__(id_, action)
252
- self.action_repr: Union[List[CreateTimerAction], CreateTimerAction]
253
-
254
- @property
255
- def is_cancelled(self) -> bool:
256
- """Check if the Timer is cancelled.
257
-
258
- Returns
259
- -------
260
- bool
261
- Returns whether a timer has been cancelled or not
262
- """
263
- action: CreateTimerAction = self._get_action()
264
- return action.is_cancelled
265
-
266
- def cancel(self):
267
- """Cancel a timer.
268
-
269
- Raises
270
- ------
271
- ValueError
272
- Raises an error if the task is already completed and an attempt is made to cancel it
273
- """
274
- if not self.is_completed:
275
- action: CreateTimerAction = self._get_action()
276
- action.is_cancelled = True
277
- else:
278
- raise ValueError("Cannot cancel a completed task.")
279
-
280
-
281
- class WhenAllTask(CompoundTask):
282
- """A Task representing `when_all` scenarios."""
283
-
284
- def __init__(self, task: List[TaskBase], replay_schema: ReplaySchema):
285
- """Initialize a WhenAllTask.
286
-
287
- Parameters
288
- ----------
289
- task : List[Task]
290
- The list of child tasks
291
- replay_schema : ReplaySchema
292
- The ReplaySchema, which determines the inner action payload representation
293
- """
294
- compound_action_constructor = None
295
- if replay_schema is ReplaySchema.V2:
296
- compound_action_constructor = WhenAllAction
297
- super().__init__(task, compound_action_constructor)
298
-
299
- def try_set_value(self, child: TaskBase):
300
- """Transition a WhenAll Task to a terminal state and set its value.
301
-
302
- Parameters
303
- ----------
304
- child : TaskBase
305
- A sub-task that just completed
306
- """
307
- if child.state is TaskState.SUCCEEDED:
308
- # A WhenAll Task only completes when it has no pending tasks
309
- # i.e _when all_ of its children have completed
310
- if len(self.pending_tasks) == 0:
311
- results = list(map(lambda x: x.result, self.children))
312
- self.set_value(is_error=False, value=results)
313
- else: # child.state is TaskState.FAILED:
314
- # a single error is sufficient to fail this task
315
- if self._first_error is None:
316
- self._first_error = child.result
317
- self.set_value(is_error=True, value=self._first_error)
318
-
319
-
320
- class WhenAnyTask(CompoundTask):
321
- """A Task representing `when_any` scenarios."""
322
-
323
- def __init__(self, task: List[TaskBase], replay_schema: ReplaySchema):
324
- """Initialize a WhenAnyTask.
325
-
326
- Parameters
327
- ----------
328
- task : List[Task]
329
- The list of child tasks
330
- replay_schema : ReplaySchema
331
- The ReplaySchema, which determines the inner action payload representation
332
- """
333
- compound_action_constructor = None
334
- if replay_schema is ReplaySchema.V2:
335
- compound_action_constructor = WhenAnyAction
336
- super().__init__(task, compound_action_constructor)
337
-
338
- def try_set_value(self, child: TaskBase):
339
- """Transition a WhenAny Task to a terminal state and set its value.
340
-
341
- Parameters
342
- ----------
343
- child : TaskBase
344
- A sub-task that just completed
345
- """
346
- if self.state is TaskState.RUNNING:
347
- self.set_value(is_error=False, value=child)
348
-
349
-
350
- class RetryAbleTask(WhenAllTask):
351
- """A Task representing `with_retry` scenarios.
352
-
353
- It inherits from WhenAllTask because retryable scenarios are Tasks
354
- with equivalent to WhenAll Tasks with dynamically increasing lists
355
- of children. At every failure, we add a Timer child and a Task child
356
- to the list of pending tasks.
357
- """
358
-
359
- def __init__(self, child: TaskBase, retry_options: RetryOptions, context):
360
- tasks = [child]
361
- super().__init__(tasks, context._replay_schema)
362
-
363
- self.retry_options = retry_options
364
- self.num_attempts = 1
365
- self.context = context
366
- self.actions = child.action_repr
367
- self.is_waiting_on_timer = False
368
- self.error = None
369
-
370
- @property
371
- def id_(self):
372
- """Obtain the task's ID.
373
-
374
- Since this is an internal-only abstraction, the task ID is represented
375
- by the ID of its inner/wrapped task _plus_ a suffix: "_retryable_proxy"
376
-
377
- Returns
378
- -------
379
- [type]
380
- [description]
381
- """
382
- return str(list(map(lambda x: x.id, self.children))) + "_retryable_proxy"
383
-
384
- def try_set_value(self, child: TaskBase):
385
- """Transition a Retryable Task to a terminal state and set its value.
386
-
387
- Parameters
388
- ----------
389
- child : TaskBase
390
- A sub-task that just completed
391
- """
392
- if self.is_waiting_on_timer:
393
- # timer fired, re-scheduling original task
394
- self.is_waiting_on_timer = False
395
- # As per DTFx semantics: we need to check the number of retires only after the final
396
- # timer has fired. This means we essentially have to wait for one "extra" timer after
397
- # the maximum number of attempts has been reached. Removing this extra timer will cause
398
- # stuck orchestrators as we need to be "in sync" with the replay logic of DTFx.
399
- if self.num_attempts >= self.retry_options.max_number_of_attempts:
400
- self.is_waiting_on_timer = True
401
- # we have reached the maximum number of attempts, set error
402
- self.set_value(is_error=True, value=self.error)
403
- else:
404
- rescheduled_task = self.context._generate_task(
405
- action=NoOpAction("rescheduled task"), parent=self)
406
- self.pending_tasks.add(rescheduled_task)
407
- self.context._add_to_open_tasks(rescheduled_task)
408
- self.num_attempts += 1
409
-
410
- return
411
- if child.state is TaskState.SUCCEEDED:
412
- if len(self.pending_tasks) == 0:
413
- # if all pending tasks have completed,
414
- # and we have a successful child, then
415
- # we can set the Task's event
416
- self.set_value(is_error=False, value=child.result)
417
-
418
- else: # child.state is TaskState.FAILED:
419
- # increase size of pending tasks by adding a timer task
420
- # when it completes, we'll retry the original task
421
- timer_task = self.context._generate_task(
422
- action=NoOpAction("-WithRetry timer"), parent=self)
423
- self.pending_tasks.add(timer_task)
424
- self.context._add_to_open_tasks(timer_task)
425
- self.is_waiting_on_timer = True
426
- self.error = child.result
1
+ from datetime import datetime
2
+ from azure.durable_functions.models.actions.NoOpAction import NoOpAction
3
+ from azure.durable_functions.models.actions.CompoundAction import CompoundAction
4
+ from azure.durable_functions.models.RetryOptions import RetryOptions
5
+ from azure.durable_functions.models.ReplaySchema import ReplaySchema
6
+ from azure.durable_functions.models.actions.Action import Action
7
+ from azure.durable_functions.models.actions.WhenAnyAction import WhenAnyAction
8
+ from azure.durable_functions.models.actions.WhenAllAction import WhenAllAction
9
+ from azure.durable_functions.models.actions.CreateTimerAction import CreateTimerAction
10
+
11
+ import enum
12
+ from typing import Any, List, Optional, Set, Type, Union
13
+
14
+
15
+ class TaskState(enum.Enum):
16
+ """The possible states that a Task can be in."""
17
+
18
+ RUNNING = 0
19
+ SUCCEEDED = 1
20
+ FAILED = 2
21
+
22
+
23
+ class TaskBase:
24
+ """The base class of all Tasks.
25
+
26
+ Contains shared logic that drives all of its sub-classes. Should never be
27
+ instantiated on its own.
28
+ """
29
+
30
+ def __init__(self, id_: Union[int, str], actions: Union[List[Action], Action]):
31
+ """Initialize the TaskBase.
32
+
33
+ Parameters
34
+ ----------
35
+ id_ : int
36
+ An ID for the task
37
+ actions : List[Any]
38
+ The list of DF actions representing this Task.
39
+ Needed for reconstruction in the extension.
40
+ """
41
+ self.id: Union[int, str] = id_
42
+ self.state = TaskState.RUNNING
43
+ self.parent: Optional[CompoundTask] = None
44
+ self._api_name: str
45
+
46
+ api_action: Union[Action, Type[CompoundAction]]
47
+ if isinstance(actions, list):
48
+ if len(actions) == 1:
49
+ api_action = actions[0]
50
+ else:
51
+ api_action = CompoundAction
52
+ else:
53
+ api_action = actions
54
+
55
+ self._api_name = api_action.__class__.__name__
56
+
57
+ self.result: Any = None
58
+ self.action_repr: Union[List[Action], Action] = actions
59
+ self.is_played = False
60
+ self._is_scheduled_flag = False
61
+
62
+ @property
63
+ def _is_scheduled(self) -> bool:
64
+ return self._is_scheduled_flag
65
+
66
+ def _set_is_scheduled(self, is_scheduled: bool):
67
+ self._is_scheduled_flag = is_scheduled
68
+
69
+ @property
70
+ def is_completed(self) -> bool:
71
+ """Get indicator of whether the task completed.
72
+
73
+ Note that completion is not equivalent to success.
74
+ """
75
+ return not (self.state is TaskState.RUNNING)
76
+
77
+ def set_is_played(self, is_played: bool):
78
+ """Set the is_played flag for the Task.
79
+
80
+ Needed for updating the orchestrator's is_replaying flag.
81
+
82
+ Parameters
83
+ ----------
84
+ is_played : bool
85
+ Whether the latest event for this Task has been played before.
86
+ """
87
+ self.is_played = is_played
88
+
89
+ def change_state(self, state: TaskState):
90
+ """Transition a running Task to a terminal state: success or failure.
91
+
92
+ Parameters
93
+ ----------
94
+ state : TaskState
95
+ The terminal state to assign to this Task
96
+
97
+ Raises
98
+ ------
99
+ Exception
100
+ When the input state is RUNNING
101
+ """
102
+ if state is TaskState.RUNNING:
103
+ raise Exception("Cannot change Task to the RUNNING state.")
104
+ self.state = state
105
+
106
+ def set_value(self, is_error: bool, value: Any):
107
+ """Set the value of this Task: either an exception of a result.
108
+
109
+ Parameters
110
+ ----------
111
+ is_error : bool
112
+ Whether the value represents an exception of a result.
113
+ value : Any
114
+ The value of this Task
115
+
116
+ Raises
117
+ ------
118
+ Exception
119
+ When the Task failed but its value was not an Exception
120
+ """
121
+ new_state = self.state
122
+ if is_error:
123
+ if not isinstance(value, Exception):
124
+ if not (isinstance(value, TaskBase) and isinstance(value.result, Exception)):
125
+ err_message = f"Task ID {self.id} failed but it's value was not an Exception"
126
+ raise Exception(err_message)
127
+ new_state = TaskState.FAILED
128
+ else:
129
+ new_state = TaskState.SUCCEEDED
130
+ self.change_state(new_state)
131
+ self.result = value
132
+ self.propagate()
133
+
134
+ def propagate(self):
135
+ """Notify parent Task of this Task's state change."""
136
+ has_completed = not (self.state is TaskState.RUNNING)
137
+ has_parent = not (self.parent is None)
138
+ if has_completed and has_parent:
139
+ self.parent.handle_completion(self)
140
+
141
+
142
+ class CompoundTask(TaskBase):
143
+ """A Task of Tasks.
144
+
145
+ Contains shared logic that drives all of its sub-classes.
146
+ Should never be instantiated on its own.
147
+ """
148
+
149
+ def __init__(self, tasks: List[TaskBase], compound_action_constructor=None):
150
+ """Instantiate CompoundTask attributes.
151
+
152
+ Parameters
153
+ ----------
154
+ tasks : List[Task]
155
+ The children/sub-tasks of this Task
156
+ compound_action_constructor : Union[WhenAllAction, WhenAnyAction, None]
157
+ Either None or, a WhenAllAction or WhenAnyAction constructor.
158
+ It is None when using the V1 replay protocol, where no Compound Action
159
+ objects size and compound actions are represented as arrays of actions.
160
+ It is not None when using the V2 replay protocol.
161
+ """
162
+ super().__init__(-1, [])
163
+ child_actions = []
164
+ for task in tasks:
165
+ task.parent = self
166
+ action_repr = task.action_repr
167
+ if isinstance(action_repr, list):
168
+ child_actions.extend(action_repr)
169
+ else:
170
+ if not task._is_scheduled:
171
+ child_actions.append(action_repr)
172
+ if compound_action_constructor is None:
173
+ self.action_repr = child_actions
174
+ else: # replay_schema >= ReplaySchema.V2
175
+ self.action_repr = compound_action_constructor(child_actions)
176
+ self._first_error: Optional[Exception] = None
177
+ self.pending_tasks: Set[TaskBase] = set(tasks)
178
+ self.completed_tasks: List[TaskBase] = []
179
+ self.children = tasks
180
+
181
+ if len(self.children) == 0:
182
+ self.state = TaskState.SUCCEEDED
183
+
184
+ # Sub-tasks may have already completed, so we process them
185
+ for child in self.children:
186
+ if not (child.state is TaskState.RUNNING):
187
+ self.handle_completion(child)
188
+
189
+ @property
190
+ def _is_scheduled(self) -> bool:
191
+ return all([child._is_scheduled for child in self.children])
192
+
193
+ def handle_completion(self, child: TaskBase):
194
+ """Manage sub-task completion events.
195
+
196
+ Parameters
197
+ ----------
198
+ child : TaskBase
199
+ The sub-task that completed
200
+
201
+ Raises
202
+ ------
203
+ Exception
204
+ When the calling sub-task was not registered
205
+ with this Task's pending sub-tasks.
206
+ """
207
+ try:
208
+ self.pending_tasks.remove(child)
209
+ except KeyError:
210
+ raise Exception(
211
+ f"Parent Task {self.id} does not have pending sub-task with ID {child.id}."
212
+ f"This most likely means that Task {child.id} completed twice.")
213
+
214
+ self.completed_tasks.append(child)
215
+ self.set_is_played(child.is_played)
216
+ self.try_set_value(child)
217
+
218
+ def try_set_value(self, child: TaskBase):
219
+ """Transition a CompoundTask to a terminal state and set its value.
220
+
221
+ Should be implemented by sub-classes.
222
+
223
+ Parameters
224
+ ----------
225
+ child : TaskBase
226
+ A sub-task that just completed
227
+
228
+ Raises
229
+ ------
230
+ NotImplementedError
231
+ This method needs to be implemented by each subclass.
232
+ """
233
+ raise NotImplementedError
234
+
235
+
236
+ class AtomicTask(TaskBase):
237
+ """A Task with no subtasks."""
238
+
239
+ def _get_action(self) -> Action:
240
+ action: Action
241
+ if isinstance(self.action_repr, list):
242
+ action = self.action_repr[0]
243
+ else:
244
+ action = self.action_repr
245
+ return action
246
+
247
+
248
+ class TimerTask(AtomicTask):
249
+ """A Timer Task."""
250
+
251
+ def __init__(self, id_: Union[int, str], action: CreateTimerAction):
252
+ super().__init__(id_, action)
253
+ self.action_repr: Union[List[CreateTimerAction], CreateTimerAction]
254
+
255
+ @property
256
+ def is_cancelled(self) -> bool:
257
+ """Check if the Timer is cancelled.
258
+
259
+ Returns
260
+ -------
261
+ bool
262
+ Returns whether a timer has been cancelled or not
263
+ """
264
+ action: CreateTimerAction = self._get_action()
265
+ return action.is_cancelled
266
+
267
+ def cancel(self):
268
+ """Cancel a timer.
269
+
270
+ Raises
271
+ ------
272
+ ValueError
273
+ Raises an error if the task is already completed and an attempt is made to cancel it
274
+ """
275
+ if not self.is_completed:
276
+ action: CreateTimerAction = self._get_action()
277
+ action.is_cancelled = True
278
+ else:
279
+ raise ValueError("Cannot cancel a completed task.")
280
+
281
+
282
+ class WhenAllTask(CompoundTask):
283
+ """A Task representing `when_all` scenarios."""
284
+
285
+ def __init__(self, task: List[TaskBase], replay_schema: ReplaySchema):
286
+ """Initialize a WhenAllTask.
287
+
288
+ Parameters
289
+ ----------
290
+ task : List[Task]
291
+ The list of child tasks
292
+ replay_schema : ReplaySchema
293
+ The ReplaySchema, which determines the inner action payload representation
294
+ """
295
+ compound_action_constructor = None
296
+ if replay_schema.value >= ReplaySchema.V2.value:
297
+ compound_action_constructor = WhenAllAction
298
+ super().__init__(task, compound_action_constructor)
299
+
300
+ def try_set_value(self, child: TaskBase):
301
+ """Transition a WhenAll Task to a terminal state and set its value.
302
+
303
+ Parameters
304
+ ----------
305
+ child : TaskBase
306
+ A sub-task that just completed
307
+ """
308
+ if child.state is TaskState.SUCCEEDED:
309
+ # A WhenAll Task only completes when it has no pending tasks
310
+ # i.e _when all_ of its children have completed
311
+ if len(self.pending_tasks) == 0:
312
+ results = list(map(lambda x: x.result, self.children))
313
+ self.set_value(is_error=False, value=results)
314
+ else: # child.state is TaskState.FAILED:
315
+ # a single error is sufficient to fail this task
316
+ if self._first_error is None:
317
+ self._first_error = child.result
318
+ self.set_value(is_error=True, value=self._first_error)
319
+
320
+
321
+ class LongTimerTask(WhenAllTask):
322
+ """A Timer Task for intervals longer than supported by the storage backend."""
323
+
324
+ def __init__(self, id_, action: CreateTimerAction, orchestration_context):
325
+ """Initialize a LongTimerTask.
326
+
327
+ Parameters
328
+ ----------
329
+ id_ : int
330
+ An ID for the task
331
+ action : CreateTimerAction
332
+ The action this task represents
333
+ orchestration_context: DurableOrchestrationContext
334
+ The orchestration context this task was created in
335
+ """
336
+ current_time = orchestration_context.current_utc_datetime
337
+ final_fire_time = action.fire_at
338
+ duration_until_fire = final_fire_time - current_time
339
+
340
+ if duration_until_fire > orchestration_context._maximum_short_timer_duration:
341
+ next_fire_time = current_time + orchestration_context._long_timer_interval_duration
342
+ else:
343
+ next_fire_time = final_fire_time
344
+
345
+ next_timer_action = CreateTimerAction(next_fire_time)
346
+ next_timer_task = TimerTask(None, next_timer_action)
347
+ super().__init__([next_timer_task], orchestration_context._replay_schema)
348
+
349
+ self.id = id_
350
+ self.action = action
351
+ self._orchestration_context = orchestration_context
352
+ self._max_short_timer_duration = self._orchestration_context._maximum_short_timer_duration
353
+ self._long_timer_interval = self._orchestration_context._long_timer_interval_duration
354
+
355
+ def is_canceled(self) -> bool:
356
+ """Check if the LongTimer is cancelled.
357
+
358
+ Returns
359
+ -------
360
+ bool
361
+ Returns whether the timer has been cancelled or not
362
+ """
363
+ return self.action.is_cancelled
364
+
365
+ def cancel(self):
366
+ """Cancel a timer.
367
+
368
+ Raises
369
+ ------
370
+ ValueError
371
+ Raises an error if the task is already completed and an attempt is made to cancel it
372
+ """
373
+ if (self.result):
374
+ raise Exception("Cannot cancel a completed task.")
375
+ self.action.is_cancelled = True
376
+
377
+ def try_set_value(self, child: TimerTask):
378
+ """Transition this LongTimer Task to a terminal state and set its value.
379
+
380
+ If the LongTimer has not yet reached the designated completion time, starts a new
381
+ TimerTask for the next interval and does not close.
382
+
383
+ Parameters
384
+ ----------
385
+ child : TimerTask
386
+ A timer sub-task that just completed
387
+ """
388
+ current_time = self._orchestration_context.current_utc_datetime
389
+ final_fire_time = self.action.fire_at
390
+ if final_fire_time > current_time:
391
+ next_timer = self.get_next_timer_task(final_fire_time, current_time)
392
+ self.add_new_child(next_timer)
393
+ return super().try_set_value(child)
394
+
395
+ def get_next_timer_task(self, final_fire_time: datetime, current_time: datetime) -> TimerTask:
396
+ """Create a TimerTask to represent the next interval of the LongTimer.
397
+
398
+ Parameters
399
+ ----------
400
+ final_fire_time : datetime.datetime
401
+ The final firing time of the LongTimer
402
+ current_time : datetime.datetime
403
+ The current time
404
+
405
+ Returns
406
+ -------
407
+ TimerTask
408
+ A TimerTask representing the next interval of the LongTimer
409
+ """
410
+ duration_until_fire = final_fire_time - current_time
411
+ if duration_until_fire > self._max_short_timer_duration:
412
+ next_fire_time = current_time + self._long_timer_interval
413
+ else:
414
+ next_fire_time = final_fire_time
415
+ return TimerTask(None, CreateTimerAction(next_fire_time))
416
+
417
+ def add_new_child(self, child_timer: TimerTask):
418
+ """Add the TimerTask to this task's children.
419
+
420
+ Also register the TimerTask with the orchestration context.
421
+
422
+ Parameters
423
+ ----------
424
+ child_timer : TimerTask
425
+ The newly created TimerTask to add
426
+ """
427
+ child_timer.parent = self
428
+ self.pending_tasks.add(child_timer)
429
+ self._orchestration_context._add_to_open_tasks(child_timer)
430
+ self._orchestration_context._add_to_actions(child_timer.action_repr)
431
+ child_timer._set_is_scheduled(True)
432
+
433
+
434
+ class WhenAnyTask(CompoundTask):
435
+ """A Task representing `when_any` scenarios."""
436
+
437
+ def __init__(self, task: List[TaskBase], replay_schema: ReplaySchema):
438
+ """Initialize a WhenAnyTask.
439
+
440
+ Parameters
441
+ ----------
442
+ task : List[Task]
443
+ The list of child tasks
444
+ replay_schema : ReplaySchema
445
+ The ReplaySchema, which determines the inner action payload representation
446
+ """
447
+ compound_action_constructor = None
448
+ if replay_schema.value >= ReplaySchema.V2.value:
449
+ compound_action_constructor = WhenAnyAction
450
+ super().__init__(task, compound_action_constructor)
451
+
452
+ def try_set_value(self, child: TaskBase):
453
+ """Transition a WhenAny Task to a terminal state and set its value.
454
+
455
+ Parameters
456
+ ----------
457
+ child : TaskBase
458
+ A sub-task that just completed
459
+ """
460
+ if self.state is TaskState.RUNNING:
461
+ self.set_value(is_error=False, value=child)
462
+
463
+
464
+ class RetryAbleTask(WhenAllTask):
465
+ """A Task representing `with_retry` scenarios.
466
+
467
+ It inherits from WhenAllTask because retryable scenarios are Tasks
468
+ with equivalent to WhenAll Tasks with dynamically increasing lists
469
+ of children. At every failure, we add a Timer child and a Task child
470
+ to the list of pending tasks.
471
+ """
472
+
473
+ def __init__(self, child: TaskBase, retry_options: RetryOptions, context):
474
+ tasks = [child]
475
+ super().__init__(tasks, context._replay_schema)
476
+
477
+ self.retry_options = retry_options
478
+ self.num_attempts = 1
479
+ self.context = context
480
+ self.actions = child.action_repr
481
+ self.is_waiting_on_timer = False
482
+ self.error = None
483
+
484
+ @property
485
+ def id_(self):
486
+ """Obtain the task's ID.
487
+
488
+ Since this is an internal-only abstraction, the task ID is represented
489
+ by the ID of its inner/wrapped task _plus_ a suffix: "_retryable_proxy"
490
+
491
+ Returns
492
+ -------
493
+ [type]
494
+ [description]
495
+ """
496
+ return str(list(map(lambda x: x.id, self.children))) + "_retryable_proxy"
497
+
498
+ def try_set_value(self, child: TaskBase):
499
+ """Transition a Retryable Task to a terminal state and set its value.
500
+
501
+ Parameters
502
+ ----------
503
+ child : TaskBase
504
+ A sub-task that just completed
505
+ """
506
+ if self.is_waiting_on_timer:
507
+ # timer fired, re-scheduling original task
508
+ self.is_waiting_on_timer = False
509
+ # As per DTFx semantics: we need to check the number of retires only after the final
510
+ # timer has fired. This means we essentially have to wait for one "extra" timer after
511
+ # the maximum number of attempts has been reached. Removing this extra timer will cause
512
+ # stuck orchestrators as we need to be "in sync" with the replay logic of DTFx.
513
+ if self.num_attempts >= self.retry_options.max_number_of_attempts:
514
+ self.is_waiting_on_timer = True
515
+ # we have reached the maximum number of attempts, set error
516
+ self.set_value(is_error=True, value=self.error)
517
+ else:
518
+ rescheduled_task = self.context._generate_task(
519
+ action=NoOpAction("rescheduled task"), parent=self)
520
+ self.pending_tasks.add(rescheduled_task)
521
+ self.context._add_to_open_tasks(rescheduled_task)
522
+ self.num_attempts += 1
523
+
524
+ return
525
+ if child.state is TaskState.SUCCEEDED:
526
+ if len(self.pending_tasks) == 0:
527
+ # if all pending tasks have completed,
528
+ # and we have a successful child, then
529
+ # we can set the Task's event
530
+ self.set_value(is_error=False, value=child.result)
531
+
532
+ else: # child.state is TaskState.FAILED:
533
+ # increase size of pending tasks by adding a timer task
534
+ # when it completes, we'll retry the original task
535
+ timer_task = self.context._generate_task(
536
+ action=NoOpAction("-WithRetry timer"), parent=self)
537
+ self.pending_tasks.add(timer_task)
538
+ self.context._add_to_open_tasks(timer_task)
539
+ self.is_waiting_on_timer = True
540
+ self.error = child.result