Skip to content

Commit eb67c35

Browse files
committed
Schedules hotfix for DTS dashboard
1 parent 8440526 commit eb67c35

4 files changed

Lines changed: 261 additions & 34 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ CHANGED
1111

1212
- Changed the default large-payload externalization threshold (`LargePayloadStorageOptions.threshold_bytes`) from 900,000 bytes to 262,144 bytes (256 KiB), matching the .NET SDK default. Behavioral change (not source/binary breaking): payloads larger than 256 KiB are now externalized by default.
1313

14+
FIXED
15+
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.
17+
1418
## v1.7.0
1519

1620
ADDED

durabletask/scheduled/models.py

Lines changed: 137 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,12 @@ def _to_iso(value: datetime | None) -> str | None:
2525

2626

2727
def _from_iso(value: str | None) -> datetime | None:
28-
return datetime.fromisoformat(value) if value else None
28+
if not value:
29+
return None
30+
# .NET serializes ``DateTimeOffset`` with a numeric offset, but tolerate a
31+
# 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"))
2934

3035

3136
def _interval_to_seconds(value: timedelta | None) -> float | None:
@@ -36,6 +41,95 @@ def _interval_from_seconds(value: float | None) -> timedelta | None:
3641
return timedelta(seconds=value) if value is not None else None
3742

3843

44+
# Number of 100-nanosecond ticks per second, matching the .NET ``TimeSpan``
45+
# resolution used when formatting the fractional component.
46+
_TICKS_PER_SECOND = 10_000_000
47+
48+
49+
def _interval_to_timespan(value: timedelta | None) -> str | None:
50+
"""Format a ``timedelta`` as a .NET ``TimeSpan`` (``[-][d.]hh:mm:ss[.fffffff]``).
51+
52+
The Durable Task Scheduler dashboard deserializes the schedule interval into
53+
a .NET ``TimeSpan``, whose JSON converter only accepts this constant format.
54+
"""
55+
if value is None:
56+
return None
57+
negative = value < timedelta(0)
58+
value = abs(value)
59+
days = value.days
60+
hours, remainder = divmod(value.seconds, 3600)
61+
minutes, seconds = divmod(remainder, 60)
62+
if days:
63+
formatted = f"{days}.{hours:02d}:{minutes:02d}:{seconds:02d}"
64+
else:
65+
formatted = f"{hours:02d}:{minutes:02d}:{seconds:02d}"
66+
if value.microseconds:
67+
ticks = value.microseconds * 10 # microseconds -> 100-ns ticks
68+
formatted += f".{ticks:07d}"
69+
return f"-{formatted}" if negative else formatted
70+
71+
72+
def _interval_from_timespan(value: str) -> timedelta:
73+
"""Parse a .NET ``TimeSpan`` string (``[-][d.]hh:mm:ss[.fffffff]``)."""
74+
text = value.strip()
75+
negative = text.startswith("-")
76+
if negative:
77+
text = text[1:]
78+
79+
fraction = 0.0
80+
if "." in text:
81+
head, _, tail = text.rpartition(".")
82+
# A dot before the first ``:`` is the day separator, not a fraction.
83+
if ":" in tail:
84+
# e.g. ``1.02:03:04`` -- the ``.`` separates days from the clock.
85+
days_part, _, clock = text.partition(".")
86+
days = int(days_part)
87+
hours, minutes, seconds = (int(p) for p in clock.split(":"))
88+
else:
89+
# ``head`` holds ``[d.]hh:mm:ss`` and ``tail`` the ticks fraction.
90+
fraction = int(tail.ljust(7, "0")[:7]) / _TICKS_PER_SECOND
91+
days, hours, minutes, seconds = _split_clock(head)
92+
else:
93+
days, hours, minutes, seconds = _split_clock(text)
94+
95+
result = timedelta(days=days, hours=hours, minutes=minutes,
96+
seconds=seconds) + timedelta(seconds=fraction)
97+
return -result if negative else result
98+
99+
100+
def _split_clock(text: str) -> tuple[int, int, int, int]:
101+
"""Split ``[d.]hh:mm:ss`` into ``(days, hours, minutes, seconds)``."""
102+
days = 0
103+
if "." in text:
104+
days_part, _, text = text.partition(".")
105+
days = int(days_part)
106+
hours, minutes, seconds = (int(part) for part in text.split(":"))
107+
return days, hours, minutes, seconds
108+
109+
110+
def _get(data: dict[str, Any], *keys: str, default: Any = None) -> Any:
111+
"""Return the first present key from ``data``.
112+
113+
Reads tolerate both the .NET-compatible PascalCase keys and the legacy
114+
snake_case keys written by earlier Python workers.
115+
"""
116+
for key in keys:
117+
if key in data:
118+
return data[key]
119+
return default
120+
121+
122+
def _parse_interval(data: dict[str, Any]) -> timedelta:
123+
"""Read the interval from either the .NET ``Interval`` or legacy field."""
124+
timespan = _get(data, "Interval", "interval")
125+
if isinstance(timespan, str):
126+
return _interval_from_timespan(timespan)
127+
seconds = _get(data, "interval_seconds")
128+
if seconds is not None:
129+
return timedelta(seconds=seconds)
130+
raise KeyError("interval")
131+
132+
39133
@dataclass
40134
class ScheduleCreationOptions:
41135
"""Options for creating a new schedule."""
@@ -230,29 +324,34 @@ def _validate(self):
230324
raise ValueError("start_at cannot be later than end_at.")
231325

