Skip to content

Commit 1c76780

Browse files
andystaplesCopilot
andauthored
Add coroutine orchestrator dispatch
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 73cac11 commit 1c76780

6 files changed

Lines changed: 808 additions & 51 deletions

File tree

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
"""Dispatch-local asyncio scheduling on top of the shared durable history engine."""
5+
6+
from __future__ import annotations
7+
8+
import asyncio
9+
import contextvars
10+
import logging
11+
from collections import deque
12+
from collections.abc import Callable, Coroutine, Generator
13+
from datetime import datetime
14+
from typing import Any, TypeVar
15+
16+
from durabletask import task
17+
18+
T = TypeVar("T")
19+
20+
21+
class _CompletionObserver(task.CompositeTask[None]):
22+
def __init__(self, durable: task.Task[Any], callback: Callable[[], None]) -> None:
23+
self._callback = callback
24+
super().__init__([durable])
25+
26+
def on_child_completed(self, task: task.Task[Any]) -> None:
27+
if not self._is_complete:
28+
self._is_complete = True
29+
self._notify_parent()
30+
self._callback()
31+
32+
def detach(self) -> None:
33+
self._notify_parent()
34+
35+
36+
class DurableEventLoop(asyncio.AbstractEventLoop):
37+
"""A FIFO microtask loop; it never polls a selector or advances wall time.
38+
39+
Only history events can complete durable waits. asyncio owns coroutine
40+
stepping; this loop owns the ordering of ready callbacks.
41+
"""
42+
43+
def __init__(
44+
self, ctx: task.OrchestrationContext, *, is_complete: Callable[[], bool],
45+
logger: logging.Logger | None = None,
46+
) -> None:
47+
self.ctx = ctx
48+
self._is_complete = is_complete
49+
self._logger = logger or logging.getLogger(__name__)
50+
self._ready: deque[asyncio.Handle] = deque()
51+
self._tasks: list[asyncio.Task[Any]] = []
52+
self._waits: dict[int, tuple[task.Task[Any], list[asyncio.Future[Any]]]] = {}
53+
self._observers: dict[int, _CompletionObserver] = {}
54+
self._completions: deque[Callable[[], None]] = deque()
55+
self._closed = False
56+
self._closing = False
57+
self._fatal: BaseException | None = None
58+
self._root: asyncio.Task[Any] | None = None
59+
60+
def time(self) -> float:
61+
# A fixed UTC epoch avoids local-time and platform timestamp differences.
62+
return (self.ctx.current_utc_datetime - datetime(1970, 1, 1)).total_seconds()
63+
64+
def get_debug(self) -> bool:
65+
return False
66+
67+
def is_running(self) -> bool:
68+
return asyncio._get_running_loop() is self
69+
70+
def is_closed(self) -> bool:
71+
return self._closed
72+
73+
def create_future(self) -> asyncio.Future[Any]:
74+
return asyncio.Future(loop=self)
75+
76+
def get_task_factory(self) -> None:
77+
return None
78+
79+
def create_task(
80+
self, coro: Coroutine[Any, Any, T] | Generator[Any, None, T], *, name: str | None = None,
81+
context: contextvars.Context | None = None, **kwargs: Any,
82+
) -> asyncio.Task[T]:
83+
if self._closed:
84+
coro.close()
85+
raise RuntimeError("The durable orchestration dispatch is closed.")
86+
if kwargs:
87+
coro.close()
88+
raise TypeError("Custom/eager task execution is not supported in durable orchestrators.")
89+
future = asyncio.Task(coro, loop=self, name=name, context=context)
90+
self._tasks.append(future)
91+
return future
92+
93+
def call_soon(
94+
self, callback: Callable[..., Any], *args: Any,
95+
context: contextvars.Context | None = None,
96+
) -> asyncio.Handle:
97+
handle = asyncio.Handle(callback, args, self, context=context)
98+
self._ready.append(handle)
99+
return handle
100+
101+
async def wait_durable(self, durable: task.Task[T]) -> T:
102+
if durable.is_complete:
103+
return durable.get_result()
104+
future = self.create_future()
105+
entry = self._waits.setdefault(id(durable), (durable, []))
106+
entry[1].append(future)
107+
if id(durable) not in self._observers:
108+
self._observers[id(durable)] = _CompletionObserver(
109+
durable, lambda: self._completions.append(lambda: self._complete_wait(durable)))
110+
try:
111+
return await future
112+
finally:
113+
# Cancelling one waiter never cancels a shared durable operation.
114+
entry[1].remove(future)
115+
if not entry[1]:
116+
self._waits.pop(id(durable), None)
117+
self._observers.pop(id(durable)).detach()
118+
119+
def _complete_wait(self, durable: task.Task[Any]) -> None:
120+
entry = self._waits.get(id(durable))
121+
if entry is not None:
122+
for future in tuple(entry[1]):
123+
if not future.done():
124+
try:
125+
future.set_result(durable.get_result())
126+
except Exception as error:
127+
future.set_exception(error)
128+
129+
def _deliver_completions(self) -> None:
130+
while self._completions:
131+
self._completions.popleft()()
132+
133+
def start(self, coro: Coroutine[Any, Any, Any]) -> None:
134+
self._root = self.create_task(coro, name="durable-orchestrator")
135+
self.resume()
136+
137+
def resume(self) -> None:
138+
if self._is_complete():
139+
return
140+
self._deliver_completions()
141+
self._drain()
142+
assert self._root is not None
143+
if self._root.done():
144+
if self._root.cancelled():
145+
raise task.TaskCancelledError(
146+
"The orchestrator coroutine was cancelled. Catch local cancellation "
147+
"inside the orchestrator; terminate the durable instance through the client.")
148+
# result() propagates the orchestrator's failure to the executor.
149+
result = self._root.result()
150+
raise StopIteration(result)
151+
if not self._has_durable_work():
152+
raise RuntimeError(
153+
"The async orchestrator is blocked without durable work. "
154+
"Await durable activities, events or timers; external I/O is unsupported.")
155+
156+
def _has_durable_work(self) -> bool:
157+
return any(not durable.is_complete for durable, _ in self._waits.values())
158+
159+
def _drain(self) -> None:
160+
previous_loop = asyncio._get_running_loop()
161+
previous_task = asyncio.current_task(previous_loop) if previous_loop is not None else None
162+
if previous_task is not None and previous_loop is not None:
163+
asyncio.tasks._leave_task(previous_loop, previous_task)
164+
asyncio._set_running_loop(self)
165+
try:
166+
while self._ready:
167+
handle = self._ready.popleft()
168+
if not handle.cancelled():
169+
handle._run()
170+
if self._fatal is not None:
171+
error, self._fatal = self._fatal, None
172+
if not self._closing:
173+
raise error
174+
self._logger.warning(
175+
"%s: Async cleanup callback failed.", self.ctx.instance_id, exc_info=error)
176+
if not self._closing:
177+
if self._is_complete():
178+
break
179+
if self._root is not None and self._root.done():
180+
break
181+
self._deliver_completions()
182+
finally:
183+
asyncio._set_running_loop(previous_loop)
184+
if previous_task is not None and previous_loop is not None:
185+
asyncio.tasks._enter_task(previous_loop, previous_task)
186+
187+
def call_exception_handler(self, context: dict[str, Any]) -> None:
188+
error = context.get("exception")
189+
if isinstance(error, StopIteration):
190+
failure = RuntimeError("An orchestration callback raised StopIteration.")
191+
failure.__cause__ = error
192+
error = failure
193+
self._fatal = error if isinstance(error, BaseException) else RuntimeError(context["message"])
194+
195+
def close(self) -> None:
196+
"""Cancel unfinished coroutines so their cleanup runs before the dispatch ends."""
197+
if self._closed:
198+
return
199+
self._closing = True
200+
try:
201+
for future in tuple(self._tasks):
202+
if not future.done():
203+
future.cancel()
204+
self._drain()
205+
failure: BaseException | None = None
206+
for future in tuple(self._tasks):
207+
if not future.done():
208+
future.get_coro().close()
209+
failure = failure or RuntimeError(
210+
"Async cleanup must finish without waiting for durable or external work.")
211+
elif not future.cancelled() and getattr(future, "_log_traceback", False):
212+
self._logger.warning(
213+
"%s: Async background task failed.", self.ctx.instance_id,
214+
exc_info=future.exception())
215+
if failure is not None:
216+
raise failure
217+
finally:
218+
self._closed = True
219+
self._ready.clear()
220+
for observer in self._observers.values():
221+
observer.detach()
222+
self._completions.clear()
223+
self._observers.clear()
224+
self._waits.clear()
225+
self._tasks.clear()

