Skip to content

Support async/await orchestrators across the Python SDK packages #294

Open

Description

@andystaples

Summary

Add support for authoring durable orchestrators with async def and await across all three packages in this repository:

  • durabletask — core SDK.
  • durabletask.azuremanaged — Azure Durable Task Scheduler provider.
  • azure-functions-durable — Azure Functions provider, including both native two-argument and compatibility single-argument orchestrators.

This would be an additive authoring option. Existing synchronous generator orchestrators using yield would remain supported.

Draft prototype: #293. The spike demonstrates a bounded change to the shared replay engine rather than separate provider implementations. It is not production-ready and does not establish a final API contract or release commitment.

Current behavior

Orchestrators suspend on durable tasks using synchronous generators and yield. Asynchronous client APIs and async activities do not currently provide an async def orchestrator authoring model.

Supporting await would make orchestrator code more familiar to Python users and allow reusable async helpers that compose durable operations, without changing how orchestration history is persisted and replayed.

Proposed shape

Illustrative usage:

from durabletask import task


async def sequence(ctx: task.OrchestrationContext, value: int) -> list[int]:
    first = await ctx.call_activity("increment", input=value, return_type=int)
    pending: list[task.Task[int]] = [
        ctx.call_activity("increment", input=first, return_type=int),
        ctx.call_activity("increment", input=value, return_type=int),
    ]
    return await task.when_all(pending)

Functions compatibility-style orchestrators would similarly use await context.call_activity(...) and await context.task_all(...).

An async helper would run within its caller's orchestration. A separately tracked durable child instance would still require await ctx.call_sub_orchestrator(...).

Prototype findings

The draft makes durable tasks awaitable and drives native coroutines through the existing replay engine's send / throw protocol. Neither provider worker needs a runtime rewrite, and no protobuf or host-extension changes are required.

The prototype runs against the core gRPC backend, the DTS emulator, and real Azure Functions hosts. It exercises mixed generator/coroutine parents and children, activities, fan-out/fan-in, timers, external events, retries, and exception handling. Entity calls and locks have replay-level coverage. Both Functions authoring styles also run with strict payload typing.

Design points and remaining work

  • Durable awaits, not general-purpose asyncio execution. Define the supported await contract and diagnostics. Use durable timers and durable task combinators rather than asyncio.sleep, gather, create_task, or TaskGroup. Direct network/storage I/O remains activity work.
  • Guardrails have limits. Rejecting non-durable suspension cannot reliably detect already-completed foreign awaitables or side effects performed before suspension. Documentation must not imply that arbitrary async code is replay-safe.
  • Coroutine lifecycle. Harden termination, cancellation, finalization, finally, and async context-manager cleanup semantics, including nested helpers and pending durable tasks.
  • Replay and migration. Preserve existing generator behavior and document conversion of in-flight orchestrators. Switching from yield to await must preserve durable action order and payloads; basic action parity in a spike is not a blanket migration guarantee.
  • Public API and coverage. Finish callable/awaitable typing review, testing-helper support, examples, and broader coverage of pre-completed tasks, failures, races, long timers, entities, and provider behavior across the supported Python versions. Cloud deployment scenarios and the full Python-version matrix remain unvalidated by the spike.
  • Release coordination. Publish the core capability first, then coordinate provider dependency minimums and releases. The draft intentionally does not change package versions or minimum dependencies.

Acceptance criteria

  • Native coroutine orchestrators are supported across all three packages, including both Azure Functions authoring styles.
  • Existing generator orchestrators remain supported, and generator/coroutine orchestrations can call one another as durable sub-orchestrations.
  • Awaiting durable activities, timers, external events, task combinators, sub-orchestrations, and entity operations preserves replay, result typing, retry, and failure behavior.
  • Coroutine lifecycle behavior and unsupported-await diagnostics are defined, documented, and covered by regression tests.
  • Provider integration, replay/migration scenarios, and the supported Python-version matrix are covered before production support.
  • Documentation clearly separates durable await syntax from arbitrary asyncio execution, and package releases enforce the required core/provider compatibility.

Community feedback

