vercel-queue 0.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- vercel/queue/__init__.py +8 -0
- vercel/queue/__main__.py +10 -0
- vercel/queue/_internal/__init__.py +1 -0
- vercel/queue/_internal/api_async.py +219 -0
- vercel/queue/_internal/api_common.py +104 -0
- vercel/queue/_internal/api_sync.py +137 -0
- vercel/queue/_internal/asgi.py +145 -0
- vercel/queue/_internal/asynctools.py +40 -0
- vercel/queue/_internal/cli.py +313 -0
- vercel/queue/_internal/client.py +1109 -0
- vercel/queue/_internal/client_sync.py +432 -0
- vercel/queue/_internal/config.py +160 -0
- vercel/queue/_internal/constants.py +50 -0
- vercel/queue/_internal/devserver.py +205 -0
- vercel/queue/_internal/embedded.py +1900 -0
- vercel/queue/_internal/errors.py +262 -0
- vercel/queue/_internal/http.py +634 -0
- vercel/queue/_internal/lease.py +1007 -0
- vercel/queue/_internal/log.py +143 -0
- vercel/queue/_internal/messages.py +122 -0
- vercel/queue/_internal/multipart.py +255 -0
- vercel/queue/_internal/names.py +101 -0
- vercel/queue/_internal/polling.py +200 -0
- vercel/queue/_internal/push.py +246 -0
- vercel/queue/_internal/response.py +111 -0
- vercel/queue/_internal/retry.py +86 -0
- vercel/queue/_internal/streams.py +313 -0
- vercel/queue/_internal/subscribers.py +1025 -0
- vercel/queue/_internal/transports.py +363 -0
- vercel/queue/_internal/types.py +300 -0
- vercel/queue/_internal/typeutils.py +203 -0
- vercel/queue/devserver.py +24 -0
- vercel/queue/embedded.py +50 -0
- vercel/queue/py.typed +1 -0
- vercel/queue/sync.py +8 -0
- vercel/queue/testing/__init__.py +14 -0
- vercel/queue/testing/pytest.py +42 -0
- vercel/queue/testing/state.py +32 -0
- vercel/queue/version.py +3 -0
- vercel_queue-0.7.0.dist-info/METADATA +681 -0
- vercel_queue-0.7.0.dist-info/RECORD +43 -0
- vercel_queue-0.7.0.dist-info/WHEEL +4 -0
- vercel_queue-0.7.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1025 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Literal, ParamSpec, Protocol, TypeAlias, TypeVar, cast, overload
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import logging
|
|
7
|
+
import threading
|
|
8
|
+
import weakref
|
|
9
|
+
from collections.abc import Awaitable, Callable, Iterable, Mapping
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from importlib import import_module
|
|
12
|
+
from itertools import count
|
|
13
|
+
from types import MappingProxyType
|
|
14
|
+
|
|
15
|
+
from .errors import (
|
|
16
|
+
DuplicateSubscriptionError,
|
|
17
|
+
PayloadValidationError,
|
|
18
|
+
SubscriptionError,
|
|
19
|
+
UnhandledMessageError,
|
|
20
|
+
)
|
|
21
|
+
from .log import debug_enabled, debug_log_for_msg
|
|
22
|
+
from .names import (
|
|
23
|
+
SanitizedName,
|
|
24
|
+
normalize_name,
|
|
25
|
+
sanitize_name,
|
|
26
|
+
validate_subscription_pattern,
|
|
27
|
+
validate_topic_name,
|
|
28
|
+
)
|
|
29
|
+
from .transports import (
|
|
30
|
+
TransportKind,
|
|
31
|
+
is_untyped_payload_annotation,
|
|
32
|
+
payload_transport_kind,
|
|
33
|
+
reject_invalid_payload_annotation,
|
|
34
|
+
transport_for_kind,
|
|
35
|
+
)
|
|
36
|
+
from .types import (
|
|
37
|
+
Duration,
|
|
38
|
+
Message,
|
|
39
|
+
MessageMetadata,
|
|
40
|
+
QueueDirective,
|
|
41
|
+
RetryAfter,
|
|
42
|
+
StrContainer,
|
|
43
|
+
Topic,
|
|
44
|
+
Transport,
|
|
45
|
+
duration_to_seconds,
|
|
46
|
+
)
|
|
47
|
+
from .typeutils import (
|
|
48
|
+
ResolvedAnnotation,
|
|
49
|
+
TypeAnnotationResolutionError,
|
|
50
|
+
args,
|
|
51
|
+
origin_is,
|
|
52
|
+
resolve_annotation_with_namespace_from_call_stack,
|
|
53
|
+
strip_annotated,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
_Subscriber: TypeAlias = Callable[..., Any | Awaitable[Any]]
|
|
57
|
+
_SubscriberRef: TypeAlias = weakref.ReferenceType[_Subscriber]
|
|
58
|
+
P = ParamSpec("P")
|
|
59
|
+
R = TypeVar("R")
|
|
60
|
+
R_co = TypeVar("R_co", covariant=True)
|
|
61
|
+
T = TypeVar("T")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class PayloadAdapter(Protocol):
|
|
65
|
+
def validate_python(self, value: Any, /) -> Any: ...
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class EmbeddedDispatcher(Protocol):
|
|
69
|
+
def register_subscription(
|
|
70
|
+
self,
|
|
71
|
+
*,
|
|
72
|
+
topic: str,
|
|
73
|
+
consumer_group: str,
|
|
74
|
+
retry_after_seconds: int | None,
|
|
75
|
+
initial_delay_seconds: int | None,
|
|
76
|
+
max_concurrency: int | None,
|
|
77
|
+
max_attempts: int | None,
|
|
78
|
+
) -> None: ...
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class QueueSubscriber(Protocol[P, R_co]):
|
|
82
|
+
__module__: str
|
|
83
|
+
__qualname__: str
|
|
84
|
+
__vercel_queue_subscriber__: Literal[True]
|
|
85
|
+
|
|
86
|
+
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R_co: ...
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class _TypedTopicSubscriberDecorator(Protocol[T]):
|
|
90
|
+
@overload
|
|
91
|
+
def __call__(self, func: Callable[[T], R], /) -> QueueSubscriber[[T], R]: ...
|
|
92
|
+
|
|
93
|
+
@overload
|
|
94
|
+
def __call__(
|
|
95
|
+
self,
|
|
96
|
+
func: Callable[[Message[T]], R],
|
|
97
|
+
/,
|
|
98
|
+
) -> QueueSubscriber[[Message[T]], R]: ...
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
InvocationMode: TypeAlias = Literal["payload", "message"]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(frozen=True, kw_only=True)
|
|
105
|
+
class InvocationPlan:
|
|
106
|
+
payload_adapter: PayloadAdapter | None
|
|
107
|
+
mode: InvocationMode
|
|
108
|
+
transport_kind: TransportKind
|
|
109
|
+
|
|
110
|
+
def prepare_payload(self, payload: Any) -> Any:
|
|
111
|
+
if self.payload_adapter is None:
|
|
112
|
+
return payload
|
|
113
|
+
try:
|
|
114
|
+
return self.payload_adapter.validate_python(payload)
|
|
115
|
+
except Exception as exc:
|
|
116
|
+
# Keep pydantic optional and private to this implementation.
|
|
117
|
+
if exc.__class__.__name__ == "ValidationError":
|
|
118
|
+
raise PayloadValidationError(str(exc)) from exc
|
|
119
|
+
raise
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass(frozen=True, kw_only=True)
|
|
123
|
+
class _Subscription:
|
|
124
|
+
func_ref: _SubscriberRef
|
|
125
|
+
order: int
|
|
126
|
+
consumer_group: SanitizedName
|
|
127
|
+
invocation: InvocationPlan
|
|
128
|
+
topic: str
|
|
129
|
+
retry_after_seconds: int | None = None
|
|
130
|
+
initial_delay_seconds: int | None = None
|
|
131
|
+
max_concurrency: int | None = None
|
|
132
|
+
max_attempts: int | None = None
|
|
133
|
+
|
|
134
|
+
def func(self) -> _Subscriber | None:
|
|
135
|
+
return self.func_ref()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass(frozen=True, kw_only=True)
|
|
139
|
+
class _MatchedSubscription:
|
|
140
|
+
subscription: _Subscription
|
|
141
|
+
func: _Subscriber
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@dataclass(frozen=True, kw_only=True)
|
|
145
|
+
class _RegistrySnapshot:
|
|
146
|
+
subscriptions: tuple[_Subscription, ...]
|
|
147
|
+
wildcard_by_consumer: Mapping[str, tuple[_Subscription, ...]]
|
|
148
|
+
exact_by_consumer_topic: Mapping[tuple[str, str], tuple[_Subscription, ...]]
|
|
149
|
+
prefix_by_consumer: Mapping[str, tuple[_Subscription, ...]]
|
|
150
|
+
dispatchers: tuple[weakref.ReferenceType[EmbeddedDispatcher], ...]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
_EMPTY_SNAPSHOT = _RegistrySnapshot(
|
|
154
|
+
subscriptions=(),
|
|
155
|
+
wildcard_by_consumer={},
|
|
156
|
+
exact_by_consumer_topic={},
|
|
157
|
+
prefix_by_consumer={},
|
|
158
|
+
dispatchers=(),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
_registry_snapshot = _EMPTY_SNAPSHOT
|
|
162
|
+
_subscriptions_lock = threading.Lock()
|
|
163
|
+
_subscription_order = count()
|
|
164
|
+
_LOGGER = logging.getLogger("vercel.queue")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _build_registry_snapshot(
|
|
168
|
+
subscriptions: Iterable[_Subscription],
|
|
169
|
+
dispatchers: Iterable[weakref.ReferenceType[EmbeddedDispatcher]],
|
|
170
|
+
) -> _RegistrySnapshot:
|
|
171
|
+
live_subscriptions = tuple(sub for sub in subscriptions if sub.func() is not None)
|
|
172
|
+
live_dispatchers = tuple(ref for ref in dispatchers if ref() is not None)
|
|
173
|
+
|
|
174
|
+
wildcard: dict[str, list[_Subscription]] = {}
|
|
175
|
+
exact: dict[tuple[str, str], list[_Subscription]] = {}
|
|
176
|
+
prefix: dict[str, list[_Subscription]] = {}
|
|
177
|
+
for sub in live_subscriptions:
|
|
178
|
+
consumer_group = str(sub.consumer_group)
|
|
179
|
+
if sub.topic == "*":
|
|
180
|
+
wildcard.setdefault(consumer_group, []).append(sub)
|
|
181
|
+
elif sub.topic.endswith("*"):
|
|
182
|
+
prefix.setdefault(consumer_group, []).append(sub)
|
|
183
|
+
else:
|
|
184
|
+
exact.setdefault((consumer_group, sub.topic), []).append(sub)
|
|
185
|
+
|
|
186
|
+
return _RegistrySnapshot(
|
|
187
|
+
subscriptions=live_subscriptions,
|
|
188
|
+
wildcard_by_consumer=MappingProxyType({
|
|
189
|
+
key: tuple(value) for key, value in wildcard.items()
|
|
190
|
+
}),
|
|
191
|
+
exact_by_consumer_topic=MappingProxyType({
|
|
192
|
+
key: tuple(value) for key, value in exact.items()
|
|
193
|
+
}),
|
|
194
|
+
prefix_by_consumer=MappingProxyType({key: tuple(value) for key, value in prefix.items()}),
|
|
195
|
+
dispatchers=live_dispatchers,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _publish_registry_snapshot(snapshot: _RegistrySnapshot) -> None:
|
|
200
|
+
global _registry_snapshot # noqa: PLW0603
|
|
201
|
+
_registry_snapshot = snapshot
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _prune_registry_snapshot_locked() -> _RegistrySnapshot:
|
|
205
|
+
snapshot = _build_registry_snapshot(
|
|
206
|
+
_registry_snapshot.subscriptions,
|
|
207
|
+
_registry_snapshot.dispatchers,
|
|
208
|
+
)
|
|
209
|
+
_publish_registry_snapshot(snapshot)
|
|
210
|
+
return snapshot
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def register_embedded_dispatcher(dispatcher: EmbeddedDispatcher) -> None:
|
|
214
|
+
with _subscriptions_lock:
|
|
215
|
+
current = _prune_registry_snapshot_locked()
|
|
216
|
+
if any(ref() is dispatcher for ref in current.dispatchers):
|
|
217
|
+
return
|
|
218
|
+
updated = _build_registry_snapshot(
|
|
219
|
+
current.subscriptions,
|
|
220
|
+
(*current.dispatchers, weakref.ref(dispatcher)),
|
|
221
|
+
)
|
|
222
|
+
_publish_registry_snapshot(updated)
|
|
223
|
+
subscriptions = updated.subscriptions
|
|
224
|
+
for subscription in subscriptions:
|
|
225
|
+
dispatcher.register_subscription(
|
|
226
|
+
topic=subscription.topic,
|
|
227
|
+
consumer_group=str(subscription.consumer_group),
|
|
228
|
+
retry_after_seconds=subscription.retry_after_seconds,
|
|
229
|
+
initial_delay_seconds=subscription.initial_delay_seconds,
|
|
230
|
+
max_concurrency=subscription.max_concurrency,
|
|
231
|
+
max_attempts=subscription.max_attempts,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def unregister_embedded_dispatcher(dispatcher: EmbeddedDispatcher) -> None:
|
|
236
|
+
with _subscriptions_lock:
|
|
237
|
+
live: list[weakref.ReferenceType[EmbeddedDispatcher]] = []
|
|
238
|
+
for ref in _registry_snapshot.dispatchers:
|
|
239
|
+
live_dispatcher = ref()
|
|
240
|
+
if live_dispatcher is None or live_dispatcher is dispatcher:
|
|
241
|
+
continue
|
|
242
|
+
live.append(ref)
|
|
243
|
+
_publish_registry_snapshot(_build_registry_snapshot(_registry_snapshot.subscriptions, live))
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _clear_embedded_dispatchers() -> None:
|
|
247
|
+
with _subscriptions_lock:
|
|
248
|
+
_publish_registry_snapshot(_build_registry_snapshot(_registry_snapshot.subscriptions, ()))
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def clear_subscriptions_for_tests() -> None:
|
|
252
|
+
with _subscriptions_lock:
|
|
253
|
+
_publish_registry_snapshot(_EMPTY_SNAPSHOT)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _notify_embedded_dispatchers(subscription: _Subscription) -> None:
|
|
257
|
+
dispatchers: list[EmbeddedDispatcher] = []
|
|
258
|
+
with _subscriptions_lock:
|
|
259
|
+
live: list[weakref.ReferenceType[EmbeddedDispatcher]] = []
|
|
260
|
+
for ref in _registry_snapshot.dispatchers:
|
|
261
|
+
dispatcher = ref()
|
|
262
|
+
if dispatcher is None:
|
|
263
|
+
continue
|
|
264
|
+
live.append(ref)
|
|
265
|
+
dispatchers.append(dispatcher)
|
|
266
|
+
if len(live) != len(_registry_snapshot.dispatchers):
|
|
267
|
+
_publish_registry_snapshot(
|
|
268
|
+
_build_registry_snapshot(_registry_snapshot.subscriptions, live)
|
|
269
|
+
)
|
|
270
|
+
for dispatcher in dispatchers:
|
|
271
|
+
dispatcher.register_subscription(
|
|
272
|
+
topic=subscription.topic,
|
|
273
|
+
consumer_group=str(subscription.consumer_group),
|
|
274
|
+
retry_after_seconds=subscription.retry_after_seconds,
|
|
275
|
+
initial_delay_seconds=subscription.initial_delay_seconds,
|
|
276
|
+
max_concurrency=subscription.max_concurrency,
|
|
277
|
+
max_attempts=subscription.max_attempts,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
@dataclass(frozen=True, kw_only=True)
|
|
282
|
+
class Subscription:
|
|
283
|
+
"""Deployment trigger metadata for a registered queue subscriber."""
|
|
284
|
+
|
|
285
|
+
func: Callable[..., Any]
|
|
286
|
+
topic: str
|
|
287
|
+
consumer_group: str
|
|
288
|
+
retry_after_seconds: int | None = None
|
|
289
|
+
initial_delay_seconds: int | None = None
|
|
290
|
+
max_concurrency: int | None = None
|
|
291
|
+
max_attempts: int | None = None
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _subscriber_ref(func: _Subscriber) -> _SubscriberRef:
|
|
295
|
+
try:
|
|
296
|
+
return weakref.WeakMethod(func) # type: ignore[arg-type]
|
|
297
|
+
except TypeError:
|
|
298
|
+
return weakref.ref(func)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _is_message_annotation(annotation: Any) -> bool:
|
|
302
|
+
annotation = strip_annotated(annotation)
|
|
303
|
+
return annotation is Message or origin_is(annotation, Message)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _message_payload_annotation(annotation: Any) -> Any:
|
|
307
|
+
annotation = strip_annotated(annotation)
|
|
308
|
+
if annotation is Message:
|
|
309
|
+
return inspect.Signature.empty
|
|
310
|
+
message_args = args(annotation)
|
|
311
|
+
if not message_args:
|
|
312
|
+
return inspect.Signature.empty
|
|
313
|
+
return message_args[0]
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _topic_payload_annotation(topic: str | SanitizedName | Topic[Any]) -> Any:
|
|
317
|
+
if not isinstance(topic, Topic):
|
|
318
|
+
return inspect.Signature.empty
|
|
319
|
+
if getattr(type(topic), "__topic_origin__", None) is not Topic:
|
|
320
|
+
return inspect.Signature.empty
|
|
321
|
+
return type(topic).__topic_payload_type__
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _normalize_subscription_topic(topic: str | SanitizedName | Topic[Any]) -> str:
|
|
325
|
+
if isinstance(topic, SanitizedName):
|
|
326
|
+
return str(topic)
|
|
327
|
+
if isinstance(topic, str):
|
|
328
|
+
validate_subscription_pattern(topic)
|
|
329
|
+
return topic
|
|
330
|
+
if isinstance(topic, Topic):
|
|
331
|
+
return validate_topic_name(topic)
|
|
332
|
+
raise TypeError("topic must be a string or Topic")
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _resolve_invocation_payload_annotation(
|
|
336
|
+
handler_annotation: Any,
|
|
337
|
+
topic_annotation: Any,
|
|
338
|
+
) -> Any:
|
|
339
|
+
handler_annotation = strip_annotated(handler_annotation)
|
|
340
|
+
topic_annotation = strip_annotated(topic_annotation)
|
|
341
|
+
if is_untyped_payload_annotation(topic_annotation):
|
|
342
|
+
return handler_annotation
|
|
343
|
+
if is_untyped_payload_annotation(handler_annotation):
|
|
344
|
+
return topic_annotation
|
|
345
|
+
if handler_annotation != topic_annotation:
|
|
346
|
+
raise SubscriptionError(
|
|
347
|
+
"queue subscriber payload annotation "
|
|
348
|
+
f"{handler_annotation!r} is incompatible with topic payload "
|
|
349
|
+
f"annotation {topic_annotation!r}"
|
|
350
|
+
)
|
|
351
|
+
return handler_annotation
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _payload_adapter(
|
|
355
|
+
annotation: Any,
|
|
356
|
+
*,
|
|
357
|
+
localns: dict[str, Any] | None = None,
|
|
358
|
+
) -> PayloadAdapter | None:
|
|
359
|
+
annotation = strip_annotated(annotation)
|
|
360
|
+
if is_untyped_payload_annotation(annotation):
|
|
361
|
+
return None
|
|
362
|
+
reject_invalid_payload_annotation(annotation)
|
|
363
|
+
if _transport_kind(annotation) != "json":
|
|
364
|
+
return None
|
|
365
|
+
try:
|
|
366
|
+
type_adapter = import_module("pydantic").TypeAdapter
|
|
367
|
+
except ImportError as exc:
|
|
368
|
+
raise RuntimeError(
|
|
369
|
+
"Typed queue subscribers require pydantic. Install `vercel-queue[typed]` "
|
|
370
|
+
"or remove the payload type annotation."
|
|
371
|
+
) from exc
|
|
372
|
+
adapter = type_adapter(annotation)
|
|
373
|
+
if localns and not adapter.pydantic_complete:
|
|
374
|
+
adapter.rebuild(_types_namespace=localns, raise_errors=False)
|
|
375
|
+
if not adapter.pydantic_complete:
|
|
376
|
+
raise SubscriptionError(
|
|
377
|
+
f"could not resolve queue subscriber payload model annotations for {annotation!r}"
|
|
378
|
+
)
|
|
379
|
+
return adapter
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _transport_kind(annotation: Any) -> TransportKind:
|
|
383
|
+
return payload_transport_kind(annotation)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _resolve_payload_annotation(
|
|
387
|
+
func: _Subscriber,
|
|
388
|
+
payload_param: inspect.Parameter,
|
|
389
|
+
) -> ResolvedAnnotation:
|
|
390
|
+
annotation = payload_param.annotation
|
|
391
|
+
if annotation in {inspect.Signature.empty, Any}:
|
|
392
|
+
return ResolvedAnnotation(annotation)
|
|
393
|
+
try:
|
|
394
|
+
return resolve_annotation_with_namespace_from_call_stack(
|
|
395
|
+
annotation,
|
|
396
|
+
globalns=getattr(func, "__globals__", {}),
|
|
397
|
+
)
|
|
398
|
+
except TypeAnnotationResolutionError as exc:
|
|
399
|
+
raise SubscriptionError(
|
|
400
|
+
f"could not resolve queue subscriber type annotations for "
|
|
401
|
+
f"{getattr(func, '__qualname__', func)!r}"
|
|
402
|
+
) from exc.__cause__
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _build_invocation_plan(
|
|
406
|
+
func: _Subscriber,
|
|
407
|
+
*,
|
|
408
|
+
topic_payload_annotation: Any = inspect.Signature.empty,
|
|
409
|
+
) -> InvocationPlan:
|
|
410
|
+
signature = inspect.signature(func)
|
|
411
|
+
input_params: list[inspect.Parameter] = []
|
|
412
|
+
for param in signature.parameters.values():
|
|
413
|
+
if param.kind in {
|
|
414
|
+
inspect.Parameter.VAR_POSITIONAL,
|
|
415
|
+
inspect.Parameter.VAR_KEYWORD,
|
|
416
|
+
}:
|
|
417
|
+
raise SubscriptionError("queue subscriber must not accept *args or **kwargs")
|
|
418
|
+
if param.kind is inspect.Parameter.KEYWORD_ONLY:
|
|
419
|
+
if param.default is inspect.Parameter.empty:
|
|
420
|
+
raise SubscriptionError(
|
|
421
|
+
"queue subscriber keyword-only parameters must have defaults"
|
|
422
|
+
)
|
|
423
|
+
continue
|
|
424
|
+
if param.default is inspect.Parameter.empty:
|
|
425
|
+
input_params.append(param)
|
|
426
|
+
|
|
427
|
+
if len(input_params) != 1:
|
|
428
|
+
raise SubscriptionError(
|
|
429
|
+
"queue subscriber must accept exactly one required payload parameter"
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
payload_param = input_params[0]
|
|
433
|
+
resolved_annotation = _resolve_payload_annotation(func, payload_param)
|
|
434
|
+
annotation = resolved_annotation.annotation
|
|
435
|
+
mode: InvocationMode = "message" if _is_message_annotation(annotation) else "payload"
|
|
436
|
+
payload_annotation = (
|
|
437
|
+
_message_payload_annotation(annotation) if mode == "message" else annotation
|
|
438
|
+
)
|
|
439
|
+
payload_annotation = _resolve_invocation_payload_annotation(
|
|
440
|
+
payload_annotation,
|
|
441
|
+
topic_payload_annotation,
|
|
442
|
+
)
|
|
443
|
+
reject_invalid_payload_annotation(payload_annotation)
|
|
444
|
+
return InvocationPlan(
|
|
445
|
+
payload_adapter=_payload_adapter(
|
|
446
|
+
payload_annotation,
|
|
447
|
+
localns=resolved_annotation.localns,
|
|
448
|
+
),
|
|
449
|
+
mode=mode,
|
|
450
|
+
transport_kind=_transport_kind(payload_annotation),
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _call_subscription(
|
|
455
|
+
matched: _MatchedSubscription,
|
|
456
|
+
message: Any,
|
|
457
|
+
metadata: MessageMetadata,
|
|
458
|
+
) -> Any:
|
|
459
|
+
invocation = matched.subscription.invocation
|
|
460
|
+
|
|
461
|
+
payload = invocation.prepare_payload(message)
|
|
462
|
+
if debug_enabled():
|
|
463
|
+
debug_log_for_msg(
|
|
464
|
+
"message.handler_start",
|
|
465
|
+
metadata,
|
|
466
|
+
handler=_handler_name(matched.func),
|
|
467
|
+
)
|
|
468
|
+
if invocation.mode == "message":
|
|
469
|
+
return matched.func(Message(payload=payload, metadata=metadata))
|
|
470
|
+
return matched.func(payload)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _handler_name(func: _Subscriber) -> str:
|
|
474
|
+
module = getattr(func, "__module__", type(func).__module__)
|
|
475
|
+
qualname = getattr(func, "__qualname__", type(func).__qualname__)
|
|
476
|
+
return f"{module}.{qualname}"
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _log_handler_exception(exc: BaseException, metadata: MessageMetadata) -> None:
|
|
480
|
+
_LOGGER.error(
|
|
481
|
+
"queue subscriber failed while polling topic %r for consumer group %r",
|
|
482
|
+
metadata.topic,
|
|
483
|
+
metadata.consumer_group,
|
|
484
|
+
exc_info=(type(exc), exc, exc.__traceback__),
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _subscription_for_func(func: _Subscriber) -> _MatchedSubscription:
|
|
489
|
+
with _subscriptions_lock:
|
|
490
|
+
snapshot = _prune_registry_snapshot_locked()
|
|
491
|
+
matches: list[_MatchedSubscription] = []
|
|
492
|
+
for sub in snapshot.subscriptions:
|
|
493
|
+
candidate = sub.func()
|
|
494
|
+
if candidate is func:
|
|
495
|
+
matches.append(_MatchedSubscription(subscription=sub, func=candidate))
|
|
496
|
+
if not matches:
|
|
497
|
+
raise SubscriptionError("queue subscriber is not registered")
|
|
498
|
+
if len(matches) > 1:
|
|
499
|
+
raise SubscriptionError("queue subscriber has multiple registrations")
|
|
500
|
+
return matches[0]
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _subscription_matches_topic(subscription: _Subscription, topic: str) -> bool:
|
|
504
|
+
pattern = subscription.topic
|
|
505
|
+
if pattern == "*":
|
|
506
|
+
return True
|
|
507
|
+
if pattern.endswith("*"):
|
|
508
|
+
return topic.startswith(pattern[:-1])
|
|
509
|
+
return topic == pattern
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
async def call_subscriber(
|
|
513
|
+
subscriber: _Subscriber,
|
|
514
|
+
message: Message[Any],
|
|
515
|
+
) -> None:
|
|
516
|
+
matched = _subscription_for_func(subscriber)
|
|
517
|
+
if not _subscription_matches_topic(matched.subscription, message.metadata.topic):
|
|
518
|
+
raise UnhandledMessageError(message.metadata.topic)
|
|
519
|
+
try:
|
|
520
|
+
await _maybe_await_result(_call_subscription(matched, message.payload, message.metadata))
|
|
521
|
+
except QueueDirective:
|
|
522
|
+
raise
|
|
523
|
+
except Exception as exc: # noqa: BLE001
|
|
524
|
+
_log_handler_exception(exc, message.metadata)
|
|
525
|
+
raise RetryAfter from None
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def call_subscriber_sync(
|
|
529
|
+
subscriber: _Subscriber,
|
|
530
|
+
message: Message[Any],
|
|
531
|
+
) -> None:
|
|
532
|
+
matched = _subscription_for_func(subscriber)
|
|
533
|
+
if not _subscription_matches_topic(matched.subscription, message.metadata.topic):
|
|
534
|
+
raise UnhandledMessageError(message.metadata.topic)
|
|
535
|
+
try:
|
|
536
|
+
_call_subscription(matched, message.payload, message.metadata)
|
|
537
|
+
except QueueDirective:
|
|
538
|
+
raise
|
|
539
|
+
except Exception as exc: # noqa: BLE001
|
|
540
|
+
_log_handler_exception(exc, message.metadata)
|
|
541
|
+
raise RetryAfter from None
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def reject_async_subscriber_for_sync(subscriber: _Subscriber) -> None:
|
|
545
|
+
matched = _subscription_for_func(subscriber)
|
|
546
|
+
if inspect.iscoroutinefunction(matched.func):
|
|
547
|
+
raise RuntimeError("async subscribers must be polled with an async polling loop")
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def poll_targets_for_subscriber(
|
|
551
|
+
subscriber: _Subscriber,
|
|
552
|
+
topics: StrContainer | None,
|
|
553
|
+
) -> tuple[tuple[str, SanitizedName], ...]:
|
|
554
|
+
matched = _subscription_for_func(subscriber)
|
|
555
|
+
subscription = matched.subscription
|
|
556
|
+
if topics is None:
|
|
557
|
+
if subscription.topic == "*" or subscription.topic.endswith("*"):
|
|
558
|
+
raise SubscriptionError(
|
|
559
|
+
"queue subscriber uses a wildcard topic pattern; pass concrete topics"
|
|
560
|
+
)
|
|
561
|
+
return ((subscription.topic, subscription.consumer_group),)
|
|
562
|
+
|
|
563
|
+
if isinstance(topics, str):
|
|
564
|
+
raise TypeError("topics must be an iterable of topic strings, not a string")
|
|
565
|
+
|
|
566
|
+
targets: list[tuple[str, SanitizedName]] = []
|
|
567
|
+
seen: set[str] = set()
|
|
568
|
+
for topic in topics:
|
|
569
|
+
if not isinstance(topic, str):
|
|
570
|
+
raise TypeError("topics must contain only strings")
|
|
571
|
+
resolved_topic = validate_topic_name(topic)
|
|
572
|
+
if resolved_topic in seen:
|
|
573
|
+
continue
|
|
574
|
+
seen.add(resolved_topic)
|
|
575
|
+
if not _subscription_matches_topic(subscription, resolved_topic):
|
|
576
|
+
raise SubscriptionError(
|
|
577
|
+
f"topic {resolved_topic!r} does not match subscriber topic pattern "
|
|
578
|
+
f"{subscription.topic!r}"
|
|
579
|
+
)
|
|
580
|
+
targets.append((resolved_topic, subscription.consumer_group))
|
|
581
|
+
if not targets:
|
|
582
|
+
raise SubscriptionError("topics must contain at least one topic")
|
|
583
|
+
return tuple(targets)
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _transport_for_kind(kind: TransportKind) -> Transport[Any]:
|
|
587
|
+
return transport_for_kind(kind)
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def infer_subscriber_transport(metadata: MessageMetadata) -> Transport[Any]:
|
|
591
|
+
matching = _matching_subscriptions(metadata)
|
|
592
|
+
if not matching:
|
|
593
|
+
raise _no_matching_subscriptions_error(metadata.topic)
|
|
594
|
+
|
|
595
|
+
kinds = {matched.subscription.invocation.transport_kind for matched in matching}
|
|
596
|
+
if len(kinds) != 1:
|
|
597
|
+
raise SubscriptionError(
|
|
598
|
+
"matching queue subscribers require incompatible payload transports: "
|
|
599
|
+
+ ", ".join(sorted(kinds))
|
|
600
|
+
)
|
|
601
|
+
return _transport_for_kind(kinds.pop())
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
async def _maybe_await_result(result: Any) -> Any:
|
|
605
|
+
if inspect.isawaitable(result):
|
|
606
|
+
return await result
|
|
607
|
+
return result
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _default_consumer_group(func: _Subscriber) -> SanitizedName:
|
|
611
|
+
module = getattr(func, "__module__", None)
|
|
612
|
+
qualname = getattr(func, "__qualname__", getattr(func, "__name__", "subscriber"))
|
|
613
|
+
if module:
|
|
614
|
+
return sanitize_name(f"{module}.{qualname}")
|
|
615
|
+
return sanitize_name(str(qualname))
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def _fully_qualified_handler_name(func: _Subscriber) -> str:
|
|
619
|
+
module = getattr(func, "__module__", None)
|
|
620
|
+
qualname = getattr(func, "__qualname__", getattr(func, "__name__", repr(func)))
|
|
621
|
+
if module:
|
|
622
|
+
return f"{module}.{qualname}"
|
|
623
|
+
return str(qualname)
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _optional_non_negative_int(name: str, value: int | None) -> int | None:
|
|
627
|
+
if value is None:
|
|
628
|
+
return None
|
|
629
|
+
if not isinstance(value, int) or isinstance(value, bool):
|
|
630
|
+
raise TypeError(f"{name} must be an integer")
|
|
631
|
+
if value < 0:
|
|
632
|
+
raise ValueError(f"{name} must be non-negative")
|
|
633
|
+
return value
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def _optional_non_negative_duration(name: str, value: Duration | None) -> int | None:
|
|
637
|
+
if value is None:
|
|
638
|
+
return None
|
|
639
|
+
seconds = duration_to_seconds(value)
|
|
640
|
+
if seconds < 0:
|
|
641
|
+
raise ValueError(f"{name} must be non-negative")
|
|
642
|
+
return seconds
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def _optional_bounded_duration(
|
|
646
|
+
name: str,
|
|
647
|
+
value: Duration | None,
|
|
648
|
+
*,
|
|
649
|
+
minimum: int,
|
|
650
|
+
maximum: int,
|
|
651
|
+
) -> int | None:
|
|
652
|
+
seconds = _optional_non_negative_duration(name, value)
|
|
653
|
+
if seconds is None:
|
|
654
|
+
return None
|
|
655
|
+
if seconds < minimum or seconds > maximum:
|
|
656
|
+
raise ValueError(f"{name} must be between {minimum} and {maximum} seconds")
|
|
657
|
+
return seconds
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _topic_prefix(pattern: str) -> str | None:
|
|
661
|
+
if pattern == "*":
|
|
662
|
+
return ""
|
|
663
|
+
if pattern.endswith("*"):
|
|
664
|
+
return pattern[:-1]
|
|
665
|
+
return None
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def _subscription_patterns_overlap(first: str, second: str) -> bool:
|
|
669
|
+
first_prefix = _topic_prefix(first)
|
|
670
|
+
second_prefix = _topic_prefix(second)
|
|
671
|
+
if first_prefix is None and second_prefix is None:
|
|
672
|
+
return first == second
|
|
673
|
+
if first_prefix is None:
|
|
674
|
+
return first.startswith(cast("str", second_prefix))
|
|
675
|
+
if second_prefix is None:
|
|
676
|
+
return second.startswith(first_prefix)
|
|
677
|
+
return first_prefix.startswith(second_prefix) or second_prefix.startswith(first_prefix)
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def _validate_subscription_is_unambiguous(
|
|
681
|
+
subscription: _Subscription,
|
|
682
|
+
existing_subscriptions: Iterable[_Subscription],
|
|
683
|
+
) -> None:
|
|
684
|
+
for existing in existing_subscriptions:
|
|
685
|
+
existing_func = existing.func()
|
|
686
|
+
if existing_func is None:
|
|
687
|
+
continue
|
|
688
|
+
if existing.consumer_group != subscription.consumer_group:
|
|
689
|
+
continue
|
|
690
|
+
if not _subscription_patterns_overlap(subscription.topic, existing.topic):
|
|
691
|
+
continue
|
|
692
|
+
raise DuplicateSubscriptionError(
|
|
693
|
+
"queue subscriber topic pattern "
|
|
694
|
+
f"{subscription.topic!r} overlaps existing topic pattern "
|
|
695
|
+
f"{existing.topic!r} for consumer group {subscription.consumer_group!r}; "
|
|
696
|
+
f"conflicting handler: {_fully_qualified_handler_name(existing_func)}"
|
|
697
|
+
)
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def _register_subscription(
|
|
701
|
+
func: Callable[P, R],
|
|
702
|
+
*,
|
|
703
|
+
consumer_group: str | SanitizedName | None = None,
|
|
704
|
+
topic: str | SanitizedName | Topic[Any],
|
|
705
|
+
retry_after: Duration | None = None,
|
|
706
|
+
initial_delay: Duration | None = None,
|
|
707
|
+
max_concurrency: int | None = None,
|
|
708
|
+
max_attempts: int | None = None,
|
|
709
|
+
) -> QueueSubscriber[P, R]:
|
|
710
|
+
topic_name = _normalize_subscription_topic(topic)
|
|
711
|
+
topic_payload_annotation = _topic_payload_annotation(topic)
|
|
712
|
+
|
|
713
|
+
resolved_consumer_group = (
|
|
714
|
+
_default_consumer_group(func)
|
|
715
|
+
if consumer_group is None
|
|
716
|
+
else normalize_name(
|
|
717
|
+
consumer_group,
|
|
718
|
+
field="consumer_group",
|
|
719
|
+
)
|
|
720
|
+
)
|
|
721
|
+
subscription = _Subscription(
|
|
722
|
+
func_ref=_subscriber_ref(cast("_Subscriber", func)),
|
|
723
|
+
order=next(_subscription_order),
|
|
724
|
+
consumer_group=resolved_consumer_group,
|
|
725
|
+
invocation=_build_invocation_plan(
|
|
726
|
+
cast("_Subscriber", func),
|
|
727
|
+
topic_payload_annotation=topic_payload_annotation,
|
|
728
|
+
),
|
|
729
|
+
topic=topic_name,
|
|
730
|
+
retry_after_seconds=_optional_bounded_duration(
|
|
731
|
+
"retry_after",
|
|
732
|
+
retry_after,
|
|
733
|
+
minimum=1,
|
|
734
|
+
maximum=86400,
|
|
735
|
+
),
|
|
736
|
+
initial_delay_seconds=_optional_bounded_duration(
|
|
737
|
+
"initial_delay",
|
|
738
|
+
initial_delay,
|
|
739
|
+
minimum=0,
|
|
740
|
+
maximum=86400,
|
|
741
|
+
),
|
|
742
|
+
max_concurrency=_optional_non_negative_int("max_concurrency", max_concurrency),
|
|
743
|
+
max_attempts=_optional_non_negative_int("max_attempts", max_attempts),
|
|
744
|
+
)
|
|
745
|
+
with _subscriptions_lock:
|
|
746
|
+
current = _prune_registry_snapshot_locked()
|
|
747
|
+
_validate_subscription_is_unambiguous(subscription, current.subscriptions)
|
|
748
|
+
updated = _build_registry_snapshot(
|
|
749
|
+
(*current.subscriptions, subscription),
|
|
750
|
+
current.dispatchers,
|
|
751
|
+
)
|
|
752
|
+
_publish_registry_snapshot(updated)
|
|
753
|
+
_notify_embedded_dispatchers(subscription)
|
|
754
|
+
return cast("QueueSubscriber[P, R]", func)
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
@overload
|
|
758
|
+
def subscribe(func: Callable[P, R], /, *, topic: str) -> QueueSubscriber[P, R]: ...
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
@overload
|
|
762
|
+
def subscribe(
|
|
763
|
+
func: Callable[P, R],
|
|
764
|
+
/,
|
|
765
|
+
*,
|
|
766
|
+
topic: SanitizedName,
|
|
767
|
+
) -> QueueSubscriber[P, R]: ...
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
@overload
|
|
771
|
+
def subscribe(func: Callable[[T], R], /, *, topic: Topic[T]) -> QueueSubscriber[[T], R]: ...
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
@overload
|
|
775
|
+
def subscribe(
|
|
776
|
+
func: Callable[[Message[T]], R],
|
|
777
|
+
/,
|
|
778
|
+
*,
|
|
779
|
+
topic: Topic[T],
|
|
780
|
+
) -> QueueSubscriber[[Message[T]], R]: ...
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
@overload
|
|
784
|
+
def subscribe(
|
|
785
|
+
*,
|
|
786
|
+
topic: str,
|
|
787
|
+
consumer_group: str | SanitizedName | None = None,
|
|
788
|
+
retry_after: Duration | None = None,
|
|
789
|
+
initial_delay: Duration | None = None,
|
|
790
|
+
max_concurrency: int | None = None,
|
|
791
|
+
max_attempts: int | None = None,
|
|
792
|
+
) -> Callable[[Callable[P, R]], QueueSubscriber[P, R]]: ...
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
@overload
|
|
796
|
+
def subscribe(
|
|
797
|
+
*,
|
|
798
|
+
topic: SanitizedName,
|
|
799
|
+
consumer_group: str | SanitizedName | None = None,
|
|
800
|
+
retry_after: Duration | None = None,
|
|
801
|
+
initial_delay: Duration | None = None,
|
|
802
|
+
max_concurrency: int | None = None,
|
|
803
|
+
max_attempts: int | None = None,
|
|
804
|
+
) -> Callable[[Callable[P, R]], QueueSubscriber[P, R]]: ...
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
@overload
|
|
808
|
+
def subscribe(
|
|
809
|
+
*,
|
|
810
|
+
topic: Topic[T],
|
|
811
|
+
consumer_group: str | SanitizedName | None = None,
|
|
812
|
+
retry_after: Duration | None = None,
|
|
813
|
+
initial_delay: Duration | None = None,
|
|
814
|
+
max_concurrency: int | None = None,
|
|
815
|
+
max_attempts: int | None = None,
|
|
816
|
+
) -> _TypedTopicSubscriberDecorator[T]: ...
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
@overload
|
|
820
|
+
def subscribe(
|
|
821
|
+
func: None,
|
|
822
|
+
/,
|
|
823
|
+
*,
|
|
824
|
+
topic: str,
|
|
825
|
+
consumer_group: str | SanitizedName | None = None,
|
|
826
|
+
retry_after: Duration | None = None,
|
|
827
|
+
initial_delay: Duration | None = None,
|
|
828
|
+
max_concurrency: int | None = None,
|
|
829
|
+
max_attempts: int | None = None,
|
|
830
|
+
) -> Callable[[Callable[P, R]], QueueSubscriber[P, R]]: ...
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
@overload
|
|
834
|
+
def subscribe(
|
|
835
|
+
func: None,
|
|
836
|
+
/,
|
|
837
|
+
*,
|
|
838
|
+
topic: SanitizedName,
|
|
839
|
+
consumer_group: str | SanitizedName | None = None,
|
|
840
|
+
retry_after: Duration | None = None,
|
|
841
|
+
initial_delay: Duration | None = None,
|
|
842
|
+
max_concurrency: int | None = None,
|
|
843
|
+
max_attempts: int | None = None,
|
|
844
|
+
) -> Callable[[Callable[P, R]], QueueSubscriber[P, R]]: ...
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
@overload
|
|
848
|
+
def subscribe(
|
|
849
|
+
func: None,
|
|
850
|
+
/,
|
|
851
|
+
*,
|
|
852
|
+
topic: Topic[T],
|
|
853
|
+
consumer_group: str | SanitizedName | None = None,
|
|
854
|
+
retry_after: Duration | None = None,
|
|
855
|
+
initial_delay: Duration | None = None,
|
|
856
|
+
max_concurrency: int | None = None,
|
|
857
|
+
max_attempts: int | None = None,
|
|
858
|
+
) -> _TypedTopicSubscriberDecorator[T]: ...
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
def subscribe(
|
|
862
|
+
func: _Subscriber | None = None,
|
|
863
|
+
/,
|
|
864
|
+
*,
|
|
865
|
+
topic: str | SanitizedName | Topic[Any],
|
|
866
|
+
consumer_group: str | SanitizedName | None = None,
|
|
867
|
+
retry_after: Duration | None = None,
|
|
868
|
+
initial_delay: Duration | None = None,
|
|
869
|
+
max_concurrency: int | None = None,
|
|
870
|
+
max_attempts: int | None = None,
|
|
871
|
+
) -> _Subscriber | Callable[[_Subscriber], _Subscriber]:
|
|
872
|
+
"""Register a function as a queue subscriber.
|
|
873
|
+
|
|
874
|
+
Args:
|
|
875
|
+
func: Function being decorated when ``@subscribe(topic=...)`` is used.
|
|
876
|
+
topic: Required topic filter for this subscriber. A string matches exactly,
|
|
877
|
+
a string ending in ``*`` matches by prefix, and ``"*"`` matches every
|
|
878
|
+
topic.
|
|
879
|
+
consumer_group: Local/in-process consumer group override. When omitted,
|
|
880
|
+
the SDK derives one from the function's fully-qualified Python name.
|
|
881
|
+
retry_after: Optional base retry delay for generated queue trigger
|
|
882
|
+
configuration.
|
|
883
|
+
initial_delay: Optional deploy-time delay before generated queue
|
|
884
|
+
consumers start processing.
|
|
885
|
+
max_concurrency: Optional push dispatcher concurrency cap.
|
|
886
|
+
max_attempts: Optional push dispatcher delivery attempt cap.
|
|
887
|
+
|
|
888
|
+
Returns:
|
|
889
|
+
The original function, or a decorator when called with arguments.
|
|
890
|
+
|
|
891
|
+
Raises:
|
|
892
|
+
RuntimeError: If the subscriber has typed payload validation but
|
|
893
|
+
pydantic is not installed.
|
|
894
|
+
TypeError: If the subscriber signature cannot accept a payload.
|
|
895
|
+
ValueError: If ``consumer_group`` is an empty string.
|
|
896
|
+
|
|
897
|
+
"""
|
|
898
|
+
|
|
899
|
+
def decorator(f: Callable[P, R]) -> Callable[P, R]:
|
|
900
|
+
return _register_subscription(
|
|
901
|
+
f,
|
|
902
|
+
consumer_group=consumer_group,
|
|
903
|
+
topic=topic,
|
|
904
|
+
retry_after=retry_after,
|
|
905
|
+
initial_delay=initial_delay,
|
|
906
|
+
max_concurrency=max_concurrency,
|
|
907
|
+
max_attempts=max_attempts,
|
|
908
|
+
)
|
|
909
|
+
|
|
910
|
+
return decorator(func) if func is not None else decorator
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
def get_subscriptions() -> tuple[Subscription, ...]:
|
|
914
|
+
"""Return deployment trigger metadata for registered subscribers."""
|
|
915
|
+
subscriptions: list[Subscription] = []
|
|
916
|
+
with _subscriptions_lock:
|
|
917
|
+
snapshot = _prune_registry_snapshot_locked()
|
|
918
|
+
for sub in snapshot.subscriptions:
|
|
919
|
+
func = sub.func()
|
|
920
|
+
if func is not None:
|
|
921
|
+
subscriptions.append(
|
|
922
|
+
Subscription(
|
|
923
|
+
func=func,
|
|
924
|
+
topic=sub.topic,
|
|
925
|
+
consumer_group=str(sub.consumer_group),
|
|
926
|
+
retry_after_seconds=sub.retry_after_seconds,
|
|
927
|
+
initial_delay_seconds=sub.initial_delay_seconds,
|
|
928
|
+
max_concurrency=sub.max_concurrency,
|
|
929
|
+
max_attempts=sub.max_attempts,
|
|
930
|
+
)
|
|
931
|
+
)
|
|
932
|
+
return tuple(subscriptions)
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
def _no_matching_subscriptions_error(
|
|
936
|
+
topic: str | None,
|
|
937
|
+
consumer_group: str | None = None,
|
|
938
|
+
) -> UnhandledMessageError:
|
|
939
|
+
return UnhandledMessageError(topic, consumer_group)
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
def _matching_prefix_subscriptions(
|
|
943
|
+
snapshot: _RegistrySnapshot,
|
|
944
|
+
*,
|
|
945
|
+
topic: str,
|
|
946
|
+
consumer_group: str,
|
|
947
|
+
) -> tuple[_Subscription, ...]:
|
|
948
|
+
return tuple(
|
|
949
|
+
sub
|
|
950
|
+
for sub in snapshot.prefix_by_consumer.get(consumer_group, ())
|
|
951
|
+
if topic.startswith(sub.topic[:-1])
|
|
952
|
+
)
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
def _merge_candidates_in_order(
|
|
956
|
+
first: tuple[_Subscription, ...],
|
|
957
|
+
second: tuple[_Subscription, ...],
|
|
958
|
+
third: tuple[_Subscription, ...],
|
|
959
|
+
) -> tuple[_Subscription, ...]:
|
|
960
|
+
if not second and not third:
|
|
961
|
+
return first
|
|
962
|
+
if not first and not third:
|
|
963
|
+
return second
|
|
964
|
+
if not first and not second:
|
|
965
|
+
return third
|
|
966
|
+
return tuple(sorted((*first, *second, *third), key=lambda sub: sub.order))
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
def _live_candidates(candidates: tuple[_Subscription, ...]) -> tuple[_MatchedSubscription, ...]:
|
|
970
|
+
live: list[_MatchedSubscription] = []
|
|
971
|
+
for sub in candidates:
|
|
972
|
+
func = sub.func()
|
|
973
|
+
if func is not None:
|
|
974
|
+
live.append(_MatchedSubscription(subscription=sub, func=func))
|
|
975
|
+
return tuple(live)
|
|
976
|
+
|
|
977
|
+
|
|
978
|
+
def _matching_subscriptions(
|
|
979
|
+
metadata: MessageMetadata,
|
|
980
|
+
) -> tuple[_MatchedSubscription, ...]:
|
|
981
|
+
snapshot = _registry_snapshot
|
|
982
|
+
consumer_group = str(metadata.consumer_group)
|
|
983
|
+
topic = metadata.topic
|
|
984
|
+
matching = _live_candidates(
|
|
985
|
+
_merge_candidates_in_order(
|
|
986
|
+
snapshot.wildcard_by_consumer.get(consumer_group, ()),
|
|
987
|
+
snapshot.exact_by_consumer_topic.get((consumer_group, topic), ()),
|
|
988
|
+
_matching_prefix_subscriptions(
|
|
989
|
+
snapshot,
|
|
990
|
+
topic=topic,
|
|
991
|
+
consumer_group=consumer_group,
|
|
992
|
+
),
|
|
993
|
+
)
|
|
994
|
+
)
|
|
995
|
+
if not matching:
|
|
996
|
+
raise _no_matching_subscriptions_error(metadata.topic, str(metadata.consumer_group))
|
|
997
|
+
return matching
|
|
998
|
+
|
|
999
|
+
|
|
1000
|
+
async def call_subscribers(
|
|
1001
|
+
message: Message[Any],
|
|
1002
|
+
) -> None:
|
|
1003
|
+
sub = _matching_subscriptions(message.metadata)[0]
|
|
1004
|
+
await _maybe_await_result(_call_subscription(sub, message.payload, message.metadata))
|
|
1005
|
+
|
|
1006
|
+
|
|
1007
|
+
def call_subscribers_sync(
|
|
1008
|
+
message: Message[Any],
|
|
1009
|
+
) -> None:
|
|
1010
|
+
sub = _matching_subscriptions(message.metadata)[0]
|
|
1011
|
+
result = _call_subscription(sub, message.payload, message.metadata)
|
|
1012
|
+
if inspect.isawaitable(result):
|
|
1013
|
+
close = getattr(result, "close", None)
|
|
1014
|
+
if callable(close):
|
|
1015
|
+
close()
|
|
1016
|
+
raise RuntimeError("async subscribers must be polled with an async polling loop")
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
# Only add public symbols to __all__; internal helpers must stay unexported.
|
|
1020
|
+
__all__ = (
|
|
1021
|
+
"QueueSubscriber",
|
|
1022
|
+
"Subscription",
|
|
1023
|
+
"get_subscriptions",
|
|
1024
|
+
"subscribe",
|
|
1025
|
+
)
|