Skip to content

Commit be20bb5

Browse files
authored
Deprecate cloudevents messages (#1118)
* Deprecate cloudevents messages Signed-off-by: Albert Callarisa <albert@diagrid.io> * Fix metadata docstring Signed-off-by: Albert Callarisa <albert@diagrid.io> * Rename test_servicier.py to test_servicer.py Signed-off-by: Albert Callarisa <albert@diagrid.io> --------- Signed-off-by: Albert Callarisa <albert@diagrid.io>
1 parent 64c75a1 commit be20bb5

9 files changed

Lines changed: 403 additions & 94 deletions

File tree

dapr/common/pubsub/subscription.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
11
import json
2-
from typing import Optional, Union
2+
from typing import Mapping, Optional, Union
33

44
from google.protobuf.json_format import MessageToDict
55

66
from dapr.proto.runtime.v1.appcallback_pb2 import TopicEventRequest
77

88

99
class SubscriptionMessage:
10-
def __init__(self, msg: TopicEventRequest):
10+
"""Message delivered to Dapr pub/sub subscribers.
11+
12+
Delivered by both the streaming subscription API (``DaprClient.subscribe``) and the
13+
App callback API (``@app.subscribe``). ``metadata`` carries delivery metadata (gRPC
14+
invocation metadata and, for bulk deliveries, per-entry publish metadata); it is only
15+
populated for App callback deliveries.
16+
"""
17+
18+
def __init__(self, msg: TopicEventRequest, metadata: Optional[Mapping[str, str]] = None):
1119
self._id: str = msg.id
1220
self._source: str = msg.source
1321
self._type: str = msg.type
@@ -17,6 +25,7 @@ def __init__(self, msg: TopicEventRequest):
1725
self._pubsub_name: str = msg.pubsub_name
1826
self._raw_data: bytes = msg.data
1927
self._data: Optional[Union[dict, str]] = None
28+
self._metadata: dict[str, str] = dict(metadata) if metadata else {}
2029

2130
try:
2231
self._extensions = MessageToDict(msg.extensions)
@@ -58,6 +67,9 @@ def extensions(self):
5867
def data(self):
5968
return self._data
6069

70+
def metadata(self) -> dict[str, str]:
71+
return self._metadata
72+
6173
def _parse_data_content(self):
6274
try:
6375
if self._data_content_type == 'application/json':

dapr/ext/grpc/AGENTS.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ dapr/ext/grpc/
1414
1515
tests/ext/grpc/
1616
├── test_app.py # Decorator registration tests
17-
├── test_servicier.py # Routing, handlers, bulk events
17+
├── test_servicer.py # Routing, handlers, bulk events
1818
├── test_health_servicer.py # Health check tests
1919
└── test_topic_event_response.py # Response status tests
2020
```
@@ -27,6 +27,7 @@ Installed via the `grpc` extra on core dapr: `pip install "dapr[grpc]"`.
2727
from dapr.ext.grpc import (
2828
App, # Main entry point — decorator-based gRPC server
2929
Rule, # CEL-based topic rule with priority
30+
SubscriptionMessage, # Event type received by pub/sub topic handlers (preferred)
3031
InvokeMethodRequest, # Request object for service invocation handlers
3132
InvokeMethodResponse, # Response object for service invocation handlers
3233
BindingRequest, # Request object for input binding handlers
@@ -56,7 +57,7 @@ def handle_method(request: InvokeMethodRequest) -> InvokeMethodResponse:
5657

5758
@app.subscribe(pubsub_name='pubsub', topic='orders', metadata={}, dead_letter_topic=None,
5859
rule=Rule('event.type == "order"', priority=1), disable_topic_validation=False)
59-
def handle_event(event: v1.Event) -> Optional[TopicEventResponse]:
60+
def handle_event(event: SubscriptionMessage) -> Optional[TopicEventResponse]:
6061
...
6162

6263
@app.binding('binding_name')
@@ -115,7 +116,7 @@ app.register_health_check(lambda: None) # Not a decorator — direct registrati
115116
## Dependencies (declared via the `grpc` extra in the root `pyproject.toml`)
116117

117118
- `dapr` (core, same wheel as this extension)
118-
- `cloudevents >= 1.0.0, < 2.0.0`
119+
- `cloudevents >= 1.0.0, < 2.0.0` (deprecated, only used for the legacy handler event type)
119120

120121
## Testing
121122

@@ -125,14 +126,14 @@ uv run python -m unittest discover -v ./tests/ext/grpc
125126

126127
Test patterns:
127128
- `test_app.py` — decorator registration, health check registration
128-
- `test_servicier.py` — handler invocation with mock gRPC context, return type handling (str, bytes, proto, response object), topic subscriptions, bulk events, bindings, duplicate registration errors
129+
- `test_servicer.py` — handler invocation with mock gRPC context, return type handling (str, bytes, proto, response object), topic subscriptions, bulk events, bindings, duplicate registration errors
129130
- `test_health_servicer.py` — health check callback invocation, missing callback (UNIMPLEMENTED)
130131
- `test_topic_event_response.py` — response creation from enum and string values
131132

132133
## Key details
133134

134135
- **Synchronous only**: Uses `grpc.server()` with `ThreadPoolExecutor(10)`. No async handler support.
135136
- **Default port**: 3010 (from `dapr.conf.global_settings.GRPC_APP_PORT`)
136-
- **CloudEvents**: Requires `cloudevents >= 1.0.0` for pub/sub event handling
137+
- **Topic handler event type**: inferred from the handler annotation. Annotating the event parameter with `dapr.ext.grpc.SubscriptionMessage` — the same SDK-owned type the streaming subscription API (`DaprClient.subscribe`) delivers, with `metadata()` populated from the gRPC invocation metadata — delivers that type. Unannotated or otherwise-annotated handlers receive the DEPRECATED `cloudevents.sdk.event.v1.Event` and `subscribe()` emits a `DeprecationWarning` at registration. Deprecation timeline: 1.20 delivers `SubscriptionMessage` to unannotated handlers (legacy only via explicit `v1.Event` annotation), 1.21 drops `cloudevents` from the `grpc` extra (import becomes conditional), 1.22 removes the legacy path entirely (same release the `flask_dapr` shim goes away). New code must annotate with `SubscriptionMessage`. (Internally the choice is plumbed through `_CallbackServicer.register_topic(legacy_cloudevent=...)`.)
137138
- **Duplicate registration**: Registering the same method/topic/binding name twice raises `ValueError`
138139
- **Missing handlers**: Calling an unregistered method/topic/binding raises `NotImplementedError` (gRPC UNIMPLEMENTED)

dapr/ext/grpc/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from dapr.clients.grpc._jobs import ConstantFailurePolicy, DropFailurePolicy, FailurePolicy, Job
1717
from dapr.clients.grpc._request import BindingRequest, InvokeMethodRequest, JobEvent
1818
from dapr.clients.grpc._response import InvokeMethodResponse, TopicEventResponse
19+
from dapr.common.pubsub.subscription import SubscriptionMessage
1920

2021
try:
2122
from dapr.ext.grpc.app import App, Rule # type:ignore
@@ -30,6 +31,7 @@
3031
__all__ = [
3132
'App',
3233
'Rule',
34+
'SubscriptionMessage',
3335
'InvokeMethodRequest',
3436
'InvokeMethodResponse',
3537
'BindingRequest',

dapr/ext/grpc/_servicer.py

Lines changed: 108 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from dapr.clients._constants import DEFAULT_JSON_CONTENT_TYPE
2626
from dapr.clients.grpc._request import BindingRequest, InvokeMethodRequest, JobEvent
2727
from dapr.clients.grpc._response import InvokeMethodResponse, TopicEventResponse
28+
from dapr.common.pubsub.subscription import SubscriptionMessage
2829
from dapr.proto import appcallback_service_v1, appcallback_v1, common_v1
2930
from dapr.proto.common.v1.common_pb2 import InvokeRequest
3031
from dapr.proto.runtime.v1.appcallback_pb2 import (
@@ -36,7 +37,9 @@
3637
)
3738

3839
InvokeMethodCallable = Callable[[InvokeMethodRequest], Union[str, bytes, InvokeMethodResponse]]
39-
TopicSubscribeCallable = Callable[[v1.Event], Optional[TopicEventResponse]]
40+
TopicSubscribeCallable = Callable[
41+
[Union[v1.Event, SubscriptionMessage]], Optional[TopicEventResponse]
42+
]
4043
BindingCallable = Callable[[BindingRequest], None]
4144
JobEventCallable = Callable[[JobEvent], None]
4245

@@ -74,6 +77,7 @@ class _CallbackServicer(
7477
def __init__(self):
7578
self._invoke_method_map: Dict[str, InvokeMethodCallable] = {}
7679
self._topic_map: Dict[str, TopicSubscribeCallable] = {}
80+
self._topic_legacy_event: Dict[TopicSubscribeCallable, bool] = {}
7781
self._binding_map: Dict[str, BindingCallable] = {}
7882
self._job_event_map: Dict[str, JobEventCallable] = {}
7983

@@ -120,8 +124,15 @@ def register_topic(
120124
dead_letter_topic: Optional[str] = None,
121125
rule: Optional[Rule] = None,
122126
disable_topic_validation: Optional[bool] = False,
127+
legacy_cloudevent: bool = True,
123128
) -> None:
124-
"""Registers topic subscription for pubsub."""
129+
"""Registers topic subscription for pubsub.
130+
131+
Args:
132+
legacy_cloudevent (bool): when True (deprecated default), the handler receives a
133+
``cloudevents.sdk.event.v1.Event``; when False, it receives a
134+
:class:`dapr.common.pubsub.subscription.SubscriptionMessage`.
135+
"""
125136
topic_key = pubsub_name + DELIMITER + topic
126137
pubsub_topic = topic_key + DELIMITER
127138
if rule is not None:
@@ -130,6 +141,7 @@ def register_topic(
130141
if pubsub_topic in self._topic_map:
131142
raise ValueError(f'{topic} is already registered with {pubsub_name}')
132143
self._topic_map[pubsub_topic] = cb
144+
self._topic_legacy_event[cb] = legacy_cloudevent
133145
routing_path = path if rule is not None else topic
134146
self._route_map[(pubsub_name, routing_path)] = cb
135147

@@ -233,21 +245,27 @@ def OnTopicEvent(self, request: TopicEventRequest, context):
233245
context.set_code(grpc.StatusCode.UNIMPLEMENTED) # type: ignore
234246
raise NotImplementedError(f'topic {request.topic} is not implemented!')
235247

236-
customdata: Struct = request.extensions
237-
extensions = dict()
238-
for k, v in customdata.items():
239-
extensions[k] = v
240-
for k, v in context.invocation_metadata():
241-
extensions['_metadata_' + k] = v
242-
243-
event = v1.Event()
244-
event.SetEventType(request.type)
245-
event.SetEventID(request.id)
246-
event.SetSource(request.source)
247-
event.SetData(request.data)
248-
event.SetContentType(request.data_content_type)
249-
event.SetSubject(request.topic)
250-
event.SetExtensions(extensions)
248+
invocation_metadata = dict(context.invocation_metadata())
249+
250+
event: Union[v1.Event, SubscriptionMessage]
251+
if self._topic_legacy_event.get(cb, True):
252+
customdata: Struct = request.extensions
253+
extensions = dict()
254+
for k, v in customdata.items():
255+
extensions[k] = v
256+
for k, v in invocation_metadata.items():
257+
extensions['_metadata_' + k] = v
258+
259+
event = v1.Event()
260+
event.SetEventType(request.type)
261+
event.SetEventID(request.id)
262+
event.SetSource(request.source)
263+
event.SetData(request.data)
264+
event.SetContentType(request.data_content_type)
265+
event.SetSubject(request.topic)
266+
event.SetExtensions(extensions)
267+
else:
268+
event = SubscriptionMessage(request, invocation_metadata)
251269

252270
response = cb(event)
253271
if isinstance(response, TopicEventResponse):
@@ -323,35 +341,20 @@ def _handle_bulk_topic_event(
323341
if cb is None:
324342
return None # we don't have a handler
325343

344+
use_legacy_event = self._topic_legacy_event.get(cb, True)
345+
invocation_metadata = dict(context.invocation_metadata())
346+
326347
statuses = []
327348
for entry in request.entries:
328349
entry_id = entry.entry_id
329350
try:
330-
# Build event from entry & send req with many entries
331-
event = v1.Event()
332-
extensions = dict()
333-
if entry.HasField('cloud_event') and entry.cloud_event:
334-
ce = entry.cloud_event
335-
event.SetEventType(ce.type)
336-
event.SetEventID(ce.id)
337-
event.SetSource(ce.source)
338-
event.SetData(ce.data)
339-
event.SetContentType(ce.data_content_type)
340-
if ce.extensions:
341-
for k, v in ce.extensions.items():
342-
extensions[k] = v
351+
event: Union[v1.Event, SubscriptionMessage]
352+
if use_legacy_event:
353+
event = self._bulk_entry_legacy_event(entry, request, invocation_metadata)
343354
else:
344-
event.SetEventID(entry_id)
345-
event.SetData(entry.bytes if entry.HasField('bytes') else b'')
346-
event.SetContentType(entry.content_type or '')
347-
event.SetSubject(request.topic)
348-
if entry.metadata:
349-
for k, v in entry.metadata.items():
350-
extensions[k] = v
351-
for k, v in context.invocation_metadata():
352-
extensions['_metadata_' + k] = v
353-
if extensions:
354-
event.SetExtensions(extensions)
355+
event = self._bulk_entry_subscription_message(
356+
entry, request, invocation_metadata
357+
)
355358

356359
response = cb(event) # invoke app registered handler and send event
357360
if isinstance(response, TopicEventResponse):
@@ -365,6 +368,70 @@ def _handle_bulk_topic_event(
365368
)
366369
return appcallback_v1.TopicEventBulkResponse(statuses=statuses)
367370

371+
def _bulk_entry_legacy_event(
372+
self,
373+
entry,
374+
request: TopicEventBulkRequest,
375+
invocation_metadata: Dict[str, str],
376+
) -> v1.Event:
377+
"""Builds the deprecated cloudevents v1.Event for a bulk entry."""
378+
event = v1.Event()
379+
extensions = dict()
380+
if entry.HasField('cloud_event') and entry.cloud_event:
381+
ce = entry.cloud_event
382+
event.SetEventType(ce.type)
383+
event.SetEventID(ce.id)
384+
event.SetSource(ce.source)
385+
event.SetData(ce.data)
386+
event.SetContentType(ce.data_content_type)
387+
if ce.extensions:
388+
for k, v in ce.extensions.items():
389+
extensions[k] = v
390+
else:
391+
event.SetEventID(entry.entry_id)
392+
event.SetData(entry.bytes if entry.HasField('bytes') else b'')
393+
event.SetContentType(entry.content_type or '')
394+
event.SetSubject(request.topic)
395+
if entry.metadata:
396+
for k, v in entry.metadata.items():
397+
extensions[k] = v
398+
for k, v in invocation_metadata.items():
399+
extensions['_metadata_' + k] = v
400+
if extensions:
401+
event.SetExtensions(extensions)
402+
return event
403+
404+
def _bulk_entry_subscription_message(
405+
self,
406+
entry,
407+
request: TopicEventBulkRequest,
408+
invocation_metadata: Dict[str, str],
409+
) -> SubscriptionMessage:
410+
"""Builds a SubscriptionMessage for a bulk entry via a synthesized TopicEventRequest."""
411+
if entry.HasField('cloud_event') and entry.cloud_event:
412+
ce = entry.cloud_event
413+
entry_request = TopicEventRequest(
414+
id=ce.id,
415+
source=ce.source,
416+
type=ce.type,
417+
spec_version=ce.spec_version,
418+
data_content_type=ce.data_content_type,
419+
data=ce.data,
420+
topic=request.topic,
421+
pubsub_name=request.pubsub_name,
422+
extensions=ce.extensions,
423+
)
424+
else:
425+
entry_request = TopicEventRequest(
426+
id=entry.entry_id,
427+
data=entry.bytes if entry.HasField('bytes') else b'',
428+
data_content_type=entry.content_type or '',
429+
topic=request.topic,
430+
pubsub_name=request.pubsub_name,
431+
)
432+
metadata = {**invocation_metadata, **dict(entry.metadata)}
433+
return SubscriptionMessage(entry_request, metadata)
434+
368435
def OnBulkTopicEvent(self, request: TopicEventBulkRequest, context):
369436
"""Subscribes bulk events from Pubsub"""
370437
response = self._handle_bulk_topic_event(request, context)

0 commit comments

Comments
 (0)