Skip to content

Commit bcf9de0

Browse files
committed
Serialize schedule input as .NET string; address PR review
Persist OrchestrationInput as a JSON-encoded string so the DTS dashboard can deserialize it (it types the field as string?). Also address Copilot review feedback: restrict _from_iso Z-normalization to a trailing Z, use PEP 604 unions in ScheduleStatus.from_dotnet, and preserve the generated execution_token when absent from persisted state.
1 parent eb67c35 commit bcf9de0

4 files changed

Lines changed: 87 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ CHANGED
1313

1414
FIXED
1515

16-
- Fixed schedules created with `durabletask.scheduled` not appearing in the Durable Task Scheduler dashboard. The schedule entity state is now persisted in a format compatible with the .NET SDK (the `status` is serialized as its numeric enum value and the `interval` as a .NET `TimeSpan` string, using .NET property names), so the dashboard can read it without a JSON deserialization error.
16+
- Fixed schedules created with `durabletask.scheduled` not appearing in the Durable Task Scheduler dashboard. The schedule entity state is now persisted in a format compatible with the .NET SDK: the `status` is serialized as its numeric enum value, the `interval` as a .NET `TimeSpan` string, and the `orchestration_input` as a JSON-encoded string, all using .NET property names, so the dashboard can read it without a JSON deserialization error.
1717

1818
## v1.7.0
1919

durabletask/scheduled/models.py

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,15 @@
88

99
from durabletask.internal.helpers import ensure_aware
1010
from durabletask.scheduled.schedule_status import ScheduleStatus
11+
from durabletask.serialization import JsonDataConverter
1112

1213
MINIMUM_INTERVAL = timedelta(seconds=1)
1314

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+
1420

1521
def _validate_interval(interval: timedelta) -> timedelta:
1622
if interval <= timedelta(0):
@@ -29,8 +35,11 @@ def _from_iso(value: str | None) -> datetime | None:
2935
return None
3036
# .NET serializes ``DateTimeOffset`` with a numeric offset, but tolerate a
3137
# trailing ``Z`` so states written by other producers still parse on the
32-
# Python versions that predate ``fromisoformat`` accepting ``Z``.
33-
return datetime.fromisoformat(value.replace("Z", "+00:00"))
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)
3443

3544

3645
def _interval_to_seconds(value: timedelta | None) -> float | None:
@@ -130,6 +139,30 @@ def _parse_interval(data: dict[str, Any]) -> timedelta:
130139
raise KeyError("interval")
131140

132141

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+
133166
@dataclass
134167
class ScheduleCreationOptions:
135168
"""Options for creating a new schedule."""
@@ -331,7 +364,7 @@ def to_json(self) -> dict[str, Any]:
331364
"ScheduleId": self.schedule_id,
332365
"OrchestrationName": self.orchestration_name,
333366
"Interval": _interval_to_timespan(self.interval),
334-
"OrchestrationInput": self.orchestration_input,
367+
"OrchestrationInput": _encode_orchestration_input(self.orchestration_input),
335368
"OrchestrationInstanceId": self.orchestration_instance_id,
336369
"StartAt": _to_iso(self.start_at),
337370
"EndAt": _to_iso(self.end_at),
@@ -345,7 +378,12 @@ def from_json(cls, data: dict[str, Any]) -> "ScheduleConfiguration":
345378
_get(data, "OrchestrationName", "orchestration_name"),
346379
_parse_interval(data),
347380
)
348-
config.orchestration_input = _get(data, "OrchestrationInput", "orchestration_input")
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")
349387
config.orchestration_instance_id = _get(
350388
data, "OrchestrationInstanceId", "orchestration_instance_id")
351389
config.start_at = _from_iso(_get(data, "StartAt", "start_at"))
@@ -396,7 +434,12 @@ def from_json(cls, data: dict[str, Any]) -> "ScheduleState":
396434
# accept both the .NET-compatible and legacy snake_case shapes.
397435
state = cls()
398436
state.status = ScheduleStatus.from_dotnet(_get(data, "Status", "status"))
399-
state.execution_token = _get(data, "ExecutionToken", "execution_token")
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
400443
state.last_run_at = _from_iso(_get(data, "LastRunAt", "last_run_at"))
401444
state.next_run_at = _from_iso(_get(data, "NextRunAt", "next_run_at"))
402445
state.schedule_created_at = _from_iso(_get(data, "ScheduleCreatedAt", "schedule_created_at"))

durabletask/scheduled/schedule_status.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
# Licensed under the MIT License.
33

44
from enum import Enum
5-
from typing import Union
65

76

87
class ScheduleStatus(str, Enum):
@@ -29,7 +28,7 @@ def to_dotnet_ordinal(self) -> int:
2928
return _STATUS_TO_ORDINAL[self]
3029

3130
@classmethod
32-
def from_dotnet(cls, value: Union[int, str, None]) -> "ScheduleStatus":
31+
def from_dotnet(cls, value: "int | str | None") -> "ScheduleStatus":
3332
"""Reconstruct a status from a persisted value.
3433
3534
Accepts the numeric ordinal written by the .NET-compatible serializer

tests/durabletask/scheduled/test_models.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,37 @@ def test_from_json_accepts_legacy_snake_case(self):
156156
assert restored.interval == timedelta(seconds=5)
157157
assert restored.start_immediately_if_late is True
158158

159+
def test_orchestration_input_persisted_as_json_string(self):
160+
# .NET / the DTS dashboard model OrchestrationInput as a string, so the
161+
# raw input must be serialized to a JSON string in the persisted state.
162+
config = ScheduleConfiguration.from_create_options(
163+
ScheduleCreationOptions(schedule_id="s1", orchestration_name="orch",
164+
interval=timedelta(seconds=5),
165+
orchestration_input="hello"))
166+
payload = config.to_json()
167+
assert payload["OrchestrationInput"] == '"hello"'
168+
restored = ScheduleConfiguration.from_json(payload)
169+
assert restored.orchestration_input == "hello"
170+
171+
def test_orchestration_input_dict_round_trips(self):
172+
config = ScheduleConfiguration.from_create_options(
173+
ScheduleCreationOptions(schedule_id="s1", orchestration_name="orch",
174+
interval=timedelta(seconds=5),
175+
orchestration_input={"key": "value"}))
176+
payload = config.to_json()
177+
assert isinstance(payload["OrchestrationInput"], str)
178+
restored = ScheduleConfiguration.from_json(payload)
179+
assert restored.orchestration_input == {"key": "value"}
180+
181+
def test_orchestration_input_none_stays_none(self):
182+
config = ScheduleConfiguration.from_create_options(
183+
ScheduleCreationOptions(schedule_id="s1", orchestration_name="orch",
184+
interval=timedelta(seconds=5)))
185+
payload = config.to_json()
186+
assert payload["OrchestrationInput"] is None
187+
restored = ScheduleConfiguration.from_json(payload)
188+
assert restored.orchestration_input is None
189+
159190

160191
class TestScheduleState:
161192
def test_round_trip_and_description(self):
@@ -213,6 +244,12 @@ def test_refresh_execution_token_changes_token(self):
213244
state.refresh_execution_token()
214245
assert state.execution_token != original
215246

247+
def test_from_json_preserves_default_token_when_missing(self):
248+
# A payload without an execution token must not clobber the token
249+
# generated by ``__init__``; otherwise every run signal looks stale.
250+
restored = ScheduleState.from_json({"Status": 1})
251+
assert restored.execution_token
252+
216253

217254
class TestScheduleQueryNormalization:
218255
def test_naive_bounds_are_coerced_to_aware_utc(self):

0 commit comments

Comments
 (0)