rabbitkit 0.9.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rabbitkit/__init__.py +201 -0
- rabbitkit/_version.py +3 -0
- rabbitkit/aio/__init__.py +31 -0
- rabbitkit/async_/__init__.py +9 -0
- rabbitkit/async_/batch.py +213 -0
- rabbitkit/async_/broker.py +1123 -0
- rabbitkit/async_/connection.py +274 -0
- rabbitkit/async_/pool.py +363 -0
- rabbitkit/async_/transport.py +877 -0
- rabbitkit/asyncapi/__init__.py +5 -0
- rabbitkit/asyncapi/generator.py +219 -0
- rabbitkit/asyncapi/schema.py +98 -0
- rabbitkit/cli/__init__.py +77 -0
- rabbitkit/cli/_utils.py +38 -0
- rabbitkit/cli/commands/__init__.py +0 -0
- rabbitkit/cli/commands/dlq.py +190 -0
- rabbitkit/cli/commands/health.py +34 -0
- rabbitkit/cli/commands/migrate.py +570 -0
- rabbitkit/cli/commands/routes.py +88 -0
- rabbitkit/cli/commands/run.py +144 -0
- rabbitkit/cli/commands/shell.py +72 -0
- rabbitkit/cli/commands/topology.py +346 -0
- rabbitkit/concurrency.py +451 -0
- rabbitkit/core/__init__.py +5 -0
- rabbitkit/core/app.py +323 -0
- rabbitkit/core/config.py +849 -0
- rabbitkit/core/env_config.py +251 -0
- rabbitkit/core/errors.py +199 -0
- rabbitkit/core/logging.py +261 -0
- rabbitkit/core/message.py +235 -0
- rabbitkit/core/path.py +53 -0
- rabbitkit/core/pipeline.py +1289 -0
- rabbitkit/core/protocols.py +349 -0
- rabbitkit/core/registry.py +284 -0
- rabbitkit/core/route.py +329 -0
- rabbitkit/core/router.py +142 -0
- rabbitkit/core/topology.py +261 -0
- rabbitkit/core/topology_dispatch.py +74 -0
- rabbitkit/core/types.py +324 -0
- rabbitkit/dashboard/__init__.py +5 -0
- rabbitkit/dashboard/app.py +212 -0
- rabbitkit/di/__init__.py +19 -0
- rabbitkit/di/context.py +193 -0
- rabbitkit/di/depends.py +42 -0
- rabbitkit/di/resolver.py +503 -0
- rabbitkit/dlq.py +320 -0
- rabbitkit/experimental/__init__.py +50 -0
- rabbitkit/fastapi.py +91 -0
- rabbitkit/health.py +654 -0
- rabbitkit/highload/__init__.py +10 -0
- rabbitkit/highload/backpressure.py +514 -0
- rabbitkit/highload/batch.py +448 -0
- rabbitkit/locking.py +277 -0
- rabbitkit/management.py +470 -0
- rabbitkit/middleware/__init__.py +27 -0
- rabbitkit/middleware/base.py +125 -0
- rabbitkit/middleware/circuit_breaker.py +131 -0
- rabbitkit/middleware/compression.py +267 -0
- rabbitkit/middleware/deduplication.py +651 -0
- rabbitkit/middleware/error_classifier.py +43 -0
- rabbitkit/middleware/exception.py +105 -0
- rabbitkit/middleware/metrics.py +440 -0
- rabbitkit/middleware/otel.py +203 -0
- rabbitkit/middleware/rate_limit.py +247 -0
- rabbitkit/middleware/retry.py +540 -0
- rabbitkit/middleware/signing.py +682 -0
- rabbitkit/middleware/timeout.py +291 -0
- rabbitkit/py.typed +0 -0
- rabbitkit/queue_metrics.py +174 -0
- rabbitkit/results/__init__.py +6 -0
- rabbitkit/results/backend.py +102 -0
- rabbitkit/results/middleware.py +123 -0
- rabbitkit/rpc.py +632 -0
- rabbitkit/serialization/__init__.py +25 -0
- rabbitkit/serialization/base.py +35 -0
- rabbitkit/serialization/json.py +122 -0
- rabbitkit/serialization/msgspec.py +136 -0
- rabbitkit/serialization/pipeline.py +255 -0
- rabbitkit/streams.py +139 -0
- rabbitkit/sync/__init__.py +11 -0
- rabbitkit/sync/batch.py +595 -0
- rabbitkit/sync/broker.py +996 -0
- rabbitkit/sync/connection.py +209 -0
- rabbitkit/sync/pool.py +262 -0
- rabbitkit/sync/transport.py +1085 -0
- rabbitkit/testing/__init__.py +20 -0
- rabbitkit/testing/app.py +99 -0
- rabbitkit/testing/broker.py +540 -0
- rabbitkit/testing/fixtures.py +56 -0
- rabbitkit-0.9.0.dist-info/METADATA +575 -0
- rabbitkit-0.9.0.dist-info/RECORD +95 -0
- rabbitkit-0.9.0.dist-info/WHEEL +5 -0
- rabbitkit-0.9.0.dist-info/entry_points.txt +2 -0
- rabbitkit-0.9.0.dist-info/licenses/LICENSE +21 -0
- rabbitkit-0.9.0.dist-info/top_level.txt +1 -0
rabbitkit/concurrency.py
ADDED
|
@@ -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)
|