azure-functions-durable 1.2.8__py3-none-any.whl → 1.2.10__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 (101) 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 +249 -249
  5. azure/durable_functions/decorators/metadata.py +109 -109
  6. azure/durable_functions/entity.py +125 -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 +781 -711
  11. azure/durable_functions/models/DurableOrchestrationContext.py +722 -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 -29
  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 +8 -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 +426 -426
  22. azure/durable_functions/models/TaskOrchestrationExecutor.py +346 -333
  23. azure/durable_functions/models/TokenSource.py +56 -56
  24. azure/durable_functions/models/__init__.py +24 -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 +76 -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 -25
  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 +69 -69
  54. azure/durable_functions/models/utils/json_utils.py +56 -56
  55. azure/durable_functions/orchestrator.py +71 -71
  56. {azure_functions_durable-1.2.8.dist-info → azure_functions_durable-1.2.10.dist-info}/LICENSE +21 -21
  57. {azure_functions_durable-1.2.8.dist-info → azure_functions_durable-1.2.10.dist-info}/METADATA +58 -58
  58. azure_functions_durable-1.2.10.dist-info/RECORD +100 -0
  59. {azure_functions_durable-1.2.8.dist-info → azure_functions_durable-1.2.10.dist-info}/WHEEL +1 -1
  60. tests/models/test_DecoratorMetadata.py +135 -135
  61. tests/models/test_Decorators.py +107 -107
  62. tests/models/test_DurableOrchestrationBindings.py +68 -56
  63. tests/models/test_DurableOrchestrationClient.py +730 -612
  64. tests/models/test_DurableOrchestrationContext.py +102 -102
  65. tests/models/test_DurableOrchestrationStatus.py +59 -59
  66. tests/models/test_OrchestrationState.py +28 -28
  67. tests/models/test_RpcManagementOptions.py +79 -79
  68. tests/models/test_TokenSource.py +10 -10
  69. tests/orchestrator/models/OrchestrationInstance.py +18 -18
  70. tests/orchestrator/orchestrator_test_utils.py +130 -130
  71. tests/orchestrator/schemas/OrchetrationStateSchema.py +66 -66
  72. tests/orchestrator/test_call_http.py +235 -176
  73. tests/orchestrator/test_continue_as_new.py +67 -67
  74. tests/orchestrator/test_create_timer.py +126 -126
  75. tests/orchestrator/test_entity.py +395 -395
  76. tests/orchestrator/test_external_event.py +53 -53
  77. tests/orchestrator/test_fan_out_fan_in.py +175 -175
  78. tests/orchestrator/test_is_replaying_flag.py +101 -101
  79. tests/orchestrator/test_retries.py +308 -308
  80. tests/orchestrator/test_sequential_orchestrator.py +841 -801
  81. tests/orchestrator/test_sequential_orchestrator_custom_status.py +119 -119
  82. tests/orchestrator/test_sequential_orchestrator_with_retry.py +465 -465
  83. tests/orchestrator/test_serialization.py +30 -30
  84. tests/orchestrator/test_sub_orchestrator.py +95 -95
  85. tests/orchestrator/test_sub_orchestrator_with_retry.py +129 -129
  86. tests/orchestrator/test_task_any.py +60 -60
  87. tests/tasks/tasks_test_utils.py +17 -17
  88. tests/tasks/test_new_uuid.py +34 -34
  89. tests/test_utils/ContextBuilder.py +174 -174
  90. tests/test_utils/EntityContextBuilder.py +56 -56
  91. tests/test_utils/constants.py +1 -1
  92. tests/test_utils/json_utils.py +30 -30
  93. tests/test_utils/testClasses.py +56 -56
  94. tests/utils/__init__.py +1 -0
  95. tests/utils/test_entity_utils.py +24 -0
  96. azure_functions_durable-1.2.8.data/data/_manifest/bsi.json +0 -1
  97. azure_functions_durable-1.2.8.data/data/_manifest/manifest.cat +0 -0
  98. azure_functions_durable-1.2.8.data/data/_manifest/manifest.spdx.json +0 -12845
  99. azure_functions_durable-1.2.8.data/data/_manifest/manifest.spdx.json.sha256 +0 -1
  100. azure_functions_durable-1.2.8.dist-info/RECORD +0 -102
  101. {azure_functions_durable-1.2.8.dist-info → azure_functions_durable-1.2.10.dist-info}/top_level.txt +0 -0
