Skip to content

Add asyncio support to the gRPC app extension (dapr.ext.grpc.aio) - #1206

Open
saikishore-p wants to merge 2 commits into
dapr:mainfrom
saikishore-p:feature/grpc-ext-aio
Open

saikishore-p wants to merge 2 commits into
dapr:mainfrom
saikishore-p:feature/grpc-ext-aio

Conversation

@saikishore-p

@saikishore-p saikishore-p commented Sep 12, 2026

Copy link
Copy Markdown

Description

Adds dapr.ext.grpc.aio, an asyncio-native App backed by a grpc.aio server, so Dapr callback handlers can be async def.

dapr.ext.grpc.App runs on grpc.server() with a 10-thread pool, so handlers must be synchronous. The async client (dapr.aio.clients.DaprClient) has been at parity with the sync client for a while, so an asyncio application can call Dapr asynchronously but cannot serve Dapr callbacks asynchronously. This PR closes that gap; there are no client-side changes.

This follows the implementation notes left on #695:

Just like we have dapr.ext.grpc which export App and Rule, we should have dapr.ext.grpc.aio which export the same. The only usage difference should be this single import.

Swapping the import is the only change an app needs, beyond async def handlers and awaiting run()/stop():

-from dapr.ext.grpc import App, InvokeMethodRequest, InvokeMethodResponse
+from dapr.ext.grpc.aio import App, InvokeMethodRequest, InvokeMethodResponse

 app = App()

 @app.method(name='my-method')
-def mymethod(request: InvokeMethodRequest) -> InvokeMethodResponse:
+async def mymethod(request: InvokeMethodRequest) -> InvokeMethodResponse:
     ...

-app.run(13551)
+asyncio.run(app.run(13551))

Reviewing this

Two commits, and the first is the one worth scrutiny:

  1. refactor: extract shared bases — touches only the three existing synchronous .py files, provably behaviour-preserving.
  2. feat: add dapr.ext.grpc.aio — purely additive apart from docs.

Why the refactor

Of the servicer's 456 lines, roughly 250 — register_topic's routing, _get_topic_callback, the bulk entry builders — contain no async content at all, and that is exactly the code that has churned most recently (SubscriptionMessage delivery, _route_map, bulk events). Copying it is what left #829 unmergeable after six months: it was written before all of those landed, and the copy silently fell behind.

So the two servicers are siblings over a shared base:

_CallbackServicerBase       # registries, topic routing, request → event translation
├── _CallbackServicer       # synchronous gRPC entry points
└── _AioCallbackServicer    # asyncio gRPC entry points

Neither subclasses the other, so there is no sync/async mixing in one MRO and no misleading isinstance. This differs from the repo's other aio modules (DaprGrpcClientAsync, dapr.ext.workflow.aio.DaprWorkflowClient), which are standalone copies — but their duplication is inherent, since every method body differs by an await. Most of this servicer's body doesn't.

Compatibility: nothing is removed. Attribute lookup walks the MRO, so every method still resolves on _CallbackServicer, instance state is untouched, and isinstance against both proto servicer types still holds — including for code reaching into internals like app._servicer._registered_topics, which examples/pubsub-simple does. The only observable delta is class-__dict__ reflection on a doubly-private class. Of the 19 methods, 12 move byte-identical; the 7 entry points delegate to 10 extracted helpers. The 48 pre-existing tests/ext/grpc tests pass unmodified against commit 1 alone.

tests/ext/grpc/aio/test_servicer.py::AsyncParityTests then locks the arrangement in: every RPC the sync servicer implements must be mirrored on the asyncio servicer as a coroutine function, and the registration helpers must stay shared rather than be reimplemented. A future RPC added to only one side fails the build.

_AioHealthCheckServicer is a standalone copy — at ~30 lines, a shared base would cost more than it saves.

If you'd rather not carry the refactor, dropping commit 1 and inlining the shared methods into a standalone _AioCallbackServicer is mechanical — happy to do that instead.

Behaviour notes

  • Full current surface, not a 2025 snapshot: service invocation, pub/sub with SubscriptionMessage delivery and the cloudevents deprecation path, topic rules, dead letter topics, disable_topic_validation, bulk topic events, job events on both the stable and alpha services, input bindings, health checks, and DAPR_GRPC_MAX_INBOUND_MESSAGE_SIZE_BYTES.
  • AppCallbackAlphaServicer is registered on the aio server, so OnJobEventAlpha1 and OnBulkTopicEventAlpha1 are actually reachable.
  • send_initial_metadata is awaited. It is a coroutine on grpc.aio's context but a plain method on the sync one; calling it without await silently drops response headers.
  • The server is built on the first run()/start(), not in __init__, because grpc.aio.server() binds to whichever event loop is current when it is called. add_external_service() therefore queues its registration and replays it at creation, so it still works before startup.
  • start() is added as a non-blocking alternative to run(), for serving the app alongside other work on the same loop (e.g. an ASGI lifespan handler). run() is start() plus wait_for_termination().
  • stop(grace=None) takes a grace period. There is no __del__ hook, since a coroutine can't be awaited from one.
  • Plain functions are still accepted as handlers — results are awaited only when awaitable — so register_health_check(lambda: None) keeps working. Documented as running inline on the event loop.

