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.
- azure/durable_functions/__init__.py +81 -81
- azure/durable_functions/constants.py +9 -9
- azure/durable_functions/decorators/__init__.py +3 -3
- azure/durable_functions/decorators/durable_app.py +249 -249
- azure/durable_functions/decorators/metadata.py +109 -109
- azure/durable_functions/entity.py +125 -125
- azure/durable_functions/models/DurableEntityContext.py +201 -201
- azure/durable_functions/models/DurableHttpRequest.py +58 -58
- azure/durable_functions/models/DurableOrchestrationBindings.py +66 -66
- azure/durable_functions/models/DurableOrchestrationClient.py +781 -711
- azure/durable_functions/models/DurableOrchestrationContext.py +722 -707
- azure/durable_functions/models/DurableOrchestrationStatus.py +156 -156
- azure/durable_functions/models/EntityStateResponse.py +23 -23
- azure/durable_functions/models/FunctionContext.py +7 -7
- azure/durable_functions/models/OrchestrationRuntimeStatus.py +32 -29
- azure/durable_functions/models/OrchestratorState.py +117 -116
- azure/durable_functions/models/PurgeHistoryResult.py +33 -33
- azure/durable_functions/models/ReplaySchema.py +8 -8
- azure/durable_functions/models/RetryOptions.py +69 -69
- azure/durable_functions/models/RpcManagementOptions.py +86 -86
- azure/durable_functions/models/Task.py +426 -426
- azure/durable_functions/models/TaskOrchestrationExecutor.py +346 -333
- azure/durable_functions/models/TokenSource.py +56 -56
- azure/durable_functions/models/__init__.py +24 -24
- azure/durable_functions/models/actions/Action.py +23 -23
- azure/durable_functions/models/actions/ActionType.py +18 -18
- azure/durable_functions/models/actions/CallActivityAction.py +41 -41
- azure/durable_functions/models/actions/CallActivityWithRetryAction.py +45 -45
- azure/durable_functions/models/actions/CallEntityAction.py +46 -46
- azure/durable_functions/models/actions/CallHttpAction.py +35 -35
- azure/durable_functions/models/actions/CallSubOrchestratorAction.py +40 -40
- azure/durable_functions/models/actions/CallSubOrchestratorWithRetryAction.py +44 -44
- azure/durable_functions/models/actions/CompoundAction.py +35 -35
- azure/durable_functions/models/actions/ContinueAsNewAction.py +36 -36
- azure/durable_functions/models/actions/CreateTimerAction.py +48 -48
- azure/durable_functions/models/actions/NoOpAction.py +35 -35
- azure/durable_functions/models/actions/SignalEntityAction.py +47 -47
- azure/durable_functions/models/actions/WaitForExternalEventAction.py +63 -63
- azure/durable_functions/models/actions/WhenAllAction.py +14 -14
- azure/durable_functions/models/actions/WhenAnyAction.py +14 -14
- azure/durable_functions/models/actions/__init__.py +24 -24
- azure/durable_functions/models/entities/EntityState.py +74 -74
- azure/durable_functions/models/entities/OperationResult.py +76 -76
- azure/durable_functions/models/entities/RequestMessage.py +53 -53
- azure/durable_functions/models/entities/ResponseMessage.py +48 -48
- azure/durable_functions/models/entities/Signal.py +62 -62
- azure/durable_functions/models/entities/__init__.py +17 -17
- azure/durable_functions/models/history/HistoryEvent.py +92 -92
- azure/durable_functions/models/history/HistoryEventType.py +27 -25
- azure/durable_functions/models/history/__init__.py +8 -8
- azure/durable_functions/models/utils/__init__.py +7 -7
- azure/durable_functions/models/utils/entity_utils.py +103 -91
- azure/durable_functions/models/utils/http_utils.py +69 -69
- azure/durable_functions/models/utils/json_utils.py +56 -56
- azure/durable_functions/orchestrator.py +71 -71
- {azure_functions_durable-1.2.8.dist-info → azure_functions_durable-1.2.10.dist-info}/LICENSE +21 -21
- {azure_functions_durable-1.2.8.dist-info → azure_functions_durable-1.2.10.dist-info}/METADATA +58 -58
- azure_functions_durable-1.2.10.dist-info/RECORD +100 -0
- {azure_functions_durable-1.2.8.dist-info → azure_functions_durable-1.2.10.dist-info}/WHEEL +1 -1
- tests/models/test_DecoratorMetadata.py +135 -135
- tests/models/test_Decorators.py +107 -107
- tests/models/test_DurableOrchestrationBindings.py +68 -56
- tests/models/test_DurableOrchestrationClient.py +730 -612
- tests/models/test_DurableOrchestrationContext.py +102 -102
- tests/models/test_DurableOrchestrationStatus.py +59 -59
- tests/models/test_OrchestrationState.py +28 -28
- tests/models/test_RpcManagementOptions.py +79 -79
- tests/models/test_TokenSource.py +10 -10
- tests/orchestrator/models/OrchestrationInstance.py +18 -18
- tests/orchestrator/orchestrator_test_utils.py +130 -130
- tests/orchestrator/schemas/OrchetrationStateSchema.py +66 -66
- tests/orchestrator/test_call_http.py +235 -176
- tests/orchestrator/test_continue_as_new.py +67 -67
- tests/orchestrator/test_create_timer.py +126 -126
- tests/orchestrator/test_entity.py +395 -395
- tests/orchestrator/test_external_event.py +53 -53
- tests/orchestrator/test_fan_out_fan_in.py +175 -175
- tests/orchestrator/test_is_replaying_flag.py +101 -101
- tests/orchestrator/test_retries.py +308 -308
- tests/orchestrator/test_sequential_orchestrator.py +841 -801
- tests/orchestrator/test_sequential_orchestrator_custom_status.py +119 -119
- tests/orchestrator/test_sequential_orchestrator_with_retry.py +465 -465
- tests/orchestrator/test_serialization.py +30 -30
- tests/orchestrator/test_sub_orchestrator.py +95 -95
- tests/orchestrator/test_sub_orchestrator_with_retry.py +129 -129
- tests/orchestrator/test_task_any.py +60 -60
- tests/tasks/tasks_test_utils.py +17 -17
- tests/tasks/test_new_uuid.py +34 -34
- tests/test_utils/ContextBuilder.py +174 -174
- tests/test_utils/EntityContextBuilder.py +56 -56
- tests/test_utils/constants.py +1 -1
- tests/test_utils/json_utils.py +30 -30
- tests/test_utils/testClasses.py +56 -56
- tests/utils/__init__.py +1 -0
- tests/utils/test_entity_utils.py +24 -0
- azure_functions_durable-1.2.8.data/data/_manifest/bsi.json +0 -1
- azure_functions_durable-1.2.8.data/data/_manifest/manifest.cat +0 -0
- azure_functions_durable-1.2.8.data/data/_manifest/manifest.spdx.json +0 -12845
- azure_functions_durable-1.2.8.data/data/_manifest/manifest.spdx.json.sha256 +0 -1
- azure_functions_durable-1.2.8.dist-info/RECORD +0 -102
- {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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
"""
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
self.
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
# new task is
|
|
251
|
-
self.
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
task.
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|