Please react with 👍 on this issue to vote for the feature. Share your use cases in the discussion below to help prioritize the work and shape the supported contract.

Useful context includes the SDK/provider you use, orchestration patterns you would simplify, reusable async helpers you would like to write, and any asyncio-library interoperability you expect. Small examples of existing yield-based code or desired await-based code are especially helpful.

Activity

  1. berndverst commented on Oct 15, 2026

    @berndverst
    Member

    How do we avoid code duplication or breaking changes?

  2. andystaples commented on Oct 15, 2026

    @andystaples
    ContributorAuthor

    Follow-up exploration: virtual activities / recorded async calls

    Preserving a design idea from the discussion of draft #293: an explicit local/virtual activity or recorded-side-effect boundary could let an orchestrator obtain the result of a nondeterministic async operation without rerunning that operation during replay.

    The intended contract would be:

    • Give the operation a stable identity/position in the orchestration. On first execution, run it and durably record its serialized result or failure; on replay, return that result or raise that failure without invoking the operation again.
    • Make the boundary explicit and accept a callable factory, so history can be consulted before the operation starts. Automatically intercepting arbitrary await expressions may be too late: side effects may already have occurred.
    • The simplest implementation could lower an explicitly registered callable to an ordinary activity, reusing existing activity scheduling and history. A truly inline variant that avoids separate activity dispatch would need a durable marker or a carefully designed journal over existing durable messages. The current worker protocol has no dedicated arbitrary-result recording action; provider behavior across DTS and Functions would need separate design and validation.
    • Preserve at-least-once, not exactly-once, expectations. If an external operation succeeds and the worker crashes before recording the outcome, recovery may execute it again. External writes still need idempotency keys or deduplication.
    • Define timeout, retry, cancellation, payload, operation-versioning, and cleanup semantics. A recorded body must not schedule durable orchestration operations that would disappear when that body is skipped during replay.

    This is a separate exploration, not functionality implemented by the async/await prototype or an expansion of its supported await contract. Ordinary asyncio I/O remains unsupported inside orchestrators unless it is placed behind an appropriate durable boundary.

  3. andystaples commented on Oct 15, 2026

    @andystaples
    ContributorAuthor

    Bernd Verst (@berndverst) Check out the draft PR linked, it required no separate provider implementations and surprisingly little code duplication to get something functional. What remains to be seen is whether that approach continues to be viable as we address the remaining items. Breaking change risk with the current approach is also limited, given the minimally invasive edits needed to the replay logic itself but this would need to be carefully guarded - our test suite is a pretty good metric for this already. If we decide to continue down the draft PR's path, or adopt the additional functionality above, agreed, we'll need to be very careful about both before accepting anything

  4. tamirdresher commented on Oct 18, 2026

    @tamirdresher
    Contributor

    Big +1 on this andystaples / Bernd Verst (@berndverst) — while building the Durable Task / DTS extensions for LangGraph and Pydantic AI (pydantic_ai.durable_exec), I hit this exact gap and ended up having to build a coroutine-to-generator bridge myself.

    In both frameworks (and especially Pydantic AI and pydantic-graph), the entire agent loop and durability hook surface (BaseDurabilityCapability / RegisteredOperationBackend) is async def-only: every model call, tool call, MCP call, and human-in-the-loop wait (wait_for_external_event) is awaited inside await agent.run(...).

    Having Task be natively awaitable and supporting async def orchestrators in the SDK would let me drop a chunk of custom bridge code and let users compose durable agents directly with await instead of yield from.

    Two quick learnings from what I built that might be useful as we take this forward:

    1. Handling internal asyncio.gather / anyio task groups (parallel tool calls): When an LLM returns multiple tool calls in one turn, Pydantic AI fans them out internally using anyio.create_task_group() / asyncio.gather(), which calls asyncio.get_running_loop(). Pure coro.send()/throw() works great for sequential await ctx.call_activity(...), and to make framework-internal gather/TaskGroup work I paired it with a tiny deterministic single-thread AbstractEventLoop shim (call_soon microtask queue only, no threads or selector I/O) that drains ready tasks in the current turn and coalesces the pending Tasks into a single task.when_all(pending).
    2. Recorded async calls: Andy's follow-up comment above on explicit recorded-side-effect / virtual-activity boundaries maps 1:1 to how both my LangGraph and Pydantic AI extensions checkpoint individual model/tool boundaries during replay.

    Happy to share the trampoline / bridge code from the Pydantic AI & LangGraph prototypes if we want to compare notes or fold any of it in!

  5. andystaples commented on Oct 30, 2026

    @andystaples
    ContributorAuthor

    The expanded implementation is now in the existing draft #293, as one coordinated release unit. This supersedes the spike's earlier guidance to avoid asyncio.gather / sleep / task groups. The supported contract now defines deterministic ordered gather, task/future scheduling, durable loop clock/deadlines/timers, sleep(0), timeouts, local cancellation, AnyIO task groups and cooperative cleanup. All three packages share the existing history/retry/payload/entity engine; generators and mixed-style durable children remain supported.

    Tamir's integration branch still points to reviewed snapshot 8e9ae67a492171b7839a94f72ff44238f03d597a. The framework proof executes actual Pydantic AI Agent.run / parallel-tool AnyIO paths using TestModel, including recorded tool failure and identical-history replay. It needs no adapter loop, trampoline or batch-wide join. Ordinary model/tool activities, registration, codecs and replay-stable framework metadata remain adapter responsibilities. No paid calls or changes to Tamir's repository were made.

    Boundaries remain explicit: no virtual activities, arbitrary-await recording, external I/O/threads/subprocesses in orchestrators, or blanket library compatibility. asyncio.wait / as_completed are deliberately excluded from deterministic support because unordered inputs/results and tie ordering can change action order. Helpers rerun; activity side effects still need idempotency. Local waiter cancellation does not undo durable operations. Dispatch teardown is separate from workflow cancellation, cannot schedule durable work or replace the snapshotted response, and closes cooperative task/context/async-generator resources. In-flight migration still requires preserving the durable history contract.

    Validation now includes 55 focused runtime/framework tests on Python 3.14; 51 passed / 4 version-gated skips on 3.10; 420 Functions unit tests; final mixed-style DTS emulator probes; and full real Functions-host runs in both payload modes (86 passed / 6 skipped in each). Repository-standard strict Pyright checks pass for core/Azure Managed and Functions. The final full-core run passed (1,511 passed / 3 skipped). All checks on implementation head 8c050a7 are green: Linux core/DTS and Windows async/replay/lifecycle Python 3.10–3.15, Functions units Python 3.13–3.15, real-host E2E in both payload modes, typing, lint and CodeQL. #293 links the runs and records the detailed evidence and limitations. No deployed Azure cloud validation is claimed.

    The PR remains draft. Versions and dependency minimums are unchanged. Nothing has been merged or released; provider dependency/version changes belong in the dedicated coordinated release PR after the complete capability is ready.

  6. tamirdresher commented on Nov 1, 2026

    @tamirdresher
    Contributor

    Andy, this is great, thanks for putting it together so quickly. this also lines up with what the Pydantic maintainers asked for. Once we have this wired through, we should be able to remove most of the bridge and use a normal async orchestrator.

    I noticed the proof uses a custom DurableModel and ReplayClock rather than our AzureDurableTaskDurability capability. That makes sense for the spike. The next step is to run the actual capability on top of this so we can see what still needs adapting on the harness side.

    The main thing I want us to work through is parallel tool execution. Pydantic AI currently uses asyncio.wait with FIRST_COMPLETED and iterates the returned done set, while the proposed contract excludes wait and as_completed. Our bridge currently handles progress through when_any. Do you see a deterministic way to support that pattern in the SDK, or should we work with the Pydantic maintainers on changing it upstream? That will tell us whether we can keep this entirely within the harness.

    We also need a public way to get the orchestration context from inside the capability, and to confirm how switching from a generator orchestrator to async works across continue as new while existing histories still replay. We can sort out the minimum SDK version when we’re ready to wire it in.

    This looks like a good path to getting rid of the custom loop. Happy to take the Pydantic side and work through the remaining pieces together.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions