Skip to content

Commit e8186e7

Browse files
committed
Update scheduled tasks with new serialization fixes
1 parent 10657aa commit e8186e7

8 files changed

Lines changed: 112 additions & 56 deletions

File tree

durabletask/internal/client_helpers.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,14 +205,16 @@ def build_signal_entity_req(
205205
entity_instance_id: EntityInstanceId,
206206
operation_name: str,
207207
input: Any | None = None,
208+
signal_time: datetime | None = None,
208209
data_converter: DataConverter | None = None) -> pb.SignalEntityRequest:
209210
"""Build a SignalEntityRequest for signaling an entity."""
211+
scheduled_time = helpers.new_timestamp(signal_time) if signal_time is not None else None
210212
return pb.SignalEntityRequest(
211213
instanceId=str(entity_instance_id),
212214
name=operation_name,
213215
input=helpers.get_string_value(_serialize(input, data_converter)),
214216
requestId=str(uuid.uuid4()),
215-
scheduledTime=None,
217+
scheduledTime=scheduled_time,
216218
parentTraceContext=None,
217219
requestTime=helpers.new_timestamp(datetime.now(timezone.utc))
218220
)

durabletask/internal/helpers.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -255,11 +255,13 @@ def new_signal_entity_action(id: int,
255255
entity_id: EntityInstanceId,
256256
operation: str,
257257
encoded_input: str | None,
258-
request_id: str) -> pb.OrchestratorAction:
258+
request_id: str,
259+
signal_time: datetime | None = None) -> pb.OrchestratorAction:
260+
scheduled_time = new_timestamp(signal_time) if signal_time is not None else None
259261
return pb.OrchestratorAction(id=id, sendEntityMessage=pb.SendEntityMessageAction(entityOperationSignaled=pb.EntityOperationSignaledEvent(
260262
requestId=request_id,
261263
operation=operation,
262-
scheduledTime=None,
264+
scheduledTime=scheduled_time,
263265
input=get_string_value(encoded_input),
264266
targetInstanceId=get_string_value(str(entity_id)),
265267
)))

durabletask/scheduled/models.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -277,20 +277,22 @@ def to_json(self) -> dict[str, Any]:
277277
}
278278

279279
@classmethod
280-
def from_json(cls, data: dict[str, Any], converter: Any) -> "ScheduleState":
281-
# The nested configuration is reconstructed through the converter, which
282-
# routes it to ``ScheduleConfiguration``'s own ``from_json`` hook (and
283-
# honors a custom converter). Only this type's datetime leaves are
284-
# rebuilt by hand.
280+
def from_json(cls, data: dict[str, Any]) -> "ScheduleState":
281+
# The nested configuration is reconstructed by calling its own
282+
# ``from_json`` hook directly. ``ScheduleConfiguration`` is an internal
283+
# type, so there is no need to route it through a (possibly custom)
284+
# converter -- keeping this hook converter-free means it round-trips
285+
# under any code path, not only the worker's threaded converter.
285286
state = cls()
286287
state.status = ScheduleStatus(data["status"])
287288
state.execution_token = data["execution_token"]
288289
state.last_run_at = _from_iso(data.get("last_run_at"))
289290
state.next_run_at = _from_iso(data.get("next_run_at"))
290291
state.schedule_created_at = _from_iso(data.get("schedule_created_at"))
291292
state.schedule_last_modified_at = _from_iso(data.get("schedule_last_modified_at"))
292-
state.schedule_configuration = converter.coerce(
293-
data.get("schedule_configuration"), ScheduleConfiguration)
293+
config_data = data.get("schedule_configuration")
294+
state.schedule_configuration = (
295+
ScheduleConfiguration.from_json(config_data) if config_data is not None else None)
294296
return state
295297

296298
def to_description(self) -> ScheduleDescription:

durabletask/scheduled/orchestrator.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@
1313
class ScheduleOperationRequest:
1414
"""Request describing an operation to execute against a schedule entity.
1515
16-
A plain dataclass: the serializer round-trips it (and its ``input`` payload)
17-
automatically. ``input`` stays an ``Any`` here -- it is reconstructed into the
18-
concrete options type at the entity-method boundary from that method's
19-
parameter annotation.
16+
A plain dataclass: the serializer round-trips it automatically. ``input`` is
17+
typed ``Any``, so it is reconstructed as the raw deserialized payload; the
18+
concrete options type is rebuilt later, at the entity-method boundary, from
19+
that method's parameter annotation.
2020
"""
2121

2222
entity_id: str

