Skip to content

Commit dc3364d

Browse files
sehervsicoyle
authored andcommitted
Add support for async workflow activities (#1053)
Signed-off-by: Sergio Herrera <627709+seherv@users.noreply.github.com> Signed-off-by: Samantha Coyle <sam@diagrid.io>
1 parent 133b93f commit dc3364d

19 files changed

Lines changed: 1374 additions & 157 deletions

examples/workflow/README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -559,3 +559,35 @@ It shows:
559559
```sh
560560
dapr run --app-id workflow-history-propagation -- python3 history_propagation.py
561561
```
562+
563+
### Async Activities
564+
565+
This example fans out several `async def` activities, then aggregates their
566+
results in a sync activity. Each async activity awaits a delay to stand in for
567+
an I/O call, so the instances run concurrently on the worker's event loop
568+
instead of taking a thread each.
569+
570+
Fan-out width and payload sizes are set with environment variables:
571+
`WORKFLOW_FAN_OUT` (default 5), `WORKFLOW_INPUT_BYTES` (default 2048),
572+
`WORKFLOW_OUTPUT_BYTES` (default 1024), and `WORKFLOW_IO_SECONDS` (default 1.0).
573+
574+
See [concurrency.md](../../dapr/ext/workflow/docs/concurrency.md) for when to
575+
prefer async over sync activities and how to size the concurrency knobs.
576+
577+
```sh
578+
dapr run --app-id workflow-async-activities -- python3 async_activities.py
579+
```
580+
581+
The output should look like this (the async lines can arrive in any order):
582+
583+
```
584+
Workflow started. Instance ID: 7b3e9c1f...
585+
[async] payload 0: 2048B in -> 1024B out
586+
[async] payload 1: 2048B in -> 1024B out
587+
[async] payload 2: 2048B in -> 1024B out
588+
[async] payload 3: 2048B in -> 1024B out
589+
[async] payload 4: 2048B in -> 1024B out
590+
[sync] 5 results, 5120 bytes
591+
Workflow completed! Status: COMPLETED
592+
Workflow result: 5 results, 5120 bytes
593+
```
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
# -*- coding: utf-8 -*-
2+
# Copyright 2026 The Dapr Authors
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
# http://www.apache.org/licenses/LICENSE-2.0
7+
# Unless required by applicable law or agreed to in writing, software
8+
# distributed under the License is distributed on an "AS IS" BASIS,
9+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
# See the License for the specific language governing permissions and
11+
# limitations under the License.
12+
13+
"""Async activities running alongside a sync one in a fan-out/fan-in workflow.
14+
15+
Each async activity simulates an I/O-bound call: it takes a payload, awaits a fixed
16+
delay (standing in for a network round-trip), and returns a result payload. The async
17+
instances run concurrently on the worker's event loop; a final sync activity aggregates
18+
the results. Fan-out width, input/output payload sizes, and the delay are configurable
19+
via environment variables.
20+
21+
Run with:
22+
23+
dapr run --app-id async-activities --app-protocol grpc --dapr-grpc-port 50001 \\
24+
-- python async_activities.py
25+
"""
26+
27+
from __future__ import annotations
28+
29+
import asyncio
30+
import os
31+
import random
32+
import string
33+
from time import sleep
34+
35+
from pydantic import BaseModel
36+
37+
import dapr.ext.workflow as wf
38+
39+
FAN_OUT = int(os.environ.get('WORKFLOW_FAN_OUT', '5'))
40+
INPUT_BYTES = int(os.environ.get('WORKFLOW_INPUT_BYTES', '2048'))
41+
OUTPUT_BYTES = int(os.environ.get('WORKFLOW_OUTPUT_BYTES', '1024'))
42+
IO_SECONDS = float(os.environ.get('WORKFLOW_IO_SECONDS', '1.0'))
43+
44+
wfr = wf.WorkflowRuntime()
45+
46+
47+
def _random_digits(n: int) -> str:
48+
return ''.join(random.choices(string.digits, k=n))
49+
50+
51+
class Payload(BaseModel):
52+
index: int
53+
data: str
54+
55+
56+
@wfr.workflow(name='fan_out_fan_in_workflow')
57+
def fan_out_fan_in_workflow(ctx: wf.DaprWorkflowContext, payloads: list[dict]):
58+
tasks = [ctx.call_activity(process_payload, input=p) for p in payloads]
59+
results = yield wf.when_all(tasks)
60+
summary = yield ctx.call_activity(summarize, input=results)
61+
return summary
62+
63+
64+
@wfr.activity(name='process_payload')
65+
async def process_payload(ctx: wf.WorkflowActivityContext, payload: Payload) -> str:
66+
"""Async activity: simulate an I/O-bound call. Instances run concurrently on the loop."""
67+
await asyncio.sleep(IO_SECONDS)
68+
result = _random_digits(OUTPUT_BYTES)
69+
print(
70+
f'[async] payload {payload.index}: {len(payload.data)}B in -> {len(result)}B out',
71+
flush=True,
72+
)
73+
return result
74+
75+
76+
@wfr.activity(name='summarize')
77+
def summarize(ctx: wf.WorkflowActivityContext, results: list[str]) -> str:
78+
"""Sync activity: aggregate the fan-out results on the thread pool."""
79+
summary = f'{len(results)} results, {sum(len(r) for r in results)} bytes'
80+
print(f'[sync] {summary}', flush=True)
81+
return summary
82+
83+
84+
def main() -> None:
85+
payloads = [
86+
Payload(index=i, data=_random_digits(INPUT_BYTES)).model_dump() for i in range(FAN_OUT)
87+
]
88+
89+
wfr.start()
90+
sleep(5) # wait for workflow runtime to start
91+
92+
wf_client = wf.DaprWorkflowClient()
93+
instance_id = wf_client.schedule_new_workflow(workflow=fan_out_fan_in_workflow, input=payloads)
94+
print(f'Workflow started. Instance ID: {instance_id}')
95+
96+
state = wf_client.wait_for_workflow_completion(instance_id, timeout_in_seconds=60)
97+
assert state is not None
98+
print(f'Workflow completed! Status: {state.runtime_status.name}')
99+
print(f'Workflow result: {state.serialized_output.strip(chr(34))}')
100+
101+
wfr.shutdown()
102+
103+
104+
if __name__ == '__main__':
105+
main()

ext/dapr-ext-workflow/AGENTS.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,26 @@ The entry point for registration and lifecycle:
105105

106106
Internally wraps user functions: workflow functions get a `DaprWorkflowContext`, activity functions get a `WorkflowActivityContext`. Tracks registration state via `_workflow_registered` / `_activity_registered` attributes on functions to prevent double registration.
107107

108+
#### Sync and async activities
109+
110+
Activities can be either `def my_activity(ctx, inp)` or `async def my_activity(ctx, inp)`. At registration, `_make_activity_wrapper` calls `_is_async_callable(fn)` to detect async-ness. That helper unwraps `functools.partial`, `@functools.wraps` chains, and callable-class `__call__` so common decorator patterns route correctly. The wrapper is built `async def` or `def` to match, then stored in the registry.
111+
112+
At dispatch time (the gRPC stream loop in `_durabletask/worker.py`), `is_async_callable(activity_fn)` on the wrapper selects between two handlers.
113+
114+
- **Async activities** go through `_execute_activity_async`, then `_ActivityExecutor.execute_async`, which awaits `fn(...)` directly on the event loop. The gRPC response is delivered via `loop.run_in_executor(self._async_worker_manager.thread_pool, stub.CompleteActivityTask, ...)` — the same pool sync activities use, sized by `maximum_thread_pool_workers`.
115+
- **Sync activities** go through `_execute_activity`, dispatched to the thread pool by `_AsyncWorkerManager._run_func`. The activity runs on a worker thread, and the response is delivered from the same thread.
116+
117+
Workflow (orchestrator) functions must remain generators (`def` with `yield`). They cannot be `async def` because durabletask's deterministic replay depends on synchronous generator semantics. Only activities support async.
118+
119+
**Decorator ordering gotcha.** Wrapping `@wfr.activity` over `@alternate_name(...)` over `async def` works because `@alternate_name` now emits an `async def innerfn` when the wrapped function is async. A user-written decorator that wraps an async function in a sync `def` (without `@functools.wraps` exposing `__wrapped__`) defeats `_is_async_callable`, routes the activity to the sync path, and produces an un-awaited coroutine. Such decorators should use `@functools.wraps(fn)` so the unwrap walks through them.
120+
121+
**`maximum_thread_pool_workers` covers both paths.** This knob sizes the worker thread pool used for sync-activity bodies and for async-activity gRPC response sends. Mixed workloads with long-running sync activities can starve async response delivery (and vice versa) since they share the pool — size to the sum of peak sync activity concurrency and peak in-flight async response sends.
122+
123+
**Concurrency sizing and load characterization.** See `docs/concurrency.md` for sizing recommendations (`maximum_concurrent_activity_work_items`, `maximum_thread_pool_workers`) and an async-vs-sync decision tree. `tests/ext/workflow/durabletask/test_async_dispatch_regression.py` (marked `perf`) guards the core invariant: a batch of async activities overlaps on the event loop instead of serializing through the thread pool.
124+
125+
**grpc.aio poller log noise.** The async client can emit benign `BlockingIOError: [Errno 11]` ERROR lines from `grpc.aio`'s `PollerCompletionQueue` under load. It is harmless and retried. `get_grpc_aio_channel` installs an internal `asyncio`-logger filter (`_silence_grpc_aio_poller_noise`) that drops only those records, so the SDK suppresses it automatically with no user action.
126+
127+
108128
### DaprWorkflowClient (`dapr_workflow_client.py`)
109129

110130
Client for workflow lifecycle management:
@@ -163,7 +183,7 @@ Retry configuration for activities and child workflows:
163183
1. **Registration**: User decorates functions with `@wfr.workflow` / `@wfr.activity`. The runtime wraps them and stores them in the durabletask worker's registry.
164184
2. **Startup**: `wfr.start()` opens a gRPC stream to the Dapr sidecar. The worker polls for work items.
165185
3. **Scheduling**: Client calls `schedule_new_workflow(fn, input=...)`. The function's name (or `_dapr_alternate_name`) is sent to the backend.
166-
4. **Execution**: The durabletask engine dispatches work items. Workflow functions are Python **generators** that `yield` tasks (activity calls, timers, child workflows). The engine records history; on replay, yielded tasks return cached results without re-executing.
186+
4. **Execution**: The durabletask engine dispatches work items. Workflow functions are Python **generators** that `yield` tasks (activity calls, timers, child workflows). Activity functions are either sync (dispatched to the worker's thread pool) or `async def` (awaited directly on the worker's event loop). The engine records history; on replay, yielded tasks return cached results without re-executing.
167187
5. **Determinism**: Workflows must be deterministic — no random, no wall-clock time, no I/O. Use `ctx.current_utc_datetime` instead of `datetime.now()`. Use `ctx.is_replaying` to guard side effects like logging.
168188
6. **Completion**: Client polls via `wait_for_workflow_completion()` or `get_workflow_state()`.
169189

@@ -191,6 +211,7 @@ Two example directories exercise workflows:
191211
- `cross-app1.py`, `cross-app2.py`, `cross-app3.py` — cross-app calls
192212
- `versioning.py` — workflow versioning with `is_patched()`
193213
- `simple_aio_client.py` — async client variant
214+
- `async_activities.py``async def` activities (fan-out/fan-in with simulated I/O, configurable payload sizes)
194215

195216
## Testing
196217

ext/dapr-ext-workflow/dapr/ext/workflow/_durabletask/aio/client.py

Lines changed: 32 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -70,18 +70,32 @@ def __init__(
7070
else:
7171
interceptors = None
7272

73-
channel = get_grpc_aio_channel(
74-
host_address=host_address,
75-
secure_channel=secure_channel,
76-
interceptors=interceptors,
77-
options=channel_options,
78-
)
79-
self._channel = channel
80-
self._stub = stubs.TaskHubSidecarServiceStub(channel)
73+
self._host_address = host_address
74+
self._secure_channel = secure_channel
75+
self._interceptors = interceptors
76+
self._channel_options = channel_options
77+
self._channel: grpc.aio.Channel | None = None
78+
self._stub: stubs.TaskHubSidecarServiceStub | None = None
8179
self._logger = shared.get_logger('client', log_handler, log_formatter)
8280

81+
def _get_stub(self) -> stubs.TaskHubSidecarServiceStub:
82+
"""Lazily create the channel and stub on first use.
83+
84+
Async grpc binds a channel to the loop active at creation, deferring it avoids binding to the wrong loop.
85+
"""
86+
if self._stub is None:
87+
self._channel = get_grpc_aio_channel(
88+
host_address=self._host_address,
89+
secure_channel=self._secure_channel,
90+
interceptors=self._interceptors,
91+
options=self._channel_options,
92+
)
93+
self._stub = stubs.TaskHubSidecarServiceStub(self._channel)
94+
return self._stub
95+
8396
async def aclose(self):
84-
await self._channel.close()
97+
if self._channel is not None:
98+
await self._channel.close()
8599

86100
async def __aenter__(self):
87101
return self
@@ -112,14 +126,14 @@ async def schedule_new_orchestration(
112126
)
113127

114128
self._logger.info(f"Starting new '{name}' instance with ID = '{req.instanceId}'.")
115-
res: pb.CreateInstanceResponse = await self._stub.StartInstance(req)
129+
res: pb.CreateInstanceResponse = await self._get_stub().StartInstance(req)
116130
return res.instanceId
117131

118132
async def get_orchestration_state(
119133
self, instance_id: str, *, fetch_payloads: bool = True
120134
) -> Optional[WorkflowState]:
121135
req = pb.GetInstanceRequest(instanceId=instance_id, getInputsAndOutputs=fetch_payloads)
122-
res: pb.GetInstanceResponse = await self._stub.GetInstance(req)
136+
res: pb.GetInstanceResponse = await self._get_stub().GetInstance(req)
123137
return new_orchestration_state(req.instanceId, res)
124138

125139
async def wait_for_orchestration_start(
@@ -131,7 +145,7 @@ async def wait_for_orchestration_start(
131145
)
132146

133147
async def _call(grpc_timeout):
134-
res: pb.GetInstanceResponse = await self._stub.WaitForInstanceStart(
148+
res: pb.GetInstanceResponse = await self._get_stub().WaitForInstanceStart(
135149
req, timeout=grpc_timeout
136150
)
137151
return new_orchestration_state(req.instanceId, res)
@@ -150,7 +164,7 @@ async def wait_for_orchestration_completion(
150164
)
151165

152166
async def _call(grpc_timeout):
153-
res: pb.GetInstanceResponse = await self._stub.WaitForInstanceCompletion(
167+
res: pb.GetInstanceResponse = await self._get_stub().WaitForInstanceCompletion(
154168
req, timeout=grpc_timeout
155169
)
156170
state = new_orchestration_state(req.instanceId, res)
@@ -261,7 +275,7 @@ async def raise_orchestration_event(
261275
)
262276

263277
self._logger.info(f"Raising event '{event_name}' for instance '{instance_id}'.")
264-
await self._stub.RaiseEvent(req)
278+
await self._get_stub().RaiseEvent(req)
265279

266280
async def terminate_orchestration(
267281
self, instance_id: str, *, output: Optional[Any] = None, recursive: bool = True
@@ -273,19 +287,19 @@ async def terminate_orchestration(
273287
)
274288

275289
self._logger.info(f"Terminating instance '{instance_id}'.")
276-
await self._stub.TerminateInstance(req)
290+
await self._get_stub().TerminateInstance(req)
277291

278292
async def suspend_orchestration(self, instance_id: str):
279293
req = pb.SuspendRequest(instanceId=instance_id)
280294
self._logger.info(f"Suspending instance '{instance_id}'.")
281-
await self._stub.SuspendInstance(req)
295+
await self._get_stub().SuspendInstance(req)
282296

283297
async def resume_orchestration(self, instance_id: str):
284298
req = pb.ResumeRequest(instanceId=instance_id)
285299
self._logger.info(f"Resuming instance '{instance_id}'.")
286-
await self._stub.ResumeInstance(req)
300+
await self._get_stub().ResumeInstance(req)
287301

288302
async def purge_orchestration(self, instance_id: str, recursive: bool = True):
289303
req = pb.PurgeInstancesRequest(instanceId=instance_id, recursive=recursive)
290304
self._logger.info(f"Purging instance '{instance_id}'.")
291-
await self._stub.PurgeInstances(req)
305+
await self._get_stub().PurgeInstances(req)

ext/dapr-ext-workflow/dapr/ext/workflow/_durabletask/aio/internal/shared.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
# See the License for the specific language governing permissions and
1010
# limitations under the License.
1111

12+
import logging
1213
from typing import Optional, Sequence, Union
1314

1415
import grpc
@@ -27,6 +28,30 @@
2728
grpc_aio.StreamStreamClientInterceptor,
2829
]
2930

31+
_POLLER_NOISE_MARKER = 'PollerCompletionQueue._handle_events'
32+
33+
34+
class _GrpcAioPollerNoiseFilter(logging.Filter):
35+
"""Drops the harmless grpc.aio poller BlockingIOError (EAGAIN) records.
36+
37+
The poller does a non-blocking read on its wake-up fd and can get EAGAIN, which
38+
asyncio logs at ERROR even though the read is retried and nothing is lost.
39+
"""
40+
41+
def filter(self, record: logging.LogRecord) -> bool:
42+
exc = record.exc_info[1] if record.exc_info else None
43+
is_poller_noise = isinstance(exc, BlockingIOError) and (
44+
_POLLER_NOISE_MARKER in record.getMessage()
45+
)
46+
return not is_poller_noise
47+
48+
49+
def _silence_grpc_aio_poller_noise() -> None:
50+
"""Install the poller-noise filter on the asyncio logger if not already present."""
51+
asyncio_logger = logging.getLogger('asyncio')
52+
if not any(isinstance(f, _GrpcAioPollerNoiseFilter) for f in asyncio_logger.filters):
53+
asyncio_logger.addFilter(_GrpcAioPollerNoiseFilter())
54+
3055

3156
def get_grpc_aio_channel(
3257
host_address: Optional[str],
@@ -42,6 +67,8 @@ def get_grpc_aio_channel(
4267
interceptors: Optional sequence of client interceptors to apply to the channel.
4368
options: Optional sequence of gRPC channel options as (key, value) tuples. Keys defined in https://grpc.github.io/grpc/core/group__grpc__arg__keys.html
4469
"""
70+
_silence_grpc_aio_poller_noise()
71+
4572
if host_address is None:
4673
host_address = get_default_host_address()
4774

ext/dapr-ext-workflow/dapr/ext/workflow/_durabletask/internal/shared.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
# limitations under the License.
1111

1212
import dataclasses
13+
import functools
14+
import inspect
1315
import json
1416
import logging
1517
import os
@@ -19,6 +21,32 @@
1921
import grpc
2022
from dapr.ext.workflow import _model_protocol
2123

24+
logger = logging.getLogger(__name__)
25+
26+
27+
def is_async_callable(fn: Any) -> bool:
28+
"""Return True if ``fn`` is async. Catches ``functools.partial`` of coroutines,
29+
sync decorators that wrap async functions, and callable instances with ``async __call__``.
30+
"""
31+
candidate = fn
32+
while isinstance(candidate, functools.partial):
33+
candidate = candidate.func
34+
if callable(candidate):
35+
try:
36+
candidate = inspect.unwrap(candidate)
37+
except ValueError:
38+
# Cyclic ``__wrapped__`` chain from a malformed decorator. Fall back to the
39+
# outermost callable; misclassification is preferable to crashing dispatch.
40+
logger.warning(
41+
f'Cyclic __wrapped__ on {fn!r}, using outermost callable for async detection.'
42+
)
43+
if inspect.iscoroutinefunction(candidate):
44+
return True
45+
if not inspect.isfunction(candidate) and hasattr(candidate, '__call__'):
46+
return inspect.iscoroutinefunction(candidate.__call__)
47+
return False
48+
49+
2250
ClientInterceptor = Union[
2351
grpc.UnaryUnaryClientInterceptor,
2452
grpc.UnaryStreamClientInterceptor,

0 commit comments

Comments
 (0)