@@ -1,333 +1,346 @@
1
- from azure.durable_functions.models.Task import TaskBase, TaskState, AtomicTask, CompoundTask
2
- from azure.durable_functions.models.OrchestratorState import OrchestratorState
3
- from azure.durable_functions.models.DurableOrchestrationContext import DurableOrchestrationContext
4
- from typing import Any, List, Optional, Union
5
- from azure.durable_functions.models.history.HistoryEventType import HistoryEventType
6
- from azure.durable_functions.models.history.HistoryEvent import HistoryEvent
7
- from types import GeneratorType
8
- import warnings
9
- from collections import namedtuple
10
- import json
11
- from ..models.entities.ResponseMessage import ResponseMessage
12
- from azure.functions._durable_functions import _deserialize_custom_object
13
-
14
-
15
- class TaskOrchestrationExecutor:
16
- """Manages the execution and replay of user-defined orchestrations."""
17
-
18
- def __init__(self):
19
- """Initialize TaskOrchestrationExecutor."""
20
- # A mapping of event types to a tuple of
21
- # (1) whether the event type represents a task success
22
- # (2) the attribute in the corresponding event object that identifies the Task
23
- # this mapping is used for processing events that transition a Task from its running state
24
- # to a terminal one
25
- SetTaskValuePayload = namedtuple("SetTaskValuePayload", ("is_success", "task_id_key"))
26
- self.event_to_SetTaskValuePayload = dict([
27
- (HistoryEventType.TASK_COMPLETED, SetTaskValuePayload(True, "TaskScheduledId")),
28
- (HistoryEventType.TIMER_FIRED, SetTaskValuePayload(True, "TimerId")),
29
- (HistoryEventType.SUB_ORCHESTRATION_INSTANCE_COMPLETED,
30
- SetTaskValuePayload(True, "TaskScheduledId")),
31
- (HistoryEventType.EVENT_RAISED, SetTaskValuePayload(True, "Name")),
32
- (HistoryEventType.TASK_FAILED, SetTaskValuePayload(False, "TaskScheduledId")),
33
- (HistoryEventType.SUB_ORCHESTRATION_INSTANCE_FAILED,
34
- SetTaskValuePayload(False, "TaskScheduledId"))
35
- ])
36
- self.task_completion_events = set(self.event_to_SetTaskValuePayload.keys())
37
- self.initialize()
38
-
39
- def initialize(self):
40
- """Initialize the TaskOrchestrationExecutor for a new orchestration invocation."""
41
- # The first task is just a placeholder to kickstart the generator.
42
- # So it's value is `None`.
43
- self.current_task: TaskBase = AtomicTask(-1, [])
44
- self.current_task.set_value(is_error=False, value=None)
45
-
46
- self.output: Any = None
47
- self.exception: Optional[Exception] = None
48
- self.orchestrator_returned: bool = False
49
-
50
- def execute(self, context: DurableOrchestrationContext,
51
- history: List[HistoryEvent], fn) -> str:
52
- """Execute an orchestration via its history to evaluate Tasks and replay events.
53
-
54
- Parameters
55
- ----------
56
- context : DurableOrchestrationContext
57
- The user's orchestration context, to interact with the user code.
58
- history : List[HistoryEvent]
59
- The orchestration history, to evaluate tasks and replay events.
60
- fn : function
61
- The user's orchestration function.
62
-
63
- Returns
64
- -------
65
- str
66
- A JSON-formatted string of the user's orchestration state, payload for the extension.
67
- """
68
- self.context = context
69
- evaluated_user_code = fn(context)
70
-
71
- # The minimum History size is 2, in the shape: [OrchestratorStarted, ExecutionStarted].
72
- # At the start of replay, the `is_replaying` flag is determined from the
73
- # ExecutionStarted event.
74
- # For some reason, OrchestratorStarted does not update its `isPlayed` field.
75
- if len(history) < 2:
76
- err_message = "Internal Durable Functions error: "\
77
- + f"received History array of size {len(history)} "\
78
- + "when a minimum size of 2 is expected. "\
79
- + "Please report this issue at "\
80
- + "https://github.com/Azure/azure-functions-durable-python/issues."
81
- raise Exception(err_message)
82
-
83
- # Set initial is_replaing state.
84
- execution_started_event = history[1]
85
- self.current_task.is_played = execution_started_event.is_played
86
-
87
- # If user code is a generator, then it uses `yield` statements (the DF API)
88
- # and so we iterate through the DF history, generating tasks and populating
89
- # them with values when the history provides them
90
- if isinstance(evaluated_user_code, GeneratorType):
91
- self.generator = evaluated_user_code
92
- for event in history:
93
- self.process_event(event)
94
- if self.has_execution_completed:
95
- break
96
-
97
- # Due to backwards compatibility reasons, it's possible
98
- # for the `continue_as_new` API to be called without `yield` statements.
99
- # Therefore, we explicitely check if `continue_as_new` was used before
100
- # flatting the orchestration as returned/completed
101
- elif not self.context.will_continue_as_new:
102
- self.orchestrator_returned = True
103
- self.output = evaluated_user_code
104
- return self.get_orchestrator_state_str()
105
-
106
- def process_event(self, event: HistoryEvent):
107
- """Evaluate a history event.
108
-
109
- This might result in updating some orchestration internal state deterministically,
110
- to evaluating some Task, or have no side-effects.
111
-
112
- Parameters
113
- ----------
114
- event : HistoryEvent
115
- The history event to process
116
- """
117
- event_type = event.event_type
118
- if event_type == HistoryEventType.ORCHESTRATOR_STARTED:
119
- # update orchestration's deterministic timestamp
120
- timestamp = event.timestamp
121
- if timestamp > self.context.current_utc_datetime:
122
- self.context.current_utc_datetime = event.timestamp
123
- elif event.event_type == HistoryEventType.CONTINUE_AS_NEW:
124
- # re-initialize the orchestration state
125
- self.initialize()
126
- elif event_type == HistoryEventType.EXECUTION_STARTED:
127
- # begin replaying user code
128
- self.resume_user_code()
129
- elif event_type == HistoryEventType.EVENT_SENT:
130
- # we want to differentiate between a "proper" event sent, and a signal/call entity
131
- key = getattr(event, "event_id")
132
- if key in self.context.open_tasks.keys():
133
- task = self.context.open_tasks[key]
134
- if task._api_name == "CallEntityAction":
135
- # in the signal entity case, the Task is represented
136
- # with a GUID, not with a sequential integer
137
- self.context.open_tasks.pop(key)
138
- event_id = json.loads(event.Input)["id"]
139
- self.context.open_tasks[event_id] = task
140
-
141
- elif self.is_task_completion_event(event.event_type):
142
- # transition a task to a success or failure state
143
- (is_success, id_key) = self.event_to_SetTaskValuePayload[event_type]
144
- self.set_task_value(event, is_success, id_key)
145
- self.resume_user_code()
146
-
147
- def set_task_value(self, event: HistoryEvent, is_success: bool, id_key: str):
148
- """Set a running task to either a success or failed state, and sets its value.
149
-
150
- Parameters
151
- ----------
152
- event : HistoryEvent
153
- The history event containing the value for the Task
154
- is_success : bool
155
- Whether the Task succeeded or failed (throws exception)
156
- id_key : str
157
- The attribute in the event object containing the ID of the Task to target
158
- """
159
-
160
- def parse_history_event(directive_result):
161
- """Based on the type of event, parse the JSON.serializable portion of the event."""
162
- event_type = directive_result.event_type
163
- if event_type is None:
164
- raise ValueError("EventType is not found in task object")
165
-
166
- # We provide the ability to deserialize custom objects, because the output of this
167
- # will be passed directly to the orchestrator as the output of some activity
168
- if event_type == HistoryEventType.SUB_ORCHESTRATION_INSTANCE_COMPLETED:
169
- return json.loads(directive_result.Result, object_hook=_deserialize_custom_object)
170
- if event_type == HistoryEventType.TASK_COMPLETED:
171
- return json.loads(directive_result.Result, object_hook=_deserialize_custom_object)
172
- if event_type == HistoryEventType.EVENT_RAISED:
173
- # TODO: Investigate why the payload is in "Input" instead of "Result"
174
- response = json.loads(directive_result.Input,
175
- object_hook=_deserialize_custom_object)
176
- return response
177
- return None
178
-
179
- # get target task
180
- key = getattr(event, id_key)
181
- try:
182
- task: Union[TaskBase, List[TaskBase]] = self.context.open_tasks.pop(key)
183
- if isinstance(task, list):
184
- task_list = task
185
- task = task_list.pop()
186
- if len(task_list) > 0:
187
- self.context.open_tasks[key] = task_list
188
- except KeyError:
189
- warning = f"Potential duplicate Task completion for TaskId: {key}"
190
- warnings.warn(warning)
191
- self.context.deferred_tasks[key] = lambda: self.set_task_value(
192
- event, is_success, id_key)
193
- return
194
-
195
- if is_success:
196
- # retrieve result
197
- new_value = parse_history_event(event)
198
- if task._api_name == "CallEntityAction":
199
- event_payload = ResponseMessage.from_dict(new_value)
200
- new_value = json.loads(event_payload.result)
201
-
202
- if event_payload.is_exception:
203
- new_value = Exception(new_value)
204
- is_success = False
205
- else:
206
- # generate exception
207
- new_value = Exception(f"{event.Reason} \n {event.Details}")
208
-
209
- # with a yielded task now evaluated, we can try to resume the user code
210
- task.set_is_played(event._is_played)
211
- task.set_value(is_error=not is_success, value=new_value)
212
-
213
- def resume_user_code(self):
214
- """Attempt to continue executing user code.
215
-
216
- We can only continue executing if the active/current task has resolved to a value.
217
- """
218
- current_task = self.current_task
219
- self.context._set_is_replaying(current_task.is_played)
220
- if current_task.state is TaskState.RUNNING:
221
- # if the current task hasn't been resolved, we can't
222
- # continue executing the user code.
223
- return
224
-
225
- new_task = None
226
- try:
227
- # resume orchestration with a resolved task's value
228
- task_value = current_task.result
229
- task_succeeded = current_task.state is TaskState.SUCCEEDED
230
- new_task = self.generator.send(
231
- task_value) if task_succeeded else self.generator.throw(task_value)
232
- if isinstance(new_task, TaskBase) and not (new_task._is_scheduled):
233
- self.context._add_to_open_tasks(new_task)
234
- except StopIteration as stop_exception:
235
- # the orchestration returned,
236
- # flag it as such and capture its output
237
- self.orchestrator_returned = True
238
- self.output = stop_exception.value
239
- except Exception as exception:
240
- # the orchestration threw an exception
241
- self.exception = exception
242
-
243
- self.current_task = new_task
244
- if not (new_task is None):
245
- if not (new_task.state is TaskState.RUNNING):
246
- # user yielded the same task multiple times, continue executing code
247
- # until a new/not-previously-yielded task is encountered
248
- self.resume_user_code()
249
- elif not (self.current_task._is_scheduled):
250
- # new task is received. it needs to be resolved to a value
251
- self.context._add_to_actions(self.current_task.action_repr)
252
- self._mark_as_scheduled(self.current_task)
253
-
254
- def _mark_as_scheduled(self, task: TaskBase):
255
- if isinstance(task, CompoundTask):
256
- for task in task.children:
257
- self._mark_as_scheduled(task)
258
- else:
259
- task._set_is_scheduled(True)
260
-
261
- def get_orchestrator_state_str(self) -> str:
262
- """Obtain a JSON-formatted string representing the orchestration's state.
263
-
264
- Returns
265
- -------
266
- str
267
- String represented orchestration's state, payload to the extension
268
-
269
- Raises
270
- ------
271
- Exception
272
- When the orchestration's state represents an error. The exception's
273
- message contains in it the string representation of the orchestration's
274
- state
275
- """
276
- state = OrchestratorState(
277
- is_done=self.orchestration_invocation_succeeded,
278
- actions=self.context._actions,
279
- output=self.output,
280
- replay_schema=self.context._replay_schema,
281
- error=None if self.exception is None else str(self.exception),
282
- custom_status=self.context.custom_status
283
- )
284
-
285
- if self.exception is not None:
286
- # Create formatted error, using out-of-proc error schema
287
- error_label = "\n\n$OutOfProcData$:"
288
- state_str = state.to_json_string()
289
- formatted_error = f"{self.exception}{error_label}{state_str}"
290
-
291
- # Raise exception, re-set stack to original location
292
- raise Exception(formatted_error) from self.exception
293
- return state.to_json_string()
294
-
295
- def is_task_completion_event(self, event_type: HistoryEventType) -> bool:
296
- """Determine if some event_type corresponds to a Task-resolution event.
297
-
298
- Parameters
299
- ----------
300
- event_type : HistoryEventType
301
- The event_type to analyze.
302
-
303
- Returns
304
- -------
305
- bool
306
- True if the event corresponds to a Task being resolved. False otherwise.
307
- """
308
- return event_type in self.task_completion_events
309
-
310
- @property
311
- def has_execution_completed(self) -> bool:
312
- """Determine if the orchestration invocation is completed.
313
-
314
- An orchestration can complete either by returning,
315
- continuing-as-new, or through an exception.
316
-
317
- Returns
318
- -------
319
- bool
320
- Whether the orchestration invocation is completed.
321
- """
322
- return self.orchestration_invocation_succeeded or not (self.exception is None)
323
-
324
- @property
325
- def orchestration_invocation_succeeded(self) -> bool:
326
- """Whether the orchestration returned or continued-as-new.
327
-
328
- Returns
329
- -------
330
- bool
331
- Whether the orchestration returned or continued-as-new
332
- """
333
- return self.orchestrator_returned or self.context.will_continue_as_new
1
+ from azure.durable_functions.models.Task import TaskBase, TaskState, AtomicTask, CompoundTask
2
+ from azure.durable_functions.models.OrchestratorState import OrchestratorState
3
+ from azure.durable_functions.models.DurableOrchestrationContext import DurableOrchestrationContext
4
+ from typing import Any, List, Optional, Union
5
+ from azure.durable_functions.models.history.HistoryEventType import HistoryEventType
6
+ from azure.durable_functions.models.history.HistoryEvent import HistoryEvent
7
+ from types import GeneratorType
8
+ import warnings
9
+ from collections import namedtuple
10
+ import json
11
+ from ..models.entities.ResponseMessage import ResponseMessage
12
+ from azure.functions._durable_functions import _deserialize_custom_object
13
+
14
+
15
+ class TaskOrchestrationExecutor:
16
+ """Manages the execution and replay of user-defined orchestrations."""
17
+
18
+ def __init__(self):
19
+ """Initialize TaskOrchestrationExecutor."""
20
+ # A mapping of event types to a tuple of
21
+ # (1) whether the event type represents a task success
22
+ # (2) the attribute in the corresponding event object that identifies the Task
23
+ # this mapping is used for processing events that transition a Task from its running state
24
+ # to a terminal one
25
+ SetTaskValuePayload = namedtuple("SetTaskValuePayload", ("is_success", "task_id_key"))
26
+ self.event_to_SetTaskValuePayload = dict([
27
+ (HistoryEventType.TASK_COMPLETED, SetTaskValuePayload(True, "TaskScheduledId")),
28
+ (HistoryEventType.TIMER_FIRED, SetTaskValuePayload(True, "TimerId")),
29
+ (HistoryEventType.SUB_ORCHESTRATION_INSTANCE_COMPLETED,
30
+ SetTaskValuePayload(True, "TaskScheduledId")),
31
+ (HistoryEventType.EVENT_RAISED, SetTaskValuePayload(True, "Name")),
32
+ (HistoryEventType.TASK_FAILED, SetTaskValuePayload(False, "TaskScheduledId")),
33
+ (HistoryEventType.SUB_ORCHESTRATION_INSTANCE_FAILED,
34
+ SetTaskValuePayload(False, "TaskScheduledId"))
35
+ ])
36
+ self.task_completion_events = set(self.event_to_SetTaskValuePayload.keys())
37
+ self.initialize()
38
+
39
+ def initialize(self):
40
+ """Initialize the TaskOrchestrationExecutor for a new orchestration invocation."""
41
+ # The first task is just a placeholder to kickstart the generator.
42
+ # So it's value is `None`.
43
+ self.current_task: TaskBase = AtomicTask(-1, [])
44
+ self.current_task.set_value(is_error=False, value=None)
45
+
46
+ self.output: Any = None
47
+ self.exception: Optional[Exception] = None
48
+ self.orchestrator_returned: bool = False
49
+
50
+ def execute(self, context: DurableOrchestrationContext,
51
+ history: List[HistoryEvent], fn) -> str:
52
+ """Execute an orchestration via its history to evaluate Tasks and replay events.
53
+
54
+ Parameters
55
+ ----------
56
+ context : DurableOrchestrationContext
57
+ The user's orchestration context, to interact with the user code.
58
+ history : List[HistoryEvent]
59
+ The orchestration history, to evaluate tasks and replay events.
60
+ fn : function
61
+ The user's orchestration function.
62
+
63
+ Returns
64
+ -------
65
+ str
66
+ A JSON-formatted string of the user's orchestration state, payload for the extension.
67
+ """
68
+ self.context = context
69
+ evaluated_user_code = fn(context)
70
+
71
+ # The minimum History size is 2, in the shape: [OrchestratorStarted, ExecutionStarted].
72
+ # At the start of replay, the `is_replaying` flag is determined from the
73
+ # ExecutionStarted event.
74
+ # For some reason, OrchestratorStarted does not update its `isPlayed` field.
75
+ if len(history) < 2:
76
+ err_message = "Internal Durable Functions error: "\
77
+ + f"received History array of size {len(history)} "\
78
+ + "when a minimum size of 2 is expected. "\
79
+ + "Please report this issue at "\
80
+ + "https://github.com/Azure/azure-functions-durable-python/issues."
81
+ raise Exception(err_message)
82
+
83
+ # Set initial is_replaing state.
84
+ execution_started_event = history[1]
85
+ self.current_task.is_played = execution_started_event.is_played
86
+
87
+ # If user code is a generator, then it uses `yield` statements (the DF API)
88
+ # and so we iterate through the DF history, generating tasks and populating
89
+ # them with values when the history provides them
90
+ if isinstance(evaluated_user_code, GeneratorType):
91
+ self.generator = evaluated_user_code
92
+ for event in history:
93
+ self.process_event(event)
94
+ if self.has_execution_completed:
95
+ break
96
+
97
+ # Due to backwards compatibility reasons, it's possible
98
+ # for the `continue_as_new` API to be called without `yield` statements.
99
+ # Therefore, we explicitely check if `continue_as_new` was used before
100
+ # flatting the orchestration as returned/completed
101
+ elif not self.context.will_continue_as_new:
102
+ self.orchestrator_returned = True
103
+ self.output = evaluated_user_code
104
+ return self.get_orchestrator_state_str()
105
+
106
+ def process_event(self, event: HistoryEvent):
107
+ """Evaluate a history event.
108
+
109
+ This might result in updating some orchestration internal state deterministically,
110
+ to evaluating some Task, or have no side-effects.
111
+
112
+ Parameters
113
+ ----------
114
+ event : HistoryEvent
115
+ The history event to process
116
+ """
117
+ event_type = event.event_type
118
+ if event_type == HistoryEventType.ORCHESTRATOR_STARTED:
119
+ # update orchestration's deterministic timestamp
120
+ timestamp = event.timestamp
121
+ if timestamp > self.context.current_utc_datetime:
122
+ self.context.current_utc_datetime = event.timestamp
123
+ elif event.event_type == HistoryEventType.CONTINUE_AS_NEW:
124
+ # re-initialize the orchestration state
125
+ self.initialize()
126
+ elif event_type == HistoryEventType.EXECUTION_STARTED:
127
+ # begin replaying user code
128
+ self.resume_user_code()
129
+ elif event_type == HistoryEventType.EVENT_SENT:
130
+ # we want to differentiate between a "proper" event sent, and a signal/call entity
131
+ key = getattr(event, "event_id")
132
+ if key in self.context.open_tasks.keys():
133
+ task = self.context.open_tasks[key]
134
+ if task._api_name == "CallEntityAction":
135
+ # in the signal entity case, the Task is represented
136
+ # with a GUID, not with a sequential integer
137
+ self.context.open_tasks.pop(key)
138
+ event_id = json.loads(event.Input)["id"]
139
+ self.context.open_tasks[event_id] = task
140
+
141
+ elif self.is_task_completion_event(event.event_type):
142
+ # transition a task to a success or failure state
143
+ (is_success, id_key) = self.event_to_SetTaskValuePayload[event_type]
144
+ self.set_task_value(event, is_success, id_key)
145
+ self.resume_user_code()
146
+
147
+ def set_task_value(self, event: HistoryEvent, is_success: bool, id_key: str):
148
+ """Set a running task to either a success or failed state, and sets its value.
149
+
150
+ Parameters
151
+ ----------
152
+ event : HistoryEvent
153
+ The history event containing the value for the Task
154
+ is_success : bool
155
+ Whether the Task succeeded or failed (throws exception)
156
+ id_key : str
157
+ The attribute in the event object containing the ID of the Task to target
158
+ """
159
+
160
+ def parse_history_event(directive_result):
161
+ """Based on the type of event, parse the JSON.serializable portion of the event."""
162
+ event_type = directive_result.event_type
163
+ if event_type is None:
164
+ raise ValueError("EventType is not found in task object")
165
+
166
+ # We provide the ability to deserialize custom objects, because the output of this
167
+ # will be passed directly to the orchestrator as the output of some activity
168
+ if (event_type == HistoryEventType.SUB_ORCHESTRATION_INSTANCE_COMPLETED
169
+ and directive_result.Result is not None):
170
+ return json.loads(directive_result.Result, object_hook=_deserialize_custom_object)
171
+ if (event_type == HistoryEventType.TASK_COMPLETED
172
+ and directive_result.Result is not None):
173
+ return json.loads(directive_result.Result, object_hook=_deserialize_custom_object)
174
+ if (event_type == HistoryEventType.EVENT_RAISED
175
+ and directive_result.Input is not None):
176
+ # TODO: Investigate why the payload is in "Input" instead of "Result"
177
+ response = json.loads(directive_result.Input,
178
+ object_hook=_deserialize_custom_object)
179
+ return response
180
+ return None
181
+
182
+ # get target task
183
+ key = getattr(event, id_key)
184
+ try:
185
+ task: Union[TaskBase, List[TaskBase]] = self.context.open_tasks.pop(key)
186
+ if isinstance(task, list):
187
+ task_list = task
188
+ task = task_list.pop()
189
+ if len(task_list) > 0:
190
+ self.context.open_tasks[key] = task_list
191
+ except KeyError:
192
+ warning = f"Potential duplicate Task completion for TaskId: {key}"
193
+ warnings.warn(warning)
194
+ self.context.deferred_tasks[key] = lambda: self.set_task_value(
195
+ event, is_success, id_key)
196
+ return
197
+
198
+ if is_success:
199
+ # retrieve result
200
+ new_value = parse_history_event(event)
201
+ if task._api_name == "CallEntityAction":
202
+ event_payload = ResponseMessage.from_dict(new_value)
203
+ new_value = json.loads(event_payload.result)
204
+
205
+ if event_payload.is_exception:
206
+ new_value = Exception(new_value)
207
+ is_success = False
208
+ else:
209
+ # generate exception
210
+ new_value = Exception(f"{event.Reason} \n {event.Details}")
211
+
212
+ # with a yielded task now evaluated, we can try to resume the user code
213
+ task.set_is_played(event._is_played)
214
+ task.set_value(is_error=not is_success, value=new_value)
215
+
216
+ def resume_user_code(self):
217
+ """Attempt to continue executing user code.
218
+
219
+ We can only continue executing if the active/current task has resolved to a value.
220
+ """
221
+ current_task = self.current_task
222
+ self.context._set_is_replaying(current_task.is_played)
223
+ if current_task.state is TaskState.RUNNING:
224
+ # if the current task hasn't been resolved, we can't
225
+ # continue executing the user code.
226
+ return
227
+
228
+ new_task = None
229
+ try:
230
+ # resume orchestration with a resolved task's value
231
+ task_value = current_task.result
232
+ task_succeeded = current_task.state is TaskState.SUCCEEDED
233
+ new_task = self.generator.send(
234
+ task_value) if task_succeeded else self.generator.throw(task_value)
235
+ if isinstance(new_task, TaskBase) and not (new_task._is_scheduled):
236
+ self.context._add_to_open_tasks(new_task)
237
+ except StopIteration as stop_exception:
238
+ # the orchestration returned,
239
+ # flag it as such and capture its output
240
+ self.orchestrator_returned = True
241
+ self.output = stop_exception.value
242
+ except Exception as exception:
243
+ # the orchestration threw an exception
244
+ self.exception = exception
245
+
246
+ self.current_task = new_task
247
+ if not (new_task is None):
248
+ if not (new_task.state is TaskState.RUNNING):
249
+ # user yielded the same task multiple times, continue executing code
250
+ # until a new/not-previously-yielded task is encountered
251
+ self.resume_user_code()
252
+ elif not (self.current_task._is_scheduled):
253
+ # new task is received. it needs to be resolved to a value
254
+ self.context._add_to_actions(self.current_task.action_repr)
255
+ self._mark_as_scheduled(self.current_task)
256
+
257
+ def _mark_as_scheduled(self, task: TaskBase):
258
+ if isinstance(task, CompoundTask):
259
+ for task in task.children:
260
+ self._mark_as_scheduled(task)
261
+ else:
262
+ task._set_is_scheduled(True)
263
+
264
+ def get_orchestrator_state_str(self) -> str:
265
+ """Obtain a JSON-formatted string representing the orchestration's state.
266
+
267
+ Returns
268
+ -------
269
+ str
270
+ String represented orchestration's state, payload to the extension
271
+
272
+ Raises
273
+ ------
274
+ Exception
275
+ When the orchestration's state represents an error. The exception's
276
+ message contains in it the string representation of the orchestration's
277
+ state
278
+ """
279
+ if(self.output is not None):
280
+ try:
281
+ # Attempt to serialize the output. If serialization fails, raise an
282
+ # error indicating that the orchestration output is not serializable,
283
+ # which is not permitted in durable Python functions.
284
+ json.dumps(self.output)
285
+ except Exception as e:
286
+ self.output = None
287
+ self.exception = e
288
+
289
+ state = OrchestratorState(
290
+ is_done=self.orchestration_invocation_succeeded,
291
+ actions=self.context._actions,
292
+ output=self.output,
293
+ replay_schema=self.context._replay_schema,
294
+ error=None if self.exception is None else str(self.exception),
295
+ custom_status=self.context.custom_status
296
+ )
297
+
298
+ if self.exception is not None:
299
+ # Create formatted error, using out-of-proc error schema
300
+ error_label = "\n\n$OutOfProcData$:"
301
+ state_str = state.to_json_string()
302
+ formatted_error = f"{self.exception}{error_label}{state_str}"
303
+
304
+ # Raise exception, re-set stack to original location
305
+ raise Exception(formatted_error) from self.exception
306
+ return state.to_json_string()
307
+
308
+ def is_task_completion_event(self, event_type: HistoryEventType) -> bool:
309
+ """Determine if some event_type corresponds to a Task-resolution event.
310
+
311
+ Parameters
312
+ ----------
313
+ event_type : HistoryEventType
314
+ The event_type to analyze.
315
+
316
+ Returns
317
+ -------
318
+ bool
319
+ True if the event corresponds to a Task being resolved. False otherwise.
320
+ """
321
+ return event_type in self.task_completion_events
322
+
323
+ @property
324
+ def has_execution_completed(self) -> bool:
325
+ """Determine if the orchestration invocation is completed.
326
+
327
+ An orchestration can complete either by returning,
328
+ continuing-as-new, or through an exception.
329
+
330
+ Returns
331
+ -------
332
+ bool
333
+ Whether the orchestration invocation is completed.
334
+ """
335
+ return self.orchestration_invocation_succeeded or not (self.exception is None)
336
+
337
+ @property
338
+ def orchestration_invocation_succeeded(self) -> bool:
339
+ """Whether the orchestration returned or continued-as-new.
340
+
341
+ Returns
342
+ -------
343
+ bool
344
+ Whether the orchestration returned or continued-as-new
345
+ """
346
+ return self.orchestrator_returned or self.context.will_continue_as_new