durabletask/serialization.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,9 @@ def _coerce_to_type(value: Any, expected_type: Any, converter: DataConverter | N
360360
if expected_type is None or value is None:
361361
return value
362362

363+
if expected_type is typing.Any:
364+
return value
365+
363366
origin = typing.get_origin(expected_type)
364367
if origin is not None:
365368
return _coerce_generic(value, expected_type, origin, converter)
@@ -447,6 +450,11 @@ def _coerce_generic(value: Any, expected_type: Any, origin: Any,
447450
# If the value already matches a member type, keep it as-is.
448451
non_none = [a for a in args if a is not type(None)]
449452
for arg in non_none:
453+
# ``Any`` imposes no constraint, so the value already satisfies the
454+
# union. (Checked explicitly because ``isinstance(value, Any)`` would
455+
# raise -- ``typing.Any`` is a class on 3.11+ but not isinstance-able.)
456+
if arg is typing.Any:
457+
return value
450458
if isinstance(arg, type) and isinstance(value, arg):
451459
return value
452460
# ``Optional[T]`` (exactly one non-None member): coerce to that member.

tests/durabletask/scheduled/test_models.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@
77

88
import pytest
99

10-
from durabletask.internal import shared
10+
from durabletask.serialization import JsonDataConverter
1111
from durabletask.scheduled.models import (ScheduleConfiguration,
1212
ScheduleCreationOptions,
1313
ScheduleState, ScheduleUpdateOptions)
1414
from durabletask.scheduled.schedule_status import ScheduleStatus
1515

16+
converter = JsonDataConverter()
17+
1618

1719
class TestCreationOptionsValidation:
1820
def test_requires_schedule_id(self):
@@ -51,8 +53,8 @@ def test_round_trip_through_json(self):
5153
orchestration_input={"key": "value"}, orchestration_instance_id="inst-1",
5254
start_at=start, end_at=end, start_immediately_if_late=True)
5355

54-
encoded = shared.to_json(options)
55-
decoded = shared.from_json(encoded, ScheduleCreationOptions)
56+
encoded = converter.serialize(options)
57+
decoded = converter.deserialize(encoded, ScheduleCreationOptions)
5658

5759
assert decoded.schedule_id == "s1"
5860
assert decoded.orchestration_name == "orch"
@@ -71,7 +73,7 @@ def test_interval_validation(self):
7173

7274
def test_round_trip_through_json(self):
7375
options = ScheduleUpdateOptions(orchestration_name="orch2", interval=timedelta(seconds=10))
74-
decoded = shared.from_json(shared.to_json(options), ScheduleUpdateOptions)
76+
decoded = converter.deserialize(converter.serialize(options), ScheduleUpdateOptions)
7577
assert decoded.orchestration_name == "orch2"
7678
assert decoded.interval == timedelta(seconds=10)
7779
assert decoded.start_at is None
@@ -108,7 +110,7 @@ def test_config_round_trip(self):
108110
ScheduleCreationOptions(schedule_id="s1", orchestration_name="orch",
109111
interval=timedelta(seconds=5),
110112
start_at=datetime(2026, 1, 1, tzinfo=timezone.utc)))
111-
restored = shared.from_json(shared.to_json(config), ScheduleConfiguration)
113+
restored = converter.deserialize(converter.serialize(config), ScheduleConfiguration)
112114
assert restored.schedule_id == "s1"
113115
assert restored.interval == timedelta(seconds=5)
114116
assert restored.start_at == datetime(2026, 1, 1, tzinfo=timezone.utc)
@@ -124,7 +126,7 @@ def test_round_trip_and_description(self):
124126
interval=timedelta(seconds=5)))
125127

126128
# The nested ``ScheduleConfiguration`` round-trips automatically.
127-
restored = shared.from_json(shared.to_json(state), ScheduleState)
129+
restored = converter.deserialize(converter.serialize(state), ScheduleState)
128130
assert restored.status == ScheduleStatus.ACTIVE
129131
assert restored.schedule_created_at == datetime(2026, 1, 1, tzinfo=timezone.utc)
130132
assert restored.schedule_configuration is not None

tests/durabletask/scheduled/test_schedule_entity.py

Lines changed: 48 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -5,66 +5,76 @@
55

66
import logging
77
from datetime import datetime, timedelta, timezone
8+
from typing import Any
89

910
import pytest
1011