Testing

  • 78 unit tests under tests/ext/grpc/aio/, mirroring the sync suite, plus test_server.py, which runs a real grpc.aio server on an ephemeral port and drives it through generated stubs — covering invoke (data, content type, response headers, invocation metadata), topic events, bindings, job events, health checks, UNIMPLEMENTED routing, and lifecycle: cancellation during and after startup, concurrent starts, event-loop binding, bind failure, a cancelled graceful drain, and a slow handler not blocking a concurrent one.
  • Two new examples with tests/examples/ coverage: invoke-simple-async and pubsub-simple-async.

Run locally on Python 3.10 (the lint/type floor and the bottom of the CI matrix) against a real Dapr 1.18.4 runtime — sidecar, placement, scheduler and Redis:

Suite Command Result
Unit pytest -m "not e2e" ./tests --ignore=tests/integration --ignore=tests/examples 1713 passed
gRPC extension pytest tests/ext/grpc/ 126 passed
New asyncio tests pytest tests/ext/grpc/aio/ 78 passed
Integration pytest tests/integration/ -m "not dapr_head" 89 passed
Examples pytest tests/examples/ 43 passed, 1 pre-existing failure
Types mypy clean, 186 source files
Lint / format ruff check / ruff format --check clean
unittest runner python -m unittest discover ./tests/ext/grpc OK

The single example failure is test_configuration, which fails identically on unmodified main in the same environment; it does not import dapr.ext.grpc and is untouched by this PR. dapr.ext.grpc.* is not in mypy's ignore list, so the new code is fully type-checked.

The first commit was also verified in isolation: with only refactor: extract shared bases... applied, the 48 pre-existing tests/ext/grpc tests pass unmodified and the full unit suite reports 1635 — exactly the pre-asyncio baseline.

pubsub-simple-async is the server-side callback model; the existing pubsub-streaming-async is the client-side streaming API. Both READMEs now cross-reference each other so the distinction is clear.

Issue reference

Please reference the issue this PR will close: #695

Some history, since it isn't obvious from the issue: #695 was closed as completed on 2026-04-01, but nothing in the repo implements it. The prior attempt, #829, was auto-closed for inactivity after 60 days. That PR was written against the old ext/dapr-ext-grpc/ layout and predates SubscriptionMessage, the _route_map routing rewrite, bulk topic events and the stable OnJobEvent, so it could not simply be rebased — this is a fresh implementation against current main.

We hit this in production and ended up vendoring our own asyncio callback server to work around it, which is what prompted picking the work back up.

Checklist

Please make sure you've completed the relevant tasks for this PR, out of the following list:

  • Code compiles correctly
  • Created/updated tests
  • Extended the documentation

@saikishore-p
saikishore-p requested review from a team as code owners September 12, 2026 16:05
@saikishore-p
saikishore-p force-pushed the feature/grpc-ext-aio branch 2 times, most recently from 04eb3b9 to e2e4ccc Compare September 13, 2026 04:05
…icers

Splits _CallbackServicer into a base holding everything that does not invoke a
user handler - the handler registries, topic routing, and the translation of
incoming requests into the SDK types handlers receive - and a thin subclass
supplying the synchronous gRPC entry points. _HealthCheckServicer is split the
same way, with callback registration moving to the base.

This is preparation for asyncio servicers, which need all of the former and none
of the latter. Keeping that logic in one place avoids the duplication that left
the previous attempt at async support (dapr#829) unmergeable once SubscriptionMessage
delivery, the _route_map rewrite and bulk topic events landed on the synchronous
side only.

Behaviour is unchanged. Nothing is removed from either class's reachable surface
- attribute lookup walks the MRO, so callers reaching into internals such as
app._servicer, which examples/pubsub-simple does, are unaffected. The existing
tests pass unmodified.

Also extracts _resolve_topic_event_type in app.py so the deprecation warning for
unannotated topic handlers has a single definition to share.

Signed-off-by: Sai Kishore Punagani <63619246+saikishore-p@users.noreply.github.com>
dapr.ext.grpc.App runs on grpc.server() with a thread pool, so handlers must be
synchronous. Applications built on asyncio have had to vendor their own callback
server to use `async def` handlers.

Add dapr.ext.grpc.aio, backed by grpc.aio.server(). It exports the same names as
dapr.ext.grpc; swapping the import is the only change an app needs, beyond
`async def` handlers and awaiting run()/stop().

_AioCallbackServicer is a sibling of _CallbackServicer over the base extracted in
the previous commit - neither subclasses the other, so there is no sync/async
mixing in one MRO and no misleading isinstance relationship. It supplies only the
gRPC entry points, which await the handler result and the grpc.aio context
coroutines.

The asyncio servicer covers the full current surface: service invocation, pub/sub
including bulk events, input bindings, job events on both the stable and alpha
services, and health checks. AppCallbackAlphaServicer is registered on the
server, and send_initial_metadata is awaited, since it is a coroutine on the aio
context rather than a plain method.

Handlers may be plain functions - results are awaited only when awaitable - so
register_health_check(lambda: None) keeps working. The server is built on the
first run()/start() call rather than in __init__, because grpc.aio.server() binds
to whichever event loop is current when it is called; add_external_service()
therefore queues its registration and replays it at creation.

Adds unit tests including an end-to-end suite over a real grpc.aio server and
parity guards asserting every sync RPC is mirrored as a coroutine, plus the
invoke-simple-async and pubsub-simple-async examples.

The async client (dapr.aio.clients.DaprClient) already exists; this closes the
remaining server-side gap.

Closes dapr#695

Signed-off-by: Sai Kishore Punagani <63619246+saikishore-p@users.noreply.github.com>
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.

[Feature Request] Async GRPC Subscriber (dapr-ext-grpc)

1 participant