Skip to content

feat(worker): support async def activities - #320

Open
KSouleymane wants to merge 1 commit into
microsoft:mainfrom
KSouleymane:feat/async-activities
Open

KSouleymane wants to merge 1 commit into
microsoft:mainfrom
KSouleymane:feat/async-activities

Conversation

@KSouleymane

Copy link
Copy Markdown

Problem

Registering an async def activity fails at runtime. _ActivityExecutor.execute calls the function without awaiting it, and the data converter then tries to serialize the coroutine (Failed to serialize object of type 'coroutine'). The only workaround is a sync activity calling asyncio.run(...), which holds a worker thread for the whole I/O wait.

Fix

Coroutine activities now run on the worker's event loop instead of the thread pool. Their concurrency stays bounded by maximum_concurrent_activity_work_items. Sync activities keep their existing path.

  • _ActivityExecutor.execute_async awaits the activity. Input deserialization and output serialization are shared with execute.
  • The worker dispatches coroutine activities (including callable objects with an async def __call__) to a new _execute_activity_async. It keeps tracing, failure details and the activity execution hooks, and offloads the blocking calls (payload store, CompleteActivityTask) with asyncio.to_thread.
  • To avoid duplicating that flow, _execute_activity is split into small helpers (span, response building, completion) used by both paths. The sync path's behavior is unchanged.
  • wrap_with_release keeps coroutine handlers awaitable. It previously wrapped every handler in a sync function, which would have sent coroutine handlers to the thread pool unawaited.
  • call_activity() gains an AsyncActivity overload in OrchestrationContext and _RuntimeOrchestrationContext, so an async activity's task is typed CompletableTask[TOutput] instead of a coroutine. No abstract member is added.
  • A sync activity that returns an awaitable now fails with a TypeError asking for async def, instead of the serialization error. This is listed under CHANGED in the changelog.

Docs: a paragraph and an httpx example under "Activities" in docs/features.md, a new examples/async_activities.py (fan-out on DTS), and changelog entries for durabletask and durabletask-azuremanaged.

Validation

Check Result
Core unit scope (tests/durabletask, -m "not e2e"), Python 3.11 1,550 passed, 17 skipped
New tests run against main without the fix 13 failed; the 2 sync-path span tests pass, as expected since they cover existing behavior
nox -s lint Passed
nox -s typecheck_core (strict Pyright) 0 errors, 0 warnings
tests/durabletask/typing/activity_results.py without the new overload Fails (CompletableTask[Coroutine[...]]) as expected

New tests:

  • executor: async inputs, exception propagation, execute_async on sync activities, the TypeError for awaitables returned by sync activities, async callable detection;
  • worker: execution hooks around successful and failed async activities, handler selection by activity kind;
  • tracing: the activity server span on both paths with emit_trace_spans on and off. The sync path was not covered before;
  • end-to-end on the in-memory backend: an async activity sequence, error handling, and five async activities that must all be in flight at once to pass an asyncio.Barrier, with maximum_thread_pool_workers=1. The test is deterministic and does not rely on timing.

Not run: live DTS / emulator integration tests, the durabletask-azuremanaged and azure-functions-durable suites (no code change there), and Python versions above 3.11.

Closes #319

Suggested reviewers: andystaples Bernd Verst (@berndverst)

Registering an `async def` activity used to fail at runtime: the activity
executor called it without awaiting, and the data converter then tried to
serialize the coroutine ("Failed to serialize object of type 'coroutine'").

Coroutine activities now run on the worker's event loop instead of the
thread pool, so an I/O-bound activity no longer holds a worker thread while
it waits. Their concurrency stays bounded by
`maximum_concurrent_activity_work_items`. Synchronous activities keep their
existing path unchanged.

- `_ActivityExecutor.execute_async` awaits awaitable results; input
  deserialization and output serialization are shared with `execute`.
- The worker dispatches coroutine activities to `_execute_activity_async`,
  which keeps tracing, failure details and the activity execution hooks,
  and offloads the blocking calls (payload store, `CompleteActivityTask`)
  to a thread.
- `wrap_with_release` keeps coroutine handlers awaitable; it previously
  wrapped every handler in a synchronous function, which would have sent
  coroutine handlers to the thread pool unawaited.
- `call_activity()` gains an overload so an async activity's task is typed
  with the awaited result rather than a coroutine.
- A synchronous activity that returns an awaitable now fails with a
  `TypeError` asking for `async def`, instead of a serialization error.

Tests cover the executor, the activity hooks, handler selection, the activity
span on both paths, end-to-end runs on the in-memory backend (including
activities that must all be in flight at once to pass a barrier, with a
single pool thread) and the static result types.
Copilot AI balanced review requested due to automatic review settings November 2, 2026 18:15
@microsoft-github-policy-service

Copy link
Copy Markdown

KSouleymane please read the following Contributor License Agreement(CLA). If you agree with the CLA, please reply with the following information.

@microsoft-github-policy-service agree [company="{your company}"]

Options:

  • (default - no company specified) I have sole ownership of intellectual property rights to my Submissions and I am not making Submissions in the course of work for my employer.
@microsoft-github-policy-service agree
  • (when company given) I am making Submissions in the course of work for my employer (or my employer has intellectual property rights in my Submissions by contract or applicable law). I have permission from my employer to make Submissions and enter into this Agreement on behalf of my employer. By signing below, the defined term “You” includes me and my employer.
@microsoft-github-policy-service agree company="Microsoft"
Contributor License Agreement

Contribution License Agreement

This Contribution License Agreement (“Agreement”) is agreed to by the party signing below (“You”),
and conveys certain license rights to Microsoft Corporation and its affiliates (“Microsoft”) for Your
contributions to Microsoft open source projects. This Agreement is effective as of the latest signature
date below.

  1. Definitions.
    “Code” means the computer software code, whether in human-readable or machine-executable form,
    that is delivered by You to Microsoft under this Agreement.
    “Project” means any of the projects owned or managed by Microsoft and offered under a license
    approved by the Open Source Initiative (www.opensource.org).
    “Submit” is the act of uploading, submitting, transmitting, or distributing code or other content to any
    Project, including but not limited to communication on electronic mailing lists, source code control
    systems, and issue tracking systems that are managed by, or on behalf of, the Project for the purpose of
    discussing and improving that Project, but excluding communication that is conspicuously marked or
    otherwise designated in writing by You as “Not a Submission.”
    “Submission” means the Code and any other copyrightable material Submitted by You, including any
    associated comments and documentation.
  2. Your Submission. You must agree to the terms of this Agreement before making a Submission to any
    Project. This Agreement covers any and all Submissions that You, now or in the future (except as
    described in Section 4 below), Submit to any Project.
  3. Originality of Work. You represent that each of Your Submissions is entirely Your original work.
    Should You wish to Submit materials that are not Your original work, You may Submit them separately
    to the Project if You (a) retain all copyright and license information that was in the materials as You
    received them, (b) in the description accompanying Your Submission, include the phrase “Submission
    containing materials of a third party:” followed by the names of the third party and any licenses or other
    restrictions of which You are aware, and (c) follow any other instructions in the Project’s written
    guidelines concerning Submissions.
  4. Your Employer. References to “employer” in this Agreement include Your employer or anyone else
    for whom You are acting in making Your Submission, e.g. as a contractor, vendor, or agent. If Your
    Submission is made in the course of Your work for an employer or Your employer has intellectual
    property rights in Your Submission by contract or applicable law, You must secure permission from Your
    employer to make the Submission before signing this Agreement. In that case, the term “You” in this
    Agreement will refer to You and the employer collectively. If You change employers in the future and
    desire to Submit additional Submissions for the new employer, then You agree to sign a new Agreement
    and secure permission from the new employer before Submitting those Submissions.
  5. Licenses.
  • Copyright License. You grant Microsoft, and those who receive the Submission directly or
    indirectly from Microsoft, a perpetual, worldwide, non-exclusive, royalty-free, irrevocable license in the
    Submission to reproduce, prepare derivative works of, publicly display, publicly perform, and distribute
    the Submission and such derivative works, and to sublicense any or all of the foregoing rights to third
    parties.
  • Patent License. You grant Microsoft, and those who receive the Submission directly or
    indirectly from Microsoft, a perpetual, worldwide, non-exclusive, royalty-free, irrevocable license under
    Your patent claims that are necessarily infringed by the Submission or the combination of the
    Submission with the Project to which it was Submitted to make, have made, use, offer to sell, sell and
    import or otherwise dispose of the Submission alone or with the Project.
  • Other Rights Reserved. Each party reserves all rights not expressly granted in this Agreement.
    No additional licenses or rights whatsoever (including, without limitation, any implied licenses) are
    granted by implication, exhaustion, estoppel or otherwise.
  1. Representations and Warranties. You represent that You are legally entitled to grant the above
    licenses. You represent that each of Your Submissions is entirely Your original work (except as You may
    have disclosed under Section 3). You represent that You have secured permission from Your employer to
    make the Submission in cases where Your Submission is made in the course of Your work for Your
    employer or Your employer has intellectual property rights in Your Submission by contract or applicable
    law. If You are signing this Agreement on behalf of Your employer, You represent and warrant that You
    have the necessary authority to bind the listed employer to the obligations contained in this Agreement.
    You are not expected to provide support for Your Submission, unless You choose to do so. UNLESS
    REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING, AND EXCEPT FOR THE WARRANTIES
    EXPRESSLY STATED IN SECTIONS 3, 4, AND 6, THE SUBMISSION PROVIDED UNDER THIS AGREEMENT IS
    PROVIDED WITHOUT WARRANTY OF ANY KIND, INCLUDING, BUT NOT LIMITED TO, ANY WARRANTY OF
    NONINFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE.
  2. Notice to Microsoft. You agree to notify Microsoft in writing of any facts or circumstances of which
    You later become aware that would make Your representations in this Agreement inaccurate in any
    respect.
  3. Information about Submissions. You agree that contributions to Projects and information about
    contributions may be maintained indefinitely and disclosed publicly, including Your name and other
    information that You submit with Your Submission.
  4. Governing Law/Jurisdiction. This Agreement is governed by the laws of the State of Washington, and
    the parties consent to exclusive jurisdiction and venue in the federal courts sitting in King County,
    Washington, unless no federal subject matter jurisdiction exists, in which case the parties consent to
    exclusive jurisdiction and venue in the Superior Court of King County, Washington. The parties waive all
    defenses of lack of personal jurisdiction and forum non-conveniens.
  5. Entire Agreement/Assignment. This Agreement is the entire agreement between the parties, and
    supersedes any and all prior agreements, understandings or communications, written or oral, between
    the parties relating to the subject matter hereof. This Agreement may be assigned by Microsoft.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Async cancellation can leave work items unresolved, blocking reconnection can stall all in-flight async activities, and callable-object activities remain unusable through the public API.

3 open issues
What changed in this PR

Adds async def activity support to the core worker, enabling I/O-bound activities to run without holding worker threads. This also extends support to the Azure Managed worker and addresses #319.

Changes:

  • Dispatches async activities onto the worker event loop while preserving concurrency limits, tracing, and execution hooks.
  • Adds awaited-result typing and clearer errors for synchronous activities returning awaitables.
  • Adds regression tests, documentation, a fan-out example, and changelog entries.
File Description
tests/​durabletask/​typing/​activity_results.py Checks sync and async activity result types.
tests/​durabletask/​test_worker_activity_hooks.py Tests async hooks and handler selection.
tests/​durabletask/​test_tracing.py Tests activity spans on both execution paths.
tests/​durabletask/​test_orchestration_e2e.py Tests async sequencing, failures, and concurrent execution.
tests/​durabletask/​test_activity_executor.py Tests awaiting, callable detection, and awaitable rejection.
examples/​async_activities.py Demonstrates async activity fan-out with Azure Managed.
durabletask/​worker.py Implements async dispatch and shared activity execution helpers.
durabletask/​task.py Adds async activity typing and a scheduling overload.
durabletask-azuremanaged/​CHANGELOG.md Documents inherited async activity support.
docs/​features.md Explains async activities and event-loop constraints.
CHANGELOG.md Records async support and changed awaitable error behavior.

Review effort: Balanced

Comment thread durabletask/worker.py
Comment on lines +1456 to +1458
result = await executor.execute_async(
instance_id, req.name, req.taskId, req.input.value
)
Comment thread durabletask/worker.py
Comment on lines 1463 to +1464
except Exception as ex:
self._logger.exception(
f"Failed to deliver activity response for '{req.name}#{req.taskId}' of orchestration ID '{instance_id}' to sidecar: {ex}"
)
res = self._new_activity_failure_response(req, completionToken, ex)
Comment thread durabletask/worker.py
Comment on lines +3507 to +3509
return inspect.iscoroutinefunction(fn) or inspect.iscoroutinefunction(
getattr(fn, "__call__", None)
)
@berndverst

Copy link
Copy Markdown
Member

Please do not open issues and immediately PRs to implement them. Maintainers should triage and discuss issues first.

