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.
Files changed (95) hide show
  1. rabbitkit/__init__.py +201 -0
  2. rabbitkit/_version.py +3 -0
  3. rabbitkit/aio/__init__.py +31 -0
  4. rabbitkit/async_/__init__.py +9 -0
  5. rabbitkit/async_/batch.py +213 -0
  6. rabbitkit/async_/broker.py +1123 -0
  7. rabbitkit/async_/connection.py +274 -0
  8. rabbitkit/async_/pool.py +363 -0
  9. rabbitkit/async_/transport.py +877 -0
  10. rabbitkit/asyncapi/__init__.py +5 -0
  11. rabbitkit/asyncapi/generator.py +219 -0
  12. rabbitkit/asyncapi/schema.py +98 -0
  13. rabbitkit/cli/__init__.py +77 -0
  14. rabbitkit/cli/_utils.py +38 -0
  15. rabbitkit/cli/commands/__init__.py +0 -0
  16. rabbitkit/cli/commands/dlq.py +190 -0
  17. rabbitkit/cli/commands/health.py +34 -0
  18. rabbitkit/cli/commands/migrate.py +570 -0
  19. rabbitkit/cli/commands/routes.py +88 -0
  20. rabbitkit/cli/commands/run.py +144 -0
  21. rabbitkit/cli/commands/shell.py +72 -0
  22. rabbitkit/cli/commands/topology.py +346 -0
  23. rabbitkit/concurrency.py +451 -0
  24. rabbitkit/core/__init__.py +5 -0
  25. rabbitkit/core/app.py +323 -0
  26. rabbitkit/core/config.py +849 -0
  27. rabbitkit/core/env_config.py +251 -0
  28. rabbitkit/core/errors.py +199 -0
  29. rabbitkit/core/logging.py +261 -0
  30. rabbitkit/core/message.py +235 -0
  31. rabbitkit/core/path.py +53 -0
  32. rabbitkit/core/pipeline.py +1289 -0
  33. rabbitkit/core/protocols.py +349 -0
  34. rabbitkit/core/registry.py +284 -0
  35. rabbitkit/core/route.py +329 -0
  36. rabbitkit/core/router.py +142 -0
  37. rabbitkit/core/topology.py +261 -0
  38. rabbitkit/core/topology_dispatch.py +74 -0
  39. rabbitkit/core/types.py +324 -0
  40. rabbitkit/dashboard/__init__.py +5 -0
  41. rabbitkit/dashboard/app.py +212 -0
  42. rabbitkit/di/__init__.py +19 -0
  43. rabbitkit/di/context.py +193 -0
  44. rabbitkit/di/depends.py +42 -0
  45. rabbitkit/di/resolver.py +503 -0
  46. rabbitkit/dlq.py +320 -0
  47. rabbitkit/experimental/__init__.py +50 -0
  48. rabbitkit/fastapi.py +91 -0
  49. rabbitkit/health.py +654 -0
  50. rabbitkit/highload/__init__.py +10 -0
  51. rabbitkit/highload/backpressure.py +514 -0
  52. rabbitkit/highload/batch.py +448 -0
  53. rabbitkit/locking.py +277 -0
  54. rabbitkit/management.py +470 -0
  55. rabbitkit/middleware/__init__.py +27 -0
  56. rabbitkit/middleware/base.py +125 -0
  57. rabbitkit/middleware/circuit_breaker.py +131 -0
  58. rabbitkit/middleware/compression.py +267 -0
  59. rabbitkit/middleware/deduplication.py +651 -0
  60. rabbitkit/middleware/error_classifier.py +43 -0
  61. rabbitkit/middleware/exception.py +105 -0
  62. rabbitkit/middleware/metrics.py +440 -0
  63. rabbitkit/middleware/otel.py +203 -0
  64. rabbitkit/middleware/rate_limit.py +247 -0
  65. rabbitkit/middleware/retry.py +540 -0
  66. rabbitkit/middleware/signing.py +682 -0
  67. rabbitkit/middleware/timeout.py +291 -0
  68. rabbitkit/py.typed +0 -0
  69. rabbitkit/queue_metrics.py +174 -0
  70. rabbitkit/results/__init__.py +6 -0
  71. rabbitkit/results/backend.py +102 -0
  72. rabbitkit/results/middleware.py +123 -0
  73. rabbitkit/rpc.py +632 -0
  74. rabbitkit/serialization/__init__.py +25 -0
  75. rabbitkit/serialization/base.py +35 -0
  76. rabbitkit/serialization/json.py +122 -0
  77. rabbitkit/serialization/msgspec.py +136 -0
  78. rabbitkit/serialization/pipeline.py +255 -0
  79. rabbitkit/streams.py +139 -0
  80. rabbitkit/sync/__init__.py +11 -0
  81. rabbitkit/sync/batch.py +595 -0
  82. rabbitkit/sync/broker.py +996 -0
  83. rabbitkit/sync/connection.py +209 -0
  84. rabbitkit/sync/pool.py +262 -0
  85. rabbitkit/sync/transport.py +1085 -0
  86. rabbitkit/testing/__init__.py +20 -0
  87. rabbitkit/testing/app.py +99 -0
  88. rabbitkit/testing/broker.py +540 -0
  89. rabbitkit/testing/fixtures.py +56 -0
  90. rabbitkit-0.9.0.dist-info/METADATA +575 -0
  91. rabbitkit-0.9.0.dist-info/RECORD +95 -0
  92. rabbitkit-0.9.0.dist-info/WHEEL +5 -0
  93. rabbitkit-0.9.0.dist-info/entry_points.txt +2 -0
  94. rabbitkit-0.9.0.dist-info/licenses/LICENSE +21 -0
  95. rabbitkit-0.9.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,996 @@
