Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ ADDED

- Added `OrchestrationQuery.task_hub_names` to filter orchestration instances
by one or more task hubs.
- Activities can now be `async def` functions. An async activity no longer
occupies a worker thread while it waits on I/O; the number running at once is
bounded by `ConcurrencyOptions.maximum_concurrent_activity_work_items`, and
`call_activity()` types its task with the awaited result. See
[activities](docs/features.md#activities).

CHANGED

Expand All @@ -20,6 +25,8 @@ Python versions use the standard-library module.
- Context managers now preserve subclass types in type checkers, including
Azure Managed clients and workers.
- Entity ID copy and pickle hooks now preserve subclass types in type checkers.
- A synchronous activity that returns an awaitable now fails with a `TypeError`
asking for `async def`, instead of a JSON serialization error.
- Multiple task completion callback failures now raise the built-in
`ExceptionGroup`, preserving each exception and its traceback in memory and
the first callback error as the explicit cause and serialized inner failure.
Expand Down
16 changes: 16 additions & 0 deletions docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,22 @@ guarantees, meaning that they will be executed at least once but may be executed
the event of a transient failure. Activity functions are where the real "work" of any orchestration
is done.

Activities can also be `async def` functions. The worker awaits them on its event loop instead of
running them in its thread pool, so I/O-bound activities (HTTP or database calls, async SDK clients)
don't each hold a thread while they wait. Their concurrency is bounded by
`ConcurrencyOptions.maximum_concurrent_activity_work_items`.

```python
async def fetch_profile(ctx: task.ActivityContext, user_id: str) -> dict:
async with httpx.AsyncClient() as http:
response = await http.get(f"https://example.com/users/{user_id}")
return response.json()
```

Async activities share the worker's event loop: avoid blocking calls in them (synchronous I/O or
long CPU-bound work), which would delay every other async activity and the dispatch of new work
items. Keep such work in ordinary `def` activities. Orchestrators remain generator functions.

### Durable timers

Orchestrations can schedule durable timers using the `create_timer` API. These timers are durable,
Expand Down
5 changes: 5 additions & 0 deletions durabletask-azuremanaged/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Unreleased

ADDED

- With the corresponding core `durabletask` SDK change, `DurableTaskSchedulerWorker`
runs `async def` activities without occupying a worker thread while they wait on I/O.

CHANGED

- Dropped Python 3.10 support. Python 3.11 or later is now required.
Expand Down
13 changes: 12 additions & 1 deletion durabletask/task.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import logging
import math
from abc import ABC, abstractmethod
from collections.abc import Callable, Generator, Sequence
from collections.abc import Awaitable, Callable, Generator, Sequence
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import Any, Generic, TypeAlias, TypeVar, cast, overload
Expand Down Expand Up @@ -140,6 +140,14 @@ def call_activity(self, activity: Activity[TInput, TOutput] | str, *,
return_type: type[T]) -> CompletableTask[T]:
...

@overload
def call_activity(self, activity: AsyncActivity[TInput, TOutput], *,
input: TInput | None = ...,
retry_policy: RetryPolicy | None = ...,
tags: dict[str, str] | None = ...,
return_type: None = ...) -> CompletableTask[TOutput]:
...