232326
def to_json(self) -> dict[str, Any]:
327+
# Serialized with .NET-compatible property names and value shapes so the
328+
# Durable Task Scheduler dashboard can deserialize the raw entity state:
329+
# PascalCase keys and the interval as a .NET ``TimeSpan`` string.
233330
return {
234-
"schedule_id": self.schedule_id,
235-
"orchestration_name": self.orchestration_name,
236-
"interval_seconds": self.interval.total_seconds(),
237-
"orchestration_input": self.orchestration_input,
238-
"orchestration_instance_id": self.orchestration_instance_id,
239-
"start_at": _to_iso(self.start_at),
240-
"end_at": _to_iso(self.end_at),
241-
"start_immediately_if_late": self.start_immediately_if_late,
331+
"ScheduleId": self.schedule_id,
332+
"OrchestrationName": self.orchestration_name,
333+
"Interval": _interval_to_timespan(self.interval),
334+
"OrchestrationInput": self.orchestration_input,
335+
"OrchestrationInstanceId": self.orchestration_instance_id,
336+
"StartAt": _to_iso(self.start_at),
337+
"EndAt": _to_iso(self.end_at),
338+
"StartImmediatelyIfLate": self.start_immediately_if_late,
242339
}
243340

244341
@classmethod
245342
def from_json(cls, data: dict[str, Any]) -> "ScheduleConfiguration":
246343
config = cls(
247-
data["schedule_id"],
248-
data["orchestration_name"],
249-
timedelta(seconds=data["interval_seconds"]),
344+
_get(data, "ScheduleId", "schedule_id"),
345+
_get(data, "OrchestrationName", "orchestration_name"),
346+
_parse_interval(data),
250347
)
251-
config.orchestration_input = data.get("orchestration_input")
252-
config.orchestration_instance_id = data.get("orchestration_instance_id")
253-
config.start_at = _from_iso(data.get("start_at"))
254-
config.end_at = _from_iso(data.get("end_at"))
255-
config.start_immediately_if_late = bool(data.get("start_immediately_if_late", False))
348+
config.orchestration_input = _get(data, "OrchestrationInput", "orchestration_input")
349+
config.orchestration_instance_id = _get(
350+
data, "OrchestrationInstanceId", "orchestration_instance_id")
351+
config.start_at = _from_iso(_get(data, "StartAt", "start_at"))
352+
config.end_at = _from_iso(_get(data, "EndAt", "end_at"))
353+
config.start_immediately_if_late = bool(
354+
_get(data, "StartImmediatelyIfLate", "start_immediately_if_late", default=False))
256355
return config
257356

258357

@@ -273,16 +372,18 @@ def refresh_execution_token(self):
273372

274373
def to_json(self) -> dict[str, Any]:
275374
# ``schedule_configuration`` is returned as the object itself; the
276-
# serializer recurses into it and fires its own ``to_json`` hook. Only
277-
# this type's non-JSON-native leaves (datetimes) are converted here.
375+
# serializer recurses into it and fires its own ``to_json`` hook. Keys
376+
# and value shapes mirror the .NET ``ScheduleState`` so the Durable Task
377+
# Scheduler dashboard can deserialize the raw entity state: PascalCase
378+
# names, the status as its numeric ordinal, and datetimes as ISO strings.
278379
return {
279-
"status": self.status.value,
280-
"execution_token": self.execution_token,
281-
"last_run_at": _to_iso(self.last_run_at),
282-
"next_run_at": _to_iso(self.next_run_at),
283-
"schedule_created_at": _to_iso(self.schedule_created_at),
284-
"schedule_last_modified_at": _to_iso(self.schedule_last_modified_at),
285-
"schedule_configuration": self.schedule_configuration,
380+
"Status": self.status.to_dotnet_ordinal(),
381+
"ExecutionToken": self.execution_token,
382+
"LastRunAt": _to_iso(self.last_run_at),
383+
"NextRunAt": _to_iso(self.next_run_at),
384+
"ScheduleCreatedAt": _to_iso(self.schedule_created_at),
385+
"ScheduleLastModifiedAt": _to_iso(self.schedule_last_modified_at),
386+
"ScheduleConfiguration": self.schedule_configuration,
286387
}
287388