@berndverst Bernd Verst (berndverst) left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Async activities address a real gap in #319, and the happy-path execution and result typing look good. I'm requesting changes before merging because the new execution path still has worker-wide liveness risks and introduces an avoidable compatibility break for synchronous activities.

Priority Concern
P1 Reconnect setup, reader cleanup, and retry backoff block the same event loop that now hosts in-flight activities and their timeout handlers.
P1 Cancelled activities release local capacity without completing or abandoning the backend work item; cancellation during a threaded completion RPC also needs an explicit uncertain-outcome policy.
P1 Blocking completion/payload calls use the default asyncio executor shared with work-item reception and DNS, allowing saturation to stall otherwise unrelated work and bypassing the configured worker-pool limit.
P2 The unconditional awaitable-result rejection breaks previously successful synchronous activities returning serializable awaitable data.
P2 Input/output conversion remains synchronous on the shared loop, so large payloads and custom converters can stall dispatch and other async activities even when the activity body is nonblocking.
P2 Callable-object support stops at detection: normal public registration/scheduling cannot name ordinary instances, and postponed call annotations are not reconstructed.

The inline comments include focused reproductions and suggested regression coverage. In particular, I reproduced the synchronous-output behavior difference against main, cancellation with neither completion nor abandonment, default-executor/DNS starvation, reconnect-induced activity starvation, conversion-induced timer delays, and the callable registration/type-discovery gaps.

There is no new abstract-method requirement here; the compatibility concern is the changed treatment of existing valid synchronous outputs. Please preserve that behavior and cover the recovery, cancellation, saturation, large-payload, and public callable-object paths before merging.

Comment thread durabletask/worker.py
try:
result = await executor.execute_async(
instance_id, req.name, req.taskId, req.input.value
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Make reconnect handling nonblocking before hosting activities on this loop

The loop awaiting this activity also runs _async_run_loop, where connection setup calls synchronous Hello, connection invalidation joins the reader thread, and each retry uses the blocking self._shutdown.wait(delay). After a stream disconnect, a retrying backend therefore freezes every in-flight async activity, including already-ready I/O callbacks and timeout handlers. Moving activity-body I/O to async does not help when transport recovery owns the event-loop thread.

I reproduced this through _async_run_loop: an activity started await asyncio.sleep(0.01), the stream disconnected, and two failed reconnect attempts used 150 ms Hello calls with 50 ms backoff. The activity could not resume for 419 ms; it resumed only after the reconnect loop exited. With continued failures, that starvation continues for the outage. Please offload blocking connection/reader cleanup and use awaitable, shutdown-aware backoff. A regression should keep an async activity/heartbeat progressing while reconnect attempts continue, rather than only testing successful dispatch.

Comment thread durabletask/worker.py
self._logger.exception(
f"Failed to deliver activity response for '{req.name}#{req.taskId}' of orchestration ID '{instance_id}' to sidecar: {ex}"
)
res = self._new_activity_failure_response(req, completionToken, ex)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Give cancelled async activities an explicit backend outcome

asyncio.CancelledError inherits from BaseException, so cancellation escapes both except Exception blocks here and the manager's _process_work_item() recovery handler. The completion hook, channel-release wrapper, and semaphore cleanup still run, but neither CompleteActivityTask nor AbandonTaskActivityWorkItem is sent. The worker considers its capacity released while the orchestration remains blocked until backend lease recovery/redelivery.

I reproduced this with an activity awaiting an already-cancelled future: zero completion RPCs, zero abandonment RPCs, and the semaphore permit returned. Please handle cancellation explicitly and test it through the manager/dispatch path. Also cover cancellation while awaiting asyncio.to_thread(...): cancelling that await does not stop the underlying thread, so releasing/retiring its channel or sending an abandonment concurrently with an already-running completion RPC can produce an uncertain outcome. The recovery path needs to distinguish cancellation during the activity from cancellation after completion delivery has started.

Comment thread durabletask/worker.py
)
res = self._new_activity_failure_response(req, completionToken, ex)

await asyncio.to_thread(self._complete_activity_task, req, stub, res)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Isolate blocking activity I/O from the loop's default executor

asyncio.to_thread() uses the loop's default executor, which _async_run_loop also uses for work_item_queue.get() and async networking libraries use for DNS resolution. A burst of slow CompleteActivityTask calls (or payload-store calls above) can occupy that entire pool, preventing incoming work from being dispatched and unrelated async activities from resolving hosts until a blocking call returns. These RPCs have no deadline here, so the stall can be indefinite.

