durabletask 1.7.0__tar.gz → 1.7.1__tar.gz
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.
- {durabletask-1.7.0 → durabletask-1.7.1}/PKG-INFO +1 -1
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/internal/helpers.py +54 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/payload/store.py +3 -3
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/scheduled/models.py +180 -34
- durabletask-1.7.1/durabletask/scheduled/schedule_status.py +66 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/task.py +16 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/worker.py +51 -13
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask.egg-info/PKG-INFO +1 -1
- {durabletask-1.7.0 → durabletask-1.7.1}/pyproject.toml +1 -1
- durabletask-1.7.0/durabletask/scheduled/schedule_status.py +0 -17
- {durabletask-1.7.0 → durabletask-1.7.1}/LICENSE +0 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/README.md +0 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/__init__.py +0 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/client.py +0 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/entities/__init__.py +0 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/entities/durable_entity.py +0 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/entities/entity_context.py +0 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/entities/entity_instance_id.py +0 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/entities/entity_lock.py +0 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/entities/entity_metadata.py +0 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/entities/entity_operation_failed_exception.py +0 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/extensions/__init__.py +0 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/extensions/azure_blob_payloads/__init__.py +0 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/extensions/azure_blob_payloads/blob_payload_store.py +0 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/extensions/azure_blob_payloads/options.py +0 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/extensions/history_export/__init__.py +0 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/extensions/history_export/_constants.py +0 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/extensions/history_export/_internal.py +0 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/extensions/history_export/_logging.py +0 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/extensions/history_export/activities.py +0 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/extensions/history_export/azure_blob.py +0 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/extensions/history_export/client.py +0 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/extensions/history_export/entity.py +0 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/extensions/history_export/exceptions.py +0 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/extensions/history_export/models.py +0 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/extensions/history_export/orchestrator.py +0 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/extensions/history_export/serialization.py +0 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/extensions/history_export/transitions.py +0 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/extensions/history_export/writer.py +0 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/grpc_options.py +0 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/history.py +0 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/internal/client_helpers.py +0 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/internal/entity_state_shim.py +0 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/internal/exceptions.py +0 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/internal/grpc_interceptor.py +0 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/internal/grpc_resiliency.py +0 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/internal/history_helpers.py +0 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/internal/json_encode_output_exception.py +0 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/internal/orchestration_entity_context.py +0 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/internal/orchestrator_service_pb2.py +0 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/internal/orchestrator_service_pb2.pyi +0 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/internal/orchestrator_service_pb2_grpc.py +0 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/internal/proto_task_hub_sidecar_service_stub.py +0 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/internal/shared.py +0 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/internal/tracing.py +0 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/internal/type_discovery.py +0 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/payload/__init__.py +0 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/payload/helpers.py +0 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/py.typed +0 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/scheduled/__init__.py +0 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/scheduled/client.py +0 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/scheduled/exceptions.py +0 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/scheduled/orchestrator.py +0 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/scheduled/schedule_entity.py +0 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/scheduled/transitions.py +0 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/serialization.py +0 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/testing/__init__.py +0 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/testing/in_memory_backend.py +0 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask.egg-info/SOURCES.txt +0 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask.egg-info/dependency_links.txt +0 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask.egg-info/requires.txt +0 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/durabletask.egg-info/top_level.txt +0 -0
- {durabletask-1.7.0 → durabletask-1.7.1}/setup.cfg +0 -0
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import traceback
|
|
5
5
|
from datetime import datetime, timezone
|
|
6
|
+
from typing import Any, cast
|
|
6
7
|
|
|
7
8
|
from google.protobuf import timestamp_pb2, wrappers_pb2
|
|
8
9
|
|
|
@@ -136,6 +137,59 @@ def new_failure_details(ex: Exception, _visited: set[int] | None = None) -> pb.T
|
|
|
136
137
|
)
|
|
137
138
|
|
|
138
139
|
|
|
140
|
+
def _failure_details_from_core_dict(fd: dict[str, Any]) -> pb.TaskFailureDetails:
|
|
141
|
+
"""Convert a serialized DurableTask.Core ``FailureDetails`` dict to protobuf."""
|
|
142
|
+
inner = fd.get("InnerFailure")
|
|
143
|
+
stack_trace = fd.get("StackTrace")
|
|
144
|
+
return pb.TaskFailureDetails(
|
|
145
|
+
errorType=str(fd.get("ErrorType") or ""),
|
|
146
|
+
errorMessage=str(fd.get("ErrorMessage") or ""),
|
|
147
|
+
stackTrace=get_string_value(str(stack_trace) if stack_trace is not None else None),
|
|
148
|
+
innerFailure=_failure_details_from_core_dict(cast(dict[str, Any], inner)) if isinstance(inner, dict) else None,
|
|
149
|
+
isNonRetriable=bool(fd.get("IsNonRetriable", False)),
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def entity_response_failure_details(
|
|
154
|
+
response: dict[str, Any],
|
|
155
|
+
error_content: Any = None) -> pb.TaskFailureDetails:
|
|
156
|
+
"""Build failure details from a failed legacy-protocol entity ``ResponseMessage``.
|
|
157
|
+
|
|
158
|
+
Call this only for responses that :func:`is_entity_error_response` reports as
|
|
159
|
+
failures. In the WebJobs "old protocol" ``ResponseMessage`` (see
|
|
160
|
+
``EntityScheduler/ResponseMessage.cs``), a failed operation serializes the
|
|
161
|
+
human-readable content into ``result`` while ``exceptionType`` carries only
|
|
162
|
+
the exception's type name (a presence marker) -- it is *not* the message.
|
|
163
|
+
This mirrors ``azure-functions-durable-python`` / ``-js``, which read the
|
|
164
|
+
message from ``result`` and ignore ``exceptionType``'s value.
|
|
165
|
+
|
|
166
|
+
Parameters
|
|
167
|
+
----------
|
|
168
|
+
response:
|
|
169
|
+
The deserialized ``ResponseMessage`` dict.
|
|
170
|
+
error_content:
|
|
171
|
+
The already-deserialized ``result`` payload, used as the failure
|
|
172
|
+
message. A structured ``failureDetails`` object, if present, takes
|
|
173
|
+
precedence (current-protocol shape).
|
|
174
|
+
"""
|
|
175
|
+
failure_details = response.get("failureDetails")
|
|
176
|
+
if isinstance(failure_details, dict):
|
|
177
|
+
return _failure_details_from_core_dict(cast(dict[str, Any], failure_details))
|
|
178
|
+
error_type = str(response.get("exceptionType") or "")
|
|
179
|
+
error_message = "" if error_content is None else str(error_content)
|
|
180
|
+
return pb.TaskFailureDetails(errorType=error_type, errorMessage=error_message)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def is_entity_error_response(response: dict[str, Any]) -> bool:
|
|
184
|
+
"""Return ``True`` if a legacy-protocol entity ``ResponseMessage`` is a failure.
|
|
185
|
+
|
|
186
|
+
In the WebJobs "old protocol" a failed operation is marked by the presence of
|
|
187
|
+
an ``exceptionType`` field (successful responses omit it). Current-protocol
|
|
188
|
+
payloads may instead carry a structured ``failureDetails`` object.
|
|
189
|
+
"""
|
|
190
|
+
return "exceptionType" in response or isinstance(response.get("failureDetails"), dict)
|
|
191
|
+
|
|
192
|
+
|
|
139
193
|
def new_event_sent_event(event_id: int, instance_id: str, input: str):
|
|
140
194
|
return pb.HistoryEvent(
|
|
141
195
|
eventId=event_id,
|
|
@@ -21,15 +21,15 @@ class LargePayloadStorageOptions:
|
|
|
21
21
|
|
|
22
22
|
Attributes:
|
|
23
23
|
threshold_bytes: Payloads larger than this value (in bytes) will
|
|
24
|
-
be externalized to the payload store. Defaults to
|
|
25
|
-
(
|
|
24
|
+
be externalized to the payload store. Defaults to 262,144
|
|
25
|
+
(256 KiB), matching the .NET SDK default.
|
|
26
26
|
max_stored_payload_bytes: Maximum payload size (in bytes) that
|
|
27
27
|
can be stored externally. Payloads exceeding this limit
|
|
28
28
|
will cause an error. Defaults to 10,485,760 (10 MB).
|
|
29
29
|
enable_compression: When ``True`` (the default), payloads are
|
|
30
30
|
GZip-compressed before uploading.
|
|
31
31
|
"""
|
|
32
|
-
threshold_bytes: int =
|
|
32
|
+
threshold_bytes: int = 262_144
|
|
33
33
|
max_stored_payload_bytes: int = 10 * 1024 * 1024 # 10 MB
|
|
34
34
|
enable_compression: bool = True
|
|
35
35
|
|
|
@@ -8,9 +8,15 @@ from typing import Any
|
|
|
8
8
|
|
|
9
9
|
from durabletask.internal.helpers import ensure_aware
|
|
10
10
|
from durabletask.scheduled.schedule_status import ScheduleStatus
|
|
11
|
+
from durabletask.serialization import JsonDataConverter
|
|
11
12
|
|
|
12
13
|
MINIMUM_INTERVAL = timedelta(seconds=1)
|
|
13
14
|
|
|
15
|
+
# Serializer used to (de)serialize the orchestration input to/from a JSON string
|
|
16
|
+
# for persistence. Matches the .NET SDK, which stores ``OrchestrationInput`` as a
|
|
17
|
+
# string so the Durable Task Scheduler dashboard can read the raw entity state.
|
|
18
|
+
_INPUT_CONVERTER = JsonDataConverter()
|
|
19
|
+
|
|
14
20
|
|
|
15
21
|
def _validate_interval(interval: timedelta) -> timedelta:
|
|
16
22
|
if interval <= timedelta(0):
|
|
@@ -25,7 +31,15 @@ def _to_iso(value: datetime | None) -> str | None:
|
|
|
25
31
|
|
|
26
32
|
|
|
27
33
|
def _from_iso(value: str | None) -> datetime | None:
|
|
28
|
-
|
|
34
|
+
if not value:
|
|
35
|
+
return None
|
|
36
|
+
# .NET serializes ``DateTimeOffset`` with a numeric offset, but tolerate a
|
|
37
|
+
# trailing ``Z`` so states written by other producers still parse on the
|
|
38
|
+
# Python versions that predate ``fromisoformat`` accepting ``Z``. Only the
|
|
39
|
+
# trailing designator is normalized so an interior ``Z`` is left untouched.
|
|
40
|
+
if value.endswith("Z"):
|
|
41
|
+
value = value[:-1] + "+00:00"
|
|
42
|
+
return datetime.fromisoformat(value)
|
|
29
43
|
|
|
30
44
|
|
|
31
45
|
def _interval_to_seconds(value: timedelta | None) -> float | None:
|
|
@@ -36,6 +50,119 @@ def _interval_from_seconds(value: float | None) -> timedelta | None:
|
|
|
36
50
|
return timedelta(seconds=value) if value is not None else None
|
|
37
51
|
|
|
38
52
|
|
|
53
|
+
# Number of 100-nanosecond ticks per second, matching the .NET ``TimeSpan``
|
|
54
|
+
# resolution used when formatting the fractional component.
|
|
55
|
+
_TICKS_PER_SECOND = 10_000_000
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _interval_to_timespan(value: timedelta | None) -> str | None:
|
|
59
|
+
"""Format a ``timedelta`` as a .NET ``TimeSpan`` (``[-][d.]hh:mm:ss[.fffffff]``).
|
|
60
|
+
|
|
61
|
+
The Durable Task Scheduler dashboard deserializes the schedule interval into
|
|
62
|
+
a .NET ``TimeSpan``, whose JSON converter only accepts this constant format.
|
|
63
|
+
"""
|
|
64
|
+
if value is None:
|
|
65
|
+
return None
|
|
66
|
+
negative = value < timedelta(0)
|
|
67
|
+
value = abs(value)
|
|
68
|
+
days = value.days
|
|
69
|
+
hours, remainder = divmod(value.seconds, 3600)
|
|
70
|
+
minutes, seconds = divmod(remainder, 60)
|
|
71
|
+
if days:
|
|
72
|
+
formatted = f"{days}.{hours:02d}:{minutes:02d}:{seconds:02d}"
|
|
73
|
+
else:
|
|
74
|
+
formatted = f"{hours:02d}:{minutes:02d}:{seconds:02d}"
|
|
75
|
+
if value.microseconds:
|
|
76
|
+
ticks = value.microseconds * 10 # microseconds -> 100-ns ticks
|
|
77
|
+
formatted += f".{ticks:07d}"
|
|
78
|
+
return f"-{formatted}" if negative else formatted
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _interval_from_timespan(value: str) -> timedelta:
|
|
82
|
+
"""Parse a .NET ``TimeSpan`` string (``[-][d.]hh:mm:ss[.fffffff]``)."""
|
|
83
|
+
text = value.strip()
|
|
84
|
+
negative = text.startswith("-")
|
|
85
|
+
if negative:
|
|
86
|
+
text = text[1:]
|
|
87
|
+
|
|
88
|
+
fraction = 0.0
|
|
89
|
+
if "." in text:
|
|
90
|
+
head, _, tail = text.rpartition(".")
|
|
91
|
+
# A dot before the first ``:`` is the day separator, not a fraction.
|
|
92
|
+
if ":" in tail:
|
|
93
|
+
# e.g. ``1.02:03:04`` -- the ``.`` separates days from the clock.
|
|
94
|
+
days_part, _, clock = text.partition(".")
|
|
95
|
+
days = int(days_part)
|
|
96
|
+
hours, minutes, seconds = (int(p) for p in clock.split(":"))
|
|
97
|
+
else:
|
|
98
|
+
# ``head`` holds ``[d.]hh:mm:ss`` and ``tail`` the ticks fraction.
|
|
99
|
+
fraction = int(tail.ljust(7, "0")[:7]) / _TICKS_PER_SECOND
|
|
100
|
+
days, hours, minutes, seconds = _split_clock(head)
|
|
101
|
+
else:
|
|
102
|
+
days, hours, minutes, seconds = _split_clock(text)
|
|
103
|
+
|
|
104
|
+
result = timedelta(days=days, hours=hours, minutes=minutes,
|
|
105
|
+
seconds=seconds) + timedelta(seconds=fraction)
|
|
106
|
+
return -result if negative else result
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _split_clock(text: str) -> tuple[int, int, int, int]:
|
|
110
|
+
"""Split ``[d.]hh:mm:ss`` into ``(days, hours, minutes, seconds)``."""
|
|
111
|
+
days = 0
|
|
112
|
+
if "." in text:
|
|
113
|
+
days_part, _, text = text.partition(".")
|
|
114
|
+
days = int(days_part)
|
|
115
|
+
hours, minutes, seconds = (int(part) for part in text.split(":"))
|
|
116
|
+
return days, hours, minutes, seconds
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _get(data: dict[str, Any], *keys: str, default: Any = None) -> Any:
|
|
120
|
+
"""Return the first present key from ``data``.
|
|
121
|
+
|
|
122
|
+
Reads tolerate both the .NET-compatible PascalCase keys and the legacy
|
|
123
|
+
snake_case keys written by earlier Python workers.
|
|
124
|
+
"""
|
|
125
|
+
for key in keys:
|
|
126
|
+
if key in data:
|
|
127
|
+
return data[key]
|
|
128
|
+
return default
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _parse_interval(data: dict[str, Any]) -> timedelta:
|
|
132
|
+
"""Read the interval from either the .NET ``Interval`` or legacy field."""
|
|
133
|
+
timespan = _get(data, "Interval", "interval")
|
|
134
|
+
if isinstance(timespan, str):
|
|
135
|
+
return _interval_from_timespan(timespan)
|
|
136
|
+
seconds = _get(data, "interval_seconds")
|
|
137
|
+
if seconds is not None:
|
|
138
|
+
return timedelta(seconds=seconds)
|
|
139
|
+
raise KeyError("interval")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _encode_orchestration_input(value: Any) -> str | None:
|
|
143
|
+
"""Serialize the orchestration input to a JSON string for persistence.
|
|
144
|
+
|
|
145
|
+
The Durable Task Scheduler dashboard (and the .NET SDK) model the persisted
|
|
146
|
+
``OrchestrationInput`` as a string, so the raw input object is serialized to
|
|
147
|
+
a JSON string here and parsed back by :func:`_decode_orchestration_input`.
|
|
148
|
+
"""
|
|
149
|
+
if value is None:
|
|
150
|
+
return None
|
|
151
|
+
return _INPUT_CONVERTER.serialize(value)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _decode_orchestration_input(value: Any) -> Any:
|
|
155
|
+
"""Reconstruct the raw orchestration input from its persisted JSON string."""
|
|
156
|
+
if value is None or not isinstance(value, str):
|
|
157
|
+
return value
|
|
158
|
+
try:
|
|
159
|
+
return _INPUT_CONVERTER.deserialize(value)
|
|
160
|
+
except (ValueError, TypeError):
|
|
161
|
+
# Not a JSON document (an unexpected shape); return it unchanged rather
|
|
162
|
+
# than failing to load the schedule.
|
|
163
|
+
return value
|
|
164
|
+
|
|
165
|
+
|
|
39
166
|
@dataclass
|
|
40
167
|
class ScheduleCreationOptions:
|
|
41
168
|
"""Options for creating a new schedule."""
|
|
@@ -230,29 +357,39 @@ class ScheduleConfiguration:
|
|
|
230
357
|
raise ValueError("start_at cannot be later than end_at.")
|
|
231
358
|
|
|
232
359
|
def to_json(self) -> dict[str, Any]:
|
|
360
|
+
# Serialized with .NET-compatible property names and value shapes so the
|
|
361
|
+
# Durable Task Scheduler dashboard can deserialize the raw entity state:
|
|
362
|
+
# PascalCase keys and the interval as a .NET ``TimeSpan`` string.
|
|
233
363
|
return {
|
|
234
|
-
"
|
|
235
|
-
"
|
|
236
|
-
"
|
|
237
|
-
"
|
|
238
|
-
"
|
|
239
|
-
"
|
|
240
|
-
"
|
|
241
|
-
"
|
|
364
|
+
"ScheduleId": self.schedule_id,
|
|
365
|
+
"OrchestrationName": self.orchestration_name,
|
|
366
|
+
"Interval": _interval_to_timespan(self.interval),
|
|
367
|
+
"OrchestrationInput": _encode_orchestration_input(self.orchestration_input),
|
|
368
|
+
"OrchestrationInstanceId": self.orchestration_instance_id,
|
|
369
|
+
"StartAt": _to_iso(self.start_at),
|
|
370
|
+
"EndAt": _to_iso(self.end_at),
|
|
371
|
+
"StartImmediatelyIfLate": self.start_immediately_if_late,
|
|
242
372
|
}
|
|
243
373
|
|
|
244
374
|
@classmethod
|
|
245
375
|
def from_json(cls, data: dict[str, Any]) -> "ScheduleConfiguration":
|
|
246
376
|
config = cls(
|
|
247
|
-
data
|
|
248
|
-
data
|
|
249
|
-
|
|
377
|
+
_get(data, "ScheduleId", "schedule_id"),
|
|
378
|
+
_get(data, "OrchestrationName", "orchestration_name"),
|
|
379
|
+
_parse_interval(data),
|
|
250
380
|
)
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
381
|
+
if "OrchestrationInput" in data:
|
|
382
|
+
# New .NET-compatible states store the input as a JSON string.
|
|
383
|
+
config.orchestration_input = _decode_orchestration_input(data["OrchestrationInput"])
|
|
384
|
+
else:
|
|
385
|
+
# Legacy states stored the raw (already-parsed) input object.
|
|
386
|
+
config.orchestration_input = data.get("orchestration_input")
|
|
387
|
+
config.orchestration_instance_id = _get(
|
|
388
|
+
data, "OrchestrationInstanceId", "orchestration_instance_id")
|
|
389
|
+
config.start_at = _from_iso(_get(data, "StartAt", "start_at"))
|
|
390
|
+
config.end_at = _from_iso(_get(data, "EndAt", "end_at"))
|
|
391
|
+
config.start_immediately_if_late = bool(
|
|
392
|
+
_get(data, "StartImmediatelyIfLate", "start_immediately_if_late", default=False))
|
|
256
393
|
return config
|
|
257
394
|
|
|
258
395
|
|
|
@@ -273,16 +410,18 @@ class ScheduleState:
|
|
|
273
410
|
|
|
274
411
|
def to_json(self) -> dict[str, Any]:
|
|
275
412
|
# ``schedule_configuration`` is returned as the object itself; the
|
|
276
|
-
# serializer recurses into it and fires its own ``to_json`` hook.
|
|
277
|
-
#
|
|
413
|
+
# serializer recurses into it and fires its own ``to_json`` hook. Keys
|
|
414
|
+
# and value shapes mirror the .NET ``ScheduleState`` so the Durable Task
|
|
415
|
+
# Scheduler dashboard can deserialize the raw entity state: PascalCase
|
|
416
|
+
# names, the status as its numeric ordinal, and datetimes as ISO strings.
|
|
278
417
|
return {
|
|
279
|
-
"
|
|
280
|
-
"
|
|
281
|
-
"
|
|
282
|
-
"
|
|
283
|
-
"
|
|
284
|
-
"
|
|
285
|
-
"
|
|
418
|
+
"Status": self.status.to_dotnet_ordinal(),
|
|
419
|
+
"ExecutionToken": self.execution_token,
|
|
420
|
+
"LastRunAt": _to_iso(self.last_run_at),
|
|
421
|
+
"NextRunAt": _to_iso(self.next_run_at),
|
|
422
|
+
"ScheduleCreatedAt": _to_iso(self.schedule_created_at),
|
|
423
|
+
"ScheduleLastModifiedAt": _to_iso(self.schedule_last_modified_at),
|
|
424
|
+
"ScheduleConfiguration": self.schedule_configuration,
|
|
286
425
|
}
|
|
287
426
|
|
|
288
427
|
@classmethod
|
|
@@ -291,15 +430,22 @@ class ScheduleState:
|
|
|
291
430
|
# ``from_json`` hook directly. ``ScheduleConfiguration`` is an internal
|
|
292
431
|
# type, so there is no need to route it through a (possibly custom)
|
|
293
432
|
# converter -- keeping this hook converter-free means it round-trips
|
|
294
|
-
# under any code path, not only the worker's threaded converter.
|
|
433
|
+
# under any code path, not only the worker's threaded converter. Reads
|
|
434
|
+
# accept both the .NET-compatible and legacy snake_case shapes.
|
|
295
435
|
state = cls()
|
|
296
|
-
state.status = ScheduleStatus(data
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
436
|
+
state.status = ScheduleStatus.from_dotnet(_get(data, "Status", "status"))
|
|
437
|
+
# Preserve the token generated by ``__init__`` when the field is absent;
|
|
438
|
+
# overwriting it with ``None`` would make every ``run_schedule`` signal
|
|
439
|
+
# look stale and silently stop the schedule.
|
|
440
|
+
token = _get(data, "ExecutionToken", "execution_token")
|
|
441
|
+
if token is not None:
|
|
442
|
+
state.execution_token = token
|
|
443
|
+
state.last_run_at = _from_iso(_get(data, "LastRunAt", "last_run_at"))
|
|
444
|
+
state.next_run_at = _from_iso(_get(data, "NextRunAt", "next_run_at"))
|
|
445
|
+
state.schedule_created_at = _from_iso(_get(data, "ScheduleCreatedAt", "schedule_created_at"))
|
|
446
|
+
state.schedule_last_modified_at = _from_iso(
|
|
447
|
+
_get(data, "ScheduleLastModifiedAt", "schedule_last_modified_at"))
|
|
448
|
+
config_data = _get(data, "ScheduleConfiguration", "schedule_configuration")
|
|
303
449
|
state.schedule_configuration = (
|
|
304
450
|
ScheduleConfiguration.from_json(config_data) if config_data is not None else None)
|
|
305
451
|
return state
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Copyright (c) Microsoft Corporation.
|
|
2
|
+
# Licensed under the MIT License.
|
|
3
|
+
|
|
4
|
+
from enum import Enum
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ScheduleStatus(str, Enum):
|
|
8
|
+
"""Represents the current status of a schedule."""
|
|
9
|
+
|
|
10
|
+
UNINITIALIZED = "Uninitialized"
|
|
11
|
+
"""Schedule has not been created."""
|
|
12
|
+
|
|
13
|
+
ACTIVE = "Active"
|
|
14
|
+
"""Schedule is active and running."""
|
|
15
|
+
|
|
16
|
+
PAUSED = "Paused"
|
|
17
|
+
"""Schedule is paused."""
|
|
18
|
+
|
|
19
|
+
def to_dotnet_ordinal(self) -> int:
|
|
20
|
+
"""Return the numeric value used by the .NET ``ScheduleStatus`` enum.
|
|
21
|
+
|
|
22
|
+
The Durable Task Scheduler dashboard reads the persisted entity state
|
|
23
|
+
with ``System.Text.Json`` (Web defaults, no string-enum converter), so
|
|
24
|
+
the status must be serialized as the enum's ordinal rather than its
|
|
25
|
+
name. The ordinals match the .NET SDK order (``Uninitialized`` = 0,
|
|
26
|
+
``Active`` = 1, ``Paused`` = 2).
|
|
27
|
+
"""
|
|
28
|
+
return _STATUS_TO_ORDINAL[self]
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def from_dotnet(cls, value: "int | str | None") -> "ScheduleStatus":
|
|
32
|
+
"""Reconstruct a status from a persisted value.
|
|
33
|
+
|
|
34
|
+
Accepts the numeric ordinal written by the .NET-compatible serializer
|
|
35
|
+
as well as the legacy string name (e.g. ``"Active"``) so that states
|
|
36
|
+
persisted by older Python workers still round-trip.
|
|
37
|
+
"""
|
|
38
|
+
if isinstance(value, bool):
|
|
39
|
+
# ``bool`` is a subclass of ``int``; reject it explicitly so a
|
|
40
|
+
# stray boolean cannot be misread as an ordinal.
|
|
41
|
+
return cls.UNINITIALIZED
|
|
42
|
+
if isinstance(value, int):
|
|
43
|
+
return _ORDINAL_TO_STATUS.get(value, cls.UNINITIALIZED)
|
|
44
|
+
if isinstance(value, str):
|
|
45
|
+
text = value.strip()
|
|
46
|
+
if text.isdigit():
|
|
47
|
+
return _ORDINAL_TO_STATUS.get(int(text), cls.UNINITIALIZED)
|
|
48
|
+
for member in cls:
|
|
49
|
+
if member.value.lower() == text.lower():
|
|
50
|
+
return member
|
|
51
|
+
# The .NET Scheduler client names the zero value "Unknown"; treat
|
|
52
|
+
# it as the equivalent uninitialized state.
|
|
53
|
+
if text.lower() == "unknown":
|
|
54
|
+
return cls.UNINITIALIZED
|
|
55
|
+
return cls.UNINITIALIZED
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
_STATUS_TO_ORDINAL: dict["ScheduleStatus", int] = {
|
|
59
|
+
ScheduleStatus.UNINITIALIZED: 0,
|
|
60
|
+
ScheduleStatus.ACTIVE: 1,
|
|
61
|
+
ScheduleStatus.PAUSED: 2,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
_ORDINAL_TO_STATUS: dict[int, "ScheduleStatus"] = {
|
|
65
|
+
ordinal: status for status, ordinal in _STATUS_TO_ORDINAL.items()
|
|
66
|
+
}
|
|
@@ -38,6 +38,22 @@ class OrchestrationContext(ABC):
|
|
|
38
38
|
"""
|
|
39
39
|
pass
|
|
40
40
|
|
|
41
|
+
@property
|
|
42
|
+
@abstractmethod
|
|
43
|
+
def parent_instance_id(self) -> str | None:
|
|
44
|
+
"""Get the ID of the parent orchestration instance.
|
|
45
|
+
|
|
46
|
+
For a sub-orchestration, this is the instance ID of the orchestration
|
|
47
|
+
that scheduled it. For a top-level orchestration, this is ``None``.
|
|
48
|
+
|
|
49
|
+
Returns
|
|
50
|
+
-------
|
|
51
|
+
str | None
|
|
52
|
+
The parent orchestration instance ID, or ``None`` if this
|
|
53
|
+
orchestration was not scheduled by a parent orchestration.
|
|
54
|
+
"""
|
|
55
|
+
pass
|
|
56
|
+
|
|
41
57
|
@property
|
|
42
58
|
@abstractmethod
|
|
43
59
|
def version(self) -> str | None:
|
|
@@ -1483,6 +1483,7 @@ class _RuntimeOrchestrationContext(task.OrchestrationContext):
|
|
|
1483
1483
|
self._registry = registry
|
|
1484
1484
|
self._entity_context = OrchestrationEntityContext(instance_id)
|
|
1485
1485
|
self._version: str | None = None
|
|
1486
|
+
self._parent_instance_id: str | None = None
|
|
1486
1487
|
self._completion_status: pb.OrchestrationStatus | None = None
|
|
1487
1488
|
self._received_events: dict[str, list[str | None]] = {}
|
|
1488
1489
|
self._pending_events: dict[str, list[task.CancellableTask[Any]]] = {}
|
|
@@ -1638,6 +1639,10 @@ class _RuntimeOrchestrationContext(task.OrchestrationContext):
|
|
|
1638
1639
|
def version(self) -> str | None:
|
|
1639
1640
|
return self._version
|
|
1640
1641
|
|
|
1642
|
+
@property
|
|
1643
|
+
def parent_instance_id(self) -> str | None:
|
|
1644
|
+
return self._parent_instance_id
|
|
1645
|
+
|
|
1641
1646
|
@property
|
|
1642
1647
|
def current_utc_datetime(self) -> datetime:
|
|
1643
1648
|
return self._current_utc_datetime
|
|
@@ -2222,6 +2227,16 @@ class _OrchestrationExecutor:
|
|
|
2222
2227
|
if event.executionStarted.version:
|
|
2223
2228
|
ctx._version = event.executionStarted.version.value # pyright: ignore[reportPrivateUsage]
|
|
2224
2229
|
|
|
2230
|
+
# Store the parent orchestration instance ID (set for
|
|
2231
|
+
# sub-orchestrations; absent for top-level orchestrations)
|
|
2232
|
+
if (
|
|
2233
|
+
event.executionStarted.HasField("parentInstance")
|
|
2234
|
+
and event.executionStarted.parentInstance.HasField("orchestrationInstance")
|
|
2235
|
+
):
|
|
2236
|
+
ctx._parent_instance_id = ( # pyright: ignore[reportPrivateUsage]
|
|
2237
|
+
event.executionStarted.parentInstance.orchestrationInstance.instanceId
|
|
2238
|
+
)
|
|
2239
|
+
|
|
2225
2240
|
# Store the parent trace context for propagation to child tasks
|
|
2226
2241
|
if event.executionStarted.HasField("parentTraceContext"):
|
|
2227
2242
|
ctx._parent_trace_context = event.executionStarted.parentTraceContext # pyright: ignore[reportPrivateUsage]
|
|
@@ -2573,10 +2588,10 @@ class _OrchestrationExecutor:
|
|
|
2573
2588
|
raise TypeError("Unexpected sub-orchestration task type")
|
|
2574
2589
|
elif event.HasField("eventRaised"):
|
|
2575
2590
|
if event.eventRaised.name in ctx._entity_task_id_map: # pyright: ignore[reportPrivateUsage]
|
|
2576
|
-
entity_id, operation, task_id = ctx._entity_task_id_map.
|
|
2577
|
-
self._handle_entity_event_raised(ctx, event, entity_id, task_id, False)
|
|
2591
|
+
entity_id, operation, task_id = ctx._entity_task_id_map.pop(event.eventRaised.name, (None, None, None)) # pyright: ignore[reportPrivateUsage]
|
|
2592
|
+
self._handle_entity_event_raised(ctx, event, entity_id, task_id, False, operation)
|
|
2578
2593
|
elif event.eventRaised.name in ctx._entity_lock_task_id_map: # pyright: ignore[reportPrivateUsage]
|
|
2579
|
-
entity_id, task_id = ctx._entity_lock_task_id_map.
|
|
2594
|
+
entity_id, task_id = ctx._entity_lock_task_id_map.pop(event.eventRaised.name, (None, None)) # pyright: ignore[reportPrivateUsage]
|
|
2580
2595
|
self._handle_entity_event_raised(ctx, event, entity_id, task_id, True)
|
|
2581
2596
|
else:
|
|
2582
2597
|
# event names are case-insensitive
|
|
@@ -2803,7 +2818,8 @@ class _OrchestrationExecutor:
|
|
|
2803
2818
|
event: pb.HistoryEvent,
|
|
2804
2819
|
entity_id: EntityInstanceId | None,
|
|
2805
2820
|
task_id: int | None,
|
|
2806
|
-
is_lock_event: bool
|
|
2821
|
+
is_lock_event: bool,
|
|
2822
|
+
operation: str | None = None):
|
|
2807
2823
|
# This eventRaised represents the result of an entity operation after being translated to the old
|
|
2808
2824
|
# entity protocol by the Durable WebJobs extension
|
|
2809
2825
|
if entity_id is None:
|
|
@@ -2813,16 +2829,38 @@ class _OrchestrationExecutor:
|
|
|
2813
2829
|
entity_task = ctx._pending_tasks.pop(task_id, None) # pyright: ignore[reportPrivateUsage]
|
|
2814
2830
|
if not entity_task:
|
|
2815
2831
|
raise RuntimeError(f"Could not retrieve entity task for entity-related eventRaised with ID '{event.eventId}'")
|
|
2816
|
-
|
|
2832
|
+
response: dict[str, Any] | None = None
|
|
2817
2833
|
if not ph.is_empty(event.eventRaised.input):
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2834
|
+
response = self._data_converter.deserialize(event.eventRaised.input.value)
|
|
2835
|
+
|
|
2836
|
+
# For entity operation calls (lock acquisitions never fail this way), the legacy WebJobs
|
|
2837
|
+
# "old protocol" ResponseMessage signals a failed operation via the presence of an
|
|
2838
|
+
# "exceptionType" marker (or a structured "failureDetails" object). The human-readable
|
|
2839
|
+
# message lives in the *serialized* "result" field -- "exceptionType" is only the exception
|
|
2840
|
+
# type name, not the message -- so deserialize "result" to recover it, matching
|
|
2841
|
+
# azure-functions-durable-python / -js. Propagate as a task failure so an awaiting
|
|
2842
|
+
# call_entity raises, like the current entity protocol and the .NET SDK.
|
|
2843
|
+
if not is_lock_event and isinstance(response, dict) and ph.is_entity_error_response(response):
|
|
2844
|
+
raw_result = response.get("result")
|
|
2845
|
+
error_content = (
|
|
2846
|
+
self._data_converter.deserialize(raw_result) if isinstance(raw_result, str) else raw_result
|
|
2847
|
+
)
|
|
2848
|
+
failure_details = ph.entity_response_failure_details(response, error_content)
|
|
2849
|
+
failure = EntityOperationFailedException(entity_id, operation or "", failure_details)
|
|
2850
|
+
ctx._entity_context.recover_lock_after_call(entity_id) # pyright: ignore[reportPrivateUsage]
|
|
2851
|
+
entity_task.fail(str(failure), failure)
|
|
2852
|
+
ctx.resume()
|
|
2853
|
+
return
|
|
2854
|
+
|
|
2855
|
+
result = None
|
|
2856
|
+
if response is not None:
|
|
2857
|
+
# The legacy protocol wraps the result as {"result": <serialized>},
|
|
2858
|
+
# where the value is a serialized JSON string (like the new protocol's
|
|
2859
|
+
# entityOperationCompleted.output). Deserialize it -- not coerce -- so
|
|
2860
|
+
# the value is fully parsed and the expected type applied; coercing
|
|
2861
|
+
# would skip JSON parsing and leave it double-encoded (e.g. '"done"').
|
|
2862
|
+
result = self._data_converter.deserialize(
|
|
2863
|
+
response["result"],
|
|
2826
2864
|
entity_task._expected_type, # pyright: ignore[reportPrivateUsage]
|
|
2827
2865
|
)
|
|
2828
2866
|
if is_lock_event:
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
# Copyright (c) Microsoft Corporation.
|
|
2
|
-
# Licensed under the MIT License.
|
|
3
|
-
|
|
4
|
-
from enum import Enum
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
class ScheduleStatus(str, Enum):
|
|
8
|
-
"""Represents the current status of a schedule."""
|
|
9
|
-
|
|
10
|
-
UNINITIALIZED = "Uninitialized"
|
|
11
|
-
"""Schedule has not been created."""
|
|
12
|
-
|
|
13
|
-
ACTIVE = "Active"
|
|
14
|
-
"""Schedule is active and running."""
|
|
15
|
-
|
|
16
|
-
PAUSED = "Paused"
|
|
17
|
-
"""Schedule is paused."""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durabletask-1.7.0 → durabletask-1.7.1}/durabletask/entities/entity_operation_failed_exception.py
RENAMED
|
File without changes
|
|
File without changes
|
{durabletask-1.7.0 → durabletask-1.7.1}/durabletask/extensions/azure_blob_payloads/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{durabletask-1.7.0 → durabletask-1.7.1}/durabletask/extensions/azure_blob_payloads/options.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durabletask-1.7.0 → durabletask-1.7.1}/durabletask/extensions/history_export/orchestrator.py
RENAMED
|
File without changes
|
{durabletask-1.7.0 → durabletask-1.7.1}/durabletask/extensions/history_export/serialization.py
RENAMED
|
File without changes
|
{durabletask-1.7.0 → durabletask-1.7.1}/durabletask/extensions/history_export/transitions.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durabletask-1.7.0 → durabletask-1.7.1}/durabletask/internal/json_encode_output_exception.py
RENAMED
|
File without changes
|
{durabletask-1.7.0 → durabletask-1.7.1}/durabletask/internal/orchestration_entity_context.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{durabletask-1.7.0 → durabletask-1.7.1}/durabletask/internal/orchestrator_service_pb2_grpc.py
RENAMED
|
File without changes
|
{durabletask-1.7.0 → durabletask-1.7.1}/durabletask/internal/proto_task_hub_sidecar_service_stub.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|