|
1 | 1 | # Copyright (c) Microsoft Corporation. |
2 | 2 | # Licensed under the MIT License. |
3 | 3 |
|
| 4 | +import asyncio |
4 | 5 | import inspect |
5 | 6 | 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 |
8 | 10 | from uuid import UUID |
9 | 11 |
|
10 | 12 | from durabletask import task |
11 | 13 | from durabletask.entities import EntityInstanceId |
| 14 | +from durabletask.internal.shared import is_async_callable |
12 | 15 | from durabletask.task import OrchestrationContext, RetryPolicy, Task |
13 | 16 |
|
| 17 | +from ...http.models import DurableHttpResponse, HttpRetryOptions |
14 | 18 | from ..serialization import DEFAULT_FUNCTIONS_DATA_CONVERTER |
15 | 19 | from .function_context import FunctionContext |
16 | 20 | from .token_source import TokenSource |
17 | 21 |
|
18 | | -if TYPE_CHECKING: |
19 | | - from ...http.models import HttpRetryOptions |
| 22 | +T = TypeVar("T") |
20 | 23 |
|
21 | 24 |
|
22 | 25 | class DurableOrchestrationContext: |
@@ -277,6 +280,271 @@ def call_http(self, |
277 | 280 | return_type=DurableHttpResponse) |
278 | 281 |
|
279 | 282 |
|
| 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 | + |
280 | 548 | def accepts_two_positional_args(fn: Callable[..., Any]) -> bool: |
281 | 549 | """Return True if ``fn`` can be called with two positional args ``(ctx, input)``. |
282 | 550 |
|
@@ -326,6 +594,20 @@ def wrap_orchestrator(fn: Callable[..., Any]) -> Callable[..., Any]: |
326 | 594 | input_type = getattr(fn, "_df_input_type", None) |
327 | 595 | name = getattr(fn, "__name__", "orchestrator") |
328 | 596 |
|
| 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 | + |
329 | 611 | if inspect.isgeneratorfunction(fn): |
330 | 612 | def _generator_wrapper(context: OrchestrationContext, _input: Any = None) -> Any: |
331 | 613 | adapter = DurableOrchestrationContext(context, _input, input_type) |
|
0 commit comments