GRPC Actors#1081
Conversation
Signed-off-by: Albert Callarisa <albert@diagrid.io>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #1081 +/- ##
==========================================
- Coverage 86.63% 82.72% -3.91%
==========================================
Files 84 122 +38
Lines 4473 10110 +5637
==========================================
+ Hits 3875 8364 +4489
- Misses 598 1746 +1148 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
This PR introduces alpha gRPC-native actor hosting for the Dapr Python SDK via an app-initiated SubscribeActorEventsAlpha1 stream, enabling actor callbacks (invoke/reminder/timer/deactivate) to be delivered over a single outbound gRPC connection without HTTP callback endpoints. It also adds a gRPC-based outbound actor client so actor apps can operate without requiring daprd’s HTTP port.
Changes:
- Added
ActorGrpcHostto host actors over theSubscribeActorEventsAlpha1bidirectional stream with reconnect/dispatch behavior. - Added
DaprActorGrpcClientto perform outbound actor operations over unary gRPC actor RPCs (invoke/state/reminders/timers) and wired runtime registration to accept a pluggable actor client. - Added unit + integration + example coverage for gRPC actor hosting, plus shared gRPC channel creation utilities and documentation updates.
Reviewed changes
Copilot reviewed 22 out of 22 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
dapr/actor/__init__.py |
Exposes ActorGrpcHost as a public actor API entrypoint. |
dapr/actor/runtime/_grpc_callbacks.py |
Builds stream registration + callback payloads and handles metadata extraction/heuristics. |
dapr/actor/runtime/config.py |
Adds read-only/snapshot properties for actor runtime/type/reentrancy config objects. |
dapr/actor/runtime/grpc_host.py |
Implements the alpha actor hosting service over the gRPC actor stream. |
dapr/actor/runtime/runtime.py |
Adds optional actor_client parameter to registration to support non-HTTP outbound ops. |
dapr/aio/clients/grpc/client.py |
Refactors async gRPC client to use shared channel creation + endpoint resolution. |
dapr/clients/base.py |
Centralizes DAPR_REENTRANCY_ID_HEADER constant. |
dapr/clients/grpc/_channel.py |
Adds shared resolve_grpc_endpoint() and create_aio_channel() helpers. |
dapr/clients/grpc/dapr_actor_grpc_client.py |
Adds gRPC actor client implementing outbound actor operations over unary RPCs. |
dapr/clients/http/dapr_actor_http_client.py |
Switches to the centralized reentrancy header constant. |
docs/actor/actor.runtime.rst |
Documents the new actor.runtime.grpc_host module in API docs. |
examples/demo_actor/README.md |
Adds “Run with gRPC actor hosting (alpha)” instructions. |
examples/demo_actor/demo_actor/demo_actor_grpc_service.py |
Adds gRPC-stream-hosted demo actor service (alpha). |
tests/actor/test_actor_runtime_config.py |
Adds tests validating new read-only config properties/snapshot behavior. |
tests/actor/test_grpc_callbacks.py |
Adds tests for registration message building + callback payload synthesis + heuristics. |
tests/actor/test_grpc_host.py |
Adds scripted fake-sidecar tests for ActorGrpcHost behavior. |
tests/actor_grpc_utils.py |
Adds helper probe to detect sidecar support for SubscribeActorEventsAlpha1. |
tests/clients/fake_dapr_server.py |
Extends fake daprd to support actor stream + unary actor RPCs for tests. |
tests/clients/test_dapr_actor_grpc_client.py |
Adds unit tests for DaprActorGrpcClient request shaping and behavior. |
tests/examples/test_demo_actor_grpc.py |
Adds example test for the gRPC-stream demo actor scenario. |
tests/integration/apps/actor_grpc_host.py |
Adds integration-test actor host subprocess using ActorGrpcHost. |
tests/integration/test_actor_grpc.py |
Adds end-to-end integration tests for stream-hosted actor callbacks and state. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Signed-off-by: Albert Callarisa <albert@diagrid.io>
Signed-off-by: Albert Callarisa <albert@diagrid.io>
Signed-off-by: Albert Callarisa <albert@diagrid.io>
seherv
left a comment
There was a problem hiding this comment.
Nice!
Just a couple of things that could be cleaned up
Signed-off-by: Albert Callarisa <albert@diagrid.io>
|
why is this labeled with 1.18 backport? |
Because the feature in the runtime is available in 1.18, so we want it in all 1.18 compatible SDKs. |
sicoyle
left a comment
There was a problem hiding this comment.
few comments from me for a first pass - thank you!!
Signed-off-by: Albert Callarisa <albert@diagrid.io>
Signed-off-by: Albert Callarisa <albert@diagrid.io>
sicoyle
left a comment
There was a problem hiding this comment.
few comments and i can rereview tomorrow :)
- Per-actor message_serializer silently ignored (Medium). _get_actor_client builds DaprActorGrpcClient once and caches it, binding actor #1's serializer to every type. Registering two actor types with different serializers gives them all the first one's. HTTP path builds a client per actor, so this is a behavioral regression (invisible with the default JSON serializer).
- State values re-serialized with the message serializer, not the state serializer (Medium). In dapr_actor_grpc_client._to_transactional_operation, the transactional value is encoded through the single (message) serializer. When state_serializer != message_serializer, persisted bytes differ from the HTTP transport — contradicting the "byte-identical" claim. Related to #2's wiring.
- Malformed reminder/timer payloads become a retryable error → poison-message loop (Medium). _on_reminder/_on_timer catch everything and route to _send_request_failed; status_code_for_exception maps non-ActorNotFoundError to UNKNOWN, which daprd retries. A structurally invalid payload (JSON decode failure during body synthesis) will redeliver forever. Treat body-decode failures as non-retryable.
- timer_response populated with the Reminder response type (Low–Medium — verify). _on_timer builds SubscribeActorEventsRequestReminderResponseAlpha1 and assigns it to the timer_response oneof. Deactivate has its own type, so the asymmetry is suspicious — if the proto declares a distinct ...TimerResponseAlpha1, every timer callback raises TypeError. Quick check against the generated proto; a timer test would catch it.
- App-listener leak on start failure (Low). _start_app_channel_listener assigns self._app_server = server after await server.start(). If add_insecure_port/start() raises (port already bound), the server isn't tracked and close() can't stop it. Assign before start(). (This is the residue of the very thing your comment was about — worth flagging alongside the DX note.)
- Bad handshake isn't retried (Low). _run_loop only catches TimeoutError/AioRpcError; a malformed ack raises RuntimeError and permanently kills the serving task, whereas an UNAVAILABLE drop reconnects. Inconsistent — decide whether a bad ack should be transient.
| @@ -0,0 +1,57 @@ | |||
| import pytest | |||
There was a problem hiding this comment.
pls add license headers to all of your files missing it
Description
Adds alpha support for hosting Dapr actors over the app-initiated
SubscribeActorEventsAlpha1gRPC stream (dapr/dapr#9812). The app dials daprd's gRPC port, registers its actor types and runtime config in the stream's first message, and receives all actor callbacks, method invocations, reminders, timers, deactivations, pushed over that single stream. No inbound HTTP or gRPC port is needed for actor callbacks; outbound actor operations (state, reminders, timers, invocations) also go over daprd's gRPC API, so actor apps are fully gRPC-native.The same actor classes,
ActorRuntime, andActorRuntimeConfigwork unchanged on both transports, only the hosting service differs. HTTP actor hosting (FastAPI/Flask extensions) is untouched.What's included
New public API (alpha)
dapr.actor.ActorGrpcHost(dapr/actor/runtime/grpc_host.py), stream lifecycle: registration handshake (with timeout), concurrent callback dispatch with serialized stream writes and request/response correlation byid, automatic reconnect + re-registration after transient drops (UNAVAILABLE/UNKNOWN/INTERNAL, gated onchannel_ready()), graceful shutdown with in-flight task draining. A stale-session guard ensures replies from handlers that outlive a connection are never sent on its successor.DaprActorGrpcClient(dapr/clients/grpc/dapr_actor_grpc_client.py), implementsDaprActorClientBaseover the unary actor RPCs (InvokeActor,GetActorState,ExecuteActorStateTransaction, reminder/timer registration incl. TTL and failure policies), withDapr-Reentrancy-Idpropagation. Closes the long-standing# TODO: We will allow to use gRPC client laterinruntime.py.Protocol semantics
request_failedwithNOT_FOUND(permanent, non-retryable in daprd); application handler exceptions →invoke_response{error=true}carrying the same JSON payload the HTTP extensions return, so callers observe identical errors on both transports.BytesValue-packedAny; fire bodies are synthesized to match daprd's HTTP delivery byte-for-byte soActorRuntimeneeds no changes. Timers registered via the unary gRPC RPC are base64-wrapped by daprd (json.Marshalof raw bytes); the host transparently recovers the original payload (documented edge case in_grpc_callbacks.py).Dapr-Reentrancy-Idandcontent-typemetadata are propagated in both directions.Supporting changes
ActorRuntime.register_actorgains an additiveactor_clientparameter (defaults to the existing HTTP client).ActorRuntimeConfig/ActorTypeConfig/ActorReentrancyConfiggain read-only properties; the registration message is built from typed config objects rather thanas_dict().create_aio_channel()/resolve_grpc_endpoint()(dapr/clients/grpc/_channel.py), single home for grpc.aio channel setup, now also used byDaprGrpcClientAsync(its inline channel construction is replaced).DAPR_REENTRANCY_ID_HEADERconsolidated intodapr/clients/base.py.Example,
examples/demo_actor/demo_actor/demo_actor_grpc_service.pyhosts the existingDemoActorover the stream; README section added. Note: daprd currently requires a gRPC app channel (--app-protocol grpc --app-portwith a listener), so the example runs an emptygrpc.aio.server()on the app port, no actor traffic flows through it.Issue reference
Close: #1078