I reproduced this with maximum_thread_pool_workers=1: 32 completion calls ran concurrently, and both a default-executor dispatch probe and loop.getaddrinfo('localhost', 80) timed out while those calls were gated. The configured worker-pool limit does not bound this work. Please keep blocking SDK I/O on an explicitly bounded executor that cannot starve the stream reader/DNS, or use nonblocking APIs (the payload helpers already have async variants), and cover executor saturation in a regression test.

Comment thread durabletask/worker.py
fn, ctx, activity_input = self._prepare(orchestration_id, name, task_id, encoded_input)

# Execute the activity function
activity_output = fn(ctx, activity_input)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Preserve serializable awaitable values from synchronous activities

This changes more than the error for an accidentally returned coroutine: inspect.isawaitable() also matches valid data objects that the existing converter can serialize. For example, a dataclass Result(value: int) with a __await__ method is serialized as {"value": 7} by a synchronous activity on main, but the same activity fails here before its data converter is called. Awaitable dictionary subclasses and custom converters can be affected too.

This is intentionally returned data, not necessarily an accidentally unawaited operation; rewriting a previously synchronous activity as async def just to retain its old output also moves its user code onto the event loop. Please preserve successfully serializable synchronous outputs and narrow the improved diagnostic to unsupported awaitable/coroutine outputs. Add a compatibility regression covering a serializable awaitable value; documenting an error-message change does not capture this successful-to-failed behavior change.

Comment thread durabletask/worker.py

return self._serialize_output(orchestration_id, name, task_id, activity_output)

def _prepare(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Keep synchronous payload conversion off the shared worker loop

Both _prepare() and _serialize_output() run synchronously inside this coroutine, so deserialization, type reconstruction, to_json() hooks, and serialization now execute on the dispatch/event-loop thread. Offloading only the payload-store and completion calls does not make the SDK's own activity plumbing nonblocking.

With the default JsonDataConverter, three concurrent activities processing approximately 4.5 MB JSON payloads delayed a 10 ms loop timer to 426 ms in my reproduction. These are within the supported external-payload size limit. A synchronous custom converter taking 150 ms for each conversion delayed the timer to 301 ms even though the activity itself only awaited sleep(0). This stalls other async activities and work-item dispatch without the user putting any blocking work in their activity. Please isolate potentially expensive/blocking conversion from the shared loop and add coverage beyond the tiny string/integer results used here.

Comment thread durabletask/worker.py
"""Returns True for ``async def`` functions and objects with an ``async def __call__``."""
return inspect.iscoroutinefunction(fn) or inspect.iscoroutinefunction(
getattr(fn, "__call__", None)
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Wire async callable objects through registration, scheduling, and type discovery

The new detector recognizes an instance with async def __call__, but that support is unreachable through the normal public API: worker.add_activity(instance) and ctx.call_activity(instance) both use task.get_name(), which unconditionally reads fn.__name__. An ordinary callable instance has no such attribute; registration raises AttributeError before dispatch. The detector-only test does not exercise this.

There is a second gap after working around the name: with from __future__ import annotations, _resolve_hints(instance) cannot resolve the __call__ annotations. I verified that both activity_input_type(instance) and activity_output_type(instance) return None, while passing instance.__call__ discovers the dataclass correctly. Consequently, a typed activity can receive a dict instead of its annotated input and fail, or return an un-reconstructed result. Please either complete callable-object support with consistent naming and __call__ type discovery, plus a public registration-to-result regression test, or narrow the claimed support and tests to the callable forms that actually work.

Comment thread durabletask/worker.py
try:
return await handler(*args, **kwargs)
finally:
release()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Retain the channel through ordinary-error abandonment as well

This async wrapper releases its channel reference before _AsyncWorkerManager._process_work_item() invokes the fallback cancellation handler. If a reconnect has retired the channel and this is its last in-flight reference, release() closes it before _cancel_activity() sends AbandonTaskActivityWorkItem through the original stub.

This also happens without cancellation: for example, deexternalize_payloads can raise before _execute_activity_async() enters its activity-failure conversion block. The exception escapes this wrapper, the retired channel closes, and only then does the manager attempt abandonment. The existing sync wrapper has the same ordering; the new async wrapper carries that ownership gap forward.

Please retain the channel reference until execution and any fallback abandonment have finished, with a regression covering an async activity whose input payload download fails after its channel is retired. Handling CancelledError alone will not cover this ordinary-exception path.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support async def activities in the core worker

4 participants