rabbitkit 0.9.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.
- rabbitkit/__init__.py +201 -0
- rabbitkit/_version.py +3 -0
- rabbitkit/aio/__init__.py +31 -0
- rabbitkit/async_/__init__.py +9 -0
- rabbitkit/async_/batch.py +213 -0
- rabbitkit/async_/broker.py +1123 -0
- rabbitkit/async_/connection.py +274 -0
- rabbitkit/async_/pool.py +363 -0
- rabbitkit/async_/transport.py +877 -0
- rabbitkit/asyncapi/__init__.py +5 -0
- rabbitkit/asyncapi/generator.py +219 -0
- rabbitkit/asyncapi/schema.py +98 -0
- rabbitkit/cli/__init__.py +77 -0
- rabbitkit/cli/_utils.py +38 -0
- rabbitkit/cli/commands/__init__.py +0 -0
- rabbitkit/cli/commands/dlq.py +190 -0
- rabbitkit/cli/commands/health.py +34 -0
- rabbitkit/cli/commands/migrate.py +570 -0
- rabbitkit/cli/commands/routes.py +88 -0
- rabbitkit/cli/commands/run.py +144 -0
- rabbitkit/cli/commands/shell.py +72 -0
- rabbitkit/cli/commands/topology.py +346 -0
- rabbitkit/concurrency.py +451 -0
- rabbitkit/core/__init__.py +5 -0
- rabbitkit/core/app.py +323 -0
- rabbitkit/core/config.py +849 -0
- rabbitkit/core/env_config.py +251 -0
- rabbitkit/core/errors.py +199 -0
- rabbitkit/core/logging.py +261 -0
- rabbitkit/core/message.py +235 -0
- rabbitkit/core/path.py +53 -0
- rabbitkit/core/pipeline.py +1289 -0
- rabbitkit/core/protocols.py +349 -0
- rabbitkit/core/registry.py +284 -0
- rabbitkit/core/route.py +329 -0
- rabbitkit/core/router.py +142 -0
- rabbitkit/core/topology.py +261 -0
- rabbitkit/core/topology_dispatch.py +74 -0
- rabbitkit/core/types.py +324 -0
- rabbitkit/dashboard/__init__.py +5 -0
- rabbitkit/dashboard/app.py +212 -0
- rabbitkit/di/__init__.py +19 -0
- rabbitkit/di/context.py +193 -0
- rabbitkit/di/depends.py +42 -0
- rabbitkit/di/resolver.py +503 -0
- rabbitkit/dlq.py +320 -0
- rabbitkit/experimental/__init__.py +50 -0
- rabbitkit/fastapi.py +91 -0
- rabbitkit/health.py +654 -0
- rabbitkit/highload/__init__.py +10 -0
- rabbitkit/highload/backpressure.py +514 -0
- rabbitkit/highload/batch.py +448 -0
- rabbitkit/locking.py +277 -0
- rabbitkit/management.py +470 -0
- rabbitkit/middleware/__init__.py +27 -0
- rabbitkit/middleware/base.py +125 -0
- rabbitkit/middleware/circuit_breaker.py +131 -0
- rabbitkit/middleware/compression.py +267 -0
- rabbitkit/middleware/deduplication.py +651 -0
- rabbitkit/middleware/error_classifier.py +43 -0
- rabbitkit/middleware/exception.py +105 -0
- rabbitkit/middleware/metrics.py +440 -0
- rabbitkit/middleware/otel.py +203 -0
- rabbitkit/middleware/rate_limit.py +247 -0
- rabbitkit/middleware/retry.py +540 -0
- rabbitkit/middleware/signing.py +682 -0
- rabbitkit/middleware/timeout.py +291 -0
- rabbitkit/py.typed +0 -0
- rabbitkit/queue_metrics.py +174 -0
- rabbitkit/results/__init__.py +6 -0
- rabbitkit/results/backend.py +102 -0
- rabbitkit/results/middleware.py +123 -0
- rabbitkit/rpc.py +632 -0
- rabbitkit/serialization/__init__.py +25 -0
- rabbitkit/serialization/base.py +35 -0
- rabbitkit/serialization/json.py +122 -0
- rabbitkit/serialization/msgspec.py +136 -0
- rabbitkit/serialization/pipeline.py +255 -0
- rabbitkit/streams.py +139 -0
- rabbitkit/sync/__init__.py +11 -0
- rabbitkit/sync/batch.py +595 -0
- rabbitkit/sync/broker.py +996 -0
- rabbitkit/sync/connection.py +209 -0
- rabbitkit/sync/pool.py +262 -0
- rabbitkit/sync/transport.py +1085 -0
- rabbitkit/testing/__init__.py +20 -0
- rabbitkit/testing/app.py +99 -0
- rabbitkit/testing/broker.py +540 -0
- rabbitkit/testing/fixtures.py +56 -0
- rabbitkit-0.9.0.dist-info/METADATA +575 -0
- rabbitkit-0.9.0.dist-info/RECORD +95 -0
- rabbitkit-0.9.0.dist-info/WHEEL +5 -0
- rabbitkit-0.9.0.dist-info/entry_points.txt +2 -0
- rabbitkit-0.9.0.dist-info/licenses/LICENSE +21 -0
- rabbitkit-0.9.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1123 @@
|
|
|
1
|
+
"""AsyncBroker — wires core registry + pipeline + AsyncTransportImpl.
|
|
2
|
+
|
|
3
|
+
The AsyncBroker is the high-level entry point for async applications.
|
|
4
|
+
It combines the registry (for handler registration), the pipeline
|
|
5
|
+
(for message processing), and the transport (for RabbitMQ I/O).
|
|
6
|
+
|
|
7
|
+
Graceful shutdown:
|
|
8
|
+
1. Cancel all consumers (cancel per consumer_tag)
|
|
9
|
+
2. Wait for in-flight messages
|
|
10
|
+
3. Close transport connection
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import contextlib
|
|
17
|
+
import signal
|
|
18
|
+
import time
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
from dataclasses import replace
|
|
21
|
+
from typing import TYPE_CHECKING, Any
|
|
22
|
+
|
|
23
|
+
import structlog
|
|
24
|
+
|
|
25
|
+
from rabbitkit.async_.transport import AsyncTransportImpl
|
|
26
|
+
from rabbitkit.concurrency import AsyncWorkerPool
|
|
27
|
+
from rabbitkit.core.config import (
|
|
28
|
+
BatchPublishConfig,
|
|
29
|
+
ConsumerConfig,
|
|
30
|
+
RabbitConfig,
|
|
31
|
+
RetryConfig,
|
|
32
|
+
RetryDisabled,
|
|
33
|
+
WorkerConfig,
|
|
34
|
+
)
|
|
35
|
+
from rabbitkit.core.errors import BackpressureError
|
|
36
|
+
from rabbitkit.core.message import RabbitMessage
|
|
37
|
+
from rabbitkit.core.path import extract_path, to_binding_key
|
|
38
|
+
from rabbitkit.core.pipeline import HandlerPipeline
|
|
39
|
+
from rabbitkit.core.registry import SubscriberRegistry
|
|
40
|
+
from rabbitkit.core.route import RouteDefinition
|
|
41
|
+
from rabbitkit.core.topology import RabbitExchange, RabbitQueue
|
|
42
|
+
from rabbitkit.core.types import (
|
|
43
|
+
AckPolicy,
|
|
44
|
+
MessageEnvelope,
|
|
45
|
+
PublishOutcome,
|
|
46
|
+
PublishStatus,
|
|
47
|
+
TopologyMode,
|
|
48
|
+
)
|
|
49
|
+
from rabbitkit.middleware.base import BaseMiddleware
|
|
50
|
+
from rabbitkit.middleware.retry import RetryRouter
|
|
51
|
+
from rabbitkit.serialization.base import Serializer
|
|
52
|
+
|
|
53
|
+
if TYPE_CHECKING:
|
|
54
|
+
from rabbitkit.core.router import RabbitRouter
|
|
55
|
+
|
|
56
|
+
logger = structlog.stdlib.get_logger(__name__)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class AsyncBroker:
|
|
60
|
+
"""Async broker — wires registry + pipeline + AsyncTransportImpl.
|
|
61
|
+
|
|
62
|
+
Usage::
|
|
63
|
+
|
|
64
|
+
config = RabbitConfig(connection=ConnectionConfig(host="localhost"))
|
|
65
|
+
broker = AsyncBroker(config)
|
|
66
|
+
|
|
67
|
+
@broker.subscriber(queue="orders", exchange="events")
|
|
68
|
+
async def handle_order(body: bytes) -> None:
|
|
69
|
+
...
|
|
70
|
+
|
|
71
|
+
await broker.start()
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
# L14: liveness heartbeat tick interval -- well under any reasonable
|
|
75
|
+
# health.broker_liveness(wedged_timeout=...) so idle-but-healthy periods
|
|
76
|
+
# never spuriously trip liveness.
|
|
77
|
+
_HEARTBEAT_INTERVAL: float = 5.0
|
|
78
|
+
|
|
79
|
+
def __init__(
|
|
80
|
+
self,
|
|
81
|
+
config: RabbitConfig | None = None,
|
|
82
|
+
*,
|
|
83
|
+
serializer: Serializer[Any] | None = None,
|
|
84
|
+
di_resolver: Any | None = None,
|
|
85
|
+
context_repo: Any | None = None,
|
|
86
|
+
batch_config: BatchPublishConfig | None = None,
|
|
87
|
+
middlewares: list[BaseMiddleware] | None = None,
|
|
88
|
+
) -> None:
|
|
89
|
+
self._config = config or RabbitConfig()
|
|
90
|
+
# Private mutable view of consumer config — brokers may apply a
|
|
91
|
+
# prefetch override derived from WorkerConfig.prefetch_per_worker.
|
|
92
|
+
# Stored separately so the caller's frozen RabbitConfig is never mutated.
|
|
93
|
+
self._consumer_config = self._config.consumer
|
|
94
|
+
|
|
95
|
+
self._registry = SubscriberRegistry(broker_retry=self._config.retry)
|
|
96
|
+
self._pipeline = HandlerPipeline(
|
|
97
|
+
serializer=serializer,
|
|
98
|
+
di_resolver=di_resolver,
|
|
99
|
+
context_repo=context_repo,
|
|
100
|
+
reject_transient_on_redelivery=self._config.consumer.reject_transient_on_redelivery,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# C3: middlewares applied to every broker.publish() call — the primary
|
|
104
|
+
# producer API. Distinct from @subscriber(middlewares=[...]), which
|
|
105
|
+
# only wraps a route's HANDLER-RESULT publishes (Contract 5); without
|
|
106
|
+
# this, e.g. SigningMiddleware never signed anything published via
|
|
107
|
+
# broker.publish() directly. The composed chain is cached by this
|
|
108
|
+
# list's identity (see HandlerPipeline.compose_broker_publish_async), so
|
|
109
|
+
# set the full list via this constructor param — mutating it in place
|
|
110
|
+
# after the first publish() call would silently reuse the stale
|
|
111
|
+
# pre-mutation chain.
|
|
112
|
+
self._publish_middlewares: list[BaseMiddleware] = middlewares or []
|
|
113
|
+
|
|
114
|
+
self._batch_config = batch_config
|
|
115
|
+
self._batch_publisher: Any | None = None # BatchPublisher, started lazily in start()
|
|
116
|
+
self._transport: Any | None = None # AsyncTransportImpl
|
|
117
|
+
self._worker_pool: AsyncWorkerPool | None = None
|
|
118
|
+
self._started = False
|
|
119
|
+
self._rpc_client: Any | None = None
|
|
120
|
+
|
|
121
|
+
# L14: liveness heartbeat (see health.broker_liveness). None until
|
|
122
|
+
# start() -- see the start() docstring for why it's set there rather
|
|
123
|
+
# than only on delivery/tick.
|
|
124
|
+
self.last_heartbeat: float | None = None
|
|
125
|
+
self._heartbeat_task: asyncio.Task[None] | None = None
|
|
126
|
+
# I-16: optional callback invoked from the signal handler so an embedding
|
|
127
|
+
# RabbitApp's shutdown event is also set (prevents the double-install hang
|
|
128
|
+
# where the broker's handler overwrites the app's). Wire
|
|
129
|
+
# ``broker.on_app_shutdown = app.request_shutdown`` before ``app.run_async()``.
|
|
130
|
+
self.on_app_shutdown: Callable[[], None] | None = None
|
|
131
|
+
|
|
132
|
+
# Bounded graceful drain (C-2): inline in-flight counter guarded by an
|
|
133
|
+
# asyncio.Condition (R-Condition). ``_in_flight`` stays a plain int so
|
|
134
|
+
# health checks that read it directly keep working (backward compat).
|
|
135
|
+
self._in_flight = 0
|
|
136
|
+
self._in_flight_cond: asyncio.Condition | None = None # lazily created in loop
|
|
137
|
+
# Task/message pairs for in-flight INLINE consumption (no worker pool
|
|
138
|
+
# configured -- the default). Mirrors AsyncWorkerPool._task_messages so
|
|
139
|
+
# a drain-deadline timeout can cancel + nack the still-running ones
|
|
140
|
+
# with delivery-tag logging, the same as the pooled path already does,
|
|
141
|
+
# instead of silently abandoning them unacked.
|
|
142
|
+
self._inflight_tasks: dict[asyncio.Task[None], RabbitMessage] = {}
|
|
143
|
+
|
|
144
|
+
# Optional publish-side flow control (C-6).
|
|
145
|
+
self._flow_controller: Any | None = None
|
|
146
|
+
|
|
147
|
+
# Signal-handler bookkeeping (H-SRE5).
|
|
148
|
+
self._original_handlers: dict[signal.Signals, Any] = {}
|
|
149
|
+
self._installed_loop_handlers = False
|
|
150
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
151
|
+
|
|
152
|
+
# H11: shutdown event awaited by run() so the drain triggered by a
|
|
153
|
+
# signal (or request_shutdown()) is joined instead of fire-and-forget.
|
|
154
|
+
# _run_waiting is True only while run() is actually awaiting the
|
|
155
|
+
# event, so a signal received under bare start() usage still falls
|
|
156
|
+
# back to the pre-H11 fire-and-forget stop() task.
|
|
157
|
+
self._shutdown_event: asyncio.Event = asyncio.Event()
|
|
158
|
+
self._run_waiting = False
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def flow_controller(self) -> Any | None:
|
|
162
|
+
"""Optional FlowController used to throttle the publish path."""
|
|
163
|
+
return self._flow_controller
|
|
164
|
+
|
|
165
|
+
@flow_controller.setter
|
|
166
|
+
def flow_controller(self, value: Any | None) -> None:
|
|
167
|
+
self._flow_controller = value
|
|
168
|
+
if self._transport is not None and value is not None:
|
|
169
|
+
self._transport.on_blocked(value.on_blocked)
|
|
170
|
+
self._transport.on_unblocked(value.on_unblocked)
|
|
171
|
+
|
|
172
|
+
def _ensure_inflight_cond(self) -> asyncio.Condition:
|
|
173
|
+
if self._in_flight_cond is None:
|
|
174
|
+
self._in_flight_cond = asyncio.Condition()
|
|
175
|
+
return self._in_flight_cond
|
|
176
|
+
|
|
177
|
+
def _mark_heartbeat(self) -> None:
|
|
178
|
+
"""Refresh the liveness heartbeat (I-4/L14).
|
|
179
|
+
|
|
180
|
+
Called both per delivered message (``on_message`` in
|
|
181
|
+
:meth:`_start_consumer`) and periodically by ``_heartbeat_loop`` --
|
|
182
|
+
the latter is what keeps a healthy but message-idle consumer from
|
|
183
|
+
being mistaken for a wedged one by :func:`health.broker_liveness`.
|
|
184
|
+
"""
|
|
185
|
+
self.last_heartbeat = time.monotonic()
|
|
186
|
+
|
|
187
|
+
async def _heartbeat_loop(self) -> None:
|
|
188
|
+
"""L14: periodic liveness heartbeat -- the async analogue of the sync
|
|
189
|
+
broker's per-``start_consuming()``-tick heartbeat.
|
|
190
|
+
|
|
191
|
+
aio-pika has no exposed manual I/O-loop-tick to hook (unlike pika's
|
|
192
|
+
``process_data_events``); this task ticking on its own interval is
|
|
193
|
+
itself the liveness signal instead -- if the event loop were
|
|
194
|
+
genuinely wedged (blocked, not just disconnected), this task would
|
|
195
|
+
not get scheduled and the heartbeat would correctly go stale. A
|
|
196
|
+
transient disconnect during reconnect is intentionally NOT
|
|
197
|
+
distinguished from "healthy but idle" here: ``broker_liveness``
|
|
198
|
+
documents that a transient disconnect is not itself a liveness
|
|
199
|
+
failure, and a reconnect attempt completes well within
|
|
200
|
+
``wedged_timeout`` in practice -- only a reconnect loop stuck for the
|
|
201
|
+
full timeout window would (correctly) trip liveness.
|
|
202
|
+
"""
|
|
203
|
+
try:
|
|
204
|
+
while True:
|
|
205
|
+
await asyncio.sleep(self._HEARTBEAT_INTERVAL)
|
|
206
|
+
self._mark_heartbeat()
|
|
207
|
+
except asyncio.CancelledError:
|
|
208
|
+
pass
|
|
209
|
+
|
|
210
|
+
async def _in_flight_inc(self) -> None:
|
|
211
|
+
async with self._ensure_inflight_cond():
|
|
212
|
+
self._in_flight += 1
|
|
213
|
+
|
|
214
|
+
async def _in_flight_dec(self) -> None:
|
|
215
|
+
cond = self._ensure_inflight_cond()
|
|
216
|
+
async with cond:
|
|
217
|
+
if self._in_flight > 0:
|
|
218
|
+
self._in_flight -= 1
|
|
219
|
+
if self._in_flight == 0:
|
|
220
|
+
cond.notify_all()
|
|
221
|
+
|
|
222
|
+
@property
|
|
223
|
+
def config(self) -> RabbitConfig:
|
|
224
|
+
return self._config
|
|
225
|
+
|
|
226
|
+
@property
|
|
227
|
+
def publish_middlewares(self) -> list[BaseMiddleware]:
|
|
228
|
+
"""Middlewares applied to every ``broker.publish()`` call (e.g. signing).
|
|
229
|
+
|
|
230
|
+
Set via the constructor's ``middlewares=`` param. See the comment on
|
|
231
|
+
``self._publish_middlewares`` for why reassigning (not mutating) is
|
|
232
|
+
required to change this after construction.
|
|
233
|
+
"""
|
|
234
|
+
return self._publish_middlewares
|
|
235
|
+
|
|
236
|
+
@property
|
|
237
|
+
def routes(self) -> list[RouteDefinition]:
|
|
238
|
+
return self._registry.routes
|
|
239
|
+
|
|
240
|
+
@property
|
|
241
|
+
def worker_pool(self) -> AsyncWorkerPool | None:
|
|
242
|
+
"""Return the worker pool (if configured)."""
|
|
243
|
+
return self._worker_pool
|
|
244
|
+
|
|
245
|
+
@property
|
|
246
|
+
def consumer_config(self) -> ConsumerConfig:
|
|
247
|
+
"""Effective consumer config (may reflect WorkerConfig.prefetch override)."""
|
|
248
|
+
return self._consumer_config
|
|
249
|
+
|
|
250
|
+
# ── Registration (decorator API) ──────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
def subscriber(
|
|
253
|
+
self,
|
|
254
|
+
queue: RabbitQueue | str,
|
|
255
|
+
exchange: RabbitExchange | str | None = None,
|
|
256
|
+
routing_key: str = "",
|
|
257
|
+
ack_policy: AckPolicy = AckPolicy.AUTO,
|
|
258
|
+
middlewares: list[BaseMiddleware] | None = None,
|
|
259
|
+
serializer: Serializer[Any] | None = None,
|
|
260
|
+
retry: RetryConfig | RetryDisabled | None = None,
|
|
261
|
+
tags: frozenset[str] | set[str] | None = None,
|
|
262
|
+
description: str = "",
|
|
263
|
+
name: str | None = None,
|
|
264
|
+
prefetch_count: int | None = None,
|
|
265
|
+
filter_fn: Callable[[RabbitMessage], bool] | None = None,
|
|
266
|
+
reject_without_dlx: str | None = None,
|
|
267
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
268
|
+
"""Register a subscriber handler."""
|
|
269
|
+
return self._registry.subscriber(
|
|
270
|
+
queue=queue,
|
|
271
|
+
exchange=exchange,
|
|
272
|
+
routing_key=routing_key,
|
|
273
|
+
ack_policy=ack_policy,
|
|
274
|
+
middlewares=middlewares,
|
|
275
|
+
serializer=serializer,
|
|
276
|
+
retry=retry,
|
|
277
|
+
tags=tags,
|
|
278
|
+
description=description,
|
|
279
|
+
name=name,
|
|
280
|
+
prefetch_count=prefetch_count,
|
|
281
|
+
filter_fn=filter_fn,
|
|
282
|
+
reject_without_dlx=reject_without_dlx,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
def publisher(
|
|
286
|
+
self,
|
|
287
|
+
exchange: RabbitExchange | str | None = None,
|
|
288
|
+
routing_key: str = "",
|
|
289
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
290
|
+
"""Register a result publisher."""
|
|
291
|
+
return self._registry.publisher(exchange=exchange, routing_key=routing_key)
|
|
292
|
+
|
|
293
|
+
def include_router(self, router: RabbitRouter, prefix: str = "") -> None:
|
|
294
|
+
"""Include routes from a RabbitRouter."""
|
|
295
|
+
self._registry.include_router(router, prefix=prefix)
|
|
296
|
+
|
|
297
|
+
# ── Lifecycle ─────────────────────────────────────────────────────────
|
|
298
|
+
|
|
299
|
+
async def start(
|
|
300
|
+
self,
|
|
301
|
+
worker_config: WorkerConfig | None = None,
|
|
302
|
+
*,
|
|
303
|
+
install_signal_handlers: bool = True,
|
|
304
|
+
) -> None:
|
|
305
|
+
"""Start the broker - connect, declare topology, start consuming.
|
|
306
|
+
|
|
307
|
+
1. Connect to RabbitMQ
|
|
308
|
+
2. Declare exchanges, queues, bindings per TopologyMode
|
|
309
|
+
3. Declare retry topology (delay queues, DLQ)
|
|
310
|
+
4. Optionally create a worker pool for concurrent processing
|
|
311
|
+
5. Start consuming from all registered queues
|
|
312
|
+
|
|
313
|
+
When ``install_signal_handlers`` is True (default), SIGINT/SIGTERM are
|
|
314
|
+
trapped so the common ``await broker.start()`` pattern drains gracefully
|
|
315
|
+
instead of hard-dying (H-SRE5). Pass ``False`` when an outer lifecycle
|
|
316
|
+
manager (e.g. ``RabbitApp``) owns signal handling.
|
|
317
|
+
|
|
318
|
+
L14: ``last_heartbeat`` is initialized here (not left ``None`` until
|
|
319
|
+
the first message/tick) so a broker that is wedged from the very
|
|
320
|
+
start -- before it ever processes a message or a periodic heartbeat
|
|
321
|
+
tick -- is still caught by :func:`health.broker_liveness`'s
|
|
322
|
+
staleness check, instead of bypassing it entirely (a ``None``
|
|
323
|
+
heartbeat is treated as "no signal available" there, which
|
|
324
|
+
previously meant "always alive").
|
|
325
|
+
"""
|
|
326
|
+
if self._started:
|
|
327
|
+
return
|
|
328
|
+
|
|
329
|
+
self.last_heartbeat = time.monotonic()
|
|
330
|
+
|
|
331
|
+
# Configure structured logging if enabled
|
|
332
|
+
if self._config.logging is not None:
|
|
333
|
+
from rabbitkit.core.logging import configure_structlog
|
|
334
|
+
|
|
335
|
+
configure_structlog(self._config.logging)
|
|
336
|
+
|
|
337
|
+
# Create transport
|
|
338
|
+
self._transport = AsyncTransportImpl(
|
|
339
|
+
connection_config=self._config.connection,
|
|
340
|
+
security_config=self._config.security,
|
|
341
|
+
topology_mode=self._config.topology_mode,
|
|
342
|
+
confirm_delivery=self._config.publisher.confirm_delivery,
|
|
343
|
+
confirm_timeout=self._config.publisher.confirm_timeout,
|
|
344
|
+
on_topology_conflict=self._config.safety.on_topology_conflict,
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
await self._transport.connect()
|
|
348
|
+
|
|
349
|
+
# Wire an opt-in FlowController's blocked/unblocked callbacks to the
|
|
350
|
+
# transport now that it exists (C-6).
|
|
351
|
+
if self._flow_controller is not None:
|
|
352
|
+
self._transport.on_blocked(self._flow_controller.on_blocked)
|
|
353
|
+
self._transport.on_unblocked(self._flow_controller.on_unblocked)
|
|
354
|
+
|
|
355
|
+
# M-P5: warn when confirms are on and the publisher channel pool is
|
|
356
|
+
# small relative to expected concurrency (default unchanged).
|
|
357
|
+
if self._config.publisher.confirm_delivery:
|
|
358
|
+
pool_size = self._config.pool.channel_pool_size
|
|
359
|
+
# Publisher concurrency ~ worker_count (handlers that publish) or 1.
|
|
360
|
+
expected = worker_config.worker_count if worker_config and worker_config.worker_count > 1 else 1
|
|
361
|
+
if pool_size < max(4, expected):
|
|
362
|
+
import warnings
|
|
363
|
+
|
|
364
|
+
warnings.warn(
|
|
365
|
+
f"confirm_delivery=True with channel_pool_size={pool_size} "
|
|
366
|
+
f"(expected publisher concurrency ~{expected}). Concurrent confirms "
|
|
367
|
+
"are capped by the pool size; increase PoolConfig.channel_pool_size "
|
|
368
|
+
"if publish throughput matters.",
|
|
369
|
+
RuntimeWarning,
|
|
370
|
+
stacklevel=2,
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
# Start batch publisher if configured (must come before topology so
|
|
374
|
+
# the flush task is alive when the first publish arrives).
|
|
375
|
+
if self._batch_config is not None:
|
|
376
|
+
import dataclasses
|
|
377
|
+
import warnings
|
|
378
|
+
|
|
379
|
+
from rabbitkit.async_.batch import AsyncBatchPublisher
|
|
380
|
+
|
|
381
|
+
batch_cfg = self._batch_config
|
|
382
|
+
pool_size = self._config.pool.channel_pool_size
|
|
383
|
+
if batch_cfg.flush_workers == 0:
|
|
384
|
+
# Auto-compute workers but cap at half the pool so at least
|
|
385
|
+
# half the channels remain available for retry/direct publishes.
|
|
386
|
+
# Batch workers hold their channels permanently; exhausting the
|
|
387
|
+
# pool deadlocks any non-batch transport.publish() call (e.g. retry).
|
|
388
|
+
auto = min(16, max(1, batch_cfg.max_in_flight // batch_cfg.batch_size))
|
|
389
|
+
safe = min(auto, max(1, pool_size // 2))
|
|
390
|
+
batch_cfg = dataclasses.replace(batch_cfg, flush_workers=safe)
|
|
391
|
+
elif batch_cfg.flush_workers > pool_size // 2:
|
|
392
|
+
warnings.warn(
|
|
393
|
+
f"BatchPublishConfig.flush_workers={batch_cfg.flush_workers} > "
|
|
394
|
+
f"channel_pool_size({pool_size}) // 2. Batch workers hold pool "
|
|
395
|
+
"channels permanently; retry/direct publish calls may exhaust "
|
|
396
|
+
"the remaining slots and deadlock. "
|
|
397
|
+
"Increase PoolConfig.channel_pool_size to at least flush_workers * 2.",
|
|
398
|
+
RuntimeWarning,
|
|
399
|
+
stacklevel=2,
|
|
400
|
+
)
|
|
401
|
+
self._batch_publisher = AsyncBatchPublisher(self._transport, batch_cfg)
|
|
402
|
+
await self._batch_publisher.start()
|
|
403
|
+
|
|
404
|
+
# Declare topology
|
|
405
|
+
await self._declare_topology()
|
|
406
|
+
|
|
407
|
+
# Install RetryMiddleware on retry-enabled routes (topology alone does
|
|
408
|
+
# not retry — the middleware routes failures into the delay queues).
|
|
409
|
+
self._wire_retry_middleware()
|
|
410
|
+
|
|
411
|
+
# Connection-churn counter: reconnects were logged but never counted.
|
|
412
|
+
self._wire_reconnect_metric()
|
|
413
|
+
|
|
414
|
+
# Create worker pool if requested
|
|
415
|
+
if worker_config is not None and worker_config.worker_count > 1:
|
|
416
|
+
# Warn if worker_count exceeds channel_pool_size — all workers
|
|
417
|
+
# publishing simultaneously will exhaust the channel pool and
|
|
418
|
+
# block until acquire_timeout, risking deadlock under load.
|
|
419
|
+
if worker_config.worker_count > self._config.pool.channel_pool_size:
|
|
420
|
+
import warnings
|
|
421
|
+
|
|
422
|
+
warnings.warn(
|
|
423
|
+
f"worker_count={worker_config.worker_count} exceeds "
|
|
424
|
+
f"channel_pool_size={self._config.pool.channel_pool_size}. "
|
|
425
|
+
"Concurrent publishes may exhaust the channel pool and deadlock. "
|
|
426
|
+
"Increase PoolConfig.channel_pool_size to at least worker_count.",
|
|
427
|
+
RuntimeWarning,
|
|
428
|
+
stacklevel=2,
|
|
429
|
+
)
|
|
430
|
+
# Override prefetch_count if prefetch_per_worker is set
|
|
431
|
+
if worker_config.prefetch_per_worker is not None:
|
|
432
|
+
self._consumer_config = replace(
|
|
433
|
+
self._config.consumer,
|
|
434
|
+
prefetch_count=worker_config.worker_count * worker_config.prefetch_per_worker,
|
|
435
|
+
)
|
|
436
|
+
self._worker_pool = AsyncWorkerPool(config=worker_config)
|
|
437
|
+
self._worker_pool.start()
|
|
438
|
+
|
|
439
|
+
# Start consuming
|
|
440
|
+
for route in self._registry.routes:
|
|
441
|
+
await self._start_consumer(route)
|
|
442
|
+
|
|
443
|
+
# L14: periodic liveness heartbeat -- keeps a healthy, message-idle
|
|
444
|
+
# consumer from going stale between deliveries. See _heartbeat_loop.
|
|
445
|
+
self._heartbeat_task = asyncio.ensure_future(self._heartbeat_loop())
|
|
446
|
+
|
|
447
|
+
self._started = True
|
|
448
|
+
logger.info(
|
|
449
|
+
"AsyncBroker started with %d routes",
|
|
450
|
+
len(self._registry.routes),
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
# H11: clear any shutdown request left over from a previous
|
|
454
|
+
# start()/stop() cycle so run() doesn't return immediately.
|
|
455
|
+
self._shutdown_event.clear()
|
|
456
|
+
|
|
457
|
+
if install_signal_handlers:
|
|
458
|
+
self._install_signal_handlers()
|
|
459
|
+
|
|
460
|
+
async def run(self, worker_config: WorkerConfig | None = None) -> None:
|
|
461
|
+
"""Start, wait for a shutdown signal, then stop (H11).
|
|
462
|
+
|
|
463
|
+
``await broker.start()`` alone installs signal handlers whose drain is
|
|
464
|
+
fire-and-forget — nothing joins the ``stop()`` task they create, so
|
|
465
|
+
whether in-flight messages actually finish draining depends on
|
|
466
|
+
incidental event-loop lifetime (e.g. it can be cut short by
|
|
467
|
+
``asyncio.run()`` cancelling outstanding tasks once the awaited
|
|
468
|
+
coroutine returns). ``run()`` is the direct-use equivalent of
|
|
469
|
+
``RabbitApp.run_async()``: it does not return until the drain
|
|
470
|
+
triggered by SIGINT/SIGTERM (or :meth:`request_shutdown`) has fully
|
|
471
|
+
completed, so awaiting it end-to-end guarantees in-flight messages are
|
|
472
|
+
settled before the process exits::
|
|
473
|
+
|
|
474
|
+
broker = AsyncBroker(config)
|
|
475
|
+
|
|
476
|
+
@broker.subscriber(queue="orders")
|
|
477
|
+
async def handle_order(body: bytes) -> None: ...
|
|
478
|
+
|
|
479
|
+
asyncio.run(broker.run())
|
|
480
|
+
|
|
481
|
+
Use plain ``start()``/``stop()`` instead when an outer lifecycle
|
|
482
|
+
manager (``RabbitApp``) owns the run loop.
|
|
483
|
+
"""
|
|
484
|
+
await self.start(worker_config=worker_config)
|
|
485
|
+
self._run_waiting = True
|
|
486
|
+
try:
|
|
487
|
+
await self._shutdown_event.wait()
|
|
488
|
+
finally:
|
|
489
|
+
self._run_waiting = False
|
|
490
|
+
await self.stop()
|
|
491
|
+
|
|
492
|
+
def _install_signal_handlers(self) -> None:
|
|
493
|
+
"""Install portable SIGINT/SIGTERM handlers that drain via stop() (H-SRE5).
|
|
494
|
+
|
|
495
|
+
Prefers ``loop.add_signal_handler``; falls back to ``signal.signal`` on
|
|
496
|
+
platforms/threads where the loop API is unavailable. Idempotent.
|
|
497
|
+
"""
|
|
498
|
+
try:
|
|
499
|
+
loop = asyncio.get_running_loop()
|
|
500
|
+
except RuntimeError: # pragma: no cover
|
|
501
|
+
return # no running loop - nothing to install
|
|
502
|
+
self._loop = loop
|
|
503
|
+
installed = False
|
|
504
|
+
try:
|
|
505
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
506
|
+
loop.add_signal_handler(sig, self._on_signal)
|
|
507
|
+
installed = True
|
|
508
|
+
except (NotImplementedError, RuntimeError, ValueError): # pragma: no cover
|
|
509
|
+
pass # pragma: no cover
|
|
510
|
+
self._installed_loop_handlers = installed
|
|
511
|
+
if not installed: # pragma: no cover
|
|
512
|
+
for sig in (signal.SIGINT, signal.SIGTERM): # pragma: no cover
|
|
513
|
+
try: # pragma: no cover
|
|
514
|
+
self._original_handlers[sig] = signal.signal(sig, self._on_signal_sync) # pragma: no cover
|
|
515
|
+
except (ValueError, OSError): # pragma: no cover - not main thread
|
|
516
|
+
pass
|
|
517
|
+
|
|
518
|
+
def _trigger_shutdown(self) -> None:
|
|
519
|
+
"""Set the shutdown event so an in-progress ``run()`` joins the drain
|
|
520
|
+
(H11). If nothing is awaiting it via ``run()``, falls back to the
|
|
521
|
+
pre-H11 fire-and-forget ``stop()`` task so bare ``await
|
|
522
|
+
broker.start()`` usage still drains on signal.
|
|
523
|
+
"""
|
|
524
|
+
self._shutdown_event.set()
|
|
525
|
+
if not self._run_waiting and self._loop is not None:
|
|
526
|
+
self._loop.create_task(self.stop())
|
|
527
|
+
|
|
528
|
+
def _on_signal(self) -> None:
|
|
529
|
+
logger.info("Received shutdown signal; initiating graceful drain")
|
|
530
|
+
self._trigger_shutdown()
|
|
531
|
+
if self.on_app_shutdown is not None:
|
|
532
|
+
try:
|
|
533
|
+
self.on_app_shutdown()
|
|
534
|
+
except Exception: # pragma: no cover - never block shutdown on the callback
|
|
535
|
+
logger.warning("on_app_shutdown callback raised", exc_info=True)
|
|
536
|
+
|
|
537
|
+
def _on_signal_sync(self, signum: int, frame: Any) -> None: # pragma: no cover
|
|
538
|
+
logger.info("Received shutdown signal %d", signum)
|
|
539
|
+
if self._loop is not None:
|
|
540
|
+
self._loop.call_soon_threadsafe(self._trigger_shutdown)
|
|
541
|
+
if self.on_app_shutdown is not None:
|
|
542
|
+
try:
|
|
543
|
+
self.on_app_shutdown()
|
|
544
|
+
except Exception: # pragma: no cover
|
|
545
|
+
logger.warning("on_app_shutdown callback raised", exc_info=True)
|
|
546
|
+
|
|
547
|
+
def request_shutdown(self) -> None:
|
|
548
|
+
"""Request a graceful shutdown from any context — e.g. a failing
|
|
549
|
+
health check or a management command (H11). Equivalent to receiving
|
|
550
|
+
SIGINT/SIGTERM: if ``run()`` is awaiting shutdown it performs the
|
|
551
|
+
drain; otherwise this schedules a fire-and-forget ``stop()``.
|
|
552
|
+
"""
|
|
553
|
+
self._trigger_shutdown()
|
|
554
|
+
|
|
555
|
+
def _restore_signal_handlers(self) -> None:
|
|
556
|
+
if self._loop is not None and self._installed_loop_handlers:
|
|
557
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
558
|
+
try:
|
|
559
|
+
self._loop.remove_signal_handler(sig)
|
|
560
|
+
except (NotImplementedError, RuntimeError, ValueError): # pragma: no cover
|
|
561
|
+
pass
|
|
562
|
+
self._installed_loop_handlers = False
|
|
563
|
+
for sig, prev in self._original_handlers.items(): # pragma: no cover
|
|
564
|
+
try: # pragma: no cover
|
|
565
|
+
signal.signal(sig, prev) # pragma: no cover
|
|
566
|
+
except (ValueError, OSError): # pragma: no cover
|
|
567
|
+
pass # pragma: no cover
|
|
568
|
+
self._original_handlers.clear()
|
|
569
|
+
|
|
570
|
+
async def _wait_in_flight(self, deadline: float | None) -> None:
|
|
571
|
+
cond = self._ensure_inflight_cond()
|
|
572
|
+
async with cond:
|
|
573
|
+
if self._in_flight == 0:
|
|
574
|
+
return
|
|
575
|
+
while self._in_flight > 0:
|
|
576
|
+
if deadline is None:
|
|
577
|
+
await cond.wait()
|
|
578
|
+
continue
|
|
579
|
+
remaining = max(0.0, deadline - time.monotonic())
|
|
580
|
+
if remaining <= 0:
|
|
581
|
+
break
|
|
582
|
+
# R-timeout: use asyncio.timeout instead of asyncio.wait_for to
|
|
583
|
+
# avoid the wrapper-task overhead and let the wait be cancelled
|
|
584
|
+
# cleanly when the deadline expires.
|
|
585
|
+
try:
|
|
586
|
+
async with asyncio.timeout(remaining):
|
|
587
|
+
await cond.wait()
|
|
588
|
+
except TimeoutError:
|
|
589
|
+
break
|
|
590
|
+
# Deadline elapsed with handlers still running: cancel + nack them
|
|
591
|
+
# explicitly (delivery-tag logged) instead of silently abandoning
|
|
592
|
+
# them unacked -- matches AsyncWorkerPool.stop()'s behavior for the
|
|
593
|
+
# pooled path. Outside the `async with cond:` block since we're no
|
|
594
|
+
# longer touching `_in_flight`/the condition itself here, and
|
|
595
|
+
# cancelling a task can re-enter this broker (e.g. its `finally`
|
|
596
|
+
# decrementing `_in_flight`), which would deadlock re-acquiring cond.
|
|
597
|
+
if self._in_flight > 0:
|
|
598
|
+
logger.warning(
|
|
599
|
+
"AsyncBroker.stop: %d in-flight handler(s) still running after "
|
|
600
|
+
"graceful drain deadline; disconnecting anyway",
|
|
601
|
+
self._in_flight,
|
|
602
|
+
)
|
|
603
|
+
tasks = dict(self._inflight_tasks)
|
|
604
|
+
for task in tasks:
|
|
605
|
+
task.cancel()
|
|
606
|
+
if tasks:
|
|
607
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
608
|
+
for message in tasks.values():
|
|
609
|
+
if message.is_settled:
|
|
610
|
+
continue # handler reached its own ack/nack before being cut off
|
|
611
|
+
logger.warning(
|
|
612
|
+
"AsyncBroker.stop: handler for delivery_tag=%s message_id=%s did not "
|
|
613
|
+
"complete within graceful_timeout; abandoning (task cancelled) and "
|
|
614
|
+
"nacking for redelivery — ensure handlers are idempotent under "
|
|
615
|
+
"at-least-once delivery",
|
|
616
|
+
message.delivery_tag,
|
|
617
|
+
message.message_id,
|
|
618
|
+
)
|
|
619
|
+
try:
|
|
620
|
+
await message.nack_async(requeue=True)
|
|
621
|
+
except Exception:
|
|
622
|
+
logger.warning("nack on abandoned handler's message raised", exc_info=True)
|
|
623
|
+
|
|
624
|
+
async def stop(self, timeout: float | None = None) -> None:
|
|
625
|
+
"""Stop the broker - cancel consumers, drain pool, drain in-flight, disconnect.
|
|
626
|
+
|
|
627
|
+
``timeout`` defaults to ``ConsumerConfig.graceful_timeout`` (C-2). The
|
|
628
|
+
whole sequence is bounded by an overall deadline.
|
|
629
|
+
|
|
630
|
+
C5: consumers are cancelled FIRST, before the worker pool is drained.
|
|
631
|
+
Draining the pool before cancelling left the consumer active for the
|
|
632
|
+
entire (potentially graceful_timeout-long) drain wait — a message
|
|
633
|
+
delivered in that window was submitted via ``AsyncWorkerPool.submit()``,
|
|
634
|
+
which creates a task unconditionally (it never checks ``_running``) and
|
|
635
|
+
adds it to ``_tasks``. If that submit happens after ``.stop()`` already
|
|
636
|
+
cleared ``_tasks``, the new task is never awaited by anything — an
|
|
637
|
+
orphaned background task racing the event loop's shutdown, with the
|
|
638
|
+
message never cleanly settled before ``disconnect()``, so it is
|
|
639
|
+
redelivered (duplicate-processing risk) on the next connection.
|
|
640
|
+
Cancelling first stops new deliveries outright, so the pool only ever
|
|
641
|
+
drains work that was already in flight.
|
|
642
|
+
"""
|
|
643
|
+
if not self._started:
|
|
644
|
+
return
|
|
645
|
+
|
|
646
|
+
self._restore_signal_handlers()
|
|
647
|
+
|
|
648
|
+
effective = timeout if timeout is not None else self._consumer_config.graceful_timeout
|
|
649
|
+
deadline = None if effective is None else time.monotonic() + effective
|
|
650
|
+
|
|
651
|
+
# L14: stop the periodic heartbeat first, alongside cancelling consumers.
|
|
652
|
+
if self._heartbeat_task is not None:
|
|
653
|
+
self._heartbeat_task.cancel()
|
|
654
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
655
|
+
await self._heartbeat_task
|
|
656
|
+
self._heartbeat_task = None
|
|
657
|
+
|
|
658
|
+
# Cancel all consumers FIRST — stop new deliveries before draining
|
|
659
|
+
# anything, so nothing new arrives while the pool/in-flight drain runs.
|
|
660
|
+
assert self._transport is not None
|
|
661
|
+
for route in self._registry.routes:
|
|
662
|
+
if route.consumer_tag:
|
|
663
|
+
await self._transport.cancel_consumer(route.consumer_tag)
|
|
664
|
+
|
|
665
|
+
# Drain the worker pool (let in-flight pooled tasks finish), bounded by
|
|
666
|
+
# the outer deadline.
|
|
667
|
+
if self._worker_pool is not None:
|
|
668
|
+
remaining = None if deadline is None else max(0.0, deadline - time.monotonic())
|
|
669
|
+
await self._worker_pool.stop(timeout=remaining)
|
|
670
|
+
self._worker_pool = None
|
|
671
|
+
|
|
672
|
+
# Drain inline in-flight handlers (C-2).
|
|
673
|
+
await self._wait_in_flight(deadline)
|
|
674
|
+
|
|
675
|
+
# Stop batch publisher (drain remaining messages before disconnecting)
|
|
676
|
+
if self._batch_publisher is not None:
|
|
677
|
+
await self._batch_publisher.stop()
|
|
678
|
+
self._batch_publisher = None
|
|
679
|
+
|
|
680
|
+
# Close RPC client if used
|
|
681
|
+
if self._rpc_client is not None:
|
|
682
|
+
await self._rpc_client.close()
|
|
683
|
+
self._rpc_client = None
|
|
684
|
+
|
|
685
|
+
# Disconnect
|
|
686
|
+
if self._transport:
|
|
687
|
+
await self._transport.disconnect()
|
|
688
|
+
|
|
689
|
+
self._started = False
|
|
690
|
+
logger.info("AsyncBroker stopped")
|
|
691
|
+
|
|
692
|
+
# ── Publishing ────────────────────────────────────────────────────────
|
|
693
|
+
|
|
694
|
+
async def publish(
|
|
695
|
+
self,
|
|
696
|
+
envelope: MessageEnvelope | None = None,
|
|
697
|
+
*,
|
|
698
|
+
exchange: str = "",
|
|
699
|
+
routing_key: str = "",
|
|
700
|
+
body: bytes | str | dict[str, Any] | None = None,
|
|
701
|
+
headers: dict[str, Any] | None = None,
|
|
702
|
+
content_type: str = "application/json",
|
|
703
|
+
correlation_id: str | None = None,
|
|
704
|
+
reply_to: str | None = None,
|
|
705
|
+
) -> PublishOutcome:
|
|
706
|
+
"""Publish a message.
|
|
707
|
+
|
|
708
|
+
Accepts either a pre-built ``MessageEnvelope`` or individual kwargs::
|
|
709
|
+
|
|
710
|
+
# Envelope form (full control):
|
|
711
|
+
await broker.publish(MessageEnvelope(routing_key="orders.created", body=b"..."))
|
|
712
|
+
|
|
713
|
+
# Kwargs form (convenient):
|
|
714
|
+
await broker.publish(
|
|
715
|
+
exchange="orders",
|
|
716
|
+
routing_key="orders.created",
|
|
717
|
+
body={"order_id": 123},
|
|
718
|
+
headers={"x-tenant": "acme"},
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
When ``body`` is a dict or str it is JSON-encoded automatically.
|
|
722
|
+
|
|
723
|
+
When an opt-in ``FlowController`` is configured (``broker.flow_controller
|
|
724
|
+
= fc``), a publish slot is acquired/released around the transport publish
|
|
725
|
+
so backpressure/rate-limiting applies to the hot path. Without a
|
|
726
|
+
controller this is a plain pass-through.
|
|
727
|
+
|
|
728
|
+
When ``middlewares=[...]`` was passed to the constructor, each
|
|
729
|
+
middleware's ``publish_scope_async`` wraps this call (e.g.
|
|
730
|
+
``SigningMiddleware`` signs the envelope) — see ``publish_middlewares``.
|
|
731
|
+
Middleware wraps OUTSIDE both flow control and batching, so a
|
|
732
|
+
middleware-transformed envelope is what gets rate-limited/batched, and
|
|
733
|
+
what actually reaches the wire.
|
|
734
|
+
"""
|
|
735
|
+
if envelope is None:
|
|
736
|
+
import json as _json
|
|
737
|
+
|
|
738
|
+
if body is None:
|
|
739
|
+
raw_body = b""
|
|
740
|
+
elif isinstance(body, bytes):
|
|
741
|
+
raw_body = body
|
|
742
|
+
elif isinstance(body, str):
|
|
743
|
+
raw_body = body.encode()
|
|
744
|
+
else:
|
|
745
|
+
raw_body = _json.dumps(body).encode()
|
|
746
|
+
# M2: honor PublisherConfig defaults for the kwargs form (they were
|
|
747
|
+
# previously dead config). Envelope-form callers keep full control.
|
|
748
|
+
envelope = MessageEnvelope(
|
|
749
|
+
routing_key=routing_key,
|
|
750
|
+
body=raw_body,
|
|
751
|
+
exchange=exchange,
|
|
752
|
+
headers=headers or {},
|
|
753
|
+
content_type=content_type,
|
|
754
|
+
correlation_id=correlation_id,
|
|
755
|
+
reply_to=reply_to,
|
|
756
|
+
mandatory=self._config.publisher.mandatory,
|
|
757
|
+
delivery_mode=2 if self._config.publisher.persistent else 1,
|
|
758
|
+
)
|
|
759
|
+
|
|
760
|
+
# M10: reject oversized bodies at publish time (see sync broker).
|
|
761
|
+
max_bytes = self._config.publisher.max_message_bytes
|
|
762
|
+
if max_bytes and len(envelope.body) > max_bytes:
|
|
763
|
+
raise ValueError(
|
|
764
|
+
f"Message body ({len(envelope.body)} bytes) exceeds "
|
|
765
|
+
f"PublisherConfig.max_message_bytes ({max_bytes}). Large messages are a "
|
|
766
|
+
"RabbitMQ anti-pattern — store the payload externally and publish a "
|
|
767
|
+
"reference, or raise the limit if this is intentional."
|
|
768
|
+
)
|
|
769
|
+
|
|
770
|
+
if self._transport is None:
|
|
771
|
+
raise RuntimeError("Broker not started. Call start() first.")
|
|
772
|
+
|
|
773
|
+
publish_fn = (
|
|
774
|
+
self._batch_publisher.publish
|
|
775
|
+
if self._batch_publisher is not None
|
|
776
|
+
else self._transport.publish
|
|
777
|
+
)
|
|
778
|
+
|
|
779
|
+
async def do_publish(env: MessageEnvelope) -> PublishOutcome:
|
|
780
|
+
fc = self._flow_controller
|
|
781
|
+
if fc is not None:
|
|
782
|
+
if not await fc.acquire_async():
|
|
783
|
+
return PublishOutcome(
|
|
784
|
+
status=PublishStatus.ERROR,
|
|
785
|
+
exchange=env.exchange,
|
|
786
|
+
routing_key=env.routing_key,
|
|
787
|
+
error=BackpressureError("publish dropped by backpressure policy"),
|
|
788
|
+
)
|
|
789
|
+
try:
|
|
790
|
+
return await publish_fn(env) # type: ignore[no-any-return]
|
|
791
|
+
finally:
|
|
792
|
+
await fc.release_async()
|
|
793
|
+
return await publish_fn(env) # type: ignore[no-any-return]
|
|
794
|
+
|
|
795
|
+
if self._publish_middlewares:
|
|
796
|
+
chain = self._pipeline.compose_broker_publish_async(self._publish_middlewares)
|
|
797
|
+
outcome: PublishOutcome = await chain(envelope, do_publish)
|
|
798
|
+
return outcome
|
|
799
|
+
return await do_publish(envelope)
|
|
800
|
+
|
|
801
|
+
async def _flow_controlled_internal_publish(self, env: MessageEnvelope) -> PublishOutcome:
|
|
802
|
+
"""M18: async mirror of ``SyncBroker._flow_controlled_internal_publish``
|
|
803
|
+
— see its docstring. Used as the ``publish_fn`` for ``RetryMiddleware``'s
|
|
804
|
+
delay-queue republish and the pipeline's result/RPC-reply publish so
|
|
805
|
+
the broker's ``FlowController`` (if configured) applies to these
|
|
806
|
+
internal publishes too. Never lets ``BackpressureError`` escape as an
|
|
807
|
+
exception — a blocked/dropped slot always resolves as a failed
|
|
808
|
+
``PublishOutcome`` so existing nack+requeue handling applies
|
|
809
|
+
regardless of the configured ``on_blocked`` policy.
|
|
810
|
+
"""
|
|
811
|
+
if self._transport is None: # pragma: no cover — defensive; callers only run while consuming
|
|
812
|
+
raise RuntimeError("Broker not started. Call start() first.")
|
|
813
|
+
transport = self._transport
|
|
814
|
+
fc = self._flow_controller
|
|
815
|
+
if fc is None:
|
|
816
|
+
return await transport.publish(env) # type: ignore[no-any-return]
|
|
817
|
+
try:
|
|
818
|
+
acquired = await fc.acquire_async()
|
|
819
|
+
except BackpressureError as exc:
|
|
820
|
+
return PublishOutcome(
|
|
821
|
+
status=PublishStatus.ERROR, exchange=env.exchange, routing_key=env.routing_key, error=exc
|
|
822
|
+
)
|
|
823
|
+
if not acquired:
|
|
824
|
+
return PublishOutcome(
|
|
825
|
+
status=PublishStatus.ERROR,
|
|
826
|
+
exchange=env.exchange,
|
|
827
|
+
routing_key=env.routing_key,
|
|
828
|
+
error=BackpressureError("publish dropped by backpressure policy"),
|
|
829
|
+
)
|
|
830
|
+
try:
|
|
831
|
+
return await transport.publish(env) # type: ignore[no-any-return]
|
|
832
|
+
finally:
|
|
833
|
+
await fc.release_async()
|
|
834
|
+
|
|
835
|
+
async def request(
|
|
836
|
+
self,
|
|
837
|
+
routing_key: str,
|
|
838
|
+
body: bytes,
|
|
839
|
+
*,
|
|
840
|
+
timeout: float = 5.0,
|
|
841
|
+
exchange: str = "",
|
|
842
|
+
headers: dict[str, Any] | None = None,
|
|
843
|
+
) -> RabbitMessage:
|
|
844
|
+
"""Send an RPC request and wait for a response.
|
|
845
|
+
|
|
846
|
+
Lazily initializes an AsyncRPCClient on first call.
|
|
847
|
+
The client is shared across calls and closed in stop().
|
|
848
|
+
|
|
849
|
+
Args:
|
|
850
|
+
routing_key: Target queue/routing key.
|
|
851
|
+
body: Request body as bytes.
|
|
852
|
+
timeout: Max seconds to wait for response.
|
|
853
|
+
exchange: Exchange to publish to (default "").
|
|
854
|
+
headers: Optional AMQP headers.
|
|
855
|
+
|
|
856
|
+
Returns:
|
|
857
|
+
RabbitMessage containing the response.
|
|
858
|
+
|
|
859
|
+
Raises:
|
|
860
|
+
RuntimeError: If broker is not started.
|
|
861
|
+
RPCTimeoutError: If no response within timeout.
|
|
862
|
+
"""
|
|
863
|
+
if self._transport is None:
|
|
864
|
+
raise RuntimeError("Broker not started. Call start() first.")
|
|
865
|
+
if self._rpc_client is None:
|
|
866
|
+
from rabbitkit.rpc import AsyncRPCClient
|
|
867
|
+
|
|
868
|
+
self._rpc_client = AsyncRPCClient(self._transport)
|
|
869
|
+
return await self._rpc_client.call(routing_key, body, timeout=timeout, exchange=exchange, headers=headers)
|
|
870
|
+
|
|
871
|
+
# ── Internal ──────────────────────────────────────────────────────────
|
|
872
|
+
|
|
873
|
+
def _wire_retry_middleware(self) -> None:
|
|
874
|
+
"""Install ``RetryMiddleware`` on routes whose retry is enabled.
|
|
875
|
+
|
|
876
|
+
``_declare_topology`` declares the retry/DLQ *topology* (delay queues +
|
|
877
|
+
source-queue DLX), but the ``RetryMiddleware`` that actually routes a
|
|
878
|
+
failed message into the delay queues must also run in the route's
|
|
879
|
+
middleware chain. Without it, ``retry=RetryConfig(...)`` would build the
|
|
880
|
+
topology while transient failures ``nack(requeue=True)`` in a hot loop —
|
|
881
|
+
the delay queues would never receive anything and ``max_retries`` would
|
|
882
|
+
never be enforced. This wires both halves from the single retry switch
|
|
883
|
+
(``RabbitConfig.retry`` / ``@subscriber(retry=...)``).
|
|
884
|
+
|
|
885
|
+
Placed outer of ordinary user middlewares (e.g. ``TimeoutMiddleware``) so
|
|
886
|
+
retry can classify and re-queue exceptions they raise, but inner of any
|
|
887
|
+
``ExceptionMiddleware`` (the documented true-outermost layer) — see
|
|
888
|
+
:func:`rabbitkit.middleware.retry.retry_middleware_insertion_index`.
|
|
889
|
+
|
|
890
|
+
Idempotent: routes that already carry a ``RetryMiddleware`` — supplied
|
|
891
|
+
explicitly via ``middlewares=[...]`` or auto-wired on a previous start —
|
|
892
|
+
are left untouched (no double-retry, no stacking on reconnect).
|
|
893
|
+
"""
|
|
894
|
+
if self._transport is None:
|
|
895
|
+
return
|
|
896
|
+
|
|
897
|
+
from rabbitkit.middleware.metrics import MetricsMiddleware
|
|
898
|
+
from rabbitkit.middleware.retry import (
|
|
899
|
+
RetryMiddleware,
|
|
900
|
+
retry_middleware_insertion_index,
|
|
901
|
+
warn_retry_middleware_without_topology,
|
|
902
|
+
warn_retry_without_confirms,
|
|
903
|
+
)
|
|
904
|
+
from rabbitkit.middleware.signing import check_signing_retry_conflict
|
|
905
|
+
|
|
906
|
+
wired = False
|
|
907
|
+
for route in self._registry.routes:
|
|
908
|
+
retry_config = route.effective_retry_config(self._config.retry)
|
|
909
|
+
has_retry_mw = any(isinstance(mw, RetryMiddleware) for mw in route.route_middlewares)
|
|
910
|
+
if retry_config is None:
|
|
911
|
+
if has_retry_mw:
|
|
912
|
+
# Half-configured: a RetryMiddleware runs but no retry topology
|
|
913
|
+
# was declared, so its delay-queue publishes target non-existent
|
|
914
|
+
# queues and are silently dropped. Surface it loudly.
|
|
915
|
+
warn_retry_middleware_without_topology(route.name)
|
|
916
|
+
continue
|
|
917
|
+
# H1: signing + retry destroys every retried message — fail fast.
|
|
918
|
+
check_signing_retry_conflict(route.name, route.route_middlewares)
|
|
919
|
+
if has_retry_mw:
|
|
920
|
+
continue
|
|
921
|
+
if not self._config.publisher.confirm_delivery:
|
|
922
|
+
warn_retry_without_confirms(route.name) # M4
|
|
923
|
+
index = retry_middleware_insertion_index(route.route_middlewares)
|
|
924
|
+
# M2: if a MetricsMiddleware is already on this route, wire it into
|
|
925
|
+
# RetryMiddleware too so messages_retried_total/dead_lettered_total
|
|
926
|
+
# are observable (RetryMiddleware settles messages the pipeline
|
|
927
|
+
# itself never sees settle, so it must record these itself).
|
|
928
|
+
metrics_mw = next(
|
|
929
|
+
(mw for mw in route.route_middlewares if isinstance(mw, MetricsMiddleware)), None
|
|
930
|
+
)
|
|
931
|
+
route.route_middlewares.insert(
|
|
932
|
+
index,
|
|
933
|
+
RetryMiddleware(
|
|
934
|
+
retry_config,
|
|
935
|
+
publish_async_fn=self._flow_controlled_internal_publish, # M18
|
|
936
|
+
metrics_collector=metrics_mw.collector if metrics_mw else None,
|
|
937
|
+
metrics_config=metrics_mw.config if metrics_mw else None,
|
|
938
|
+
),
|
|
939
|
+
)
|
|
940
|
+
wired = True
|
|
941
|
+
|
|
942
|
+
if not self._config.publisher.confirm_delivery:
|
|
943
|
+
for route in self._registry.routes:
|
|
944
|
+
if route.result_publisher is not None:
|
|
945
|
+
warn_retry_without_confirms(route.name, context="result") # M4
|
|
946
|
+
|
|
947
|
+
if wired:
|
|
948
|
+
# Drop any middleware chains cached before the retry mw was installed.
|
|
949
|
+
self._pipeline.clear_caches()
|
|
950
|
+
|
|
951
|
+
def _wire_reconnect_metric(self) -> None:
|
|
952
|
+
"""Async mirror of ``SyncBroker._wire_reconnect_metric`` — count
|
|
953
|
+
transport reconnects (connection churn) via the first route
|
|
954
|
+
``MetricsMiddleware``'s collector, if any. No-op without metrics."""
|
|
955
|
+
if self._transport is None:
|
|
956
|
+
return
|
|
957
|
+
from rabbitkit.middleware.metrics import MetricsMiddleware
|
|
958
|
+
|
|
959
|
+
metrics_mw = next(
|
|
960
|
+
(
|
|
961
|
+
mw
|
|
962
|
+
for route in self._registry.routes
|
|
963
|
+
for mw in route.route_middlewares
|
|
964
|
+
if isinstance(mw, MetricsMiddleware) and mw.collector is not None
|
|
965
|
+
),
|
|
966
|
+
None,
|
|
967
|
+
)
|
|
968
|
+
if metrics_mw is None or metrics_mw.collector is None:
|
|
969
|
+
return
|
|
970
|
+
collector = metrics_mw.collector
|
|
971
|
+
metric_name = metrics_mw.config.reconnects_total
|
|
972
|
+
self._transport.on_reconnect(lambda: collector.inc_counter(metric_name, {}))
|
|
973
|
+
|
|
974
|
+
async def _declare_topology(self) -> None:
|
|
975
|
+
"""Declare exchanges, queues, and bindings for all routes."""
|
|
976
|
+
if self._transport is None:
|
|
977
|
+
return
|
|
978
|
+
|
|
979
|
+
for route in self._registry.routes:
|
|
980
|
+
# Declare exchange
|
|
981
|
+
if route.exchange is not None:
|
|
982
|
+
await self._transport.declare_exchange(route.exchange)
|
|
983
|
+
|
|
984
|
+
# Exchange-to-exchange binding
|
|
985
|
+
bind_kwargs = route.exchange.to_bind_kwargs()
|
|
986
|
+
if bind_kwargs is not None:
|
|
987
|
+
await self._transport.bind_exchange(
|
|
988
|
+
destination=bind_kwargs["destination"],
|
|
989
|
+
source=bind_kwargs["source"],
|
|
990
|
+
routing_key=bind_kwargs["routing_key"],
|
|
991
|
+
arguments=bind_kwargs["arguments"],
|
|
992
|
+
)
|
|
993
|
+
|
|
994
|
+
# Determine retry config early so source queue can include DLQ routing
|
|
995
|
+
retry_config = route.effective_retry_config(self._config.retry)
|
|
996
|
+
# C3: a route with no dead-letter path can reject(requeue=False)
|
|
997
|
+
# (permanent errors, filter_fn, RejectMessage) and RabbitMQ would
|
|
998
|
+
# DISCARD the message. Apply SafetyConfig.reject_without_dlx:
|
|
999
|
+
# auto-provision "<queue>.dlq" (default), fail startup, or warn
|
|
1000
|
+
# and allow discard. Only under AUTO_DECLARE — in passive/manual
|
|
1001
|
+
# modes rabbitkit does not own the queue's arguments.
|
|
1002
|
+
safety_dlq_name: str | None = None
|
|
1003
|
+
if self._config.topology_mode is TopologyMode.AUTO_DECLARE:
|
|
1004
|
+
safety_dlq_name = route.resolve_safety_dlq(self._config.safety, self._config.retry)
|
|
1005
|
+
# A queue that IS another route's DLQ is terminal — consuming your
|
|
1006
|
+
# own DLQ is a legitimate pattern (inspect/replay consumers), and
|
|
1007
|
+
# auto-chaining more topology onto it (safety DLX injection, or
|
|
1008
|
+
# BROKER-DEFAULT retry inherited by the DLQ-consumer route) would
|
|
1009
|
+
# re-declare the DLQ with different arguments than the retry/
|
|
1010
|
+
# safety topology already declared it with — a 406 inequivalent-
|
|
1011
|
+
# arg startup failure, caught by the real-broker CI suite. An
|
|
1012
|
+
# EXPLICIT per-route retry= on a DLQ consumer still wins.
|
|
1013
|
+
is_anothers_dlq = any(
|
|
1014
|
+
other is not route and route.queue.name == f"{other.queue.name}.dlq"
|
|
1015
|
+
for other in self._registry.routes
|
|
1016
|
+
)
|
|
1017
|
+
if is_anothers_dlq:
|
|
1018
|
+
safety_dlq_name = None
|
|
1019
|
+
if route.retry_override is None:
|
|
1020
|
+
retry_config = None # don't inherit broker-default retry
|
|
1021
|
+
|
|
1022
|
+
if retry_config is not None:
|
|
1023
|
+
retry_router = RetryRouter(retry_config)
|
|
1024
|
+
dlq_name = retry_router.get_dlq_name(route.queue.name)
|
|
1025
|
+
# Re-declare source queue with x-dead-letter fields so RabbitMQ
|
|
1026
|
+
# automatically routes nacked/rejected messages to the DLQ.
|
|
1027
|
+
import dataclasses
|
|
1028
|
+
|
|
1029
|
+
source_queue = dataclasses.replace(
|
|
1030
|
+
route.queue,
|
|
1031
|
+
dead_letter_exchange="",
|
|
1032
|
+
dead_letter_routing_key=dlq_name,
|
|
1033
|
+
)
|
|
1034
|
+
elif safety_dlq_name is not None:
|
|
1035
|
+
import dataclasses
|
|
1036
|
+
|
|
1037
|
+
logger.info(
|
|
1038
|
+
"Auto-provisioned DLQ %r for queue %r (reject_without_dlx=auto_provision)",
|
|
1039
|
+
safety_dlq_name,
|
|
1040
|
+
route.queue.name,
|
|
1041
|
+
)
|
|
1042
|
+
source_queue = dataclasses.replace(
|
|
1043
|
+
route.queue,
|
|
1044
|
+
dead_letter_exchange="",
|
|
1045
|
+
dead_letter_routing_key=safety_dlq_name,
|
|
1046
|
+
)
|
|
1047
|
+
else:
|
|
1048
|
+
source_queue = route.queue
|
|
1049
|
+
|
|
1050
|
+
# Declare queue (with DLQ routing arguments if retry/safety DLX applies)
|
|
1051
|
+
await self._transport.declare_queue(source_queue)
|
|
1052
|
+
|
|
1053
|
+
# Bind queue to exchange (C4: bind_arguments matter for headers exchanges)
|
|
1054
|
+
if route.exchange is not None:
|
|
1055
|
+
await self._transport.bind_queue(
|
|
1056
|
+
queue=route.queue.name,
|
|
1057
|
+
exchange=route.exchange.name,
|
|
1058
|
+
routing_key=to_binding_key(route.queue.routing_key),
|
|
1059
|
+
arguments=route.queue.bind_arguments or None,
|
|
1060
|
+
)
|
|
1061
|
+
|
|
1062
|
+
# Declare retry/DLQ topology if retry is enabled
|
|
1063
|
+
if retry_config is not None:
|
|
1064
|
+
exchange_name = route.exchange.name if route.exchange else ""
|
|
1065
|
+
delay_queues = retry_router.get_delay_queue_definitions(route.queue.name, exchange_name)
|
|
1066
|
+
for delay_queue in delay_queues:
|
|
1067
|
+
await self._transport.declare_queue(delay_queue)
|
|
1068
|
+
elif safety_dlq_name is not None:
|
|
1069
|
+
await self._transport.declare_queue(RabbitQueue(name=safety_dlq_name, durable=True))
|
|
1070
|
+
|
|
1071
|
+
async def _start_consumer(self, route: RouteDefinition) -> None:
|
|
1072
|
+
"""Start consuming for a single route."""
|
|
1073
|
+
if self._transport is None:
|
|
1074
|
+
return
|
|
1075
|
+
|
|
1076
|
+
pool = self._worker_pool
|
|
1077
|
+
|
|
1078
|
+
async def on_message(message: RabbitMessage) -> None:
|
|
1079
|
+
"""Process incoming message through the pipeline."""
|
|
1080
|
+
# Track inline in-flight so stop() can drain gracefully (C-2).
|
|
1081
|
+
# Also register this task/message pair so a drain-deadline timeout
|
|
1082
|
+
# can cancel + nack it explicitly (with delivery-tag logging),
|
|
1083
|
+
# matching AsyncWorkerPool.stop()'s behavior for the pooled path,
|
|
1084
|
+
# instead of silently abandoning it unacked.
|
|
1085
|
+
await self._in_flight_inc()
|
|
1086
|
+
task = asyncio.current_task()
|
|
1087
|
+
if task is not None:
|
|
1088
|
+
self._inflight_tasks[task] = message
|
|
1089
|
+
self._mark_heartbeat()
|
|
1090
|
+
try:
|
|
1091
|
+
# Set the original queue in headers for retry routing
|
|
1092
|
+
if "x-rabbitkit-original-queue" not in message.headers:
|
|
1093
|
+
message.headers["x-rabbitkit-original-queue"] = route.queue.name
|
|
1094
|
+
|
|
1095
|
+
# Populate named routing-key segments for Path() DI
|
|
1096
|
+
message.path = extract_path(message.routing_key, route.queue.routing_key)
|
|
1097
|
+
|
|
1098
|
+
await self._pipeline.process_async(
|
|
1099
|
+
route,
|
|
1100
|
+
message,
|
|
1101
|
+
publish_fn=self._flow_controlled_internal_publish, # M18
|
|
1102
|
+
)
|
|
1103
|
+
finally:
|
|
1104
|
+
if task is not None:
|
|
1105
|
+
self._inflight_tasks.pop(task, None)
|
|
1106
|
+
await self._in_flight_dec()
|
|
1107
|
+
|
|
1108
|
+
if pool is not None:
|
|
1109
|
+
|
|
1110
|
+
async def on_message_pooled(message: RabbitMessage) -> None:
|
|
1111
|
+
await pool.submit(on_message, message)
|
|
1112
|
+
|
|
1113
|
+
callback = on_message_pooled
|
|
1114
|
+
else:
|
|
1115
|
+
callback = on_message
|
|
1116
|
+
|
|
1117
|
+
effective_prefetch = route.prefetch_count or self._consumer_config.prefetch_count
|
|
1118
|
+
consumer_tag = await self._transport.consume(
|
|
1119
|
+
queue=route.queue.name,
|
|
1120
|
+
callback=callback,
|
|
1121
|
+
prefetch=effective_prefetch,
|
|
1122
|
+
)
|
|
1123
|
+
route.runtime_state.consumer_tag = consumer_tag
|