12+
import durabletask.internal.orchestrator_service_pb2 as pb
1113
from durabletask.entities import EntityInstanceId
12-
from durabletask.internal import shared
1314
from durabletask.internal.entity_state_shim import StateShim
1415
from durabletask.scheduled.exceptions import ScheduleInvalidTransitionError
15-
from durabletask.scheduled.models import (ScheduleCreationOptions,
16+
from durabletask.scheduled.models import (ScheduleCreationOptions, ScheduleState,
1617
ScheduleUpdateOptions)
1718
from durabletask.scheduled.schedule_entity import (ENTITY_NAME, Schedule)
1819
from durabletask.scheduled.schedule_status import ScheduleStatus
20+
from durabletask.serialization import JsonDataConverter
1921
from durabletask.worker import _EntityExecutor, _Registry
2022

2123
SCHEDULE_ID = "sched-1"
2224

2325

2426
class Harness:
25-
"""Drives Schedule entity operations against a persistent in-memory state."""
27+
"""Drives Schedule entity operations against a persistent in-memory state.
28+
29+
Mirrors the worker's entity-batch lifecycle: the ``StateShim`` holds the
30+
serialized state between operations, so each ``run`` deserializes on read and
31+
serializes on write through the data converter -- exactly the wire round-trip
32+
a real entity experiences across batches.
33+
"""
2634

2735
def __init__(self):
2836
registry = _Registry()
2937
registry.add_entity(Schedule, ENTITY_NAME)
30-
self.executor = _EntityExecutor(registry, logging.getLogger("test"))
31-
self.state = StateShim(None)
38+
self.converter = JsonDataConverter()
39+
self.executor = _EntityExecutor(registry, logging.getLogger("test"), self.converter)
40+
self.shim = StateShim(None, self.converter)
3241
self.entity_id = EntityInstanceId(ENTITY_NAME, SCHEDULE_ID)
3342

34-
def run(self, operation, input=None):
35-
before = len(self.state.get_operation_actions())
36-
encoded = shared.to_json(input) if input is not None else None
37-
result = self.executor.execute("orch-1", self.entity_id, operation, self.state, encoded)
38-
self.state.commit()
39-
# Mimic the wire round-trip: the worker serializes the entity state at
40-
# the end of each batch, and the next batch receives it as deserialized
41-
# JSON (a plain dict). This exercises the state ``to_json``/``from_json``
42-
# hooks between operations and keeps assertions dict-based.
43-
current = self.state._current_state # pyright: ignore[reportPrivateUsage]
44-
if current is not None:
45-
self.state._current_state = shared.from_json(shared.to_json(current)) # pyright: ignore[reportPrivateUsage]
46-
actions = self.state.get_operation_actions()[before:]
43+
def run(self, operation: str, input: Any = None) -> tuple[str | None, list[pb.OperationAction]]:
44+
before = len(self.shim.get_operation_actions())
45+
encoded = self.converter.serialize(input) if input is not None else None
46+
result = self.executor.execute("orch-1", self.entity_id, operation, self.shim, encoded)
47+
self.shim.commit()
48+
actions = self.shim.get_operation_actions()[before:]
4749
return result, actions
4850

51+
def state(self) -> ScheduleState | None:
52+
"""Reconstruct the typed state object, the way the entity itself would."""
53+
return self.shim.get_state(ScheduleState)
54+
4955
@property
50-
def state_dict(self):
51-
return self.state._current_state # pyright: ignore[reportPrivateUsage]
56+
def current(self) -> ScheduleState:
57+
"""Like :meth:`state` but asserts the state exists (most operations)."""
58+
state = self.state()
59+
assert state is not None
60+
return state
5261

5362
@property
54-
def token(self):
55-
return self.state_dict["execution_token"]
63+
def token(self) -> str:
64+
return self.current.execution_token
5665

5766

58-
def _signal_actions(actions):
67+
def _signal_actions(actions: list[pb.OperationAction]) -> list[pb.OperationAction]:
5968
return [a for a in actions if a.HasField("sendSignal")]
6069

6170

62-
def _start_actions(actions):
71+
def _start_actions(actions: list[pb.OperationAction]) -> list[pb.OperationAction]:
6372
return [a for a in actions if a.HasField("startNewOrchestration")]
6473

6574

66-
def _creation_options(**kwargs):
67-
base = dict(schedule_id=SCHEDULE_ID, orchestration_name="my_orch", interval=timedelta(seconds=30))
75+
def _creation_options(**kwargs: Any) -> ScheduleCreationOptions:
76+
base: dict[str, Any] = dict(
77+
schedule_id=SCHEDULE_ID, orchestration_name="my_orch", interval=timedelta(seconds=30))
6878
base.update(kwargs)
6979
return ScheduleCreationOptions(**base)
7080

@@ -74,8 +84,9 @@ def test_create_activates_and_signals_run(self):
7484
h = Harness()
7585
_, actions = h.run("create_schedule", _creation_options())
7686

77-
assert h.state_dict["status"] == ScheduleStatus.ACTIVE.value
78-
assert h.state_dict["schedule_created_at"] is not None
87+
state = h.current
88+
assert state.status == ScheduleStatus.ACTIVE
89+
assert state.schedule_created_at is not None
7990
signals = _signal_actions(actions)
8091
assert len(signals) == 1
8192
assert signals[0].sendSignal.name == "run_schedule"
@@ -88,7 +99,7 @@ def test_create_twice_updates_in_place(self):
8899
h.run("create_schedule", _creation_options(interval=timedelta(seconds=60)))
89100
# Re-creation refreshes the execution token.
90101
assert h.token != first_token
91-
assert h.state_dict["status"] == ScheduleStatus.ACTIVE.value
102+
assert h.current.status == ScheduleStatus.ACTIVE
92103

93104

94105
class TestPauseResume:
@@ -97,11 +108,11 @@ def test_pause_then_resume(self):
97108
h.run("create_schedule", _creation_options())
98109

99110
h.run("pause_schedule")
100-
assert h.state_dict["status"] == ScheduleStatus.PAUSED.value
101-
assert h.state_dict["next_run_at"] is None
111+
assert h.current.status == ScheduleStatus.PAUSED
112+
assert h.current.next_run_at is None
102113

103114
_, actions = h.run("resume_schedule")
104-
assert h.state_dict["status"] == ScheduleStatus.ACTIVE.value
115+
assert h.current.status == ScheduleStatus.ACTIVE
105116
assert len(_signal_actions(actions)) == 1
106117

107118
def test_pause_when_not_active_raises(self):
@@ -118,7 +129,9 @@ def test_update_changes_config_and_resignals(self):
118129
h.run("create_schedule", _creation_options())
119130
_, actions = h.run("update_schedule",
120131
ScheduleUpdateOptions(interval=timedelta(seconds=120)))
121-
assert abs(h.state_dict["schedule_configuration"]["interval_seconds"] - 120) < 0.001
132+
config = h.current.schedule_configuration
133+
assert config is not None
134+
assert config.interval == timedelta(seconds=120)
122135
assert len(_signal_actions(actions)) == 1
123136

124137
def test_update_no_change_does_not_signal(self):
@@ -140,7 +153,7 @@ def test_runs_orchestration_when_due_and_rearms(self):
140153
starts = _start_actions(actions)
141154
assert len(starts) == 1
142155
assert starts[0].startNewOrchestration.name == "my_orch"
143-
assert h.state_dict["last_run_at"] is not None
156+
assert h.current.last_run_at is not None
144157

145158
# Re-arm signal should carry a future scheduled time.
146159
signals = _signal_actions(actions)
@@ -180,4 +193,4 @@ def test_delete_clears_state(self):
180193
h = Harness()
181194
h.run("create_schedule", _creation_options())
182195
h.run("delete")
183-
assert h.state_dict is None
196+
assert h.state() is None

tests/durabletask/test_serialization.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from collections import namedtuple
1313
from dataclasses import dataclass
1414
from types import SimpleNamespace
15-
from typing import List, Optional, Union, get_args
15+
from typing import Any, List, Optional, Union, get_args
1616

1717
import pytest
1818

@@ -422,6 +422,33 @@ def test_coerce_dict_values_recursively():
422422
assert isinstance(result["home"], Address)
423423

424424

425+
def test_coerce_bare_any_returns_value_unchanged():
426+
# ``Any`` carries no type info; the parsed value is already the result.
427+
value = {"k": "v"}
428+
assert coerce_to_type(value, Any) is value
429+
430+
431+
def test_coerce_optional_any_returns_value_unchanged():
432+
# ``Any | None`` must pass the value through rather than raising on the
433+
# ``isinstance(value, Any)`` check inside the union loop.
434+
value = {"k": "v"}
435+
assert coerce_to_type(value, Optional[Any]) == {"k": "v"}
436+
437+
438+
def test_coerce_dataclass_with_any_field_round_trips():
439+
# A plain dataclass whose field is annotated ``Any | None`` must reconstruct
440+
# with that field left as the raw parsed JSON, not crash.
441+
@dataclass
442+
class Envelope:
443+
name: str
444+
payload: Any | None = None
445+
446+
restored = from_json(to_json(Envelope("x", {"nested": [1, 2]})), Envelope)
447+
assert isinstance(restored, Envelope)
448+
assert restored.name == "x"
449+
assert restored.payload == {"nested": [1, 2]}
450+
451+
425452
# ----- from_json converter hook (PR #154 follow-up) -----
426453

427454

0 commit comments

Comments
 (0)