1
+ """SyncBroker — wires core registry + pipeline + SyncTransport.
2
+
3
+ The SyncBroker is the high-level entry point for sync 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 (basic_cancel per consumer_tag)
9
+ 2. Wait for in-flight messages (up to graceful_timeout)
10
+ 3. Close transport connection
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import signal
16
+ import threading
17
+ import time
18
+ from collections.abc import Callable
19
+ from dataclasses import replace
20
+ from typing import TYPE_CHECKING, Any
21
+
22
+ import structlog
23
+
24
+ from rabbitkit.concurrency import SyncWorkerPool
25
+ from rabbitkit.core.config import (
26
+ ConsumerConfig,
27
+ RabbitConfig,
28
+ RetryConfig,
29
+ RetryDisabled,
30
+ WorkerConfig,
31
+ )
32
+ from rabbitkit.core.errors import BackpressureError
33
+ from rabbitkit.core.message import RabbitMessage
34
+ from rabbitkit.core.path import extract_path, to_binding_key
35
+ from rabbitkit.core.pipeline import HandlerPipeline
36
+ from rabbitkit.core.registry import SubscriberRegistry
37
+ from rabbitkit.core.route import RouteDefinition
38
+ from rabbitkit.core.topology import RabbitExchange, RabbitQueue
39
+ from rabbitkit.core.types import (
40
+ AckPolicy,
41
+ MessageEnvelope,
42
+ PublishOutcome,
43
+ PublishStatus,
44
+ TopologyMode,
45
+ )
46
+ from rabbitkit.middleware.base import BaseMiddleware
47
+ from rabbitkit.middleware.retry import RetryRouter
48
+ from rabbitkit.serialization.base import Serializer
49
+ from rabbitkit.sync.connection import get_connection_errors
50
+ from rabbitkit.sync.transport import SyncTransport
51
+
52
+ if TYPE_CHECKING:
53
+ from rabbitkit.core.router import RabbitRouter
54
+
55
+ logger = structlog.stdlib.get_logger(__name__)
56
+
57
+
58
+ class SyncBroker:
59
+ """Sync broker — wires registry + pipeline + SyncTransport.
60
+
61
+ Usage::
62
+
63
+ config = RabbitConfig(connection=ConnectionConfig(host="localhost"))
64
+ broker = SyncBroker(config)
65
+
66
+ @broker.subscriber(queue="orders", exchange="events")
67
+ def handle_order(body: bytes) -> None:
68
+ ...
69
+
70
+ broker.start()
71
+ """
72
+
73
+ def __init__(
74
+ self,
75
+ config: RabbitConfig | None = None,
76
+ *,
77
+ serializer: Serializer[Any] | None = None,
78
+ di_resolver: Any | None = None,
79
+ context_repo: Any | None = None,
80
+ middlewares: list[BaseMiddleware] | None = None,
81
+ ) -> None:
82
+ self._config = config or RabbitConfig()
83
+ # C3: middlewares applied to every broker.publish() call — the primary
84
+ # producer API. Distinct from @subscriber(middlewares=[...]), which
85
+ # only wraps a route's HANDLER-RESULT publishes (Contract 5); without
86
+ # this, e.g. SigningMiddleware never signed anything published via
87
+ # broker.publish() directly. The composed chain is cached by this
88
+ # list's identity (see HandlerPipeline.compose_broker_publish_sync), so
89
+ # set the full list via this constructor param — mutating it in place
90
+ # after the first publish() call would silently reuse the stale
91
+ # pre-mutation chain.
92
+ self._publish_middlewares: list[BaseMiddleware] = middlewares or []
93
+ # Private mutable view of consumer config — brokers may apply a
94
+ # prefetch override derived from WorkerConfig.prefetch_per_worker.
95
+ # Stored separately so the caller's frozen RabbitConfig is never mutated.
96
+ self._consumer_config = self._config.consumer
97
+
98
+ self._registry = SubscriberRegistry(broker_retry=self._config.retry)
99
+ self._pipeline = HandlerPipeline(
100
+ serializer=serializer,
101
+ di_resolver=di_resolver,
102
+ context_repo=context_repo,
103
+ reject_transient_on_redelivery=self._config.consumer.reject_transient_on_redelivery,
104
+ )
105
+
106
+ self._transport: SyncTransport | None = None
107
+ self._worker_pool: SyncWorkerPool | None = None
108
+ self._started = False
109
+ self._rpc_client: Any | None = None
110
+
111
+ # L14: liveness heartbeat (see health.broker_liveness). Read by
112
+ # health.py via duck-typed attribute access (no formal HealthProvider
113
+ # method for it). None until start() -- see the start() docstring for
114
+ # why it's set there rather than only on delivery/tick.
115
+ self.last_heartbeat: float | None = None
116
+
117
+ # Bounded graceful drain (C-2): track inline in-flight handlers so
118
+ # stop() can wait for them to finish (up to graceful_timeout) instead
119
+ # of disconnecting mid-handler. R-Condition: a threading.Condition
120
+ # replaces the prior Event+int+manual set/clear; ``_in_flight`` stays
121
+ # a plain int for backward-compat reads (health checks).
122
+ self._in_flight = 0
123
+ self._in_flight_cond = threading.Condition()
124
+
125
+ # Optional publish-side flow control (C-6). Opt-in via the
126
+ # flow_controller setter; when set, publish() acquires/releases a
127
+ # slot around transport.publish().
128
+ self._flow_controller: Any | None = None
129
+
130
+ # Signal-handler bookkeeping (C-1).
131
+ self._original_handlers: dict[int, Any] = {}
132
+ self._sigterm_thread: threading.Thread | None = None
133
+
134
+ @property
135
+ def flow_controller(self) -> Any | None:
136
+ """Optional FlowController used to throttle the publish path."""
137
+ return self._flow_controller
138
+
139
+ @flow_controller.setter
140
+ def flow_controller(self, value: Any | None) -> None:
141
+ self._flow_controller = value
142
+ # Wire the controller's blocked/unblocked callbacks to the transport if
143
+ # it is already up (registration before start() is also fine: start()
144
+ # wires them after constructing the transport).
145
+ if self._transport is not None and value is not None:
146
+ self._transport.on_blocked(value.on_blocked)
147
+ self._transport.on_unblocked(value.on_unblocked)
148
+
149
+ @property
150
+ def config(self) -> RabbitConfig:
151
+ return self._config
152
+
153
+ @property
154
+ def publish_middlewares(self) -> list[BaseMiddleware]:
155
+ """Middlewares applied to every ``broker.publish()`` call (e.g. signing).
156
+
157
+ Set via the constructor's ``middlewares=`` param. See the comment on
158
+ ``self._publish_middlewares`` for why reassigning (not mutating) is
159
+ required to change this after construction.
160
+ """
161
+ return self._publish_middlewares
162
+
163
+ @property
164
+ def routes(self) -> list[RouteDefinition]:
165
+ return self._registry.routes
166
+
167
+ @property
168
+ def worker_pool(self) -> SyncWorkerPool | None:
169
+ """Return the worker pool (if configured)."""
170
+ return self._worker_pool
171
+
172
+ @property
173
+ def consumer_config(self) -> ConsumerConfig:
174
+ """Effective consumer config (may reflect WorkerConfig.prefetch override)."""
175
+ return self._consumer_config
176
+
177
+ # ── Registration (decorator API) ──────────────────────────────────────
178
+
179
+ def subscriber(
180
+ self,
181
+ queue: RabbitQueue | str,
182
+ exchange: RabbitExchange | str | None = None,
183
+ routing_key: str = "",
184
+ ack_policy: AckPolicy = AckPolicy.AUTO,
185
+ middlewares: list[BaseMiddleware] | None = None,
186
+ serializer: Serializer[Any] | None = None,
187
+ retry: RetryConfig | RetryDisabled | None = None,
188
+ tags: frozenset[str] | set[str] | None = None,
189
+ description: str = "",
190
+ name: str | None = None,
191
+ prefetch_count: int | None = None,
192
+ filter_fn: Callable[[RabbitMessage], bool] | None = None,
193
+ reject_without_dlx: str | None = None,
194
+ ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
195
+ """Register a subscriber handler."""
196
+ return self._registry.subscriber(
197
+ queue=queue,
198
+ exchange=exchange,
199
+ routing_key=routing_key,
200
+ ack_policy=ack_policy,
201
+ middlewares=middlewares,
202
+ serializer=serializer,
203
+ retry=retry,
204
+ tags=tags,
205
+ description=description,
206
+ name=name,
207
+ prefetch_count=prefetch_count,
208
+ filter_fn=filter_fn,
209
+ reject_without_dlx=reject_without_dlx,
210
+ )
211
+
212
+ def publisher(
213
+ self,
214
+ exchange: RabbitExchange | str | None = None,
215
+ routing_key: str = "",
216
+ ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
217
+ """Register a result publisher."""
218
+ return self._registry.publisher(exchange=exchange, routing_key=routing_key)
219
+
220
+ def include_router(self, router: RabbitRouter, prefix: str = "") -> None:
221
+ """Include routes from a RabbitRouter."""
222
+ self._registry.include_router(router, prefix=prefix)
223
+
224
+ # ── Lifecycle ─────────────────────────────────────────────────────────
225
+
226
+ def start(self, worker_config: WorkerConfig | None = None) -> None:
227
+ """Start the broker — connect, declare topology, start consuming.
228
+
229
+ 1. Connect to RabbitMQ
230
+ 2. Declare exchanges, queues, bindings per TopologyMode
231
+ 3. Declare retry topology (delay queues, DLQ)
232
+ 4. Optionally create a worker pool for concurrent processing
233
+ 5. Start consuming from all registered queues
234
+
235
+ L14: ``last_heartbeat`` is initialized here (not left ``None`` until
236
+ the first message/tick) so a broker that is wedged from the very
237
+ start -- before it ever processes a message or a
238
+ ``start_consuming()`` loop iteration -- is still caught by
239
+ :func:`health.broker_liveness`'s staleness check, instead of
240
+ bypassing it entirely (a ``None`` heartbeat is treated as "no
241
+ signal available" there, which previously meant "always alive").
242
+ """
243
+ if self._started:
244
+ return
245
+
246
+ self.last_heartbeat = time.monotonic()
247
+
248
+ # Configure structured logging if enabled
249
+ if self._config.logging is not None:
250
+ from rabbitkit.core.logging import configure_structlog
251
+
252
+ configure_structlog(self._config.logging)
253
+
254
+ # Create transport
255
+ self._transport = SyncTransport(
256
+ connection_config=self._config.connection,
257
+ socket_config=self._config.socket,
258
+ security_config=self._config.security,
259
+ topology_mode=self._config.topology_mode,
260
+ confirm_delivery=self._config.publisher.confirm_delivery,
261
+ confirm_timeout=self._config.publisher.confirm_timeout,
262
+ on_topology_conflict=self._config.safety.on_topology_conflict,
263
+ )
264
+
265
+ self._transport.connect()
266
+ self._transport.on_io_tick(self._mark_heartbeat)
267
+
268
+ # Wire an opt-in FlowController's blocked/unblocked callbacks to the
269
+ # transport now that it exists (C-6).
270
+ if self._flow_controller is not None:
271
+ self._transport.on_blocked(self._flow_controller.on_blocked)
272
+ self._transport.on_unblocked(self._flow_controller.on_unblocked)
273
+
274
+ # M2: the old M-P5 "channel_pool_size caps concurrent confirms"
275
+ # warning was removed. The default SyncTransport publishes on a single
276
+ # dedicated channel (not SyncChannelPool — see sync/pool.py), so
277
+ # channel_pool_size does not gate publish concurrency or confirms;
278
+ # tuning it changed nothing on this path. The real sync
279
+ # confirmed-publish ceiling (RTT-bound, ~0.9k msg/s, H6) is documented
280
+ # on SyncBroker.publish and in the README.
281
+
282
+ # Declare topology
283
+ self._declare_topology()
284
+
285
+ # Install RetryMiddleware on retry-enabled routes (topology alone does
286
+ # not retry — the middleware routes failures into the delay queues).
287
+ self._wire_retry_middleware()
288
+
289
+ # Connection-churn counter: reconnects were logged but never counted.
290
+ self._wire_reconnect_metric()
291
+
292
+ # H2: single-worker sync consumers run the handler INLINE on the pika
293
+ # I/O thread, so nothing services heartbeat frames while a handler
294
+ # runs. A handler that runs longer than ~2x the heartbeat interval
295
+ # gets its connection killed broker-side mid-handler → the ack fails,
296
+ # the message is redelivered, and side effects can repeat (possibly in
297
+ # a loop). worker_count>1 is immune (handlers run on pool threads while
298
+ # the I/O thread keeps pumping). Warn so the failure mode is visible.
299
+ single_worker = worker_config is None or worker_config.worker_count <= 1
300
+ if single_worker and self._registry.routes and self._config.connection.heartbeat > 0:
301
+ import warnings
302
+
303
+ warnings.warn(
304
+ f"Starting a single-worker sync consumer (worker_count=1) with "
305
+ f"heartbeat={self._config.connection.heartbeat}s. Handlers run inline on "
306
+ "the I/O thread, so any handler taking longer than ~"
307
+ f"{self._config.connection.heartbeat * 2}s will starve heartbeats and the "
308
+ "broker will drop the connection mid-handler (→ redelivery + duplicate "
309
+ "side effects). For slow handlers, pass "
310
+ "start(worker_config=WorkerConfig(worker_count=N)) so handlers run off the "
311
+ "I/O thread, or raise ConnectionConfig.heartbeat well above your worst-case "
312
+ "handler duration.",
313
+ RuntimeWarning,
314
+ stacklevel=2,
315
+ )
316
+
317
+ # Create worker pool if requested
318
+ if worker_config is not None and worker_config.worker_count > 1:
319
+ # M2: the old channel_pool_size deadlock warning was removed — the
320
+ # default SyncTransport publishes on a single dedicated publisher
321
+ # channel, NOT through SyncChannelPool (see sync/pool.py), so
322
+ # worker_count vs channel_pool_size cannot cause the described
323
+ # publish deadlock. Tuning channel_pool_size changed nothing on
324
+ # this path; the warning only misled operators.
325
+ # Override prefetch_count if prefetch_per_worker is set
326
+ if worker_config.prefetch_per_worker is not None:
327
+ self._consumer_config = replace(
328
+ self._config.consumer,
329
+ prefetch_count=worker_config.worker_count * worker_config.prefetch_per_worker,
330
+ )
331
+ self._worker_pool = SyncWorkerPool(config=worker_config)
332
+ self._worker_pool.start()
333
+
334
+ # Start consuming
335
+ for route in self._registry.routes:
336
+ self._start_consumer(route)
337
+
338
+ self._started = True
339
+ logger.info(
340
+ "SyncBroker started with %d routes",
341
+ len(self._registry.routes),
342
+ )
343
+
344
+ def _in_flight_inc(self) -> None:
345
+ with self._in_flight_cond:
346
+ self._in_flight += 1
347
+
348
+ def _in_flight_dec(self) -> None:
349
+ with self._in_flight_cond:
350
+ if self._in_flight > 0:
351
+ self._in_flight -= 1
352
+ if self._in_flight == 0:
353
+ self._in_flight_cond.notify_all()
354
+
355
+ def _wait_in_flight(self, deadline: float | None) -> None:
356
+ """Wait for in-flight handlers to finish (bounded by deadline).
357
+
358
+ H2: polls in short slices and pumps the transport's I/O loop between
359
+ them (rather than one long condvar wait) so a worker thread's
360
+ ack/nack/reject — marshaled onto the transport's owner thread via
361
+ ``_run_on_io_thread`` once a consume loop has run — actually gets
362
+ drained instead of stalling for the whole wait. Safe only because
363
+ ``stop()`` (and therefore this method) runs on the transport's owner
364
+ thread, matching ``SyncBroker.run()``'s call pattern.
365
+ """
366
+ transport = self._transport
367
+ poll = 0.05
368
+ with self._in_flight_cond:
369
+ if self._in_flight == 0:
370
+ return
371
+ while self._in_flight > 0:
372
+ if transport is not None:
373
+ transport.pump(poll)
374
+ if deadline is None:
375
+ self._in_flight_cond.wait(timeout=poll)
376
+ continue
377
+ remaining = max(0.0, deadline - time.monotonic())
378
+ if remaining <= 0:
379
+ break
380
+ self._in_flight_cond.wait(timeout=min(poll, remaining))
381
+ if self._in_flight > 0:
382
+ logger.warning(
383
+ "SyncBroker.stop: %d in-flight handler(s) still running after "
384
+ "graceful drain deadline; disconnecting anyway",
385
+ self._in_flight,
386
+ )
387
+
388
+ def stop(self, timeout: float | None = None) -> None:
389
+ """Stop the broker - cancel consumers, drain pool, drain in-flight, disconnect.
390
+
391
+ ``timeout`` defaults to ``ConsumerConfig.graceful_timeout`` (C-2). The
392
+ whole stop sequence is bounded by an overall deadline.
393
+
394
+ C5: consumers are cancelled FIRST, before the worker pool is drained.
395
+ Draining the pool before cancelling left the consumer active for the
396
+ entire (potentially graceful_timeout-long) drain wait — a message
397
+ delivered in that window was submitted to a pool already mid-shutdown:
398
+ ``SyncWorkerPool.submit()`` either raises ``RuntimeError`` (uncaught,
399
+ propagating into pika's callback machinery) or, once ``.stop()`` has
400
+ fully returned, silently runs the handler *inline* on the pika I/O
401
+ thread — either way the message is never cleanly settled before
402
+ ``disconnect()``, so it is redelivered (duplicate-processing risk) on
403
+ the next connection. Cancelling first stops new deliveries outright,
404
+ so the pool only ever drains work that was already in flight.
405
+ """
406
+ if not self._started:
407
+ return
408
+
409
+ effective = timeout if timeout is not None else self._consumer_config.graceful_timeout
410
+ deadline = None if effective is None else time.monotonic() + effective
411
+
412
+ # Cancel all consumers FIRST — stop new deliveries before draining
413
+ # anything, so nothing new arrives while the pool/in-flight drain runs.
414
+ assert self._transport is not None
415
+ for route in self._registry.routes:
416
+ if route.consumer_tag:
417
+ self._transport.cancel_consumer(route.consumer_tag)
418
+
419
+ # Drain the worker pool (let in-flight pooled tasks finish), bounded by
420
+ # the outer deadline. H2: pass the transport's pump so a worker
421
+ # thread's marshaled ack/nack/reject is actually drained during the
422
+ # wait, instead of the transport falling back to an unsafe inline
423
+ # cross-thread call once the consume loop has stopped.
424
+ if self._worker_pool is not None:
425
+ remaining = None if deadline is None else max(0.0, deadline - time.monotonic())
426
+ self._worker_pool.stop(timeout=remaining, pump=self._transport.pump)
427
+ self._worker_pool = None
428
+
429
+ # Drain inline in-flight handlers (C-2).
430
+ self._wait_in_flight(deadline)
431
+
432
+ # Close RPC client if used
433
+ if self._rpc_client is not None:
434
+ self._rpc_client.close()
435
+ self._rpc_client = None
436
+
437
+ # Disconnect
438
+ if self._transport:
439
+ self._transport.disconnect()
440
+
441
+ self._started = False
442
+ logger.info("SyncBroker stopped")
443
+
444
+ def _install_sigterm_handler(self) -> None:
445
+ """Install a SIGTERM handler that breaks the pika consume loop (C-1).
446
+
447
+ pika's BlockingConnection is not signal-safe, so the handler spawns a
448
+ short-lived daemon thread that calls ``transport.stop_consuming()``.
449
+ Only installed when running in the main thread; failures are ignored.
450
+ """
451
+ try:
452
+ self._original_handlers[signal.SIGTERM] = signal.signal(signal.SIGTERM, self._on_sigterm)
453
+ except (ValueError, OSError): # pragma: no cover - not in main thread - best effort
454
+ logger.debug("SIGTERM handler not installed (not in main thread)")
455
+
456
+ def _restore_signal_handlers(self) -> None:
457
+ for sig, prev in self._original_handlers.items():
458
+ try:
459
+ signal.signal(sig, prev)
460
+ except (ValueError, OSError): # pragma: no cover
461
+ pass
462
+ self._original_handlers.clear()
463
+
464
+ def _on_sigterm(self, signum: int, frame: Any) -> None:
465
+ logger.info("Received SIGTERM; initiating graceful drain")
466
+ # pika BlockingConnection is not signal-safe: do the stop on a thread.
467
+ transport = self._transport
468
+ if transport is None:
469
+ return
470
+
471
+ def _drain() -> None:
472
+ try:
473
+ transport.stop_consuming()
474
+ except Exception: # pragma: no cover - best effort
475
+ logger.warning("stop_consuming raised during SIGTERM drain", exc_info=True)
476
+
477
+ self._sigterm_thread = threading.Thread(target=_drain, name="rabbitkit-sigterm", daemon=True)
478
+ self._sigterm_thread.start()
479
+
480
+ def run(self, worker_config: WorkerConfig | None = None) -> None:
481
+ """Start and run the blocking consume loop.
482
+
483
+ Blocks until SIGINT/SIGTERM or stop() is called. Installs a SIGTERM
484
+ handler (C-1) so k8s pod termination drains instead of hard-killing.
485
+ Recovers from connection drops by reconnecting, re-declaring topology,
486
+ and re-subscribing all consumers - pika's BlockingConnection has no
487
+ built-in recovery, so without this a single blip kills the consumer.
488
+
489
+ ``worker_config`` is forwarded to :meth:`start`, so a multi-worker
490
+ consumer (``worker_count > 1``) also gets the recovery loop.
491
+ """
492
+ self.start(worker_config=worker_config)
493
+ self._install_sigterm_handler()
494
+ connection_errors = get_connection_errors()
495
+ try:
496
+ while True:
497
+ try:
498
+ if self._transport is None:
499
+ break
500
+ self._transport.start_consuming()
501
+ break # clean stop_consuming() -> exit
502
+ except KeyboardInterrupt:
503
+ break
504
+ except connection_errors as exc:
505
+ logger.warning("consumer connection lost; recovering", error=str(exc))
506
+ self._recover_consumers()
507
+ finally:
508
+ self._restore_signal_handlers()
509
+ self.stop()
510
+
511
+ def pump_idle(self, time_limit: float = 0.05) -> None:
512
+ """Service the connection's I/O loop without consuming (idle keep-alive).
513
+
514
+ ``run()``/``start_consuming()`` pumps ``process_data_events()``
515
+ continuously while consumers are registered, which incidentally
516
+ keeps the (single, shared) connection's heartbeats serviced too —
517
+ see ``sync/transport.py``'s module docstring on the one-connection
518
+ model. A **publish-only** broker (no registered routes, or one that
519
+ never calls ``run()``) has nothing driving that pump: the connection
520
+ is only touched when ``publish()`` actually runs, so a long idle gap
521
+ can get it heartbeat-timed-out broker-side, and a dead connection is
522
+ only discovered (and reconnected) on the *next* publish attempt.
523
+
524
+ Call this periodically — from the SAME thread that called
525
+ ``start()``, same invariant as every other transport call — from
526
+ your own idle loop (e.g. between polling for work) to reconnect
527
+ proactively if the connection died, service pending heartbeat
528
+ frames, and refresh the liveness heartbeat (see
529
+ ``health.broker_liveness``) even though no message was delivered.
530
+ A no-op if the broker is not started.
531
+ """
532
+ if self._transport is None:
533
+ return
534
+ self._transport.ensure_connected()
535
+ self._transport.pump(time_limit)
536
+ self._mark_heartbeat()
537
+
538
+ # ── Publishing ────────────────────────────────────────────────────────
539
+
540
+ def publish(
541
+ self,
542
+ envelope: MessageEnvelope | None = None,
543
+ *,
544
+ exchange: str = "",
545
+ routing_key: str = "",
546
+ body: bytes | str | dict[str, Any] | None = None,
547
+ headers: dict[str, Any] | None = None,
548
+ content_type: str = "application/json",
549
+ correlation_id: str | None = None,
550
+ reply_to: str | None = None,
551
+ ) -> PublishOutcome:
552
+ """Publish a message.
553
+
554
+ Accepts either a pre-built ``MessageEnvelope`` or individual kwargs::
555
+
556
+ # Envelope form (full control):
557
+ broker.publish(MessageEnvelope(routing_key="orders.created", body=b"..."))
558
+
559
+ # Kwargs form (convenient):
560
+ broker.publish(
561
+ exchange="orders",
562
+ routing_key="orders.created",
563
+ body={"order_id": 123},
564
+ headers={"x-tenant": "acme"},
565
+ )
566
+
567
+ When ``body`` is a dict or str it is JSON-encoded automatically.
568
+
569
+ When an opt-in ``FlowController`` is configured (``broker.flow_controller
570
+ = fc``), a publish slot is acquired before and released after the
571
+ transport publish so backpressure/rate-limiting applies to the hot path.
572
+ Without a controller this is a plain pass-through.
573
+
574
+ When ``middlewares=[...]`` was passed to the constructor, each
575
+ middleware's ``publish_scope`` wraps this call (e.g. ``SigningMiddleware``
576
+ signs the envelope) — see ``publish_middlewares``. Middleware wraps
577
+ OUTSIDE the flow-control gate, so a middleware-transformed envelope is
578
+ what gets rate-limited/blocked, and what the transport actually sends.
579
+
580
+ Throughput note (H6): with publisher confirms on (the default), this
581
+ waits for a broker confirm per message on a single channel, so it is
582
+ RTT-bound at ~0.9k msg/s and does NOT scale with worker_count — pika's
583
+ BlockingConnection serializes confirms, it cannot pipeline them. To
584
+ drain a large backlog fast, use AsyncBroker + AsyncBatchPublisher
585
+ (pipelined confirms, ~6.1k msg/s) or more processes.
586
+ """
587
+ if envelope is None:
588
+ import json as _json
589
+
590
+ if body is None:
591
+ raw_body = b""
592
+ elif isinstance(body, bytes):
593
+ raw_body = body
594
+ elif isinstance(body, str):
595
+ raw_body = body.encode()
596
+ else:
597
+ raw_body = _json.dumps(body).encode()
598
+ # M2: honor PublisherConfig defaults for the kwargs form (they were
599
+ # previously dead config). Envelope-form callers keep full control.
600
+ envelope = MessageEnvelope(
601
+ routing_key=routing_key,
602
+ body=raw_body,
603
+ exchange=exchange,
604
+ headers=headers or {},
605
+ content_type=content_type,
606
+ correlation_id=correlation_id,
607
+ reply_to=reply_to,
608
+ mandatory=self._config.publisher.mandatory,
609
+ delivery_mode=2 if self._config.publisher.persistent else 1,
610
+ )
611
+
612
+ # M10: reject oversized bodies at publish time (input validation at
613
+ # the trust boundary — a too-large message is a programming/policy
614
+ # error, caught before it hits the wire).
615
+ max_bytes = self._config.publisher.max_message_bytes
616
+ if max_bytes and len(envelope.body) > max_bytes:
617
+ raise ValueError(
618
+ f"Message body ({len(envelope.body)} bytes) exceeds "
619
+ f"PublisherConfig.max_message_bytes ({max_bytes}). Large messages are a "
620
+ "RabbitMQ anti-pattern — store the payload externally and publish a "
621
+ "reference, or raise the limit if this is intentional."
622
+ )
623
+
624
+ if self._transport is None:
625
+ raise RuntimeError("Broker not started. Call start() first.")
626
+ transport = self._transport # narrowed local capture for the closure below
627
+
628
+ def do_transport_publish(env: MessageEnvelope) -> PublishOutcome:
629
+ fc = self._flow_controller
630
+ if fc is not None:
631
+ if not fc.acquire():
632
+ # Dropped by backpressure policy (drop/timeout). Do not publish.
633
+ return PublishOutcome(
634
+ status=PublishStatus.ERROR,
635
+ exchange=env.exchange,
636
+ routing_key=env.routing_key,
637
+ error=BackpressureError("publish dropped by backpressure policy"),
638
+ )
639
+ try:
640
+ return transport.publish(env)
641
+ finally:
642
+ fc.release()
643
+ return transport.publish(env)
644
+
645
+ if self._publish_middlewares:
646
+ chain = self._pipeline.compose_broker_publish_sync(self._publish_middlewares)
647
+ outcome: PublishOutcome = chain(envelope, do_transport_publish)
648
+ return outcome
649
+ return do_transport_publish(envelope)
650
+
651
+ def _flow_controlled_internal_publish(self, env: MessageEnvelope) -> PublishOutcome:
652
+ """M18: apply the broker's ``FlowController`` (if configured) to an
653
+ INTERNAL republish — ``RetryMiddleware``'s delay-queue publish, or a
654
+ handler's result/RPC-reply publish — used as their ``publish_fn``
655
+ instead of the raw, unthrottled ``transport.publish``.
656
+
657
+ Deliberately diverges from ``do_transport_publish`` above: a
658
+ configured ``on_blocked="raise"`` must NEVER raise ``BackpressureError``
659
+ out of here. ``RetryMiddleware`` and the pipeline's result-publish path
660
+ only understand a returned ``PublishOutcome`` (checked via ``.ok``),
661
+ not exceptions — letting one escape would propagate as an unclassified
662
+ error, default to PERMANENT, and reject/destroy the message instead of
663
+ the existing safe nack+requeue-on-publish-failure behavior both paths
664
+ already implement. So regardless of the configured policy, a
665
+ blocked/dropped slot here always resolves as a failed
666
+ ``PublishOutcome`` (status=ERROR), never an exception — the same
667
+ outcome shape a real transport failure already produces.
668
+ """
669
+ if self._transport is None: # pragma: no cover — defensive; callers only run while consuming
670
+ raise RuntimeError("Broker not started. Call start() first.")
671
+ transport = self._transport
672
+ fc = self._flow_controller
673
+ if fc is None:
674
+ return transport.publish(env)
675
+ try:
676
+ acquired = fc.acquire()
677
+ except BackpressureError as exc:
678
+ return PublishOutcome(
679
+ status=PublishStatus.ERROR, exchange=env.exchange, routing_key=env.routing_key, error=exc
680
+ )
681
+ if not acquired:
682
+ return PublishOutcome(
683
+ status=PublishStatus.ERROR,
684
+ exchange=env.exchange,
685
+ routing_key=env.routing_key,
686
+ error=BackpressureError("publish dropped by backpressure policy"),
687
+ )
688
+ try:
689
+ return transport.publish(env)
690
+ finally:
691
+ fc.release()
692
+
693
+ def request(
694
+ self,
695
+ routing_key: str,
696
+ body: bytes,
697
+ *,
698
+ timeout: float = 5.0,
699
+ exchange: str = "",
700
+ headers: dict[str, Any] | None = None,
701
+ ) -> RabbitMessage:
702
+ """Send an RPC request and wait for a response (sync).
703
+
704
+ Lazily initializes an RPCClient on first call.
705
+ The client is shared across calls and closed in stop().
706
+ """
707
+ if self._transport is None:
708
+ raise RuntimeError("Broker not started. Call start() first.")
709
+ if self._rpc_client is None:
710
+ from rabbitkit.rpc import RPCClient
711
+
712
+ self._rpc_client = RPCClient(self._transport)
713
+ return self._rpc_client.call(routing_key, body, timeout=timeout, exchange=exchange, headers=headers)
714
+
715
+ # ── Internal ──────────────────────────────────────────────────────────
716
+
717
+ def _recover_consumers(self) -> None:
718
+ """Reconnect and re-establish topology + subscriptions after a drop."""
719
+ if self._transport is None:
720
+ return
721
+ self._transport.reconnect()
722
+ self._declare_topology()
723
+ for route in self._registry.routes:
724
+ self._start_consumer(route)
725
+
726
+ def _wire_retry_middleware(self) -> None:
727
+ """Install ``RetryMiddleware`` on routes whose retry is enabled.
728
+
729
+ ``_declare_topology`` declares the retry/DLQ *topology* (delay queues +
730
+ source-queue DLX), but the ``RetryMiddleware`` that actually routes a
731
+ failed message into the delay queues must also run in the route's
732
+ middleware chain. Without it, ``retry=RetryConfig(...)`` would build the
733
+ topology while transient failures ``nack(requeue=True)`` in a hot loop —
734
+ the delay queues would never receive anything and ``max_retries`` would
735
+ never be enforced. This wires both halves from the single retry switch
736
+ (``RabbitConfig.retry`` / ``@subscriber(retry=...)``).
737
+
738
+ Placed outer of ordinary user middlewares (e.g. ``TimeoutMiddleware``) so
739
+ retry can classify and re-queue exceptions they raise, but inner of any
740
+ ``ExceptionMiddleware`` (the documented true-outermost layer) — see
741
+ :func:`rabbitkit.middleware.retry.retry_middleware_insertion_index`.
742
+
743
+ Idempotent: routes that already carry a ``RetryMiddleware`` — supplied
744
+ explicitly via ``middlewares=[...]`` or auto-wired on a previous start —
745
+ are left untouched (no double-retry, no stacking on reconnect).
746
+ """
747
+ if self._transport is None:
748
+ return
749
+
750
+ from rabbitkit.middleware.metrics import MetricsMiddleware
751
+ from rabbitkit.middleware.retry import (
752
+ RetryMiddleware,
753
+ retry_middleware_insertion_index,
754
+ warn_retry_middleware_without_topology,
755
+ warn_retry_without_confirms,
756
+ )
757
+ from rabbitkit.middleware.signing import check_signing_retry_conflict
758
+
759
+ wired = False
760
+ for route in self._registry.routes:
761
+ retry_config = route.effective_retry_config(self._config.retry)
762
+ has_retry_mw = any(isinstance(mw, RetryMiddleware) for mw in route.route_middlewares)
763
+ if retry_config is None:
764
+ if has_retry_mw:
765
+ # Half-configured: a RetryMiddleware runs but no retry topology
766
+ # was declared, so its delay-queue publishes target non-existent
767
+ # queues and are silently dropped. Surface it loudly.
768
+ warn_retry_middleware_without_topology(route.name)
769
+ continue
770
+ # H1: signing + retry destroys every retried message — fail fast.
771
+ check_signing_retry_conflict(route.name, route.route_middlewares)
772
+ if has_retry_mw:
773
+ continue
774
+ if not self._config.publisher.confirm_delivery:
775
+ warn_retry_without_confirms(route.name) # M4
776
+ index = retry_middleware_insertion_index(route.route_middlewares)
777
+ # M2: wire in an existing route MetricsMiddleware (if any) so
778
+ # messages_retried_total/dead_lettered_total are observable.
779
+ metrics_mw = next(
780
+ (mw for mw in route.route_middlewares if isinstance(mw, MetricsMiddleware)), None
781
+ )
782
+ route.route_middlewares.insert(
783
+ index,
784
+ RetryMiddleware(
785
+ retry_config,
786
+ publish_fn=self._flow_controlled_internal_publish, # M18: honor FlowController
787
+ metrics_collector=metrics_mw.collector if metrics_mw else None,
788
+ metrics_config=metrics_mw.config if metrics_mw else None,
789
+ ),
790
+ )
791
+ wired = True
792
+
793
+ if not self._config.publisher.confirm_delivery:
794
+ for route in self._registry.routes:
795
+ if route.result_publisher is not None:
796
+ warn_retry_without_confirms(route.name, context="result") # M4
797
+
798
+ if wired:
799
+ # Drop any middleware chains cached before the retry mw was installed.
800
+ self._pipeline.clear_caches()
801
+
802
+ def _wire_reconnect_metric(self) -> None:
803
+ """Count transport reconnects (connection churn) via the first route
804
+ ``MetricsMiddleware``'s collector, if any. Reconnects were logged but
805
+ never counted, so a flapping broker/network was invisible to
806
+ metrics-based alerting. No-op when no route carries metrics."""
807
+ if self._transport is None:
808
+ return
809
+ from rabbitkit.middleware.metrics import MetricsMiddleware
810
+
811
+ metrics_mw = next(
812
+ (
813
+ mw
814
+ for route in self._registry.routes
815
+ for mw in route.route_middlewares
816
+ if isinstance(mw, MetricsMiddleware) and mw.collector is not None
817
+ ),
818
+ None,
819
+ )
820
+ if metrics_mw is None or metrics_mw.collector is None:
821
+ return
822
+ collector = metrics_mw.collector
823
+ metric_name = metrics_mw.config.reconnects_total
824
+ self._transport.on_reconnect(lambda: collector.inc_counter(metric_name, {}))
825
+
826
+ def _declare_topology(self) -> None:
827
+ """Declare exchanges, queues, and bindings for all routes."""
828
+ if self._transport is None:
829
+ return
830
+
831
+ for route in self._registry.routes:
832
+ # Declare exchange
833
+ if route.exchange is not None:
834
+ self._transport.declare_exchange(route.exchange)
835
+
836
+ # Exchange-to-exchange binding
837
+ bind_kwargs = route.exchange.to_bind_kwargs()
838
+ if bind_kwargs is not None:
839
+ self._transport.bind_exchange(
840
+ destination=bind_kwargs["destination"],
841
+ source=bind_kwargs["source"],
842
+ routing_key=bind_kwargs["routing_key"],
843
+ arguments=bind_kwargs["arguments"],
844
+ )
845
+
846
+ # Determine retry config early so source queue can include DLQ routing
847
+ retry_config = route.effective_retry_config(self._config.retry)
848
+ # C3: a route with no dead-letter path can reject(requeue=False)
849
+ # (permanent errors, filter_fn, RejectMessage) and RabbitMQ would
850
+ # DISCARD the message. Apply SafetyConfig.reject_without_dlx:
851
+ # auto-provision "<queue>.dlq" (default), fail startup, or warn
852
+ # and allow discard. Only under AUTO_DECLARE — in passive/manual
853
+ # modes rabbitkit does not own the queue's arguments.
854
+ safety_dlq_name: str | None = None
855
+ if self._config.topology_mode is TopologyMode.AUTO_DECLARE:
856
+ safety_dlq_name = route.resolve_safety_dlq(self._config.safety, self._config.retry)
857
+ # A queue that IS another route's DLQ is terminal — consuming your
858
+ # own DLQ is a legitimate pattern (inspect/replay consumers), and
859
+ # auto-chaining more topology onto it (safety DLX injection, or
860
+ # BROKER-DEFAULT retry inherited by the DLQ-consumer route) would
861
+ # re-declare the DLQ with different arguments than the retry/
862
+ # safety topology already declared it with — a 406 inequivalent-
863
+ # arg startup failure, caught by the real-broker CI suite. An
864
+ # EXPLICIT per-route retry= on a DLQ consumer still wins.
865
+ is_anothers_dlq = any(
866
+ other is not route and route.queue.name == f"{other.queue.name}.dlq"
867
+ for other in self._registry.routes
868
+ )
869
+ if is_anothers_dlq:
870
+ safety_dlq_name = None
871
+ if route.retry_override is None:
872
+ retry_config = None # don't inherit broker-default retry
873
+
874
+ if retry_config is not None:
875
+ retry_router = RetryRouter(retry_config)
876
+ dlq_name = retry_router.get_dlq_name(route.queue.name)
877
+ # Re-declare source queue with x-dead-letter fields so RabbitMQ
878
+ # automatically routes nacked/rejected messages to the DLQ.
879
+ import dataclasses
880
+
881
+ source_queue = dataclasses.replace(
882
+ route.queue,
883
+ dead_letter_exchange="",
884
+ dead_letter_routing_key=dlq_name,
885
+ )
886
+ elif safety_dlq_name is not None:
887
+ import dataclasses
888
+
889
+ logger.info(
890
+ "Auto-provisioned DLQ %r for queue %r (reject_without_dlx=auto_provision)",
891
+ safety_dlq_name,
892
+ route.queue.name,
893
+ )
894
+ source_queue = dataclasses.replace(
895
+ route.queue,
896
+ dead_letter_exchange="",
897
+ dead_letter_routing_key=safety_dlq_name,
898
+ )
899
+ else:
900
+ source_queue = route.queue
901
+
902
+ # Declare queue (with DLQ routing arguments if retry/safety DLX applies)
903
+ self._transport.declare_queue(source_queue)
904
+
905
+ # Bind queue to exchange (C4: bind_arguments matter for headers exchanges)
906
+ if route.exchange is not None:
907
+ self._transport.bind_queue(
908
+ queue=route.queue.name,
909
+ exchange=route.exchange.name,
910
+ routing_key=to_binding_key(route.queue.routing_key),
911
+ arguments=route.queue.bind_arguments or None,
912
+ )
913
+
914
+ # Declare retry/DLQ topology if retry is enabled
915
+ if retry_config is not None:
916
+ exchange_name = route.exchange.name if route.exchange else ""
917
+ delay_queues = retry_router.get_delay_queue_definitions(route.queue.name, exchange_name)
918
+ for delay_queue in delay_queues:
919
+ self._transport.declare_queue(delay_queue)
920
+ elif safety_dlq_name is not None:
921
+ self._transport.declare_queue(RabbitQueue(name=safety_dlq_name, durable=True))
922
+
923
+ def _mark_heartbeat(self) -> None:
924
+ """Refresh the liveness heartbeat (I-4/L14).
925
+
926
+ Called both per delivered message (``on_message`` below) and once
927
+ per ``start_consuming()`` I/O loop tick (wired via
928
+ ``transport.on_io_tick`` in :meth:`start`) -- the latter is what
929
+ keeps a healthy but message-idle consumer from being mistaken for a
930
+ wedged one by :func:`health.broker_liveness`.
931
+ """
932
+ self.last_heartbeat = time.monotonic()
933
+
934
+ def _start_consumer(self, route: RouteDefinition) -> None:
935
+ """Start consuming for a single route."""
936
+ if self._transport is None:
937
+ return
938
+
939
+ pool = self._worker_pool
940
+
941
+ def on_message(message: RabbitMessage) -> None:
942
+ """Process incoming message through the pipeline."""
943
+ # Track inline in-flight so stop() can drain gracefully (C-2).
944
+ self._in_flight_inc()
945
+ self._mark_heartbeat()
946
+ try:
947
+ # Set the original queue in headers for retry routing
948
+ if "x-rabbitkit-original-queue" not in message.headers:
949
+ message.headers["x-rabbitkit-original-queue"] = route.queue.name
950
+
951
+ # Populate named routing-key segments for Path() DI
952
+ message.path = extract_path(message.routing_key, route.queue.routing_key)
953
+
954
+ try:
955
+ self._pipeline.process_sync(
956
+ route,
957
+ message,
958
+ publish_fn=self._flow_controlled_internal_publish, # M18
959
+ )
960
+ except Exception:
961
+ # M12: AUTO/NACK_ON_ERROR settle inside the pipeline and
962
+ # never reach here — but a MANUAL-policy handler that
963
+ # raises without settling used to propagate out of the
964
+ # delivery callback and STOP the entire run loop (one bad
965
+ # handler took down the broker). Contain it: log and
966
+ # nack-requeue if still unsettled, so the failure degrades
967
+ # to a redelivery instead of a broker-wide halt. Matches
968
+ # the pooled/async paths, which already isolate handler
969
+ # exceptions.
970
+ logger.error(
971
+ "Handler raised through the pipeline; nacking for redelivery",
972
+ queue=route.queue.name,
973
+ message_id=message.message_id,
974
+ exc_info=True,
975
+ )
976
+ if not message.is_settled:
977
+ message.nack(requeue=True)
978
+ finally:
979
+ self._in_flight_dec()
980
+
981
+ if pool is not None:
982
+
983
+ def on_message_pooled(message: RabbitMessage) -> None:
984
+ pool.submit(on_message, message)
985
+
986
+ callback = on_message_pooled
987
+ else:
988
+ callback = on_message
989
+
990
+ effective_prefetch = route.prefetch_count or self._consumer_config.prefetch_count
991
+ consumer_tag = self._transport.consume(
992
+ queue=route.queue.name,
993
+ callback=callback,
994
+ prefetch=effective_prefetch,
995
+ )
996
+ route.runtime_state.consumer_tag = consumer_tag