@overload
def call_activity(self, activity: Activity[TInput, TOutput] | str, *,
input: TInput | None = ...,
Expand Down Expand Up @@ -948,6 +956,9 @@ def task_id(self) -> int:
# Activities are simple functions that can be scheduled by orchestrators
Activity: TypeAlias = Callable[[ActivityContext, TInput], TOutput]

# Async activities are awaited by the worker; their tasks complete with the awaited result
AsyncActivity: TypeAlias = Callable[[ActivityContext, TInput], Awaitable[TOutput]]

Entity: TypeAlias = Callable[[EntityContext, TInput], TOutput] | type[DurableEntity]


Expand Down
242 changes: 197 additions & 45 deletions durabletask/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import time
from collections.abc import Callable, Generator, Sequence
from concurrent.futures import ThreadPoolExecutor
from contextlib import contextmanager
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from threading import Event, Lock, Thread
Expand Down Expand Up @@ -830,6 +831,18 @@ def wrap_with_release(
handler: Callable[..., Any],
release: Callable[[], None],
) -> Callable[..., Any]:
if inspect.iscoroutinefunction(handler):
# Keep coroutine handlers recognizable as such, so the worker
# manager awaits them on its event loop rather than calling
# them from the thread pool.
async def wrapped_async(*args: Any, **kwargs: Any) -> Any:
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.


return wrapped_async

def wrapped(*args: Any, **kwargs: Any) -> Any:
try:
return handler(*args, **kwargs)
Expand Down Expand Up @@ -1025,7 +1038,7 @@ def stream_reader(response_stream: Any = response_stream) -> None:
elif work_item.HasField("activityRequest"):
submit_work_item(
self._async_worker_manager.submit_activity,
self._execute_activity,
self._get_activity_handler(work_item.activityRequest),
self._cancel_activity,
work_item.activityRequest,
stub,
Expand Down Expand Up @@ -1399,54 +1412,130 @@ def _execute_activity(
payload_helpers.deexternalize_payloads(req, self._payload_store)
try:
executor = _ActivityExecutor(self._registry, self._logger, self._data_converter)
with tracing.suppress_span_emission(not self._emit_trace_spans):
with tracing.start_span(
tracing.create_span_name("activity", req.name),
trace_context=req.parentTraceContext,
kind=tracing.SpanKind.SERVER,
attributes={
tracing.ATTR_TASK_TYPE: "activity",
tracing.ATTR_TASK_INSTANCE_ID: instance_id,
tracing.ATTR_TASK_NAME: req.name,
tracing.ATTR_TASK_TASK_ID: str(req.taskId),
},
) as span:
try:
result = executor.execute(
instance_id, req.name, req.taskId, req.input.value
)
except Exception as ex:
tracing.set_span_error(span, ex)
raise
res = pb.ActivityResponse(
instanceId=instance_id,
taskId=req.taskId,
result=ph.get_string_value(result),
completionToken=completionToken,
)
with self._start_activity_span(req) as span:
try:
result = executor.execute(
instance_id, req.name, req.taskId, req.input.value
)
except Exception as ex:
tracing.set_span_error(span, ex)
raise
res = self._new_activity_response(req, completionToken, result)
except Exception as ex:
res = pb.ActivityResponse(
instanceId=instance_id,
taskId=req.taskId,
failureDetails=ph.new_failure_details(
ex, self._exception_properties_provider, self._logger),
completionToken=completionToken,
)
res = self._new_activity_failure_response(req, completionToken, ex)

self._complete_activity_task(req, stub, res)
finally:
self._on_activity_execution_completed(req)

async def _execute_activity_async(
self,
req: pb.ActivityRequest,
stub: Any,
completionToken: Any,
) -> None:
"""Executes an ``async def`` activity on the worker's event loop.
Mirrors :meth:`_execute_activity`, but awaits the activity instead of
occupying a thread-pool worker. The blocking calls around it (payload
store access and the gRPC completion) are offloaded to a thread so
they don't stall the event loop.
"""
instance_id = req.orchestrationInstance.instanceId
self._on_activity_execution_started(req)
try:
# De-externalize any large-payload tokens in the incoming request
if self._payload_store is not None:
await asyncio.to_thread(
payload_helpers.deexternalize_payloads, req, self._payload_store
)
try:
# Externalize any large payloads in the response
if self._payload_store is not None:
payload_helpers.externalize_payloads(
res, self._payload_store, instance_id=instance_id,
)
stub.CompleteActivityTask(res)
executor = _ActivityExecutor(self._registry, self._logger, self._data_converter)
with self._start_activity_span(req) as span:
try:
result = await executor.execute_async(
instance_id, req.name, req.taskId, req.input.value
)
Comment on lines +1456 to +1458

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.

except Exception as ex:
tracing.set_span_error(span, ex)
raise
res = self._new_activity_response(req, completionToken, result)
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 on lines 1463 to +1464

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.


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.

finally:
self._on_activity_execution_completed(req)

def _get_activity_handler(self, req: pb.ActivityRequest) -> Callable[..., Any]:
"""Returns the handler that executes the activity named by the request."""
fn = self._registry.get_activity(req.name)
if fn is not None and _is_async_callable(fn):
return self._execute_activity_async
return self._execute_activity

@contextmanager
def _start_activity_span(self, req: pb.ActivityRequest):
with tracing.suppress_span_emission(not self._emit_trace_spans):
with tracing.start_span(
tracing.create_span_name("activity", req.name),
trace_context=req.parentTraceContext,
kind=tracing.SpanKind.SERVER,
attributes={
tracing.ATTR_TASK_TYPE: "activity",
tracing.ATTR_TASK_INSTANCE_ID: req.orchestrationInstance.instanceId,
tracing.ATTR_TASK_NAME: req.name,
tracing.ATTR_TASK_TASK_ID: str(req.taskId),
},
) as span:
yield span

def _new_activity_response(
self,
req: pb.ActivityRequest,
completionToken: Any,
result: str | None,
) -> pb.ActivityResponse:
return pb.ActivityResponse(
instanceId=req.orchestrationInstance.instanceId,
taskId=req.taskId,
result=ph.get_string_value(result),
completionToken=completionToken,
)

def _new_activity_failure_response(
self,
req: pb.ActivityRequest,
completionToken: Any,
ex: Exception,
) -> pb.ActivityResponse:
return pb.ActivityResponse(
instanceId=req.orchestrationInstance.instanceId,
taskId=req.taskId,
failureDetails=ph.new_failure_details(
ex, self._exception_properties_provider, self._logger),
completionToken=completionToken,
)

def _complete_activity_task(
self,
req: pb.ActivityRequest,
stub: Any,
res: pb.ActivityResponse,
) -> None:
instance_id = req.orchestrationInstance.instanceId
try:
# Externalize any large payloads in the response
if self._payload_store is not None:
payload_helpers.externalize_payloads(
res, self._payload_store, instance_id=instance_id,
)
stub.CompleteActivityTask(res)
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}"
)

def _cancel_activity(
self,
req: pb.ActivityRequest,
Expand Down Expand Up @@ -1878,6 +1967,18 @@ def call_activity(
) -> task.CompletableTask[T]:
...

@overload
def call_activity(
self,
activity: task.AsyncActivity[TInput, TOutput],
*,
input: TInput | None = ...,
retry_policy: task.RetryPolicy | None = ...,
tags: dict[str, str] | None = ...,
return_type: None = ...,
) -> task.CompletableTask[TOutput]:
...

@overload
def call_activity(
self,
Expand Down Expand Up @@ -3401,6 +3502,13 @@ def compare_versions(self, source_version: str | None, default_version: str | No
return (source_version > default_version) - (source_version < default_version)


def _is_async_callable(fn: Callable[..., Any]) -> bool:
"""Returns True for ``async def`` functions and objects with an ``async def __call__``."""
return inspect.iscoroutinefunction(fn) or inspect.iscoroutinefunction(
getattr(fn, "__call__", None)
)
Comment on lines +3507 to +3509

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.



class _ActivityExecutor:
def __init__(self, registry: _Registry, logger: logging.Logger,
data_converter: DataConverter):
Expand All @@ -3416,6 +3524,45 @@ def execute(
encoded_input: str | None,
) -> str | None:
"""Executes an activity function and returns the serialized result, if any."""
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.

if inspect.isawaitable(activity_output):
if inspect.iscoroutine(activity_output):
activity_output.close()
raise TypeError(
f"Activity '{name}' returned an awaitable from a synchronous call. "
"Declare the activity with 'async def' so the worker awaits it."
)

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

async def execute_async(
self,
orchestration_id: str,
name: str,
task_id: int,
encoded_input: str | None,
) -> str | None:
"""Executes an activity function, awaiting its result if it is awaitable,
and returns the serialized result, if any."""
fn, ctx, activity_input = self._prepare(orchestration_id, name, task_id, encoded_input)

# Execute the activity function
activity_output = fn(ctx, activity_input)
if inspect.isawaitable(activity_output):
activity_output = await activity_output

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.

self,
orchestration_id: str,
name: str,
task_id: int,
encoded_input: str | None,
) -> tuple[task.Activity[Any, Any], task.ActivityContext, Any]:
self._logger.debug(
f"{orchestration_id}/{task_id}: Executing activity '{name}'..."
)
Expand All @@ -3428,10 +3575,15 @@ def execute(
input_type = type_discovery.activity_input_type(fn, self._data_converter) if encoded_input else None
activity_input = self._data_converter.deserialize(encoded_input, input_type)
ctx = task.ActivityContext(orchestration_id, task_id)
return fn, ctx, activity_input

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

def _serialize_output(
self,
orchestration_id: str,
name: str,
task_id: int,
activity_output: Any,
) -> str | None:
encoded_output = self._data_converter.serialize(activity_output)
chars = len(encoded_output) if encoded_output else 0
self._logger.debug(
Expand Down
Loading