durabletask 0.4.0__py3-none-any.whl → 0.5.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.
Potentially problematic release.
This version of durabletask might be problematic. Click here for more details.
- durabletask/client.py +15 -1
- durabletask/entities/__init__.py +13 -0
- durabletask/entities/durable_entity.py +93 -0
- durabletask/entities/entity_context.py +154 -0
- durabletask/entities/entity_instance_id.py +40 -0
- durabletask/entities/entity_lock.py +17 -0
- durabletask/internal/entity_state_shim.py +66 -0
- durabletask/internal/exceptions.py +7 -3
- durabletask/internal/helpers.py +58 -0
- durabletask/internal/orchestration_entity_context.py +115 -0
- durabletask/task.py +69 -0
- durabletask/worker.py +489 -32
- durabletask-0.5.0.dist-info/METADATA +64 -0
- durabletask-0.5.0.dist-info/RECORD +23 -0
- durabletask-0.4.0.dist-info/METADATA +0 -254
- durabletask-0.4.0.dist-info/RECORD +0 -16
- {durabletask-0.4.0.dist-info → durabletask-0.5.0.dist-info}/WHEEL +0 -0
- {durabletask-0.4.0.dist-info → durabletask-0.5.0.dist-info}/licenses/LICENSE +0 -0
- {durabletask-0.4.0.dist-info → durabletask-0.5.0.dist-info}/top_level.txt +0 -0
durabletask/client.py
CHANGED
|
@@ -4,13 +4,14 @@
|
|
|
4
4
|
import logging
|
|
5
5
|
import uuid
|
|
6
6
|
from dataclasses import dataclass
|
|
7
|
-
from datetime import datetime
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
8
|
from enum import Enum
|
|
9
9
|
from typing import Any, Optional, Sequence, TypeVar, Union
|
|
10
10
|
|
|
11
11
|
import grpc
|
|
12
12
|
from google.protobuf import wrappers_pb2
|
|
13
13
|
|
|
14
|
+
from durabletask.entities import EntityInstanceId
|
|
14
15
|
import durabletask.internal.helpers as helpers
|
|
15
16
|
import durabletask.internal.orchestrator_service_pb2 as pb
|
|
16
17
|
import durabletask.internal.orchestrator_service_pb2_grpc as stubs
|
|
@@ -227,3 +228,16 @@ class TaskHubGrpcClient:
|
|
|
227
228
|
req = pb.PurgeInstancesRequest(instanceId=instance_id, recursive=recursive)
|
|
228
229
|
self._logger.info(f"Purging instance '{instance_id}'.")
|
|
229
230
|
self._stub.PurgeInstances(req)
|
|
231
|
+
|
|
232
|
+
def signal_entity(self, entity_instance_id: EntityInstanceId, operation_name: str, input: Optional[Any] = None):
|
|
233
|
+
req = pb.SignalEntityRequest(
|
|
234
|
+
instanceId=str(entity_instance_id),
|
|
235
|
+
name=operation_name,
|
|
236
|
+
input=wrappers_pb2.StringValue(value=shared.to_json(input)) if input else None,
|
|
237
|
+
requestId=str(uuid.uuid4()),
|
|
238
|
+
scheduledTime=None,
|
|
239
|
+
parentTraceContext=None,
|
|
240
|
+
requestTime=helpers.new_timestamp(datetime.now(timezone.utc))
|
|
241
|
+
)
|
|
242
|
+
self._logger.info(f"Signaling entity '{entity_instance_id}' operation '{operation_name}'.")
|
|
243
|
+
self._stub.SignalEntity(req, None) # TODO: Cancellation timeout?
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Copyright (c) Microsoft Corporation.
|
|
2
|
+
# Licensed under the MIT License.
|
|
3
|
+
|
|
4
|
+
"""Durable Task SDK for Python entities component"""
|
|
5
|
+
|
|
6
|
+
from durabletask.entities.entity_instance_id import EntityInstanceId
|
|
7
|
+
from durabletask.entities.durable_entity import DurableEntity
|
|
8
|
+
from durabletask.entities.entity_lock import EntityLock
|
|
9
|
+
from durabletask.entities.entity_context import EntityContext
|
|
10
|
+
|
|
11
|
+
__all__ = ["EntityInstanceId", "DurableEntity", "EntityLock", "EntityContext"]
|
|
12
|
+
|
|
13
|
+
PACKAGE_NAME = "durabletask.entities"
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from typing import Any, Optional, Type, TypeVar, Union, overload
|
|
2
|
+
|
|
3
|
+
from durabletask.entities.entity_context import EntityContext
|
|
4
|
+
from durabletask.entities.entity_instance_id import EntityInstanceId
|
|
5
|
+
|
|
6
|
+
TState = TypeVar("TState")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class DurableEntity:
|
|
10
|
+
def _initialize_entity_context(self, context: EntityContext):
|
|
11
|
+
self.entity_context = context
|
|
12
|
+
|
|
13
|
+
@overload
|
|
14
|
+
def get_state(self, intended_type: Type[TState], default: TState) -> TState:
|
|
15
|
+
...
|
|
16
|
+
|
|
17
|
+
@overload
|
|
18
|
+
def get_state(self, intended_type: Type[TState]) -> Optional[TState]:
|
|
19
|
+
...
|
|
20
|
+
|
|
21
|
+
@overload
|
|
22
|
+
def get_state(self, intended_type: None = None, default: Any = None) -> Any:
|
|
23
|
+
...
|
|
24
|
+
|
|
25
|
+
def get_state(self, intended_type: Optional[Type[TState]] = None, default: Optional[TState] = None) -> Union[None, TState, Any]:
|
|
26
|
+
"""Get the current state of the entity, optionally converting it to a specified type.
|
|
27
|
+
|
|
28
|
+
Parameters
|
|
29
|
+
----------
|
|
30
|
+
intended_type : Type[TState] | None, optional
|
|
31
|
+
The type to which the state should be converted. If None, the state is returned as-is.
|
|
32
|
+
default : TState, optional
|
|
33
|
+
The default value to return if the state is not found or cannot be converted.
|
|
34
|
+
|
|
35
|
+
Returns
|
|
36
|
+
-------
|
|
37
|
+
TState | Any
|
|
38
|
+
The current state of the entity, optionally converted to the specified type.
|
|
39
|
+
"""
|
|
40
|
+
return self.entity_context.get_state(intended_type, default)
|
|
41
|
+
|
|
42
|
+
def set_state(self, state: Any):
|
|
43
|
+
"""Set the state of the entity to a new value.
|
|
44
|
+
|
|
45
|
+
Parameters
|
|
46
|
+
----------
|
|
47
|
+
new_state : Any
|
|
48
|
+
The new state to set for the entity.
|
|
49
|
+
"""
|
|
50
|
+
self.entity_context.set_state(state)
|
|
51
|
+
|
|
52
|
+
def signal_entity(self, entity_instance_id: EntityInstanceId, operation: str, input: Optional[Any] = None) -> None:
|
|
53
|
+
"""Signal another entity to perform an operation.
|
|
54
|
+
|
|
55
|
+
Parameters
|
|
56
|
+
----------
|
|
57
|
+
entity_instance_id : EntityInstanceId
|
|
58
|
+
The ID of the entity instance to signal.
|
|
59
|
+
operation : str
|
|
60
|
+
The operation to perform on the entity.
|
|
61
|
+
input : Any, optional
|
|
62
|
+
The input to provide to the entity for the operation.
|
|
63
|
+
"""
|
|
64
|
+
self.entity_context.signal_entity(entity_instance_id, operation, input)
|
|
65
|
+
|
|
66
|
+
def schedule_new_orchestration(self, orchestration_name: str, input: Optional[Any] = None, instance_id: Optional[str] = None) -> str:
|
|
67
|
+
"""Schedule a new orchestration instance.
|
|
68
|
+
|
|
69
|
+
Parameters
|
|
70
|
+
----------
|
|
71
|
+
orchestration_name : str
|
|
72
|
+
The name of the orchestration to schedule.
|
|
73
|
+
input : Any, optional
|
|
74
|
+
The input to provide to the new orchestration.
|
|
75
|
+
instance_id : str, optional
|
|
76
|
+
The instance ID to assign to the new orchestration. If None, a new ID will be generated.
|
|
77
|
+
|
|
78
|
+
Returns
|
|
79
|
+
-------
|
|
80
|
+
str
|
|
81
|
+
The instance ID of the scheduled orchestration.
|
|
82
|
+
"""
|
|
83
|
+
return self.entity_context.schedule_new_orchestration(orchestration_name, input, instance_id=instance_id)
|
|
84
|
+
|
|
85
|
+
def delete(self, input: Any = None) -> None:
|
|
86
|
+
"""Delete the entity instance.
|
|
87
|
+
|
|
88
|
+
Parameters
|
|
89
|
+
----------
|
|
90
|
+
input : Any, optional
|
|
91
|
+
Unused: The input for the entity "delete" operation.
|
|
92
|
+
"""
|
|
93
|
+
self.set_state(None)
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
|
|
2
|
+
from typing import Any, Optional, Type, TypeVar, Union, overload
|
|
3
|
+
import uuid
|
|
4
|
+
from durabletask.entities.entity_instance_id import EntityInstanceId
|
|
5
|
+
from durabletask.internal import helpers, shared
|
|
6
|
+
from durabletask.internal.entity_state_shim import StateShim
|
|
7
|
+
import durabletask.internal.orchestrator_service_pb2 as pb
|
|
8
|
+
|
|
9
|
+
TState = TypeVar("TState")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class EntityContext:
|
|
13
|
+
def __init__(self, orchestration_id: str, operation: str, state: StateShim, entity_id: EntityInstanceId):
|
|
14
|
+
self._orchestration_id = orchestration_id
|
|
15
|
+
self._operation = operation
|
|
16
|
+
self._state = state
|
|
17
|
+
self._entity_id = entity_id
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def orchestration_id(self) -> str:
|
|
21
|
+
"""Get the ID of the orchestration instance that scheduled this entity.
|
|
22
|
+
|
|
23
|
+
Returns
|
|
24
|
+
-------
|
|
25
|
+
str
|
|
26
|
+
The ID of the current orchestration instance.
|
|
27
|
+
"""
|
|
28
|
+
return self._orchestration_id
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def operation(self) -> str:
|
|
32
|
+
"""Get the operation associated with this entity invocation.
|
|
33
|
+
|
|
34
|
+
The operation is a string that identifies the specific action being
|
|
35
|
+
performed on the entity. It can be used to distinguish between
|
|
36
|
+
multiple operations that are part of the same entity invocation.
|
|
37
|
+
|
|
38
|
+
Returns
|
|
39
|
+
-------
|
|
40
|
+
str
|
|
41
|
+
The operation associated with this entity invocation.
|
|
42
|
+
"""
|
|
43
|
+
return self._operation
|
|
44
|
+
|
|
45
|
+
@overload
|
|
46
|
+
def get_state(self, intended_type: Type[TState], default: TState) -> TState:
|
|
47
|
+
...
|
|
48
|
+
|
|
49
|
+
@overload
|
|
50
|
+
def get_state(self, intended_type: Type[TState]) -> Optional[TState]:
|
|
51
|
+
...
|
|
52
|
+
|
|
53
|
+
@overload
|
|
54
|
+
def get_state(self, intended_type: None = None, default: Any = None) -> Any:
|
|
55
|
+
...
|
|
56
|
+
|
|
57
|
+
def get_state(self, intended_type: Optional[Type[TState]] = None, default: Optional[TState] = None) -> Union[None, TState, Any]:
|
|
58
|
+
"""Get the current state of the entity, optionally converting it to a specified type.
|
|
59
|
+
|
|
60
|
+
Parameters
|
|
61
|
+
----------
|
|
62
|
+
intended_type : Type[TState] | None, optional
|
|
63
|
+
The type to which the state should be converted. If None, the state is returned as-is.
|
|
64
|
+
default : TState, optional
|
|
65
|
+
The default value to return if the state is not found or cannot be converted.
|
|
66
|
+
|
|
67
|
+
Returns
|
|
68
|
+
-------
|
|
69
|
+
TState | Any
|
|
70
|
+
The current state of the entity, optionally converted to the specified type.
|
|
71
|
+
"""
|
|
72
|
+
return self._state.get_state(intended_type, default)
|
|
73
|
+
|
|
74
|
+
def set_state(self, new_state: Any):
|
|
75
|
+
"""Set the state of the entity to a new value.
|
|
76
|
+
|
|
77
|
+
Parameters
|
|
78
|
+
----------
|
|
79
|
+
new_state : Any
|
|
80
|
+
The new state to set for the entity.
|
|
81
|
+
"""
|
|
82
|
+
self._state.set_state(new_state)
|
|
83
|
+
|
|
84
|
+
def signal_entity(self, entity_instance_id: EntityInstanceId, operation: str, input: Optional[Any] = None) -> None:
|
|
85
|
+
"""Signal another entity to perform an operation.
|
|
86
|
+
|
|
87
|
+
Parameters
|
|
88
|
+
----------
|
|
89
|
+
entity_instance_id : EntityInstanceId
|
|
90
|
+
The ID of the entity instance to signal.
|
|
91
|
+
operation : str
|
|
92
|
+
The operation to perform on the entity.
|
|
93
|
+
input : Any, optional
|
|
94
|
+
The input to provide to the entity for the operation.
|
|
95
|
+
"""
|
|
96
|
+
encoded_input = shared.to_json(input) if input is not None else None
|
|
97
|
+
self._state.add_operation_action(
|
|
98
|
+
pb.OperationAction(
|
|
99
|
+
sendSignal=pb.SendSignalAction(
|
|
100
|
+
instanceId=str(entity_instance_id),
|
|
101
|
+
name=operation,
|
|
102
|
+
input=helpers.get_string_value(encoded_input),
|
|
103
|
+
scheduledTime=None,
|
|
104
|
+
requestTime=None,
|
|
105
|
+
parentTraceContext=None,
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def schedule_new_orchestration(self, orchestration_name: str, input: Optional[Any] = None, instance_id: Optional[str] = None) -> str:
|
|
111
|
+
"""Schedule a new orchestration instance.
|
|
112
|
+
|
|
113
|
+
Parameters
|
|
114
|
+
----------
|
|
115
|
+
orchestration_name : str
|
|
116
|
+
The name of the orchestration to schedule.
|
|
117
|
+
input : Any, optional
|
|
118
|
+
The input to provide to the new orchestration.
|
|
119
|
+
instance_id : str, optional
|
|
120
|
+
The instance ID to assign to the new orchestration. If None, a new ID will be generated.
|
|
121
|
+
|
|
122
|
+
Returns
|
|
123
|
+
-------
|
|
124
|
+
str
|
|
125
|
+
The instance ID of the scheduled orchestration.
|
|
126
|
+
"""
|
|
127
|
+
encoded_input = shared.to_json(input) if input is not None else None
|
|
128
|
+
if not instance_id:
|
|
129
|
+
instance_id = uuid.uuid4().hex
|
|
130
|
+
self._state.add_operation_action(
|
|
131
|
+
pb.OperationAction(
|
|
132
|
+
startNewOrchestration=pb.StartNewOrchestrationAction(
|
|
133
|
+
instanceId=instance_id,
|
|
134
|
+
name=orchestration_name,
|
|
135
|
+
input=helpers.get_string_value(encoded_input),
|
|
136
|
+
version=None,
|
|
137
|
+
scheduledTime=None,
|
|
138
|
+
requestTime=None,
|
|
139
|
+
parentTraceContext=None
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
return instance_id
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def entity_id(self) -> EntityInstanceId:
|
|
147
|
+
"""Get the ID of the entity instance.
|
|
148
|
+
|
|
149
|
+
Returns
|
|
150
|
+
-------
|
|
151
|
+
str
|
|
152
|
+
The ID of the current entity instance.
|
|
153
|
+
"""
|
|
154
|
+
return self._entity_id
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class EntityInstanceId:
|
|
5
|
+
def __init__(self, entity: str, key: str):
|
|
6
|
+
self.entity = entity
|
|
7
|
+
self.key = key
|
|
8
|
+
|
|
9
|
+
def __str__(self) -> str:
|
|
10
|
+
return f"@{self.entity}@{self.key}"
|
|
11
|
+
|
|
12
|
+
def __eq__(self, other):
|
|
13
|
+
if not isinstance(other, EntityInstanceId):
|
|
14
|
+
return False
|
|
15
|
+
return self.entity == other.entity and self.key == other.key
|
|
16
|
+
|
|
17
|
+
def __lt__(self, other):
|
|
18
|
+
if not isinstance(other, EntityInstanceId):
|
|
19
|
+
return self < other
|
|
20
|
+
return str(self) < str(other)
|
|
21
|
+
|
|
22
|
+
@staticmethod
|
|
23
|
+
def parse(entity_id: str) -> Optional["EntityInstanceId"]:
|
|
24
|
+
"""Parse a string representation of an entity ID into an EntityInstanceId object.
|
|
25
|
+
|
|
26
|
+
Parameters
|
|
27
|
+
----------
|
|
28
|
+
entity_id : str
|
|
29
|
+
The string representation of the entity ID, in the format '@entity@key'.
|
|
30
|
+
|
|
31
|
+
Returns
|
|
32
|
+
-------
|
|
33
|
+
Optional[EntityInstanceId]
|
|
34
|
+
The parsed EntityInstanceId object, or None if the input is None.
|
|
35
|
+
"""
|
|
36
|
+
try:
|
|
37
|
+
_, entity, key = entity_id.split("@", 2)
|
|
38
|
+
return EntityInstanceId(entity=entity, key=key)
|
|
39
|
+
except ValueError as ex:
|
|
40
|
+
raise ValueError("Invalid entity ID format", ex)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from typing import TYPE_CHECKING
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
if TYPE_CHECKING:
|
|
5
|
+
from durabletask.task import OrchestrationContext
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class EntityLock:
|
|
9
|
+
# Note: This should
|
|
10
|
+
def __init__(self, context: 'OrchestrationContext'):
|
|
11
|
+
self._context = context
|
|
12
|
+
|
|
13
|
+
def __enter__(self):
|
|
14
|
+
return self
|
|
15
|
+
|
|
16
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
17
|
+
self._context._exit_critical_section()
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from typing import Any, TypeVar, Union
|
|
2
|
+
from typing import Optional, Type, overload
|
|
3
|
+
|
|
4
|
+
import durabletask.internal.orchestrator_service_pb2 as pb
|
|
5
|
+
|
|
6
|
+
TState = TypeVar("TState")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class StateShim:
|
|
10
|
+
def __init__(self, start_state):
|
|
11
|
+
self._current_state: Any = start_state
|
|
12
|
+
self._checkpoint_state: Any = start_state
|
|
13
|
+
self._operation_actions: list[pb.OperationAction] = []
|
|
14
|
+
self._actions_checkpoint_state: int = 0
|
|
15
|
+
|
|
16
|
+
@overload
|
|
17
|
+
def get_state(self, intended_type: Type[TState], default: TState) -> TState:
|
|
18
|
+
...
|
|
19
|
+
|
|
20
|
+
@overload
|
|
21
|
+
def get_state(self, intended_type: Type[TState]) -> Optional[TState]:
|
|
22
|
+
...
|
|
23
|
+
|
|
24
|
+
@overload
|
|
25
|
+
def get_state(self, intended_type: None = None, default: Any = None) -> Any:
|
|
26
|
+
...
|
|
27
|
+
|
|
28
|
+
def get_state(self, intended_type: Optional[Type[TState]] = None, default: Optional[TState] = None) -> Union[None, TState, Any]:
|
|
29
|
+
if self._current_state is None and default is not None:
|
|
30
|
+
return default
|
|
31
|
+
|
|
32
|
+
if intended_type is None:
|
|
33
|
+
return self._current_state
|
|
34
|
+
|
|
35
|
+
if isinstance(self._current_state, intended_type):
|
|
36
|
+
return self._current_state
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
return intended_type(self._current_state) # type: ignore[call-arg]
|
|
40
|
+
except Exception as ex:
|
|
41
|
+
raise TypeError(
|
|
42
|
+
f"Could not convert state of type '{type(self._current_state).__name__}' to '{intended_type.__name__}'"
|
|
43
|
+
) from ex
|
|
44
|
+
|
|
45
|
+
def set_state(self, state):
|
|
46
|
+
self._current_state = state
|
|
47
|
+
|
|
48
|
+
def add_operation_action(self, action: pb.OperationAction):
|
|
49
|
+
self._operation_actions.append(action)
|
|
50
|
+
|
|
51
|
+
def get_operation_actions(self) -> list[pb.OperationAction]:
|
|
52
|
+
return self._operation_actions[:self._actions_checkpoint_state]
|
|
53
|
+
|
|
54
|
+
def commit(self):
|
|
55
|
+
self._checkpoint_state = self._current_state
|
|
56
|
+
self._actions_checkpoint_state = len(self._operation_actions)
|
|
57
|
+
|
|
58
|
+
def rollback(self):
|
|
59
|
+
self._current_state = self._checkpoint_state
|
|
60
|
+
self._operation_actions = self._operation_actions[:self._actions_checkpoint_state]
|
|
61
|
+
|
|
62
|
+
def reset(self):
|
|
63
|
+
self._current_state = None
|
|
64
|
+
self._checkpoint_state = None
|
|
65
|
+
self._operation_actions = []
|
|
66
|
+
self._actions_checkpoint_state = 0
|
|
@@ -1,7 +1,11 @@
|
|
|
1
|
+
import durabletask.internal.orchestrator_service_pb2 as pb
|
|
2
|
+
|
|
3
|
+
|
|
1
4
|
class VersionFailureException(Exception):
|
|
2
|
-
|
|
5
|
+
def __init__(self, error_details: pb.TaskFailureDetails) -> None:
|
|
6
|
+
super().__init__()
|
|
7
|
+
self.error_details = error_details
|
|
3
8
|
|
|
4
9
|
|
|
5
10
|
class AbandonOrchestrationError(Exception):
|
|
6
|
-
|
|
7
|
-
super().__init__(*args)
|
|
11
|
+
pass
|
durabletask/internal/helpers.py
CHANGED
|
@@ -7,6 +7,7 @@ from typing import Optional
|
|
|
7
7
|
|
|
8
8
|
from google.protobuf import timestamp_pb2, wrappers_pb2
|
|
9
9
|
|
|
10
|
+
from durabletask.entities import EntityInstanceId
|
|
10
11
|
import durabletask.internal.orchestrator_service_pb2 as pb
|
|
11
12
|
|
|
12
13
|
# TODO: The new_xxx_event methods are only used by test code and should be moved elsewhere
|
|
@@ -159,6 +160,12 @@ def get_string_value(val: Optional[str]) -> Optional[wrappers_pb2.StringValue]:
|
|
|
159
160
|
return wrappers_pb2.StringValue(value=val)
|
|
160
161
|
|
|
161
162
|
|
|
163
|
+
def get_string_value_or_empty(val: Optional[str]) -> wrappers_pb2.StringValue:
|
|
164
|
+
if val is None:
|
|
165
|
+
return wrappers_pb2.StringValue(value="")
|
|
166
|
+
return wrappers_pb2.StringValue(value=val)
|
|
167
|
+
|
|
168
|
+
|
|
162
169
|
def new_complete_orchestration_action(
|
|
163
170
|
id: int,
|
|
164
171
|
status: pb.OrchestrationStatus,
|
|
@@ -189,6 +196,57 @@ def new_schedule_task_action(id: int, name: str, encoded_input: Optional[str],
|
|
|
189
196
|
))
|
|
190
197
|
|
|
191
198
|
|
|
199
|
+
def new_call_entity_action(id: int, parent_instance_id: str, entity_id: EntityInstanceId, operation: str, encoded_input: Optional[str]):
|
|
200
|
+
return pb.OrchestratorAction(id=id, sendEntityMessage=pb.SendEntityMessageAction(entityOperationCalled=pb.EntityOperationCalledEvent(
|
|
201
|
+
requestId=f"{parent_instance_id}:{id}",
|
|
202
|
+
operation=operation,
|
|
203
|
+
scheduledTime=None,
|
|
204
|
+
input=get_string_value(encoded_input),
|
|
205
|
+
parentInstanceId=get_string_value(parent_instance_id),
|
|
206
|
+
parentExecutionId=None,
|
|
207
|
+
targetInstanceId=get_string_value(str(entity_id)),
|
|
208
|
+
)))
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def new_signal_entity_action(id: int, entity_id: EntityInstanceId, operation: str, encoded_input: Optional[str]):
|
|
212
|
+
return pb.OrchestratorAction(id=id, sendEntityMessage=pb.SendEntityMessageAction(entityOperationSignaled=pb.EntityOperationSignaledEvent(
|
|
213
|
+
requestId=f"{entity_id}:{id}",
|
|
214
|
+
operation=operation,
|
|
215
|
+
scheduledTime=None,
|
|
216
|
+
input=get_string_value(encoded_input),
|
|
217
|
+
targetInstanceId=get_string_value(str(entity_id)),
|
|
218
|
+
)))
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def new_lock_entities_action(id: int, entity_message: pb.SendEntityMessageAction):
|
|
222
|
+
return pb.OrchestratorAction(id=id, sendEntityMessage=entity_message)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def convert_to_entity_batch_request(req: pb.EntityRequest) -> tuple[pb.EntityBatchRequest, list[pb.OperationInfo]]:
|
|
226
|
+
batch_request = pb.EntityBatchRequest(entityState=req.entityState, instanceId=req.instanceId, operations=[])
|
|
227
|
+
|
|
228
|
+
operation_infos: list[pb.OperationInfo] = []
|
|
229
|
+
|
|
230
|
+
for op in req.operationRequests:
|
|
231
|
+
if op.HasField("entityOperationSignaled"):
|
|
232
|
+
batch_request.operations.append(pb.OperationRequest(requestId=op.entityOperationSignaled.requestId,
|
|
233
|
+
operation=op.entityOperationSignaled.operation,
|
|
234
|
+
input=op.entityOperationSignaled.input))
|
|
235
|
+
operation_infos.append(pb.OperationInfo(requestId=op.entityOperationSignaled.requestId,
|
|
236
|
+
responseDestination=None))
|
|
237
|
+
elif op.HasField("entityOperationCalled"):
|
|
238
|
+
batch_request.operations.append(pb.OperationRequest(requestId=op.entityOperationCalled.requestId,
|
|
239
|
+
operation=op.entityOperationCalled.operation,
|
|
240
|
+
input=op.entityOperationCalled.input))
|
|
241
|
+
operation_infos.append(pb.OperationInfo(requestId=op.entityOperationCalled.requestId,
|
|
242
|
+
responseDestination=pb.OrchestrationInstance(
|
|
243
|
+
instanceId=op.entityOperationCalled.parentInstanceId.value,
|
|
244
|
+
executionId=op.entityOperationCalled.parentExecutionId
|
|
245
|
+
)))
|
|
246
|
+
|
|
247
|
+
return batch_request, operation_infos
|
|
248
|
+
|
|
249
|
+
|
|
192
250
|
def new_timestamp(dt: datetime) -> timestamp_pb2.Timestamp:
|
|
193
251
|
ts = timestamp_pb2.Timestamp()
|
|
194
252
|
ts.FromDatetime(dt)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from typing import Generator, List, Optional, Tuple, Union
|
|
3
|
+
|
|
4
|
+
from durabletask.internal.helpers import get_string_value
|
|
5
|
+
import durabletask.internal.orchestrator_service_pb2 as pb
|
|
6
|
+
from durabletask.entities import EntityInstanceId
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class OrchestrationEntityContext:
|
|
10
|
+
def __init__(self, instance_id: str):
|
|
11
|
+
self.instance_id = instance_id
|
|
12
|
+
|
|
13
|
+
self.lock_acquisition_pending = False
|
|
14
|
+
|
|
15
|
+
self.critical_section_id = None
|
|
16
|
+
self.critical_section_locks: list[EntityInstanceId] = []
|
|
17
|
+
self.available_locks: list[EntityInstanceId] = []
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def is_inside_critical_section(self) -> bool:
|
|
21
|
+
return self.critical_section_id is not None
|
|
22
|
+
|
|
23
|
+
def get_available_entities(self) -> Generator[EntityInstanceId, None, None]:
|
|
24
|
+
if self.is_inside_critical_section:
|
|
25
|
+
for available_lock in self.available_locks:
|
|
26
|
+
yield available_lock
|
|
27
|
+
|
|
28
|
+
def validate_suborchestration_transition(self) -> Tuple[bool, str]:
|
|
29
|
+
if self.is_inside_critical_section:
|
|
30
|
+
return False, "While holding locks, cannot call suborchestrators."
|
|
31
|
+
return True, ""
|
|
32
|
+
|
|
33
|
+
def validate_operation_transition(self, target_instance_id: EntityInstanceId, one_way: bool) -> Tuple[bool, str]:
|
|
34
|
+
if self.is_inside_critical_section:
|
|
35
|
+
lock_to_use = target_instance_id
|
|
36
|
+
if one_way:
|
|
37
|
+
if target_instance_id in self.critical_section_locks:
|
|
38
|
+
return False, "Must not signal a locked entity from a critical section."
|
|
39
|
+
else:
|
|
40
|
+
try:
|
|
41
|
+
self.available_locks.remove(lock_to_use)
|
|
42
|
+
except ValueError:
|
|
43
|
+
if self.lock_acquisition_pending:
|
|
44
|
+
return False, "Must await the completion of the lock request prior to calling any entity."
|
|
45
|
+
if lock_to_use in self.critical_section_locks:
|
|
46
|
+
return False, "Must not call an entity from a critical section while a prior call to the same entity is still pending."
|
|
47
|
+
else:
|
|
48
|
+
return False, "Must not call an entity from a critical section if it is not one of the locked entities."
|
|
49
|
+
return True, ""
|
|
50
|
+
|
|
51
|
+
def validate_acquire_transition(self) -> Tuple[bool, str]:
|
|
52
|
+
if self.is_inside_critical_section:
|
|
53
|
+
return False, "Must not enter another critical section from within a critical section."
|
|
54
|
+
return True, ""
|
|
55
|
+
|
|
56
|
+
def recover_lock_after_call(self, target_instance_id: EntityInstanceId):
|
|
57
|
+
if self.is_inside_critical_section:
|
|
58
|
+
self.available_locks.append(target_instance_id)
|
|
59
|
+
|
|
60
|
+
def emit_lock_release_messages(self):
|
|
61
|
+
if self.is_inside_critical_section:
|
|
62
|
+
for entity_id in self.critical_section_locks:
|
|
63
|
+
unlock_event = pb.SendEntityMessageAction(entityUnlockSent=pb.EntityUnlockSentEvent(
|
|
64
|
+
criticalSectionId=self.critical_section_id,
|
|
65
|
+
targetInstanceId=get_string_value(str(entity_id)),
|
|
66
|
+
parentInstanceId=get_string_value(self.instance_id)
|
|
67
|
+
))
|
|
68
|
+
yield unlock_event
|
|
69
|
+
|
|
70
|
+
self.critical_section_locks = []
|
|
71
|
+
self.available_locks = []
|
|
72
|
+
self.critical_section_id = None
|
|
73
|
+
|
|
74
|
+
def emit_request_message(self, target, operation_name: str, one_way: bool, operation_id: str,
|
|
75
|
+
scheduled_time_utc: datetime, input: Optional[str],
|
|
76
|
+
request_time: Optional[datetime] = None, create_trace: bool = False):
|
|
77
|
+
raise NotImplementedError()
|
|
78
|
+
|
|
79
|
+
def emit_acquire_message(self, critical_section_id: str, entities: List[EntityInstanceId]) -> Union[Tuple[None, None], Tuple[pb.SendEntityMessageAction, pb.OrchestrationInstance]]:
|
|
80
|
+
if not entities:
|
|
81
|
+
return None, None
|
|
82
|
+
|
|
83
|
+
# Acquire the locks in a globally fixed order to avoid deadlocks
|
|
84
|
+
# Also remove duplicates - this can be optimized for perf if necessary
|
|
85
|
+
entity_ids = sorted(entities)
|
|
86
|
+
entity_ids_dedup = []
|
|
87
|
+
for i, entity_id in enumerate(entity_ids):
|
|
88
|
+
if entity_id != entity_ids[i - 1] if i > 0 else True:
|
|
89
|
+
entity_ids_dedup.append(entity_id)
|
|
90
|
+
|
|
91
|
+
target = pb.OrchestrationInstance(instanceId=str(entity_ids_dedup[0]))
|
|
92
|
+
request = pb.SendEntityMessageAction(entityLockRequested=pb.EntityLockRequestedEvent(
|
|
93
|
+
criticalSectionId=critical_section_id,
|
|
94
|
+
parentInstanceId=get_string_value(self.instance_id),
|
|
95
|
+
lockSet=[str(eid) for eid in entity_ids_dedup],
|
|
96
|
+
position=0,
|
|
97
|
+
))
|
|
98
|
+
|
|
99
|
+
self.critical_section_id = critical_section_id
|
|
100
|
+
self.critical_section_locks = entity_ids_dedup
|
|
101
|
+
self.lock_acquisition_pending = True
|
|
102
|
+
|
|
103
|
+
return request, target
|
|
104
|
+
|
|
105
|
+
def complete_acquire(self, critical_section_id):
|
|
106
|
+
if self.critical_section_id != critical_section_id:
|
|
107
|
+
raise RuntimeError(f"Unexpected lock acquire for critical section ID '{critical_section_id}' (expected '{self.critical_section_id}')")
|
|
108
|
+
self.available_locks = self.critical_section_locks
|
|
109
|
+
self.lock_acquisition_pending = False
|
|
110
|
+
|
|
111
|
+
def adjust_outgoing_message(self, instance_id: str, request_message, capped_time: datetime) -> str:
|
|
112
|
+
raise NotImplementedError()
|
|
113
|
+
|
|
114
|
+
def deserialize_entity_response_event(self, event_content: str):
|
|
115
|
+
raise NotImplementedError()
|