288389
@classmethod
@@ -291,15 +392,17 @@ def from_json(cls, data: dict[str, Any]) -> "ScheduleState":
291392
# ``from_json`` hook directly. ``ScheduleConfiguration`` is an internal
292393
# type, so there is no need to route it through a (possibly custom)
293394
# converter -- keeping this hook converter-free means it round-trips
294-
# under any code path, not only the worker's threaded converter.
395+
# under any code path, not only the worker's threaded converter. Reads
396+
# accept both the .NET-compatible and legacy snake_case shapes.
295397
state = cls()
296-
state.status = ScheduleStatus(data["status"])
297-
state.execution_token = data["execution_token"]
298-
state.last_run_at = _from_iso(data.get("last_run_at"))
299-
state.next_run_at = _from_iso(data.get("next_run_at"))
300-
state.schedule_created_at = _from_iso(data.get("schedule_created_at"))
301-
state.schedule_last_modified_at = _from_iso(data.get("schedule_last_modified_at"))
302-
config_data = data.get("schedule_configuration")
398+
state.status = ScheduleStatus.from_dotnet(_get(data, "Status", "status"))
399+
state.execution_token = _get(data, "ExecutionToken", "execution_token")
400+
state.last_run_at = _from_iso(_get(data, "LastRunAt", "last_run_at"))
401+
state.next_run_at = _from_iso(_get(data, "NextRunAt", "next_run_at"))
402+
state.schedule_created_at = _from_iso(_get(data, "ScheduleCreatedAt", "schedule_created_at"))
403+
state.schedule_last_modified_at = _from_iso(
404+
_get(data, "ScheduleLastModifiedAt", "schedule_last_modified_at"))
405+
config_data = _get(data, "ScheduleConfiguration", "schedule_configuration")
303406
state.schedule_configuration = (
304407
ScheduleConfiguration.from_json(config_data) if config_data is not None else None)
305408
return state

durabletask/scheduled/schedule_status.py

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

44
from enum import Enum
5+
from typing import Union
56

67

78
class ScheduleStatus(str, Enum):
@@ -15,3 +16,52 @@ class ScheduleStatus(str, Enum):
1516

1617
PAUSED = "Paused"
1718
"""Schedule is paused."""
19+
20+
def to_dotnet_ordinal(self) -> int:
21+
"""Return the numeric value used by the .NET ``ScheduleStatus`` enum.
22+
23+
The Durable Task Scheduler dashboard reads the persisted entity state
24+
with ``System.Text.Json`` (Web defaults, no string-enum converter), so
25+
the status must be serialized as the enum's ordinal rather than its
26+
name. The ordinals match the .NET SDK order (``Uninitialized`` = 0,
27+
``Active`` = 1, ``Paused`` = 2).
28+
"""
29+
return _STATUS_TO_ORDINAL[self]
30+
31+
@classmethod
32+
def from_dotnet(cls, value: Union[int, str, None]) -> "ScheduleStatus":
33+
"""Reconstruct a status from a persisted value.
34+
35+
Accepts the numeric ordinal written by the .NET-compatible serializer
36+
as well as the legacy string name (e.g. ``"Active"``) so that states
37+
persisted by older Python workers still round-trip.
38+
"""
39+
if isinstance(value, bool):
40+
# ``bool`` is a subclass of ``int``; reject it explicitly so a
41+
# stray boolean cannot be misread as an ordinal.
42+
return cls.UNINITIALIZED
43+
if isinstance(value, int):
44+
return _ORDINAL_TO_STATUS.get(value, cls.UNINITIALIZED)
45+
if isinstance(value, str):
46+
text = value.strip()
47+
if text.isdigit():
48+
return _ORDINAL_TO_STATUS.get(int(text), cls.UNINITIALIZED)
49+
for member in cls:
50+
if member.value.lower() == text.lower():
51+
return member
52+
# The .NET Scheduler client names the zero value "Unknown"; treat
53+
# it as the equivalent uninitialized state.
54+
if text.lower() == "unknown":
55+
return cls.UNINITIALIZED
56+
return cls.UNINITIALIZED
57+
58+
59+
_STATUS_TO_ORDINAL: dict["ScheduleStatus", int] = {
60+
ScheduleStatus.UNINITIALIZED: 0,
61+
ScheduleStatus.ACTIVE: 1,
62+
ScheduleStatus.PAUSED: 2,
63+
}
64+
65+
_ORDINAL_TO_STATUS: dict[int, "ScheduleStatus"] = {
66+
ordinal: status for status, ordinal in _STATUS_TO_ORDINAL.items()
67+
}

tests/durabletask/scheduled/test_models.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,47 @@ def test_config_round_trip(self):
115115
assert restored.interval == timedelta(seconds=5)
116116
assert restored.start_at == datetime(2026, 1, 1, tzinfo=timezone.utc)
117117

118+
def test_to_json_uses_dotnet_compatible_shape(self):
119+
# The Durable Task Scheduler dashboard deserializes the raw entity state
120+
# into the .NET types, so the persisted JSON must use PascalCase keys and
121+
# a .NET ``TimeSpan`` string for the interval.
122+
config = ScheduleConfiguration.from_create_options(
123+
ScheduleCreationOptions(schedule_id="s1", orchestration_name="orch",
124+
interval=timedelta(hours=1)))
125+
payload = config.to_json()
126+
assert payload["ScheduleId"] == "s1"
127+
assert payload["OrchestrationName"] == "orch"
128+
assert payload["Interval"] == "01:00:00"
129+
assert payload["StartImmediatelyIfLate"] is False
130+
assert "interval_seconds" not in payload
131+
132+
@pytest.mark.parametrize("interval,expected", [
133+
(timedelta(seconds=1), "00:00:01"),
134+
(timedelta(hours=1), "01:00:00"),
135+
(timedelta(days=1, hours=2, minutes=3, seconds=4), "1.02:03:04"),
136+
(timedelta(seconds=1, milliseconds=500), "00:00:01.5000000"),
137+
])
138+
def test_interval_timespan_round_trip(self, interval, expected):
139+
config = ScheduleConfiguration.from_create_options(
140+
ScheduleCreationOptions(schedule_id="s1", orchestration_name="orch",
141+
interval=interval))
142+
payload = config.to_json()
143+
assert payload["Interval"] == expected
144+
restored = ScheduleConfiguration.from_json(payload)
145+
assert restored.interval == interval
146+
147+
def test_from_json_accepts_legacy_snake_case(self):
148+
legacy = {
149+
"schedule_id": "s1",
150+
"orchestration_name": "orch",
151+
"interval_seconds": 5.0,
152+
"start_immediately_if_late": True,
153+
}
154+
restored = ScheduleConfiguration.from_json(legacy)
155+
assert restored.schedule_id == "s1"
156+
assert restored.interval == timedelta(seconds=5)
157+
assert restored.start_immediately_if_late is True
158+
118159

119160
class TestScheduleState:
120161
def test_round_trip_and_description(self):
@@ -137,6 +178,35 @@ def test_round_trip_and_description(self):
137178
assert description.status == ScheduleStatus.ACTIVE
138179
assert description.interval == timedelta(seconds=5)
139180

181+
def test_to_json_serializes_status_as_dotnet_ordinal(self):
182+
# System.Text.Json (Web defaults) reads the schedule status as a numeric
183+
# enum, so the persisted status must be its ordinal, not its name.
184+
state = ScheduleState()
185+
state.status = ScheduleStatus.ACTIVE
186+
state.schedule_configuration = ScheduleConfiguration.from_create_options(
187+
ScheduleCreationOptions(schedule_id="s1", orchestration_name="orch",
188+
interval=timedelta(seconds=5)))
189+
payload = state.to_json()
190+
assert payload["Status"] == 1
191+
assert payload["ExecutionToken"] == state.execution_token
192+
assert "status" not in payload
193+
194+
def test_from_json_accepts_legacy_string_status(self):
195+
legacy = {
196+
"status": "Active",
197+
"execution_token": "token-abc",
198+
"schedule_configuration": {
199+
"schedule_id": "s1",
200+
"orchestration_name": "orch",
201+
"interval_seconds": 5.0,
202+
},
203+
}
204+
restored = ScheduleState.from_json(legacy)
205+
assert restored.status == ScheduleStatus.ACTIVE
206+
assert restored.execution_token == "token-abc"
207+
assert restored.schedule_configuration is not None
208+
assert restored.schedule_configuration.interval == timedelta(seconds=5)
209+
140210
def test_refresh_execution_token_changes_token(self):
141211
state = ScheduleState()
142212
original = state.execution_token

0 commit comments

Comments
 (0)