Add asyncio support to the gRPC app extension (dapr.ext.grpc.aio) - #1206
Open
saikishore-p wants to merge 2 commits into
Open
saikishore-p wants to merge 2 commits into
saikishore-p wants to merge 2 commits into
Conversation
3 tasks
saikishore-p
force-pushed
the
feature/grpc-ext-aio
branch
2 times, most recently
from
September 13, 2026 04:05
04eb3b9 to
e2e4ccc
Compare
…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>
saikishore-p
force-pushed
the
feature/grpc-ext-aio
branch
from
September 16, 2026 03:13
e2e4ccc to
feb4373
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Adds
dapr.ext.grpc.aio, an asyncio-nativeAppbacked by agrpc.aioserver, so Dapr callback handlers can beasync def.dapr.ext.grpc.Appruns ongrpc.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:
Swapping the import is the only change an app needs, beyond
async defhandlers and awaitingrun()/stop():Reviewing this
Two commits, and the first is the one worth scrutiny:
refactor: extract shared bases— touches only the three existing synchronous.pyfiles, provably behaviour-preserving.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 (SubscriptionMessagedelivery,_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:
Neither subclasses the other, so there is no sync/async mixing in one MRO and no misleading
isinstance. This differs from the repo's otheraiomodules (DaprGrpcClientAsync,dapr.ext.workflow.aio.DaprWorkflowClient), which are standalone copies — but their duplication is inherent, since every method body differs by anawait. 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, andisinstanceagainst both proto servicer types still holds — including for code reaching into internals likeapp._servicer._registered_topics, whichexamples/pubsub-simpledoes. 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-existingtests/ext/grpctests pass unmodified against commit 1 alone.tests/ext/grpc/aio/test_servicer.py::AsyncParityTeststhen 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._AioHealthCheckServiceris 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
_AioCallbackServiceris mechanical — happy to do that instead.Behaviour notes
SubscriptionMessagedelivery 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, andDAPR_GRPC_MAX_INBOUND_MESSAGE_SIZE_BYTES.AppCallbackAlphaServiceris registered on the aio server, soOnJobEventAlpha1andOnBulkTopicEventAlpha1are actually reachable.send_initial_metadatais awaited. It is a coroutine ongrpc.aio's context but a plain method on the sync one; calling it withoutawaitsilently drops response headers.run()/start(), not in__init__, becausegrpc.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 torun(), for serving the app alongside other work on the same loop (e.g. an ASGI lifespan handler).run()isstart()pluswait_for_termination().stop(grace=None)takes a grace period. There is no__del__hook, since a coroutine can't be awaited from one.register_health_check(lambda: None)keeps working. Documented as running inline on the event loop.Testing
tests/ext/grpc/aio/, mirroring the sync suite, plustest_server.py, which runs a realgrpc.aioserver 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,UNIMPLEMENTEDrouting, 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.tests/examples/coverage:invoke-simple-asyncandpubsub-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:
pytest -m "not e2e" ./tests --ignore=tests/integration --ignore=tests/examplespytest tests/ext/grpc/pytest tests/ext/grpc/aio/pytest tests/integration/ -m "not dapr_head"pytest tests/examples/mypyruff check/ruff format --checkpython -m unittest discover ./tests/ext/grpcThe single example failure is
test_configuration, which fails identically on unmodifiedmainin the same environment; it does not importdapr.ext.grpcand 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-existingtests/ext/grpctests pass unmodified and the full unit suite reports 1635 — exactly the pre-asyncio baseline.pubsub-simple-asyncis the server-side callback model; the existingpubsub-streaming-asyncis 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 predatesSubscriptionMessage, the_route_maprouting rewrite, bulk topic events and the stableOnJobEvent, so it could not simply be rebased — this is a fresh implementation against currentmain.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: