rabbitkit 0.9.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rabbitkit/__init__.py +201 -0
- rabbitkit/_version.py +3 -0
- rabbitkit/aio/__init__.py +31 -0
- rabbitkit/async_/__init__.py +9 -0
- rabbitkit/async_/batch.py +213 -0
- rabbitkit/async_/broker.py +1123 -0
- rabbitkit/async_/connection.py +274 -0
- rabbitkit/async_/pool.py +363 -0
- rabbitkit/async_/transport.py +877 -0
- rabbitkit/asyncapi/__init__.py +5 -0
- rabbitkit/asyncapi/generator.py +219 -0
- rabbitkit/asyncapi/schema.py +98 -0
- rabbitkit/cli/__init__.py +77 -0
- rabbitkit/cli/_utils.py +38 -0
- rabbitkit/cli/commands/__init__.py +0 -0
- rabbitkit/cli/commands/dlq.py +190 -0
- rabbitkit/cli/commands/health.py +34 -0
- rabbitkit/cli/commands/migrate.py +570 -0
- rabbitkit/cli/commands/routes.py +88 -0
- rabbitkit/cli/commands/run.py +144 -0
- rabbitkit/cli/commands/shell.py +72 -0
- rabbitkit/cli/commands/topology.py +346 -0
- rabbitkit/concurrency.py +451 -0
- rabbitkit/core/__init__.py +5 -0
- rabbitkit/core/app.py +323 -0
- rabbitkit/core/config.py +849 -0
- rabbitkit/core/env_config.py +251 -0
- rabbitkit/core/errors.py +199 -0
- rabbitkit/core/logging.py +261 -0
- rabbitkit/core/message.py +235 -0
- rabbitkit/core/path.py +53 -0
- rabbitkit/core/pipeline.py +1289 -0
- rabbitkit/core/protocols.py +349 -0
- rabbitkit/core/registry.py +284 -0
- rabbitkit/core/route.py +329 -0
- rabbitkit/core/router.py +142 -0
- rabbitkit/core/topology.py +261 -0
- rabbitkit/core/topology_dispatch.py +74 -0
- rabbitkit/core/types.py +324 -0
- rabbitkit/dashboard/__init__.py +5 -0
- rabbitkit/dashboard/app.py +212 -0
- rabbitkit/di/__init__.py +19 -0
- rabbitkit/di/context.py +193 -0
- rabbitkit/di/depends.py +42 -0
- rabbitkit/di/resolver.py +503 -0
- rabbitkit/dlq.py +320 -0
- rabbitkit/experimental/__init__.py +50 -0
- rabbitkit/fastapi.py +91 -0
- rabbitkit/health.py +654 -0
- rabbitkit/highload/__init__.py +10 -0
- rabbitkit/highload/backpressure.py +514 -0
- rabbitkit/highload/batch.py +448 -0
- rabbitkit/locking.py +277 -0
- rabbitkit/management.py +470 -0
- rabbitkit/middleware/__init__.py +27 -0
- rabbitkit/middleware/base.py +125 -0
- rabbitkit/middleware/circuit_breaker.py +131 -0
- rabbitkit/middleware/compression.py +267 -0
- rabbitkit/middleware/deduplication.py +651 -0
- rabbitkit/middleware/error_classifier.py +43 -0
- rabbitkit/middleware/exception.py +105 -0
- rabbitkit/middleware/metrics.py +440 -0
- rabbitkit/middleware/otel.py +203 -0
- rabbitkit/middleware/rate_limit.py +247 -0
- rabbitkit/middleware/retry.py +540 -0
- rabbitkit/middleware/signing.py +682 -0
- rabbitkit/middleware/timeout.py +291 -0
- rabbitkit/py.typed +0 -0
- rabbitkit/queue_metrics.py +174 -0
- rabbitkit/results/__init__.py +6 -0
- rabbitkit/results/backend.py +102 -0
- rabbitkit/results/middleware.py +123 -0
- rabbitkit/rpc.py +632 -0
- rabbitkit/serialization/__init__.py +25 -0
- rabbitkit/serialization/base.py +35 -0
- rabbitkit/serialization/json.py +122 -0
- rabbitkit/serialization/msgspec.py +136 -0
- rabbitkit/serialization/pipeline.py +255 -0
- rabbitkit/streams.py +139 -0
- rabbitkit/sync/__init__.py +11 -0
- rabbitkit/sync/batch.py +595 -0
- rabbitkit/sync/broker.py +996 -0
- rabbitkit/sync/connection.py +209 -0
- rabbitkit/sync/pool.py +262 -0
- rabbitkit/sync/transport.py +1085 -0
- rabbitkit/testing/__init__.py +20 -0
- rabbitkit/testing/app.py +99 -0
- rabbitkit/testing/broker.py +540 -0
- rabbitkit/testing/fixtures.py +56 -0
- rabbitkit-0.9.0.dist-info/METADATA +575 -0
- rabbitkit-0.9.0.dist-info/RECORD +95 -0
- rabbitkit-0.9.0.dist-info/WHEEL +5 -0
- rabbitkit-0.9.0.dist-info/entry_points.txt +2 -0
- rabbitkit-0.9.0.dist-info/licenses/LICENSE +21 -0
- rabbitkit-0.9.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""Handler timeout middleware.
|
|
2
|
+
|
|
3
|
+
Enforces a maximum processing time per message. If the handler takes longer
|
|
4
|
+
than ``TimeoutConfig.timeout_seconds``, a ``HandlerTimeoutError`` is raised.
|
|
5
|
+
|
|
6
|
+
Implementation strategy
|
|
7
|
+
-----------------------
|
|
8
|
+
* **Async** — ``asyncio.wait_for()`` cancels the coroutine cleanly.
|
|
9
|
+
* **Sync** — handler is run in a ``daemon=True`` ``threading.Thread``; the
|
|
10
|
+
calling thread waits ``timeout_seconds`` and raises if the thread is still
|
|
11
|
+
alive. The background thread continues to its natural end but its result is
|
|
12
|
+
discarded. (CPython has no safe way to kill a thread, so long-running IO
|
|
13
|
+
handlers will not be interrupted — they just become detached.)
|
|
14
|
+
|
|
15
|
+
``HandlerTimeoutError`` is a subclass of ``TimeoutError``. The default error
|
|
16
|
+
classifier treats it as **TRANSIENT**, so retry middleware (if configured) will
|
|
17
|
+
re-queue the message. Override the classifier if you want timeouts to go
|
|
18
|
+
straight to the DLQ.
|
|
19
|
+
|
|
20
|
+
Quick start
|
|
21
|
+
-----------
|
|
22
|
+
from rabbitkit.middleware.timeout import TimeoutMiddleware, TimeoutConfig
|
|
23
|
+
|
|
24
|
+
# 10-second hard limit on all handlers for this route
|
|
25
|
+
timeout_mw = TimeoutMiddleware(TimeoutConfig(timeout_seconds=10.0))
|
|
26
|
+
|
|
27
|
+
@broker.subscriber(queue="slow-tasks", middlewares=[timeout_mw])
|
|
28
|
+
async def handle_task(body: bytes) -> None:
|
|
29
|
+
await some_slow_operation()
|
|
30
|
+
|
|
31
|
+
Default timeout (30 s)::
|
|
32
|
+
|
|
33
|
+
timeout_mw = TimeoutMiddleware() # uses TimeoutConfig(timeout_seconds=30.0)
|
|
34
|
+
|
|
35
|
+
Combining with retry::
|
|
36
|
+
|
|
37
|
+
from rabbitkit import RetryConfig
|
|
38
|
+
from rabbitkit.middleware.retry import RetryMiddleware
|
|
39
|
+
|
|
40
|
+
retry_mw = RetryMiddleware(RetryConfig(max_retries=3, delays=(5, 30, 120)))
|
|
41
|
+
timeout_mw = TimeoutMiddleware(TimeoutConfig(timeout_seconds=15.0))
|
|
42
|
+
|
|
43
|
+
# Order: timeout wraps handler, retry wraps timeout
|
|
44
|
+
@broker.subscriber(
|
|
45
|
+
queue="jobs",
|
|
46
|
+
middlewares=[retry_mw, timeout_mw], # retry outermost
|
|
47
|
+
)
|
|
48
|
+
async def run_job(body: bytes) -> None: ...
|
|
49
|
+
|
|
50
|
+
Exception hierarchy
|
|
51
|
+
-------------------
|
|
52
|
+
``HandlerTimeoutError(TimeoutError)`` — carries ``.timeout_seconds`` attribute
|
|
53
|
+
for logging / classification.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
from __future__ import annotations
|
|
57
|
+
|
|
58
|
+
import asyncio
|
|
59
|
+
import logging
|
|
60
|
+
import threading
|
|
61
|
+
from collections.abc import Callable
|
|
62
|
+
from dataclasses import dataclass
|
|
63
|
+
from typing import Any
|
|
64
|
+
|
|
65
|
+
from rabbitkit.core.message import RabbitMessage
|
|
66
|
+
from rabbitkit.middleware.base import BaseMiddleware
|
|
67
|
+
|
|
68
|
+
logger = logging.getLogger(__name__)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class _DiscardedSettlement(BaseException):
|
|
72
|
+
"""H9 internal sentinel — raised (never caught by user code, since it's
|
|
73
|
+
not an ``Exception``) from a guarded settlement stand-in to abort a
|
|
74
|
+
discarded ack()/nack()/reject() call from an abandoned timed-out handler
|
|
75
|
+
thread BEFORE ``RabbitMessage.ack()``/``nack()``/``reject()`` sets
|
|
76
|
+
``_disposition``. Without this, the discarded call would still flip
|
|
77
|
+
``_disposition`` away from "pending" (their guard for the real fn calls
|
|
78
|
+
it AFTER the settlement fn returns, unconditionally) — silently
|
|
79
|
+
preventing the consumer thread's own later, legitimate settlement from
|
|
80
|
+
doing anything at all, since ``RabbitMessage`` would think the message
|
|
81
|
+
was already settled. Landing back in ``_run()``'s ``except
|
|
82
|
+
BaseException`` — harmless, since the background thread's outcome is
|
|
83
|
+
already discarded by that point (see ``TimeoutMiddleware.consume_scope``)."""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class HandlerTimeoutError(TimeoutError):
|
|
87
|
+
"""Raised when a handler exceeds the configured timeout."""
|
|
88
|
+
|
|
89
|
+
def __init__(self, timeout_seconds: float) -> None:
|
|
90
|
+
super().__init__(f"Handler exceeded timeout of {timeout_seconds}s")
|
|
91
|
+
self.timeout_seconds = timeout_seconds
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True, slots=True)
|
|
95
|
+
class TimeoutConfig:
|
|
96
|
+
"""Configuration for handler timeout.
|
|
97
|
+
|
|
98
|
+
Attributes:
|
|
99
|
+
timeout_seconds: Maximum handler execution time in seconds.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
timeout_seconds: float = 30.0
|
|
103
|
+
|
|
104
|
+
def __post_init__(self) -> None:
|
|
105
|
+
if self.timeout_seconds <= 0:
|
|
106
|
+
raise ValueError("timeout_seconds must be positive")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class TimeoutMiddleware(BaseMiddleware):
|
|
110
|
+
"""Enforces a maximum processing time per message.
|
|
111
|
+
|
|
112
|
+
Async: uses ``asyncio.wait_for()`` — clean cancellation.
|
|
113
|
+
Sync: runs ``call_next`` in a daemon ``threading.Thread`` and raises
|
|
114
|
+
``HandlerTimeoutError`` if the thread is still alive at the deadline.
|
|
115
|
+
|
|
116
|
+
.. warning::
|
|
117
|
+
CPython cannot safely kill a thread, so a sync handler that exceeds the
|
|
118
|
+
timeout is **abandoned** — it keeps running detached until it finishes
|
|
119
|
+
naturally. This can leak threads and resources for long-running / blocked
|
|
120
|
+
IO handlers. Use an **async** handler for true cooperative cancellation.
|
|
121
|
+
When a sync timeout fires, a CRITICAL log record is emitted and the
|
|
122
|
+
optional ``on_timeout`` callback (if configured) is invoked so the
|
|
123
|
+
abandonment is observable (e.g. increment a metric counter).
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
def __init__(
|
|
127
|
+
self,
|
|
128
|
+
config: TimeoutConfig | None = None,
|
|
129
|
+
*,
|
|
130
|
+
on_timeout: Callable[[RabbitMessage, float], None] | None = None,
|
|
131
|
+
) -> None:
|
|
132
|
+
self._config = config or TimeoutConfig()
|
|
133
|
+
self._on_timeout = on_timeout
|
|
134
|
+
# Observable counter of sync threads abandoned due to timeout.
|
|
135
|
+
self.abandoned_threads: int = 0
|
|
136
|
+
|
|
137
|
+
def consume_scope(
|
|
138
|
+
self,
|
|
139
|
+
call_next: Any,
|
|
140
|
+
message: RabbitMessage,
|
|
141
|
+
) -> Any:
|
|
142
|
+
"""Sync timeout — runs call_next in a thread with timeout.
|
|
143
|
+
|
|
144
|
+
If the deadline elapses with the thread still alive, the thread is
|
|
145
|
+
abandoned (CPython cannot kill it), a CRITICAL log is emitted, the
|
|
146
|
+
``abandoned_threads`` counter is incremented, ``on_timeout`` (if any)
|
|
147
|
+
is invoked, and ``HandlerTimeoutError`` is raised.
|
|
148
|
+
|
|
149
|
+
H9 — settlement is exclusively from the consumer (this) thread, never
|
|
150
|
+
the background thread, even under ``AckPolicy.MANUAL`` (where the
|
|
151
|
+
handler itself calls ``message.ack()``/``nack()``/``reject()``):
|
|
152
|
+
while the background thread is running, ``message``'s settlement
|
|
153
|
+
functions are swapped for stand-ins that CAPTURE (rather than
|
|
154
|
+
execute) any settlement attempt made from that specific thread —
|
|
155
|
+
calling the real pika-backed function from there, while THIS thread
|
|
156
|
+
is blocked in ``thread.join()`` (i.e. not pumping the connection's
|
|
157
|
+
I/O loop), can deadlock: the settlement call would marshal onto this
|
|
158
|
+
thread and wait for it to drain the callback, but this thread is
|
|
159
|
+
itself waiting on the background thread to finish. If the background
|
|
160
|
+
thread finishes within the deadline, any settlement it captured is
|
|
161
|
+
replayed for real on this (consumer/owner) thread, safely, after
|
|
162
|
+
``join()`` returns. If it does not, the guards stay installed
|
|
163
|
+
(deliberately NOT restored — the background thread may still call
|
|
164
|
+
ack()/nack()/reject() at any point later) but their thread-identity
|
|
165
|
+
check means any FUTURE call specifically from that thread is
|
|
166
|
+
discarded, while THIS thread's own subsequent settlement (e.g. via
|
|
167
|
+
AckPolicy/RetryMiddleware after ``HandlerTimeoutError`` propagates
|
|
168
|
+
below) is routed straight to the real fn — safe, since it's a
|
|
169
|
+
same-thread call.
|
|
170
|
+
"""
|
|
171
|
+
result_holder: list[Any] = []
|
|
172
|
+
exception_holder: list[BaseException] = []
|
|
173
|
+
captured_settlement: list[Callable[[], None]] = []
|
|
174
|
+
timed_out = threading.Event()
|
|
175
|
+
|
|
176
|
+
real_ack, real_nack, real_reject = message._ack_fn, message._nack_fn, message._reject_fn
|
|
177
|
+
|
|
178
|
+
def _guarded_ack() -> None:
|
|
179
|
+
if threading.get_ident() != thread.ident:
|
|
180
|
+
if real_ack is not None:
|
|
181
|
+
real_ack()
|
|
182
|
+
return
|
|
183
|
+
if timed_out.is_set():
|
|
184
|
+
logger.warning("Discarding ack() from an abandoned timed-out handler thread")
|
|
185
|
+
# Raise (rather than return) so RabbitMessage.ack() does not
|
|
186
|
+
# proceed to set _disposition="acked" for a call that never
|
|
187
|
+
# actually touched the channel — that would silently block
|
|
188
|
+
# the consumer thread's own later, real settlement.
|
|
189
|
+
raise _DiscardedSettlement
|
|
190
|
+
if real_ack is not None:
|
|
191
|
+
captured_settlement.append(real_ack)
|
|
192
|
+
|
|
193
|
+
def _guarded_nack(requeue: bool = True) -> None:
|
|
194
|
+
if threading.get_ident() != thread.ident:
|
|
195
|
+
if real_nack is not None:
|
|
196
|
+
real_nack(requeue)
|
|
197
|
+
return
|
|
198
|
+
if timed_out.is_set():
|
|
199
|
+
logger.warning("Discarding nack() from an abandoned timed-out handler thread")
|
|
200
|
+
raise _DiscardedSettlement
|
|
201
|
+
if real_nack is not None:
|
|
202
|
+
captured_settlement.append(lambda: real_nack(requeue))
|
|
203
|
+
|
|
204
|
+
def _guarded_reject(requeue: bool = False) -> None:
|
|
205
|
+
if threading.get_ident() != thread.ident:
|
|
206
|
+
if real_reject is not None:
|
|
207
|
+
real_reject(requeue)
|
|
208
|
+
return
|
|
209
|
+
if timed_out.is_set():
|
|
210
|
+
logger.warning("Discarding reject() from an abandoned timed-out handler thread")
|
|
211
|
+
raise _DiscardedSettlement
|
|
212
|
+
if real_reject is not None:
|
|
213
|
+
captured_settlement.append(lambda: real_reject(requeue))
|
|
214
|
+
|
|
215
|
+
# Only install a guard where a real fn exists — leaving an already-None
|
|
216
|
+
# fn as None preserves message.ack()/nack()/reject()'s own "no
|
|
217
|
+
# settlement fn set" RuntimeError for e.g. no-ack deliveries, instead
|
|
218
|
+
# of silently swallowing it inside the guard.
|
|
219
|
+
if real_ack is not None:
|
|
220
|
+
message._ack_fn = _guarded_ack
|
|
221
|
+
if real_nack is not None:
|
|
222
|
+
message._nack_fn = _guarded_nack
|
|
223
|
+
if real_reject is not None:
|
|
224
|
+
message._reject_fn = _guarded_reject
|
|
225
|
+
|
|
226
|
+
def _run() -> None:
|
|
227
|
+
try:
|
|
228
|
+
result_holder.append(call_next(message))
|
|
229
|
+
except BaseException as exc:
|
|
230
|
+
exception_holder.append(exc)
|
|
231
|
+
|
|
232
|
+
thread = threading.Thread(target=_run, daemon=True)
|
|
233
|
+
thread.start()
|
|
234
|
+
thread.join(timeout=self._config.timeout_seconds)
|
|
235
|
+
|
|
236
|
+
if thread.is_alive():
|
|
237
|
+
# CPython cannot safely kill a thread — the handler keeps running
|
|
238
|
+
# detached. Make the abandonment explicit and observable.
|
|
239
|
+
#
|
|
240
|
+
# Deliberately do NOT restore the real settlement fns here: the
|
|
241
|
+
# background thread is still running and may call
|
|
242
|
+
# ack()/nack()/reject() at any point after this method returns —
|
|
243
|
+
# if the real fn were restored, that eventual call would hit the
|
|
244
|
+
# pika channel directly from a non-owner thread. The guards stay
|
|
245
|
+
# installed; their threading.get_ident() check already routes
|
|
246
|
+
# THIS thread's own subsequent settlement (AckPolicy/
|
|
247
|
+
# RetryMiddleware, after the exception below propagates) straight
|
|
248
|
+
# to the real fn (safe, same-thread), while any FUTURE call
|
|
249
|
+
# specifically from the background thread is discarded now that
|
|
250
|
+
# timed_out is set.
|
|
251
|
+
timed_out.set()
|
|
252
|
+
self.abandoned_threads += 1
|
|
253
|
+
logger.critical(
|
|
254
|
+
"Sync handler exceeded %.1fs timeout; thread abandoned (still running). "
|
|
255
|
+
"Use an async handler for real cancellation. "
|
|
256
|
+
"abandoned_threads=%d",
|
|
257
|
+
self._config.timeout_seconds,
|
|
258
|
+
self.abandoned_threads,
|
|
259
|
+
)
|
|
260
|
+
if self._on_timeout is not None:
|
|
261
|
+
try:
|
|
262
|
+
self._on_timeout(message, self._config.timeout_seconds)
|
|
263
|
+
except Exception: # pragma: no cover - callback must not break flow
|
|
264
|
+
logger.exception("on_timeout callback raised")
|
|
265
|
+
raise HandlerTimeoutError(self._config.timeout_seconds)
|
|
266
|
+
|
|
267
|
+
# Finished within the deadline — restore the real fns, then replay
|
|
268
|
+
# any settlement the background thread captured, for real, on this
|
|
269
|
+
# (consumer/owner) thread.
|
|
270
|
+
message._ack_fn, message._nack_fn, message._reject_fn = real_ack, real_nack, real_reject
|
|
271
|
+
for replay in captured_settlement:
|
|
272
|
+
replay()
|
|
273
|
+
|
|
274
|
+
if exception_holder:
|
|
275
|
+
raise exception_holder[0]
|
|
276
|
+
|
|
277
|
+
return result_holder[0] if result_holder else None
|
|
278
|
+
|
|
279
|
+
async def consume_scope_async(
|
|
280
|
+
self,
|
|
281
|
+
call_next: Any,
|
|
282
|
+
message: RabbitMessage,
|
|
283
|
+
) -> Any:
|
|
284
|
+
"""Async timeout — uses asyncio.wait_for()."""
|
|
285
|
+
try:
|
|
286
|
+
return await asyncio.wait_for(
|
|
287
|
+
call_next(message),
|
|
288
|
+
timeout=self._config.timeout_seconds,
|
|
289
|
+
)
|
|
290
|
+
except TimeoutError:
|
|
291
|
+
raise HandlerTimeoutError(self._config.timeout_seconds) from None
|
rabbitkit/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""QueueMetricsPoller — bridge the RabbitMQ management API into metrics (H5).
|
|
2
|
+
|
|
3
|
+
The consume/publish counters (``MetricsMiddleware``) can only see messages
|
|
4
|
+
*this process* handles — they go on reading healthy while a queue silently
|
|
5
|
+
accumulates millions of messages because a consumer fell behind or died.
|
|
6
|
+
Queue depth / consumer lag / DLQ growth is the #1 RabbitMQ incident signal,
|
|
7
|
+
and it lives on the broker, not in-process.
|
|
8
|
+
|
|
9
|
+
``QueueMetricsPoller`` periodically calls a management client's
|
|
10
|
+
``list_queues()`` and emits gauges (labeled by queue) through the same
|
|
11
|
+
``MetricsCollector`` the rest of rabbitkit uses:
|
|
12
|
+
|
|
13
|
+
- ``{ns}_queue_messages_ready`` — backlog depth
|
|
14
|
+
- ``{ns}_queue_messages_unacked`` — delivered-but-unacked
|
|
15
|
+
- ``{ns}_queue_messages_total`` — ready + unacked
|
|
16
|
+
- ``{ns}_queue_consumers`` — 0 means nothing is draining
|
|
17
|
+
|
|
18
|
+
Usage (sync)::
|
|
19
|
+
|
|
20
|
+
from rabbitkit import RabbitManagementClient, PrometheusCollector, QueueMetricsPoller
|
|
21
|
+
|
|
22
|
+
poller = QueueMetricsPoller(
|
|
23
|
+
management_client=RabbitManagementClient(...),
|
|
24
|
+
collector=PrometheusCollector(),
|
|
25
|
+
interval=15.0,
|
|
26
|
+
)
|
|
27
|
+
poller.start() # background daemon thread
|
|
28
|
+
...
|
|
29
|
+
poller.stop()
|
|
30
|
+
|
|
31
|
+
Async brokers use ``QueueMetricsPoller.start_async()`` with a management
|
|
32
|
+
client exposing ``list_queues_async()``.
|
|
33
|
+
|
|
34
|
+
Alert on ``queue_messages_ready`` growth and ``queue_consumers == 0`` — those
|
|
35
|
+
are the signals rabbitkit's own metrics cannot provide.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import asyncio
|
|
41
|
+
import logging
|
|
42
|
+
import threading
|
|
43
|
+
from collections.abc import Callable
|
|
44
|
+
from typing import Any
|
|
45
|
+
|
|
46
|
+
from rabbitkit.core.config import MetricsConfig
|
|
47
|
+
|
|
48
|
+
logger = logging.getLogger(__name__)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class QueueMetricsPoller:
|
|
52
|
+
"""Polls the management API and emits queue-depth gauges.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
management_client: object with ``list_queues(vhost)`` (sync) and/or
|
|
56
|
+
``list_queues_async(vhost)`` (async) returning a list of dicts
|
|
57
|
+
with ``name``/``messages``/``messages_ready``/
|
|
58
|
+
``messages_unacknowledged``/``consumers`` keys (rabbitkit's
|
|
59
|
+
``RabbitManagementClient`` satisfies this).
|
|
60
|
+
collector: any ``MetricsCollector`` (needs ``set_gauge``).
|
|
61
|
+
config: metric-naming config (defaults to ``MetricsConfig()``).
|
|
62
|
+
vhost: vhost to poll (default ``"/"``).
|
|
63
|
+
interval: seconds between polls in the background loop.
|
|
64
|
+
queue_filter: optional predicate ``(queue_name) -> bool`` — only
|
|
65
|
+
matching queues emit gauges (e.g. skip rabbitkit's own delay
|
|
66
|
+
queues, or restrict to a service's queues to bound cardinality).
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
management_client: Any,
|
|
72
|
+
collector: Any,
|
|
73
|
+
config: MetricsConfig | None = None,
|
|
74
|
+
*,
|
|
75
|
+
vhost: str = "/",
|
|
76
|
+
interval: float = 15.0,
|
|
77
|
+
queue_filter: Callable[[str], bool] | None = None,
|
|
78
|
+
) -> None:
|
|
79
|
+
self._mgmt = management_client
|
|
80
|
+
self._collector = collector
|
|
81
|
+
self._cfg = config or MetricsConfig()
|
|
82
|
+
self._vhost = vhost
|
|
83
|
+
self._interval = interval
|
|
84
|
+
self._queue_filter = queue_filter
|
|
85
|
+
self._stop = threading.Event()
|
|
86
|
+
self._thread: threading.Thread | None = None
|
|
87
|
+
|
|
88
|
+
# ── Core: one poll → gauges ───────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
def _emit(self, queues: list[dict[str, Any]]) -> int:
|
|
91
|
+
"""Set gauges for each (filtered) queue. Returns count emitted."""
|
|
92
|
+
emitted = 0
|
|
93
|
+
for q in queues:
|
|
94
|
+
name = q.get("name")
|
|
95
|
+
if not name:
|
|
96
|
+
continue
|
|
97
|
+
if self._queue_filter is not None and not self._queue_filter(name):
|
|
98
|
+
continue
|
|
99
|
+
labels = {"queue": name}
|
|
100
|
+
# The management API omits counts for a queue mid-declaration or
|
|
101
|
+
# in some states; default missing values to 0 rather than skip so
|
|
102
|
+
# a gauge reset (queue drained) is still visible.
|
|
103
|
+
ready = int(q.get("messages_ready", 0) or 0)
|
|
104
|
+
unacked = int(q.get("messages_unacknowledged", 0) or 0)
|
|
105
|
+
total = int(q.get("messages", ready + unacked) or 0)
|
|
106
|
+
consumers = int(q.get("consumers", 0) or 0)
|
|
107
|
+
self._collector.set_gauge(self._cfg.queue_messages_ready, labels, ready)
|
|
108
|
+
self._collector.set_gauge(self._cfg.queue_messages_unacked, labels, unacked)
|
|
109
|
+
self._collector.set_gauge(self._cfg.queue_messages_total, labels, total)
|
|
110
|
+
self._collector.set_gauge(self._cfg.queue_consumers, labels, consumers)
|
|
111
|
+
emitted += 1
|
|
112
|
+
return emitted
|
|
113
|
+
|
|
114
|
+
def poll_once(self) -> int:
|
|
115
|
+
"""Fetch queues once (sync) and emit gauges. Returns count emitted.
|
|
116
|
+
|
|
117
|
+
Never raises — a management-API error is logged and the poll is
|
|
118
|
+
skipped, so a transient management-plane outage does not crash the
|
|
119
|
+
poller thread (the next tick retries).
|
|
120
|
+
"""
|
|
121
|
+
try:
|
|
122
|
+
queues = self._mgmt.list_queues(self._vhost)
|
|
123
|
+
except Exception:
|
|
124
|
+
logger.warning("QueueMetricsPoller: list_queues failed; skipping this poll", exc_info=True)
|
|
125
|
+
return 0
|
|
126
|
+
return self._emit(queues)
|
|
127
|
+
|
|
128
|
+
async def poll_once_async(self) -> int:
|
|
129
|
+
"""Async variant of :meth:`poll_once`."""
|
|
130
|
+
try:
|
|
131
|
+
queues = await self._mgmt.list_queues_async(self._vhost)
|
|
132
|
+
except Exception:
|
|
133
|
+
logger.warning("QueueMetricsPoller: list_queues_async failed; skipping this poll", exc_info=True)
|
|
134
|
+
return 0
|
|
135
|
+
return self._emit(queues)
|
|
136
|
+
|
|
137
|
+
# ── Background loops ──────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
def start(self) -> None:
|
|
140
|
+
"""Start a background daemon thread polling every ``interval`` seconds."""
|
|
141
|
+
if self._thread is not None:
|
|
142
|
+
return
|
|
143
|
+
self._stop.clear()
|
|
144
|
+
self._thread = threading.Thread(
|
|
145
|
+
target=self._run_loop, name="rabbitkit-queue-metrics", daemon=True
|
|
146
|
+
)
|
|
147
|
+
self._thread.start()
|
|
148
|
+
|
|
149
|
+
def _run_loop(self) -> None:
|
|
150
|
+
# Poll immediately, then every interval. Event.wait doubles as the
|
|
151
|
+
# sleep and the stop signal (interruptible shutdown).
|
|
152
|
+
while not self._stop.is_set():
|
|
153
|
+
self.poll_once()
|
|
154
|
+
self._stop.wait(self._interval)
|
|
155
|
+
|
|
156
|
+
def stop(self, timeout: float = 5.0) -> None:
|
|
157
|
+
"""Signal the background thread to stop and join it."""
|
|
158
|
+
self._stop.set()
|
|
159
|
+
if self._thread is not None:
|
|
160
|
+
self._thread.join(timeout=timeout)
|
|
161
|
+
self._thread = None
|
|
162
|
+
|
|
163
|
+
async def run_async(self) -> None:
|
|
164
|
+
"""Async polling loop — run as a task; cancel to stop.
|
|
165
|
+
|
|
166
|
+
Usage::
|
|
167
|
+
|
|
168
|
+
task = asyncio.create_task(poller.run_async())
|
|
169
|
+
...
|
|
170
|
+
task.cancel()
|
|
171
|
+
"""
|
|
172
|
+
while True:
|
|
173
|
+
await self.poll_once_async()
|
|
174
|
+
await asyncio.sleep(self._interval)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Result backend protocol and implementations.
|
|
2
|
+
|
|
3
|
+
A **result backend** stores handler return values so callers can retrieve
|
|
4
|
+
them later using a ``correlation_id``. This is the server side of the
|
|
5
|
+
request/result pattern — the broker stores results; clients fetch them.
|
|
6
|
+
|
|
7
|
+
Protocol
|
|
8
|
+
--------
|
|
9
|
+
``ResultBackend`` is a ``@runtime_checkable`` generic ``Protocol[T]``. ``T``
|
|
10
|
+
is the type of the stored result, so the type flows from
|
|
11
|
+
``store(correlation_id, result: T)`` into ``fetch(correlation_id) -> T | None``.
|
|
12
|
+
Any object with ``store`` / ``fetch`` (sync) and ``store_async`` / ``fetch_async``
|
|
13
|
+
(async) methods qualifies.
|
|
14
|
+
|
|
15
|
+
Built-in implementation
|
|
16
|
+
-----------------------
|
|
17
|
+
``RedisResultBackend`` stores results as Redis strings with configurable TTL,
|
|
18
|
+
so it satisfies ``ResultBackend[bytes]``:
|
|
19
|
+
|
|
20
|
+
import redis
|
|
21
|
+
from rabbitkit.results.backend import RedisResultBackend
|
|
22
|
+
|
|
23
|
+
r = redis.Redis(host="redis")
|
|
24
|
+
backend = RedisResultBackend(r, key_prefix="myapp:result:")
|
|
25
|
+
|
|
26
|
+
Keys are stored as ``{key_prefix}{correlation_id}``. Default TTL is 3600 s (1 h).
|
|
27
|
+
|
|
28
|
+
Async variant (redis-py >= 4.2 async client)::
|
|
29
|
+
|
|
30
|
+
import redis.asyncio as aioredis
|
|
31
|
+
r = aioredis.Redis(host="redis")
|
|
32
|
+
backend = RedisResultBackend(r)
|
|
33
|
+
await backend.store_async("corr-123", b'{"status": "done"}', ttl=600)
|
|
34
|
+
result = await backend.fetch_async("corr-123")
|
|
35
|
+
|
|
36
|
+
Custom backend
|
|
37
|
+
--------------
|
|
38
|
+
Implement the protocol to use any other storage:
|
|
39
|
+
|
|
40
|
+
class PostgresResultBackend:
|
|
41
|
+
def store(self, correlation_id: str, result: bytes, ttl: int = 3600) -> None:
|
|
42
|
+
db.execute("INSERT INTO results ...", (correlation_id, result))
|
|
43
|
+
|
|
44
|
+
def fetch(self, correlation_id: str, timeout: float = 5.0) -> bytes | None:
|
|
45
|
+
row = db.fetchone("SELECT result FROM results WHERE id=%s", (correlation_id,))
|
|
46
|
+
return row[0] if row else None
|
|
47
|
+
|
|
48
|
+
async def store_async(...): ...
|
|
49
|
+
async def fetch_async(...): ...
|
|
50
|
+
|
|
51
|
+
See also
|
|
52
|
+
--------
|
|
53
|
+
``ResultMiddleware`` — middleware that wires this backend to the pipeline so
|
|
54
|
+
handler return values are stored automatically.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
from __future__ import annotations
|
|
58
|
+
|
|
59
|
+
from typing import Any, Protocol, TypeVar, runtime_checkable
|
|
60
|
+
|
|
61
|
+
T = TypeVar("T")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@runtime_checkable
|
|
65
|
+
class ResultBackend(Protocol[T]):
|
|
66
|
+
"""Protocol for result storage backends.
|
|
67
|
+
|
|
68
|
+
Generic in ``T`` (the stored result type) so the type flows from
|
|
69
|
+
``store`` into ``fetch``. ``RedisResultBackend`` stores ``bytes``, so it
|
|
70
|
+
satisfies ``ResultBackend[bytes]``.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def store(self, correlation_id: str, result: T, ttl: int = 3600) -> None: ...
|
|
74
|
+
def fetch(self, correlation_id: str, timeout: float = 5.0) -> T | None: ...
|
|
75
|
+
async def store_async(self, correlation_id: str, result: T, ttl: int = 3600) -> None: ...
|
|
76
|
+
async def fetch_async(self, correlation_id: str, timeout: float = 5.0) -> T | None: ...
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class RedisResultBackend:
|
|
80
|
+
"""Redis-based result backend using GET/SET with TTL.
|
|
81
|
+
|
|
82
|
+
Stores results as raw bytes, so it satisfies ``ResultBackend[bytes]``.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def __init__(self, redis_client: Any, key_prefix: str = "rabbitkit:result:") -> None:
|
|
86
|
+
self._redis = redis_client
|
|
87
|
+
self._prefix = key_prefix
|
|
88
|
+
|
|
89
|
+
def _key(self, correlation_id: str) -> str:
|
|
90
|
+
return f"{self._prefix}{correlation_id}"
|
|
91
|
+
|
|
92
|
+
def store(self, correlation_id: str, result: bytes, ttl: int = 3600) -> None:
|
|
93
|
+
self._redis.set(self._key(correlation_id), result, ex=ttl)
|
|
94
|
+
|
|
95
|
+
def fetch(self, correlation_id: str, timeout: float = 5.0) -> bytes | None:
|
|
96
|
+
return self._redis.get(self._key(correlation_id)) # type: ignore[no-any-return]
|
|
97
|
+
|
|
98
|
+
async def store_async(self, correlation_id: str, result: bytes, ttl: int = 3600) -> None:
|
|
99
|
+
await self._redis.set(self._key(correlation_id), result, ex=ttl)
|
|
100
|
+
|
|
101
|
+
async def fetch_async(self, correlation_id: str, timeout: float = 5.0) -> bytes | None:
|
|
102
|
+
return await self._redis.get(self._key(correlation_id)) # type: ignore[no-any-return]
|