‎durabletask/internal/shared.py‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import logging
66
import warnings
77
from collections.abc import Sequence
8+
from functools import partial
89
from typing import Any, TypeAlias
910

1011
import grpc
@@ -73,6 +74,23 @@ def from_json(json_str: str | bytes | bytearray, expected_type: type | None = No
7374
INSECURE_PROTOCOLS = ["http://", "grpc://"]
7475

7576

77+
def is_async_callable(fn: Any) -> bool:
78+
"""Recognize async functions, wrapped functions, partials and callable objects."""
79+
while True:
80+
if inspect.iscoroutinefunction(fn):
81+
return True
82+
fn = inspect.unwrap(fn)
83+
if isinstance(fn, partial):
84+
fn = fn.func
85+
continue
86+
if inspect.iscoroutinefunction(fn):
87+
return True
88+
if inspect.isfunction(fn) or inspect.ismethod(fn) or inspect.isbuiltin(fn):
89+
return False
90+
call = getattr(fn, "__call__", None)
91+
return call is not None and inspect.iscoroutinefunction(inspect.unwrap(call))
92+
93+
7694
def _get_legacy_logging_warning_stacklevel() -> int:
7795
stacklevel = 1
7896
frame = inspect.currentframe()

‎durabletask/task.py‎

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@
66

77
import logging
88
import math
9+
import asyncio
910
from abc import ABC, abstractmethod
10-
from collections.abc import Callable, Generator, Sequence
11+
from collections.abc import Callable, Coroutine, Generator, Sequence
1112
from dataclasses import dataclass
1213
from datetime import UTC, datetime, timedelta
1314
from typing import Any, Generic, TypeAlias, TypeVar, cast, overload
@@ -612,6 +613,31 @@ def __init__(self) -> None:
612613
# Dict insertion order preserves subscription order for shared races.
613614
self._parents = {}
614615

616+
def __await__(self) -> Generator[Task[T], Any, T]:
617+
"""Wait for this task in a replay-driven ``async def`` orchestrator.
618+
619+
A local asyncio waiter does not own or cancel the durable operation.
620+
"""
621+
from durabletask.internal.asyncio_loop import DurableEventLoop
622+
623+
loop = asyncio._get_running_loop()
624+
if isinstance(loop, DurableEventLoop):
625+
return (yield from loop.wait_durable(self).__await__())
626+
if loop is not None:
627+
raise RuntimeError("Durable tasks can only be awaited inside a durable orchestrator.")
628+
# Allow mock-based orchestration drivers to send results without an event loop.
629+
if not self.is_complete:
630+
yield self
631+
return self.get_result()
632+
633+
async def wait(self) -> T:
634+
"""Borrow this operation's result through a fresh native coroutine.
635+
636+
Calling this method does not schedule another durable operation. Each
637+
call creates an independent waiter.
638+
"""
639+
return await self
640+
615641
@property
616642
def is_complete(self) -> bool:
617643
"""Returns True if the task has completed, False otherwise."""
@@ -951,8 +977,17 @@ def task_id(self) -> int:
951977
return self._task_id
952978

953979

954-
# Orchestrators are generators that yield tasks, receive any type, and return TOutput
955-
Orchestrator: TypeAlias = Callable[[OrchestrationContext, TInput], Generator[Task[Any], Any, TOutput] | TOutput]
980+
# Generator and immediate-result orchestrators use eager durable task handles.
981+
SyncOrchestrator: TypeAlias = Callable[
982+
[OrchestrationContext, TInput],
983+
Generator[Task[Any], Any, TOutput] | TOutput,
984+
]
985+
986+
AsyncOrchestrator: TypeAlias = Callable[
987+
[OrchestrationContext, TInput], Coroutine[Any, Any, TOutput],
988+
]
989+
990+
Orchestrator: TypeAlias = SyncOrchestrator[TInput, TOutput] | AsyncOrchestrator[TInput, TOutput]
956991

957992
# Activities are simple functions that can be scheduled by orchestrators
958993
Activity: TypeAlias = Callable[[ActivityContext, TInput], TOutput]

0 commit comments

Comments
 (0)