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,448 @@
1
+ """Batch publish and multi-ack — high-throughput helpers.
2
+
3
+ ``BatchPublisher`` buffers outgoing envelopes and flushes them as a batch,
4
+ optionally confirming delivery after flush.
5
+
6
+ ``BatchAcker`` accumulates delivery tags and issues a single
7
+ ``ack(max_tag, multiple=True)`` when the batch fills or is flushed.
8
+
9
+ Both are channel-scoped — never cross channels.
10
+
11
+ NOTE (I-7): do NOT mix the sync and async APIs on a single instance. The sync
12
+ ``add``/``flush``/``close`` use a ``threading.Lock`` and a ``threading.Timer``;
13
+ the async ``add_async``/``flush_async``/``close_async`` use an ``asyncio.Lock``
14
+ and an ``asyncio.Task``. Each API path cancels the *other* path's timer/task on
15
+ close so a stray leftover does not fire after shutdown, but the buffer is not
16
+ safe to mutate concurrently from both worlds at once. Pick one mode per
17
+ instance.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import logging
24
+ import threading
25
+ from collections.abc import Callable
26
+ from typing import Any
27
+
28
+ from rabbitkit.core.config import BatchAckConfig, BatchPublishConfig
29
+ from rabbitkit.core.types import MessageEnvelope
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+
34
+ # ── BatchPublisher ───────────────────────────────────────────────────────
35
+
36
+
37
+ class BatchPublisher:
38
+ """Buffer outgoing envelopes and flush as a batch.
39
+
40
+ When ``flush_interval_ms > 0`` (default 50 ms), a background timer
41
+ fires periodically to flush any buffered envelopes even if
42
+ ``batch_size`` has not been reached. The timer starts lazily on the
43
+ first call to ``add()`` and is cancelled by ``close()`` / ``close_async()``.
44
+
45
+ ``max_in_flight`` is reserved for future async-confirm support and has
46
+ no runtime effect in the current synchronous-confirm model.
47
+
48
+ NOTE (throughput): this is a *buffering/timing* helper, not wire-level
49
+ batching. ``flush`` publishes each buffered envelope via ``publish_fn``, so
50
+ if ``publish_fn`` awaits a confirm per message the confirms do not pipeline —
51
+ you get ergonomics, not extra throughput. For high-volume confirmed
52
+ publishing, pipeline confirms yourself (publish many, then await) or use the
53
+ transactional outbox; for safety-critical events, always use the outbox.
54
+
55
+ Usage::
56
+
57
+ bp = BatchPublisher(
58
+ config=BatchPublishConfig(batch_size=50, flush_interval_ms=100),
59
+ publish_fn=transport.publish,
60
+ confirm_fn=transport.wait_for_confirms, # optional
61
+ )
62
+ bp.add(envelope1)
63
+ bp.add(envelope2)
64
+ ...
65
+ bp.flush() # publishes all buffered
66
+ bp.close() # flush remaining + cancel timer
67
+ """
68
+
69
+ def __init__(
70
+ self,
71
+ publish_fn: Callable[[MessageEnvelope], Any],
72
+ config: BatchPublishConfig | None = None,
73
+ confirm_fn: Callable[[], Any] | None = None,
74
+ ) -> None:
75
+ self._config = config or BatchPublishConfig()
76
+ self._publish_fn = publish_fn
77
+ self._confirm_fn = confirm_fn
78
+ self._buffer: list[MessageEnvelope] = []
79
+ self._lock = threading.Lock()
80
+ self._timer: threading.Timer | None = None
81
+ self._closed = False
82
+ self._flush_task: asyncio.Task[None] | None = None
83
+ # I-7: an asyncio.Lock guards _buffer/_flush_task mutations in the async
84
+ # path so the background interval loop and add_async/flush_async/close_async
85
+ # cannot race and lose messages. Lazily created inside the event loop.
86
+ self._async_lock: asyncio.Lock | None = None
87
+
88
+ @property
89
+ def pending(self) -> int:
90
+ """Number of envelopes buffered but not yet flushed."""
91
+ return len(self._buffer)
92
+
93
+ def _ensure_async_lock(self) -> asyncio.Lock:
94
+ if self._async_lock is None:
95
+ self._async_lock = asyncio.Lock()
96
+ return self._async_lock
97
+
98
+ # ── Timer helpers ────────────────────────────────────────────────────
99
+
100
+ def _schedule_timer(self) -> None:
101
+ # MH-1: defensive — only arm a new timer when none is already running
102
+ # so a concurrent add()/timer-callback cannot double-schedule an orphan
103
+ # timer that fires forever (leaking daemon threads).
104
+ if self._timer is None and self._config.flush_interval_ms > 0 and not self._closed:
105
+ interval = self._config.flush_interval_ms / 1000.0
106
+ self._timer = threading.Timer(interval, self._timer_callback)
107
+ self._timer.daemon = True
108
+ self._timer.start()
109
+
110
+ def _timer_callback(self) -> None:
111
+ # I-7: clear the timer slot under the lock so concurrent flush()/add()
112
+ # see a consistent None and cannot double-schedule.
113
+ with self._lock:
114
+ self._timer = None
115
+ self.flush()
116
+ # MH-1: reschedule UNDER the lock with a None-guard. A concurrent add()
117
+ # that armed a timer during flush() (outside the lock) leaves
118
+ # self._timer set, so we skip rescheduling here instead of starting an
119
+ # orphan timer that fires forever.
120
+ with self._lock:
121
+ if self._timer is None and self._config.flush_interval_ms > 0 and not self._closed:
122
+ self._schedule_timer()
123
+
124
+ # ── Sync API ─────────────────────────────────────────────────────────
125
+
126
+ def add(self, envelope: MessageEnvelope) -> None:
127
+ """Add an envelope to the batch buffer.
128
+
129
+ Auto-flushes when ``batch_size`` is reached. Starts the interval
130
+ timer on the first call when ``flush_interval_ms > 0``.
131
+ """
132
+ with self._lock:
133
+ self._buffer.append(envelope)
134
+ should_flush = len(self._buffer) >= self._config.batch_size
135
+ if self._timer is None and self._config.flush_interval_ms > 0 and not self._closed:
136
+ self._schedule_timer()
137
+ if should_flush:
138
+ self.flush()
139
+
140
+ def flush(self) -> int:
141
+ """Publish all buffered envelopes.
142
+
143
+ Returns the number of envelopes published.
144
+ """
145
+ with self._lock:
146
+ if not self._buffer:
147
+ return 0
148
+ count = len(self._buffer)
149
+ batch = list(self._buffer)
150
+ self._buffer.clear()
151
+
152
+ for envelope in batch:
153
+ self._publish_fn(envelope)
154
+
155
+ if self._confirm_fn is not None:
156
+ self._confirm_fn()
157
+
158
+ logger.debug("Batch-published %d envelopes", count)
159
+ return count
160
+
161
+ def close(self) -> int:
162
+ """Flush remaining envelopes, cancel the interval timer, and clean up.
163
+
164
+ Returns the number of envelopes flushed.
165
+ """
166
+ self._closed = True
167
+ if self._timer is not None:
168
+ self._timer.cancel()
169
+ self._timer = None
170
+ # I-7: also cancel a stray async interval task so closing via the sync
171
+ # API does not leave the async loop running.
172
+ if self._flush_task is not None:
173
+ self._flush_task.cancel()
174
+ self._flush_task = None
175
+ return self.flush()
176
+
177
+ # ── Async API ────────────────────────────────────────────────────────
178
+
179
+ async def _interval_loop_async(self) -> None:
180
+ interval = self._config.flush_interval_ms / 1000.0
181
+ try:
182
+ while True:
183
+ await asyncio.sleep(interval)
184
+ await self.flush_async()
185
+ except asyncio.CancelledError:
186
+ pass
187
+
188
+ async def add_async(self, envelope: MessageEnvelope) -> None:
189
+ """Async: add envelope; auto-flush at batch_size.
190
+
191
+ Starts the async interval loop on the first call when
192
+ ``flush_interval_ms > 0``.
193
+ """
194
+ # I-7: guard _buffer / _flush_task mutations with the async lock so a
195
+ # concurrent interval-loop flush_async cannot interleave with this add
196
+ # and lose/duplicate buffered envelopes.
197
+ should_flush = False
198
+ async with self._ensure_async_lock():
199
+ self._buffer.append(envelope)
200
+ if len(self._buffer) >= self._config.batch_size:
201
+ should_flush = True
202
+ elif self._config.flush_interval_ms > 0 and self._flush_task is None:
203
+ self._flush_task = asyncio.create_task(self._interval_loop_async())
204
+ if should_flush:
205
+ await self.flush_async()
206
+
207
+ async def flush_async(self) -> int:
208
+ """Async: publish all buffered envelopes."""
209
+ # I-7: take the batch snapshot under the async lock so concurrent
210
+ # flush_async / add_async calls cannot both grab the same buffer.
211
+ async with self._ensure_async_lock():
212
+ if not self._buffer:
213
+ return 0
214
+ count = len(self._buffer)
215
+ batch = list(self._buffer)
216
+ self._buffer.clear()
217
+
218
+ for envelope in batch:
219
+ result = self._publish_fn(envelope)
220
+ if hasattr(result, "__await__"):
221
+ await result
222
+
223
+ if self._confirm_fn is not None:
224
+ result = self._confirm_fn()
225
+ if hasattr(result, "__await__"):
226
+ await result
227
+
228
+ logger.debug("Async batch-published %d envelopes", count)
229
+ return count
230
+
231
+ async def close_async(self) -> int:
232
+ """Async: cancel the interval loop, flush remaining, and clean up."""
233
+ self._closed = True
234
+ async with self._ensure_async_lock():
235
+ if self._flush_task is not None:
236
+ self._flush_task.cancel()
237
+ self._flush_task = None
238
+ # I-7: also cancel a stray sync timer so closing via the async API does
239
+ # not leave the sync timer firing after shutdown.
240
+ if self._timer is not None:
241
+ self._timer.cancel()
242
+ self._timer = None
243
+ return await self.flush_async()
244
+
245
+
246
+ # ── BatchAcker ───────────────────────────────────────────────────────────
247
+
248
+
249
+ class BatchAcker:
250
+ """Accumulate delivery tags and ack in batches.
251
+
252
+ Uses ``multiple=True`` on the maximum delivery tag in the batch.
253
+
254
+ When ``flush_interval_ms > 0`` (default 200 ms), a background timer
255
+ fires periodically to ack any buffered tags even if ``batch_size`` has
256
+ not been reached. The timer starts lazily on the first call to
257
+ ``add()`` and is cancelled by ``close()`` / ``close_async()``.
258
+
259
+ **Ownership rules:**
260
+ - Channel-scoped — NEVER cross channels
261
+ - Handlers MUST NOT call ``msg.ack()`` when BatchAcker is active
262
+ - Compatible with AUTO and NACK_ON_ERROR policies only
263
+
264
+ Usage (sync / pika) — ``ack_fn`` MUST NOT be a raw ``channel.basic_ack``.
265
+ The interval timer fires ``flush()`` from a background
266
+ ``threading.Timer`` thread, not pika's connection I/O thread; pika
267
+ channel methods are not thread-safe, so a direct ``channel.basic_ack``
268
+ reference here is a real cross-thread violation waiting to happen the
269
+ first time the timer (rather than ``add()``) triggers the flush.
270
+ Marshal onto the I/O thread instead, e.g. via
271
+ ``connection.add_callback_threadsafe``::
272
+
273
+ def safe_ack(delivery_tag: int, multiple: bool = False) -> None:
274
+ connection.add_callback_threadsafe(
275
+ lambda: channel.basic_ack(delivery_tag=delivery_tag, multiple=multiple)
276
+ )
277
+
278
+ ba = BatchAcker(
279
+ config=BatchAckConfig(batch_size=50, flush_interval_ms=200),
280
+ ack_fn=safe_ack,
281
+ )
282
+ ba.add(delivery_tag=1)
283
+ ba.add(delivery_tag=2)
284
+ ...
285
+ ba.flush() # ack(max_tag, multiple=True)
286
+ ba.close() # flush remaining + cancel timer
287
+
288
+ The async path (``add_async``/``flush_async``) has no such hazard —
289
+ ``_interval_loop_async`` schedules via ``asyncio.create_task`` on the
290
+ same event loop the aio-pika channel already runs on, so an aio-pika
291
+ ``channel.basic_ack`` (a coroutine function) can be passed directly as
292
+ ``ack_fn``.
293
+ """
294
+
295
+ def __init__(
296
+ self,
297
+ ack_fn: Callable[..., Any],
298
+ config: BatchAckConfig | None = None,
299
+ ) -> None:
300
+ self._config = config or BatchAckConfig()
301
+ self._ack_fn = ack_fn
302
+ self._tags: list[int] = []
303
+ self._max_tag: int = 0 # O(1) max tracking (perf)
304
+ self._lock = threading.Lock()
305
+ self._timer: threading.Timer | None = None
306
+ self._closed = False
307
+ self._flush_task: asyncio.Task[None] | None = None
308
+ # I-7: asyncio.Lock guarding _tags/_flush_task in the async path.
309
+ # Lazily created inside the event loop.
310
+ self._async_lock: asyncio.Lock | None = None
311
+
312
+ @property
313
+ def pending(self) -> int:
314
+ """Number of delivery tags buffered."""
315
+ return len(self._tags)
316
+
317
+ def _ensure_async_lock(self) -> asyncio.Lock:
318
+ if self._async_lock is None:
319
+ self._async_lock = asyncio.Lock()
320
+ return self._async_lock
321
+
322
+ # ── Timer helpers ────────────────────────────────────────────────────
323
+
324
+ def _schedule_timer(self) -> None:
325
+ # MH-1: defensive — only arm a new timer when none is already running
326
+ # so a concurrent add()/timer-callback cannot double-schedule an orphan
327
+ # timer that fires forever (leaking daemon threads).
328
+ if self._timer is None and self._config.flush_interval_ms > 0 and not self._closed:
329
+ interval = self._config.flush_interval_ms / 1000.0
330
+ self._timer = threading.Timer(interval, self._timer_callback)
331
+ self._timer.daemon = True
332
+ self._timer.start()
333
+
334
+ def _timer_callback(self) -> None:
335
+ # I-7: clear the timer slot under the lock for consistency.
336
+ with self._lock:
337
+ self._timer = None
338
+ self.flush()
339
+ # MH-1: reschedule UNDER the lock with a None-guard so a concurrent
340
+ # add() that armed a timer during flush() doesn't leave us starting an
341
+ # orphan timer that fires forever.
342
+ with self._lock:
343
+ if self._timer is None and self._config.flush_interval_ms > 0 and not self._closed:
344
+ self._schedule_timer()
345
+
346
+ # ── Sync API ─────────────────────────────────────────────────────────
347
+
348
+ def add(self, delivery_tag: int) -> None:
349
+ """Add a delivery tag to the batch.
350
+
351
+ Auto-flushes when ``batch_size`` is reached. Starts the interval
352
+ timer on the first call when ``flush_interval_ms > 0``.
353
+ """
354
+ with self._lock:
355
+ self._tags.append(delivery_tag)
356
+ if delivery_tag > self._max_tag:
357
+ self._max_tag = delivery_tag
358
+ should_flush = len(self._tags) >= self._config.batch_size
359
+ if self._timer is None and self._config.flush_interval_ms > 0 and not self._closed:
360
+ self._schedule_timer()
361
+ if should_flush:
362
+ self.flush()
363
+
364
+ def flush(self) -> int:
365
+ """Ack all buffered tags using the max tag with multiple=True.
366
+
367
+ Returns the number of tags acked.
368
+ """
369
+ with self._lock:
370
+ if not self._tags:
371
+ return 0
372
+ count = len(self._tags)
373
+ max_tag = max(self._tags)
374
+ self._tags.clear()
375
+
376
+ self._ack_fn(max_tag, multiple=True)
377
+ logger.debug("Batch-acked %d messages (max_tag=%d)", count, max_tag)
378
+ return count
379
+
380
+ def close(self) -> int:
381
+ """Flush remaining tags, cancel the interval timer, and clean up."""
382
+ self._closed = True
383
+ if self._timer is not None:
384
+ self._timer.cancel()
385
+ self._timer = None
386
+ # I-7: also cancel a stray async interval task.
387
+ if self._flush_task is not None:
388
+ self._flush_task.cancel()
389
+ self._flush_task = None
390
+ return self.flush()
391
+
392
+ # ── Async API ────────────────────────────────────────────────────────
393
+
394
+ async def _interval_loop_async(self) -> None:
395
+ interval = self._config.flush_interval_ms / 1000.0
396
+ try:
397
+ while True:
398
+ await asyncio.sleep(interval)
399
+ await self.flush_async()
400
+ except asyncio.CancelledError:
401
+ pass
402
+
403
+ async def add_async(self, delivery_tag: int) -> None:
404
+ """Async: add tag; auto-flush at batch_size.
405
+
406
+ Starts the async interval loop on the first call when
407
+ ``flush_interval_ms > 0``.
408
+ """
409
+ # I-7: guard _tags / _flush_task mutations with the async lock.
410
+ should_flush = False
411
+ async with self._ensure_async_lock():
412
+ self._tags.append(delivery_tag)
413
+ if len(self._tags) >= self._config.batch_size:
414
+ should_flush = True
415
+ elif self._config.flush_interval_ms > 0 and self._flush_task is None:
416
+ self._flush_task = asyncio.create_task(self._interval_loop_async())
417
+ if should_flush:
418
+ await self.flush_async()
419
+
420
+ async def flush_async(self) -> int:
421
+ """Async: ack all buffered tags."""
422
+ # I-7: take the batch snapshot under the async lock.
423
+ async with self._ensure_async_lock():
424
+ if not self._tags:
425
+ return 0
426
+ count = len(self._tags)
427
+ max_tag = max(self._tags)
428
+ self._tags.clear()
429
+
430
+ result = self._ack_fn(max_tag, multiple=True)
431
+ if hasattr(result, "__await__"):
432
+ await result
433
+
434
+ logger.debug("Async batch-acked %d messages (max_tag=%d)", count, max_tag)
435
+ return count
436
+
437
+ async def close_async(self) -> int:
438
+ """Async: cancel the interval loop, flush remaining, and clean up."""
439
+ self._closed = True
440
+ async with self._ensure_async_lock():
441
+ if self._flush_task is not None:
442
+ self._flush_task.cancel()
443
+ self._flush_task = None
444
+ # I-7: also cancel a stray sync timer.
445
+ if self._timer is not None:
446
+ self._timer.cancel()
447
+ self._timer = None
448
+ return await self.flush_async()