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,451 @@
1
+ """Consumer concurrency -- multi-worker message processing.
2
+
3
+ Provides concurrent execution of message handlers within a single broker.
4
+
5
+ - Sync: a daemon-thread worker pool with configurable worker_count
6
+ - Async: asyncio.Semaphore limiting concurrent handler tasks
7
+
8
+ Compatible with all AckPolicy modes. Workers share the same transport
9
+ connection but process messages independently.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import concurrent.futures
16
+ import logging
17
+ import queue
18
+ import threading
19
+ import time
20
+ from collections.abc import Awaitable, Callable
21
+ from typing import Any
22
+
23
+ from rabbitkit.core.config import WorkerConfig
24
+ from rabbitkit.core.message import RabbitMessage
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ class _DaemonWorkerPool:
30
+ """A bounded pool of DAEMON worker threads.
31
+
32
+ ``concurrent.futures.ThreadPoolExecutor`` creates non-daemon threads, so a
33
+ handler stuck in an uninterruptible call keeps the process alive past
34
+ ``stop()`` until SIGKILL — bad for k8s graceful shutdown. This pool uses
35
+ ``threading.Thread(daemon=True)`` workers, so the process can exit even if a
36
+ worker is wedged, while still giving well-behaved handlers a bounded drain
37
+ window via :meth:`shutdown`.
38
+
39
+ Idle accounting: ``_idle_count`` counts workers that are *currently waiting
40
+ for work* (not workers that are not running a task). It is incremented when a
41
+ worker re-enters the wait and decremented when it picks up work, so
42
+ ``_adjust_thread_count`` spawns a new worker only when no worker is idle —
43
+ giving true ``max_workers`` parallelism under bursts (R-1/R-2 fix).
44
+ """
45
+
46
+ def __init__(
47
+ self,
48
+ max_workers: int,
49
+ thread_name_prefix: str = "rabbitkit-worker",
50
+ max_queue_size: int = 0,
51
+ ) -> None:
52
+ self._max_workers = max_workers
53
+ self._thread_name_prefix = thread_name_prefix
54
+ # (future, fn, args, kwargs). M11: maxsize=0 is unbounded (default);
55
+ # >0 bounds the backlog. put() blocks when full — the caller (the pika
56
+ # I/O thread) should keep max_queue_size >= prefetch so it never fills.
57
+ self._work: queue.Queue[
58
+ tuple[concurrent.futures.Future[Any], Callable[..., Any], tuple[Any, ...], dict[str, Any]]
59
+ ] = queue.Queue(maxsize=max_queue_size)
60
+ self._threads: list[threading.Thread] = []
61
+ self._shutdown = False
62
+ self._idle_count = 0
63
+ self._lock = threading.Lock() # guards _idle_count, _threads, _shutdown reads
64
+
65
+ def submit(self, fn: Callable[..., Any], /, *args: Any, **kwargs: Any) -> concurrent.futures.Future[Any]:
66
+ with self._lock:
67
+ if self._shutdown:
68
+ raise RuntimeError("cannot schedule new futures after shutdown")
69
+ fut: concurrent.futures.Future[Any] = concurrent.futures.Future()
70
+ self._work.put((fut, fn, args, kwargs))
71
+ self._adjust_thread_count()
72
+ return fut
73
+
74
+ def _adjust_thread_count(self) -> None:
75
+ # Hold the lock across the whole check+spawn+append so concurrent
76
+ # submit()s can't both pass the idle/needed checks and oversubscribe (R-2).
77
+ with self._lock:
78
+ idle = self._idle_count
79
+ needed = self._max_workers - len(self._threads)
80
+ if idle > 0 or needed <= 0 or self._shutdown:
81
+ return # an idle worker will pick it up, or we're at the cap / shutting down
82
+ t = threading.Thread(
83
+ target=self._worker,
84
+ name=f"{self._thread_name_prefix}-{len(self._threads)}",
85
+ daemon=True,
86
+ )
87
+ self._threads.append(t)
88
+ t.start()
89
+
90
+ def _worker(self) -> None:
91
+ while True:
92
+ # Mark idle BEFORE waiting so _adjust_thread_count sees an available worker
93
+ # and doesn't oversubscribe. (R-1: this was the missing increment.)
94
+ with self._lock:
95
+ self._idle_count += 1
96
+ try:
97
+ try:
98
+ item = self._work.get(timeout=0.1)
99
+ except queue.Empty:
100
+ with self._lock:
101
+ self._idle_count -= 1
102
+ if self._shutdown:
103
+ return
104
+ continue # pragma: no cover — timing-dependent idle loop
105
+ # Picked up work — no longer idle.
106
+ with self._lock:
107
+ self._idle_count -= 1
108
+ except BaseException: # pragma: no cover - defensive
109
+ with self._lock:
110
+ self._idle_count = max(0, self._idle_count - 1)
111
+ raise
112
+ fut, fn, args, kwargs = item
113
+ if fut.set_running_or_notify_cancel():
114
+ try:
115
+ fut.set_result(fn(*args, **kwargs))
116
+ except BaseException as exc:
117
+ fut.set_exception(exc)
118
+ # loop back: re-mark idle at the top of the next iteration
119
+
120
+ def shutdown(self, wait: bool = False, cancel_futures: bool = False) -> None:
121
+ with self._lock:
122
+ self._shutdown = True
123
+ if cancel_futures:
124
+ # Drain queued work, marking each as cancelled.
125
+ while True:
126
+ try:
127
+ fut, _fn, _a, _k = self._work.get_nowait()
128
+ except queue.Empty:
129
+ break
130
+ fut.cancel()
131
+ if wait:
132
+ for t in self._threads:
133
+ t.join(timeout=None)
134
+ # Daemon threads: even if `wait=False` and some are stuck, the process
135
+ # can still exit — they're daemon=True.
136
+
137
+ @property
138
+ def worker_count(self) -> int:
139
+ with self._lock:
140
+ return len(self._threads)
141
+
142
+
143
+ class SyncWorkerPool:
144
+ """Thread pool for concurrent sync message processing.
145
+
146
+ Wraps a handler callback to execute it in a thread pool with
147
+ limited concurrency.
148
+
149
+ Usage::
150
+
151
+ pool = SyncWorkerPool(config=WorkerConfig(worker_count=4))
152
+ pool.start()
153
+ # Use pool.submit(callback, message) instead of callback(message)
154
+ pool.stop()
155
+ """
156
+
157
+ def __init__(self, config: WorkerConfig | None = None) -> None:
158
+ self._config = config or WorkerConfig()
159
+ self._executor: _DaemonWorkerPool | None = None
160
+ self._futures: set[concurrent.futures.Future[Any]] = set()
161
+ # H12: message associated with each in-flight future, so a future
162
+ # abandoned at the stop_timeout deadline can be logged by delivery
163
+ # tag/message id instead of just a bare count.
164
+ self._future_messages: dict[concurrent.futures.Future[Any], RabbitMessage] = {}
165
+ self._futures_lock = threading.Lock()
166
+
167
+ @property
168
+ def worker_count(self) -> int:
169
+ """Return the configured worker count."""
170
+ return self._config.worker_count
171
+
172
+ def start(self) -> None:
173
+ """Start the worker pool.
174
+
175
+ Uses daemon worker threads (see :class:`_DaemonWorkerPool`) so a stuck
176
+ handler cannot keep the process alive past ``stop()`` — important for
177
+ k8s graceful shutdown where ``terminationGracePeriodSeconds`` must be
178
+ honored without relying on SIGKILL.
179
+ """
180
+ if self._config.worker_count <= 1:
181
+ return # No pool needed for single worker
182
+ self._executor = _DaemonWorkerPool(
183
+ max_workers=self._config.worker_count,
184
+ thread_name_prefix="rabbitkit-worker",
185
+ max_queue_size=self._config.max_queue_size,
186
+ )
187
+ logger.info("SyncWorkerPool started with %d workers", self._config.worker_count)
188
+
189
+ def stop(self, timeout: float | None = None, pump: Callable[[], None] | None = None) -> None:
190
+ """Stop the worker pool, waiting for in-flight tasks.
191
+
192
+ Cancels pending (not-yet-started) futures and bounds the wait for
193
+ running ones by ``timeout`` (default ``WorkerConfig.stop_timeout``).
194
+ Because workers are daemon threads, any task that does not finish in
195
+ time is abandoned and the process can still exit cleanly — SIGKILL is
196
+ no longer required as a backstop.
197
+
198
+ H2: when *pump* is given (``SyncBroker.stop()`` passes
199
+ ``SyncTransport.pump``), the wait is polled in short slices with
200
+ *pump* called between them, instead of one blocking
201
+ ``concurrent.futures.wait(timeout=effective)``. A worker thread
202
+ finishing its handler acks/nacks by marshaling onto the transport's
203
+ owner thread via ``_run_on_io_thread`` — without something draining
204
+ the connection's I/O loop while this method blocks, those marshaled
205
+ callbacks would never run (they'd eventually time out on the worker
206
+ side, or — before this fix — the transport fell back to an unsafe
207
+ inline cross-thread call). *pump* must be safe to call from whichever
208
+ thread calls ``stop()`` (i.e. ``stop()`` must run on the transport's
209
+ owner thread when *pump* is given). Without *pump*, behavior is
210
+ unchanged: a single blocking wait for the full *effective* timeout.
211
+ """
212
+ if self._executor is None:
213
+ return
214
+ effective = timeout if timeout is not None else self._config.stop_timeout
215
+ with self._futures_lock:
216
+ futures_snapshot = list(self._futures)
217
+ if pump is None or not futures_snapshot:
218
+ _done, not_done = concurrent.futures.wait(futures_snapshot, timeout=effective)
219
+ else:
220
+ poll = 0.05
221
+ deadline = time.monotonic() + effective
222
+ not_done = set(futures_snapshot)
223
+ while not_done:
224
+ pump()
225
+ remaining = deadline - time.monotonic()
226
+ if remaining <= 0:
227
+ break
228
+ _done, not_done = concurrent.futures.wait(not_done, timeout=min(poll, remaining))
229
+ pump() # final drain in case the last ack was just marshaled
230
+ if not_done:
231
+ # H12: identify WHICH deliveries were abandoned (delivery_tag /
232
+ # message_id), not just a count — the handler thread keeps
233
+ # running (daemon, not killed) and may still ack/nack/produce
234
+ # side effects later, so operators need to be able to correlate
235
+ # an abandoned delivery with what shows up (or doesn't) downstream.
236
+ with self._futures_lock:
237
+ abandoned = [self._future_messages.get(f) for f in not_done]
238
+ for message in abandoned:
239
+ if message is None:
240
+ # Not tracked — e.g. a future submitted directly via the
241
+ # underlying executor rather than through submit().
242
+ continue
243
+ logger.warning(
244
+ "SyncWorkerPool: handler for delivery_tag=%s message_id=%s did not "
245
+ "complete within stop_timeout; abandoning (its daemon thread keeps "
246
+ "running — ensure handlers are idempotent under at-least-once delivery)",
247
+ message.delivery_tag,
248
+ message.message_id,
249
+ )
250
+ logger.warning(
251
+ "SyncWorkerPool: %d tasks did not complete within timeout; "
252
+ "abandoning (daemon threads will not block process exit)",
253
+ len(not_done),
254
+ )
255
+ # cancel_futures=True abandons queued-but-unstarted work; daemon threads
256
+ # ensure the process exits even if a running handler is wedged.
257
+ self._executor.shutdown(wait=False, cancel_futures=True)
258
+ self._executor = None
259
+ with self._futures_lock:
260
+ self._futures.clear()
261
+ self._future_messages.clear()
262
+ logger.info("SyncWorkerPool stopped")
263
+
264
+ def submit(
265
+ self,
266
+ callback: Callable[[RabbitMessage], None],
267
+ message: RabbitMessage,
268
+ ) -> None:
269
+ """Submit a message for processing.
270
+
271
+ If worker_count=1 (default), runs synchronously in the current thread.
272
+ Otherwise, submits to the thread pool.
273
+ """
274
+ if self._executor is None:
275
+ # Single worker mode -- run directly
276
+ callback(message)
277
+ return
278
+
279
+ future = self._executor.submit(callback, message)
280
+ with self._futures_lock:
281
+ self._futures.add(future)
282
+ self._future_messages[future] = message
283
+ # Self-cleaning: O(1) add + discard instead of rebuilding the list
284
+ # on every message. Callback fires on the worker thread (or inline if
285
+ # already done) and removes the future under the lock.
286
+ future.add_done_callback(self._discard_future)
287
+
288
+ def _discard_future(self, future: concurrent.futures.Future[Any]) -> None:
289
+ with self._futures_lock:
290
+ self._futures.discard(future)
291
+ self._future_messages.pop(future, None)
292
+
293
+ @property
294
+ def pending_count(self) -> int:
295
+ """Number of tasks currently pending/running."""
296
+ if self._executor is None:
297
+ return 0
298
+ with self._futures_lock:
299
+ return len(self._futures)
300
+
301
+
302
+ class AsyncWorkerPool:
303
+ """Semaphore-based concurrent async message processing.
304
+
305
+ Limits the number of concurrently processing async handlers.
306
+
307
+ Usage::
308
+
309
+ pool = AsyncWorkerPool(config=WorkerConfig(worker_count=4))
310
+ pool.start()
311
+ await pool.submit(callback, message)
312
+ await pool.stop()
313
+ """
314
+
315
+ def __init__(self, config: WorkerConfig | None = None) -> None:
316
+ self._config = config or WorkerConfig()
317
+ self._semaphore: asyncio.Semaphore | None = None
318
+ self._tasks: set[asyncio.Task[None]] = set()
319
+ # H12: message behind each task, so a task abandoned at the
320
+ # stop_timeout deadline can be nacked (redelivery) and logged by
321
+ # delivery tag/message id instead of just cancelled and forgotten.
322
+ self._task_messages: dict[asyncio.Task[None], RabbitMessage] = {}
323
+ self._running = False
324
+
325
+ @property
326
+ def worker_count(self) -> int:
327
+ """Return the configured worker count."""
328
+ return self._config.worker_count
329
+
330
+ def start(self) -> None:
331
+ """Start the worker pool."""
332
+ if self._config.worker_count <= 1:
333
+ return
334
+ self._semaphore = asyncio.Semaphore(self._config.worker_count)
335
+ self._running = True
336
+ logger.info("AsyncWorkerPool started with %d workers", self._config.worker_count)
337
+
338
+ async def stop(self, timeout: float | None = None) -> None:
339
+ """Stop the worker pool, waiting for in-flight tasks.
340
+
341
+ R-TaskGroup: rather than ``asyncio.wait`` + a manual ``task.cancel()``
342
+ loop, we ``gather(*tasks, return_exceptions=True)`` bounded by
343
+ ``asyncio.timeout`` (the 3.11+ idiom). Tasks that don't finish before
344
+ the deadline are cancelled and awaited once more so their
345
+ ``CancelledError`` is consumed rather than leaking as "Task was
346
+ destroyed but it is pending" warnings.
347
+
348
+ H12: a task cancelled by the deadline is *not* guaranteed to have
349
+ reached its own ``ack``/``nack`` — ``CancelledError`` is a
350
+ ``BaseException``, so it skips right past the pipeline's
351
+ ``except Exception`` handling and the message is left unsettled.
352
+ Rather than leave that to the implicit requeue-on-unacked-channel-close
353
+ behavior when the transport eventually disconnects, any message still
354
+ unsettled after a task is abandoned is nacked (requeue) here and
355
+ logged by delivery tag — an explicit, immediate, observable
356
+ redelivery instead of an implicit one.
357
+ """
358
+ self._running = False
359
+ if not self._tasks:
360
+ return
361
+ effective = timeout if timeout is not None else self._config.stop_timeout
362
+ tasks = list(self._tasks)
363
+ task_messages = dict(self._task_messages)
364
+ try:
365
+ async with asyncio.timeout(effective):
366
+ await asyncio.gather(*tasks, return_exceptions=True)
367
+ except TimeoutError:
368
+ # Deadline elapsed: cancel any still-running tasks and drain them
369
+ # so cancellation propagates cleanly (no pending-task warnings).
370
+ # In practice `asyncio.timeout` firing cancels this method's own
371
+ # await of gather(), and gather's cancellation handling already
372
+ # cancels + drains every task before re-raising -- so `not_done`
373
+ # is normally empty by the time we get here; this loop is a
374
+ # defensive backstop, not the primary abandonment path.
375
+ not_done = [t for t in tasks if not t.done()]
376
+ for task in not_done: # pragma: no cover — gather already drains before raising
377
+ task.cancel() # pragma: no cover
378
+ if not_done: # pragma: no cover
379
+ await asyncio.gather(*not_done, return_exceptions=True) # pragma: no cover
380
+ for message in task_messages.values():
381
+ if message.is_settled:
382
+ continue # handler reached its own ack/nack before being cut off
383
+ logger.warning(
384
+ "AsyncWorkerPool: handler for delivery_tag=%s message_id=%s did not "
385
+ "complete within stop_timeout; abandoning (task cancelled) and nacking "
386
+ "for redelivery — ensure handlers are idempotent under at-least-once delivery",
387
+ message.delivery_tag,
388
+ message.message_id,
389
+ )
390
+ try:
391
+ await message.nack_async(requeue=True)
392
+ except Exception:
393
+ logger.warning(
394
+ "nack on abandoned handler's message raised", exc_info=True
395
+ )
396
+ finally:
397
+ self._tasks.clear()
398
+ self._task_messages.clear()
399
+ logger.info("AsyncWorkerPool stopped")
400
+
401
+ async def submit(
402
+ self,
403
+ callback: Callable[[RabbitMessage], Awaitable[None]],
404
+ message: RabbitMessage,
405
+ ) -> None:
406
+ """Submit a message for concurrent processing.
407
+
408
+ If worker_count=1, runs directly (no semaphore).
409
+ Otherwise, acquires semaphore slot before executing.
410
+
411
+ H12: refuses (nacks for redelivery) instead of scheduling an unawaited
412
+ task when the pool isn't running — e.g. a delivery callback firing
413
+ after ``stop()`` already cleared ``_tasks``. Nothing would ever await
414
+ that task, so it would silently race the event loop's own shutdown
415
+ instead of the message being cleanly settled.
416
+ """
417
+ if self._semaphore is None:
418
+ await callback(message)
419
+ return
420
+
421
+ if not self._running:
422
+ logger.warning(
423
+ "AsyncWorkerPool.submit() called while stopped (delivery_tag=%s, "
424
+ "message_id=%s); nacking for redelivery instead of orphaning an "
425
+ "unawaited task",
426
+ message.delivery_tag,
427
+ message.message_id,
428
+ )
429
+ if not message.is_settled:
430
+ await message.nack_async(requeue=True)
431
+ return
432
+
433
+ semaphore = self._semaphore
434
+
435
+ async def _run() -> None:
436
+ async with semaphore:
437
+ await callback(message)
438
+
439
+ task = asyncio.create_task(_run())
440
+ self._tasks.add(task)
441
+ self._task_messages[task] = message
442
+ task.add_done_callback(self._on_task_done)
443
+
444
+ def _on_task_done(self, task: asyncio.Task[None]) -> None:
445
+ self._tasks.discard(task)
446
+ self._task_messages.pop(task, None)
447
+
448
+ @property
449
+ def pending_count(self) -> int:
450
+ """Number of tasks currently pending/running."""
451
+ return len(self._tasks)
@@ -0,0 +1,5 @@
1
+ """Core module — transport-agnostic business logic.
2
+
3
+ This module has ZERO pika or aio-pika imports.
4
+ All transport-specific code lives in sync/ and async_/.
5
+ """