55
66import logging
77from datetime import datetime , timedelta , timezone
8+ from typing import Any
89
910import pytest
1011
12+ import durabletask .internal .orchestrator_service_pb2 as pb
1113from durabletask .entities import EntityInstanceId
12- from durabletask .internal import shared
1314from durabletask .internal .entity_state_shim import StateShim
1415from durabletask .scheduled .exceptions import ScheduleInvalidTransitionError
15- from durabletask .scheduled .models import (ScheduleCreationOptions ,
16+ from durabletask .scheduled .models import (ScheduleCreationOptions , ScheduleState ,
1617 ScheduleUpdateOptions )
1718from durabletask .scheduled .schedule_entity import (ENTITY_NAME , Schedule )
1819from durabletask .scheduled .schedule_status import ScheduleStatus
20+ from durabletask .serialization import JsonDataConverter
1921from durabletask .worker import _EntityExecutor , _Registry
2022
2123SCHEDULE_ID = "sched-1"
2224
2325
2426class 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
94105class 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
0 commit comments