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
rabbitkit/sync/broker.py
ADDED
|
@@ -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
|