1010import subprocess
1111import sys
1212from collections .abc import AsyncGenerator , Generator
13- from datetime import datetime , timedelta
13+ from datetime import UTC , datetime , timedelta , timezone
1414from functools import partial , update_wrapper , wraps
15- from typing import Any
15+ from types import SimpleNamespace
16+ from typing import Any , cast
1617
1718import pytest
1819
1920from durabletask import entities , task , worker
2021from durabletask .internal import helpers , shared
2122from durabletask .internal import orchestrator_service_pb2 as pb
23+ from durabletask .internal .asyncio_loop import DurableEventLoop
2224from durabletask .serialization import JsonDataConverter
2325
26+ pytestmark = pytest .mark .filterwarnings ("error::RuntimeWarning" )
27+
2428
2529INSTANCE_ID = "coroutine-test"
2630NOW = datetime (2026 , 1 , 1 )
@@ -217,14 +221,55 @@ def wrapper(*args: Any, **kwargs: Any) -> Generator[int, None, None]:
217221 assert inspect .getgeneratorstate (created [0 ]) == inspect .GEN_CLOSED
218222
219223
220- def test_cyclic_async_wrapper_fails_instead_of_hanging () -> None :
224+ @pytest .mark .parametrize ("shape" , ["function" , "partial" , "callable" ])
225+ def test_generator_wrapper_of_async_function_is_synchronous (shape : str ) -> None :
226+ async def async_orchestrator (ctx : task .OrchestrationContext , value : int ) -> int :
227+ return value
228+
229+ @wraps (async_orchestrator )
230+ def generator (ctx : task .OrchestrationContext , value : int ) -> Generator [task .Task [Any ], Any , int ]:
231+ return (yield ctx .call_activity ("work" , input = value , return_type = int ))
232+
233+ class GeneratorCallable :
234+ def __call__ (self , ctx : task .OrchestrationContext , value : int ) -> Generator [task .Task [Any ], Any , int ]:
235+ return (yield ctx .call_activity ("work" , input = value , return_type = int ))
236+
237+ update_wrapper (GeneratorCallable .__call__ , async_orchestrator )
238+ variants : dict [str , Any ] = {
239+ "function" : generator ,
240+ "partial" : partial (generator ),
241+ "callable" : GeneratorCallable (),
242+ }
243+ fn = variants [shape ]
244+ assert not shared .is_async_callable (fn )
245+ actions = _execute (fn , [], _start ()).actions
246+ assert [a .scheduleTask .name for a in actions ] == ["work" ]
247+
248+
249+ def test_orchestrator_is_classified_once_at_registration (monkeypatch : pytest .MonkeyPatch ) -> None :
250+ async def orchestrator (ctx : task .OrchestrationContext , value : int ) -> int :
251+ return await ctx .call_activity ("work" , input = value , return_type = int )
252+
253+ registry = worker ._Registry () # pyright: ignore[reportPrivateUsage]
254+ registry .add_named_orchestrator ("test" , orchestrator )
255+ calls : list [Any ] = []
256+ original = shared .is_async_callable
257+ monkeypatch .setattr (shared , "is_async_callable" , lambda fn : calls .append (fn ) or original (fn ))
258+ executor = worker ._OrchestrationExecutor ( # pyright: ignore[reportPrivateUsage]
259+ registry , logging .getLogger (__name__ ), JsonDataConverter ())
260+ old = _start () + [helpers .new_task_scheduled_event (1 , "work" )]
261+ executor .execute (INSTANCE_ID , [], _start ())
262+ executor .execute (INSTANCE_ID , old , [helpers .new_task_completed_event (1 , "4" )])
263+ assert calls == []
264+
265+
266+ def test_cyclic_async_wrapper_is_rejected_at_registration () -> None :
221267 def wrapper (ctx : task .OrchestrationContext , value : int ) -> int :
222268 return value
223269
224270 wrapper .__wrapped__ = wrapper # type: ignore[attr-defined]
225- complete = _completion (_execute (wrapper , [], _start ()))
226- assert complete .orchestrationStatus == pb .ORCHESTRATION_STATUS_FAILED
227- assert "wrapper loop" in complete .failureDetails .errorMessage
271+ with pytest .raises (ValueError , match = "wrapper loop" ):
272+ worker ._Registry ().add_named_orchestrator ("test" , wrapper ) # pyright: ignore[reportPrivateUsage]
228273
229274
230275def _assert_mixed_wrapper_cycle_fails (shape : str ) -> None :
@@ -241,21 +286,16 @@ def __call__(self, ctx: task.OrchestrationContext, value: int) -> int:
241286 wrapper .__wrapped__ = CallableWrapper () # type: ignore[attr-defined]
242287 CallableWrapper .__call__ .__wrapped__ = partial (wrapper ) # type: ignore[attr-defined]
243288
244- # No input: dispatch must detect the cycle without relying on type discovery.
245- history = [
246- helpers .new_orchestrator_started_event (NOW ),
247- helpers .new_execution_started_event ("test" , INSTANCE_ID ),
248- ]
249- complete = _completion (_execute (wrapper , [], history ))
250- assert complete .orchestrationStatus == pb .ORCHESTRATION_STATUS_FAILED
251- assert complete .failureDetails .errorType == "builtins.ValueError"
252- assert "wrapper loop" in complete .failureDetails .errorMessage
289+ registry = worker ._Registry () # pyright: ignore[reportPrivateUsage]
290+ with pytest .raises (ValueError , match = "wrapper loop" ):
291+ registry .add_named_orchestrator ("test" , wrapper )
292+ assert registry .get_orchestrator ("test" ) is None
253293 with pytest .raises (ValueError , match = "wrapper loop" ):
254294 shared .is_async_callable (wrapper )
255295
256296
257297@pytest .mark .parametrize ("shape" , ["partial" , "callable-partial" ])
258- def test_mixed_wrapper_cycle_fails_within_bounded_worker_dispatch (shape : str ) -> None :
298+ def test_mixed_wrapper_cycle_is_rejected_within_bounded_registration (shape : str ) -> None :
259299 result = subprocess .run (
260300 [
261301 sys .executable , "-c" ,
@@ -579,3 +619,12 @@ async def orchestrator(ctx: task.OrchestrationContext, _: Any) -> AsyncGenerator
579619 complete = _completion (_execute (orchestrator , [], _start ()))
580620 assert complete .orchestrationStatus == pb .ORCHESTRATION_STATUS_FAILED
581621 assert "Async generator orchestrators are not supported" in complete .failureDetails .errorMessage
622+
623+
624+ @pytest .mark .parametrize (("now" , "offset" ), [
625+ (NOW , 0 ), (NOW .replace (tzinfo = UTC ), 0 ), (NOW .replace (tzinfo = timezone (timedelta (hours = 2 ))), 7200 ),
626+ ])
627+ def test_loop_time_accepts_naive_and_aware_orchestration_clocks (now : datetime , offset : int ) -> None :
628+ ctx = cast (task .OrchestrationContext , SimpleNamespace (current_utc_datetime = now ))
629+ loop = DurableEventLoop (ctx , is_complete = lambda : False )
630+ assert loop .time () == (NOW - datetime (1970 , 1 , 1 )).total_seconds () - offset
0 commit comments