Skip to content

Commit 4ebbc8b

Browse files
andystaplesCopilot
andcommitted
Add Functions coroutine orchestrator support
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 7c3a7d9 commit 4ebbc8b

12 files changed

Lines changed: 1335 additions & 30 deletions

File tree

‎azure-functions-durable/azure/durable_functions/__init__.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@
1717
from .internal.compat.entity_state_response import EntityStateResponse
1818
from .internal.compat.entity_id import EntityId
1919
from .internal.compat.token_source import ManagedIdentityTokenSource, TokenSource
20-
from .internal.compat.orchestration_context import DurableOrchestrationContext
20+
from .internal.compat.orchestration_context import (
21+
AsyncDurableOrchestrationContext,
22+
DurableOrchestrationContext,
23+
)
2124
from .internal.compat.entity_context import DurableEntityContext
2225
from .internal.compat.compat_aliases import (
2326
DurableOrchestrationClient,
@@ -39,6 +42,7 @@
3942

4043
__all__ = [
4144
"Blueprint",
45+
"AsyncDurableOrchestrationContext",
4246
"DFApp",
4347
"DurableEntityContext",
4448
"DurableFunctionsClient",

‎azure-functions-durable/azure/durable_functions/decorators/durable_app.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
builtin_http_poll_orchestrator,
2222
)
2323
from ..internal.compat.activity import wrap_activity, wrap_activity_payloads
24+
from ..internal.compat.orchestration_context import FunctionsOrchestrator
2425
from ..internal.invocation import preserve_function_source, wrap_invocation
2526
from ..worker import DurableFunctionsWorker
2627

@@ -169,7 +170,7 @@ def _configure_orchestrator_callable(
169170
wrap: Callable[[Callable[..., Any]], FunctionBuilder],
170171
context_name: str,
171172
input_type: Optional[type] = None
172-
) -> Callable[[task.Orchestrator[Any, Any]], FunctionBuilder]:
173+
) -> Callable[[FunctionsOrchestrator], FunctionBuilder]:
173174
"""Obtain decorator to construct an Orchestrator class from a user-defined Function.
174175
175176
Parameters
@@ -187,7 +188,7 @@ def _configure_orchestrator_callable(
187188
The function to construct an Orchestrator class from the user-defined Function,
188189
wrapped by the next decorator in the sequence.
189190
"""
190-
def decorator(orchestrator_func: task.Orchestrator[Any, Any]) -> FunctionBuilder:
191+
def decorator(orchestrator_func: FunctionsOrchestrator) -> FunctionBuilder:
191192
# Construct an orchestrator based on the end-user code
192193

