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,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,6 @@
1
+ """Result storage backends for rabbitkit."""
2
+
3
+ from rabbitkit.results.backend import RedisResultBackend, ResultBackend
4
+ from rabbitkit.results.middleware import ResultMiddleware
5
+
6
+ __all__ = ["RedisResultBackend", "ResultBackend", "ResultMiddleware"]
@@ -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]