193194
if input_type is not None:
@@ -281,7 +282,7 @@ def decorator(func: Callable[..., Any]) -> FunctionBuilder:
281282
def orchestration_trigger(self, context_name: str,
282283
orchestration: Optional[str] = None,
283284
input_type: Optional[type] = None
284-
) -> Callable[[task.Orchestrator[Any, Any]], FunctionBuilder]:
285+
) -> Callable[[FunctionsOrchestrator], FunctionBuilder]:
285286
"""Register an Orchestrator Function.
286287
287288
Parameters

‎azure-functions-durable/azure/durable_functions/internal/compat/orchestration_context.py‎

Lines changed: 286 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,25 @@
11
# Copyright (c) Microsoft Corporation.
22
# Licensed under the MIT License.
33

4+
import asyncio
45
import inspect
56
import json
6-
from datetime import UTC, datetime
7-
from typing import TYPE_CHECKING, Any, Callable, Generator, Optional, cast
7+
from collections.abc import Awaitable, Callable, Coroutine, Generator, Sequence
8+
from datetime import UTC, datetime, timedelta
9+
from typing import Any, Optional, TypeAlias, TypeVar, cast, overload
810
from uuid import UUID
911

1012
from durabletask import task
1113
from durabletask.entities import EntityInstanceId
14+
from durabletask.internal.shared import is_async_callable
1215
from durabletask.task import OrchestrationContext, RetryPolicy, Task
1316

17+
from ...http.models import DurableHttpResponse, HttpRetryOptions
1418
from ..serialization import DEFAULT_FUNCTIONS_DATA_CONVERTER
1519
from .function_context import FunctionContext
1620
from .token_source import TokenSource
1721

18-
if TYPE_CHECKING:
19-
from ...http.models import HttpRetryOptions
22+
T = TypeVar("T")
2023

2124

2225
class DurableOrchestrationContext:
@@ -277,6 +280,271 @@ def call_http(self,
277280
return_type=DurableHttpResponse)
278281

279282

283+
def _schedule_awaitables(
284+
operations: Sequence[Awaitable[T]],
285+
) -> tuple[list[asyncio.Future[T]], list[asyncio.Future[T]]]:
286+
"""Schedule each distinct awaitable once, retaining input order."""
287+
scheduled: dict[int, asyncio.Future[T]] = {}
288+
owned: list[asyncio.Future[T]] = []
289+
futures: list[asyncio.Future[T]] = []
290+
loop = asyncio.get_running_loop()
291+
for operation in operations:
292+
if not inspect.isawaitable(operation):
293+
raise TypeError("Orchestration operations must be awaitable.")
294+
if isinstance(operation, asyncio.Future) and operation.get_loop() is not loop:
295+
raise ValueError("Orchestration tasks must belong to the current durable event loop.")
296+
for operation in operations:
297+
key = id(operation)
298+
if key not in scheduled:
299+
future = asyncio.ensure_future(operation)
300+
scheduled[key] = future
301+
if future is not operation:
302+
owned.append(future)
303+
futures.append(scheduled[key])
304+
return futures, owned
305+
306+
307+
async def _cancel_owned(futures: Sequence[asyncio.Future[Any]]) -> None:
308+
pending = [future for future in futures if not future.done()]
309+
for future in pending:
310+
future.cancel()
311+
if pending:
312+
await asyncio.gather(*pending, return_exceptions=True)
313+
314+
315+
class AsyncDurableOrchestrationContext:
316+
"""Functions v1-style context supplied automatically to async orchestrators.
317+
318+
Durable operations are cold, single-use coroutines. Synchronous commands
319+
and metadata have the same behavior and argument names as the generator
320+
context. Use :meth:`as_task_context` only when eager durable task handles
321+
are needed for interoperability.
322+
"""
323+
324+
def __init__(
325+
self, ctx: task.AsyncOrchestrationContext,
326+
orchestration_input: Any = None, input_type: type | None = None,
327+
) -> None:
328+
self._ctx = ctx
329+
self._compat = DurableOrchestrationContext(ctx.as_task_context(), orchestration_input, input_type)
330+
331+
@overload
332+
def get_input(self, expected_type: type[T]) -> T:
333+
...
334+
335+
@overload
336+
def get_input(self, expected_type: None = None) -> Any:
337+
...
338+
339+
def get_input(self, expected_type: type | None = None) -> Any:
340+
"""Get the orchestration input, optionally coerced to the expected type."""
341+
return self._compat.get_input(expected_type)
342+
343+
@property
344+
def instance_id(self) -> str:
345+
"""Get the current orchestration instance ID."""
346+
return self._compat.instance_id
347+
348+
@property
349+
def parent_instance_id(self) -> str | None:
350+
"""Get the parent orchestration instance ID, if any."""
351+
return self._compat.parent_instance_id
352+
353+
@property
354+
def version(self) -> str | None:
355+
"""Get the orchestration instance version, if any."""
356+
return self._compat.version
357+
358+
@property
359+
def current_utc_datetime(self) -> datetime:
360+
"""Get the replay-safe current UTC time as a timezone-aware datetime."""
361+
return self._compat.current_utc_datetime
362+
363+
@property
364+
def is_replaying(self) -> bool:
365+
"""Get whether the orchestration is replaying its history."""
366+
return self._compat.is_replaying
367+
368+
@property
369+
def custom_status(self) -> Any:
370+
"""Get the custom status set during this execution."""
371+
return self._compat.custom_status
372+
373+
@property
374+
def will_continue_as_new(self) -> bool:
375+
"""Get whether continue-as-new was requested during this execution."""
376+
return self._compat.will_continue_as_new
377+
378+
@property
379+
def function_context(self) -> FunctionContext:
380+
"""Get the Functions context bag."""
381+
return self._compat.function_context
382+
383+
@property
384+
def histories(self) -> Any:
385+
"""History is not exposed on orchestration contexts."""
386+
return self._compat.histories
387+
388+
def continue_as_new(self, input_: Any) -> None:
389+
"""Restart the orchestration with a new input."""
390+
self._compat.continue_as_new(input_)
391+
392+
def set_custom_status(self, status: Any) -> None:
393+
"""Set the orchestration's custom status."""
394+
self._compat.set_custom_status(status)
395+
396+
def new_uuid(self) -> str:
397+
"""Create a replay-safe UUID string."""
398+
return self._compat.new_uuid()
399+
400+
def new_guid(self) -> UUID:
401+
"""Create a replay-safe UUID."""
402+
return self._compat.new_guid()
403+
404+
def signal_entity(
405+
self, entityId: EntityInstanceId, operationName: str, operationInput: Any = None,
406+
) -> None:
407+
"""Signal an entity without waiting for a result."""
408+
self._compat.signal_entity(entityId, operationName, operationInput)
409+
410+
def as_task_context(self) -> DurableOrchestrationContext:
411+
"""Get the v1-compatible eager task-handle context."""
412+
return self._compat
413+
414+
async def call_activity(self, name: Callable[..., Any] | str, input_: Any = None) -> Any:
415+
"""Start and await an activity."""
416+
return await self._ctx.call_activity(name, input=input_)
417+
418+
async def call_activity_with_retry(
419+
self, name: Callable[..., Any] | str, retry_options: RetryPolicy, input_: Any = None,
420+
) -> Any:
421+
"""Start and await an activity with retries."""
422+
return await self._ctx.call_activity(name, input=input_, retry_policy=retry_options)
423+
424+
async def call_sub_orchestrator(
425+
self, name: Callable[..., Any] | str, input_: Any = None, instance_id: str | None = None,
426+
) -> Any:
427+
"""Start and await a sub-orchestration."""
428+
return await self._ctx.call_sub_orchestrator(name, input=input_, instance_id=instance_id)
429+
430+
async def call_sub_orchestrator_with_retry(
431+
self, name: Callable[..., Any] | str, retry_options: RetryPolicy,
432+
input_: Any = None, instance_id: str | None = None,
433+
) -> Any:
434+
"""Start and await a sub-orchestration with retries."""
435+
return await self._ctx.call_sub_orchestrator(
436+
name, input=input_, instance_id=instance_id, retry_policy=retry_options)
437+
438+
async def create_timer(self, fire_at: datetime | timedelta) -> None:
439+
"""Wait for a durable timer, withdrawing it locally if cancelled."""
440+
await self._ctx.create_timer(fire_at)
441+
442+
@overload
443+
async def wait_for_external_event(
444+
self, name: str, expected_type: type[T], *, timeout: timedelta | None = None,
445+
) -> T:
446+
...
447+
448+
@overload
449+
async def wait_for_external_event(
450+
self, name: str, expected_type: None = None, *, timeout: timedelta | None = None,
451+
) -> Any:
452+
...
453+
454+
async def wait_for_external_event(
455+
self, name: str, expected_type: type[Any] | None = None, *, timeout: timedelta | None = None,
456+
) -> Any:
457+
"""Wait for an event, withdrawing the local subscription on cancellation or timeout."""
458+
return await self._ctx.wait_for_external_event(name, data_type=expected_type, timeout=timeout)
459+
460+
async def call_entity(
461+
self, entityId: EntityInstanceId, operationName: str, operationInput: Any = None,
462+
) -> Any:
463+
"""Start and await an entity operation."""
464+
return await self._ctx.call_entity(entityId, operationName, operationInput)
465+
466+
async def call_http(
467+
self, method: str, uri: str, content: Any = None, headers: dict[str, str] | None = None,
468+
token_source: TokenSource | None = None, is_raw_str: bool = False,
469+
retry_options: HttpRetryOptions | None = None,
470+
) -> DurableHttpResponse:
471+
"""Start and await a durable HTTP request."""
472+
return await self._compat.call_http(
473+
method, uri, content, headers, token_source, is_raw_str, retry_options)
474+
475+
async def task_all(self, operations: Sequence[Awaitable[T]]) -> list[T]:
476+
"""Wait for every operation, preserving input order and duplicate references.
477+
478+
The first observed failure is raised after all operations finish.
479+
Existing asyncio futures are borrowed; cancelling this group leaves
480+
them running. Coroutines started by this group are cancelled and
481+
cleaned up if the group itself is cancelled.
482+
"""
483+
futures, owned = _schedule_awaitables(operations)
484+
unique = list({id(future): future for future in futures}.values())
485+
failures: list[BaseException] = []
486+
487+
def record_failure(future: asyncio.Future[T]) -> None:
488+
if not failures:
489+
if future.cancelled():
490+
failures.append(asyncio.CancelledError())
491+
else:
492+
error = future.exception()
493+
if error is not None:
494+
failures.append(error)
495+
496+
for future in unique:
497+
future.add_done_callback(record_failure)
498+
try:
499+
results = await asyncio.gather(*(asyncio.shield(future) for future in futures),
500+
return_exceptions=True)
501+
if failures:
502+
raise failures[0]
503+
return cast(list[T], results)
504+
except asyncio.CancelledError:
505+
await _cancel_owned(owned)
506+
raise
507+
finally:
508+
for future in unique:
509+
future.remove_done_callback(record_failure)
510+
511+
async def task_any(self, operations: Sequence[Awaitable[T]]) -> asyncio.Future[T]:
512+
"""Return the first completed operation without cancelling the losers.
513+
514+
The winner is the original asyncio future if one was supplied; a raw
515+
coroutine is wrapped in a new task. Use tasks when identity or later
516+
access to losing operations is needed. Cancelling the group cancels
517+
only coroutines it started, not futures supplied by its caller.
518+
"""
519+
if not operations:
520+
raise ValueError("task_any requires at least one operation.")
521+
futures, owned = _schedule_awaitables(operations)
522+
unique = list({id(future): future for future in futures}.values())
523+
winner: asyncio.Future[asyncio.Future[T]] = asyncio.get_running_loop().create_future()
524+
525+
def completed(future: asyncio.Future[T]) -> None:
526+
if not winner.done():
527+
winner.set_result(future)
528+
529+
for future in unique:
530+
future.add_done_callback(completed)
531+
try:
532+
return await winner
533+
except asyncio.CancelledError:
534+
await _cancel_owned(owned)
535+
raise
536+
finally:
537+
for future in unique:
538+
future.remove_done_callback(completed)
539+
540+
541+
FunctionsOrchestrator: TypeAlias = (
542+
task.Orchestrator[Any, Any]
543+
| Callable[[DurableOrchestrationContext], Any]
544+
| Callable[[AsyncDurableOrchestrationContext], Coroutine[Any, Any, Any]]
545+
)
546+
547+
280548
def accepts_two_positional_args(fn: Callable[..., Any]) -> bool:
281549
"""Return True if ``fn`` can be called with two positional args ``(ctx, input)``.
282550
@@ -326,6 +594,20 @@ def wrap_orchestrator(fn: Callable[..., Any]) -> Callable[..., Any]:
326594
input_type = getattr(fn, "_df_input_type", None)
327595
name = getattr(fn, "__name__", "orchestrator")
328596

597+
if is_async_callable(fn):
598+
async def _async_wrapper(context: task.AsyncOrchestrationContext, _input: Any = None) -> Any:
599+
adapter = AsyncDurableOrchestrationContext(context, _input, input_type)
600+
coroutine = fn(adapter)
601+
if not inspect.iscoroutine(coroutine):
602+
if inspect.isgenerator(coroutine):
603+
coroutine.close()
604+
raise TypeError("An async orchestrator must return a coroutine.")
605+
return await coroutine
606+
_async_wrapper.__name__ = name
607+
if input_type is not None:
608+
_async_wrapper.__annotations__["_input"] = input_type
609+
return _async_wrapper
610+
329611
if inspect.isgeneratorfunction(fn):
330612
def _generator_wrapper(context: OrchestrationContext, _input: Any = None) -> Any:
331613
adapter = DurableOrchestrationContext(context, _input, input_type)

0 commit comments

Comments
 (0)