rabbitkit 0.9.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rabbitkit/__init__.py +201 -0
- rabbitkit/_version.py +3 -0
- rabbitkit/aio/__init__.py +31 -0
- rabbitkit/async_/__init__.py +9 -0
- rabbitkit/async_/batch.py +213 -0
- rabbitkit/async_/broker.py +1123 -0
- rabbitkit/async_/connection.py +274 -0
- rabbitkit/async_/pool.py +363 -0
- rabbitkit/async_/transport.py +877 -0
- rabbitkit/asyncapi/__init__.py +5 -0
- rabbitkit/asyncapi/generator.py +219 -0
- rabbitkit/asyncapi/schema.py +98 -0
- rabbitkit/cli/__init__.py +77 -0
- rabbitkit/cli/_utils.py +38 -0
- rabbitkit/cli/commands/__init__.py +0 -0
- rabbitkit/cli/commands/dlq.py +190 -0
- rabbitkit/cli/commands/health.py +34 -0
- rabbitkit/cli/commands/migrate.py +570 -0
- rabbitkit/cli/commands/routes.py +88 -0
- rabbitkit/cli/commands/run.py +144 -0
- rabbitkit/cli/commands/shell.py +72 -0
- rabbitkit/cli/commands/topology.py +346 -0
- rabbitkit/concurrency.py +451 -0
- rabbitkit/core/__init__.py +5 -0
- rabbitkit/core/app.py +323 -0
- rabbitkit/core/config.py +849 -0
- rabbitkit/core/env_config.py +251 -0
- rabbitkit/core/errors.py +199 -0
- rabbitkit/core/logging.py +261 -0
- rabbitkit/core/message.py +235 -0
- rabbitkit/core/path.py +53 -0
- rabbitkit/core/pipeline.py +1289 -0
- rabbitkit/core/protocols.py +349 -0
- rabbitkit/core/registry.py +284 -0
- rabbitkit/core/route.py +329 -0
- rabbitkit/core/router.py +142 -0
- rabbitkit/core/topology.py +261 -0
- rabbitkit/core/topology_dispatch.py +74 -0
- rabbitkit/core/types.py +324 -0
- rabbitkit/dashboard/__init__.py +5 -0
- rabbitkit/dashboard/app.py +212 -0
- rabbitkit/di/__init__.py +19 -0
- rabbitkit/di/context.py +193 -0
- rabbitkit/di/depends.py +42 -0
- rabbitkit/di/resolver.py +503 -0
- rabbitkit/dlq.py +320 -0
- rabbitkit/experimental/__init__.py +50 -0
- rabbitkit/fastapi.py +91 -0
- rabbitkit/health.py +654 -0
- rabbitkit/highload/__init__.py +10 -0
- rabbitkit/highload/backpressure.py +514 -0
- rabbitkit/highload/batch.py +448 -0
- rabbitkit/locking.py +277 -0
- rabbitkit/management.py +470 -0
- rabbitkit/middleware/__init__.py +27 -0
- rabbitkit/middleware/base.py +125 -0
- rabbitkit/middleware/circuit_breaker.py +131 -0
- rabbitkit/middleware/compression.py +267 -0
- rabbitkit/middleware/deduplication.py +651 -0
- rabbitkit/middleware/error_classifier.py +43 -0
- rabbitkit/middleware/exception.py +105 -0
- rabbitkit/middleware/metrics.py +440 -0
- rabbitkit/middleware/otel.py +203 -0
- rabbitkit/middleware/rate_limit.py +247 -0
- rabbitkit/middleware/retry.py +540 -0
- rabbitkit/middleware/signing.py +682 -0
- rabbitkit/middleware/timeout.py +291 -0
- rabbitkit/py.typed +0 -0
- rabbitkit/queue_metrics.py +174 -0
- rabbitkit/results/__init__.py +6 -0
- rabbitkit/results/backend.py +102 -0
- rabbitkit/results/middleware.py +123 -0
- rabbitkit/rpc.py +632 -0
- rabbitkit/serialization/__init__.py +25 -0
- rabbitkit/serialization/base.py +35 -0
- rabbitkit/serialization/json.py +122 -0
- rabbitkit/serialization/msgspec.py +136 -0
- rabbitkit/serialization/pipeline.py +255 -0
- rabbitkit/streams.py +139 -0
- rabbitkit/sync/__init__.py +11 -0
- rabbitkit/sync/batch.py +595 -0
- rabbitkit/sync/broker.py +996 -0
- rabbitkit/sync/connection.py +209 -0
- rabbitkit/sync/pool.py +262 -0
- rabbitkit/sync/transport.py +1085 -0
- rabbitkit/testing/__init__.py +20 -0
- rabbitkit/testing/app.py +99 -0
- rabbitkit/testing/broker.py +540 -0
- rabbitkit/testing/fixtures.py +56 -0
- rabbitkit-0.9.0.dist-info/METADATA +575 -0
- rabbitkit-0.9.0.dist-info/RECORD +95 -0
- rabbitkit-0.9.0.dist-info/WHEEL +5 -0
- rabbitkit-0.9.0.dist-info/entry_points.txt +2 -0
- rabbitkit-0.9.0.dist-info/licenses/LICENSE +21 -0
- rabbitkit-0.9.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""aio-pika-specific connection parameter builders and error tuples.
|
|
2
|
+
|
|
3
|
+
This is where all aio-pika imports live — core/ stays clean.
|
|
4
|
+
Provides helpers to build aio_pika.connect_robust() kwargs from rabbitkit config objects.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import logging
|
|
11
|
+
import random
|
|
12
|
+
import ssl
|
|
13
|
+
from typing import Any
|
|
14
|
+
from urllib.parse import quote
|
|
15
|
+
|
|
16
|
+
from rabbitkit.core.config import ConnectionConfig, SecurityConfig, SSLConfig
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ── Transport-specific connection errors ──────────────────────────────────
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_connection_errors() -> tuple[type[BaseException], ...]:
|
|
25
|
+
"""Get aio-pika-specific connection error tuple.
|
|
26
|
+
|
|
27
|
+
Returns generic stdlib errors if aio-pika is not installed.
|
|
28
|
+
"""
|
|
29
|
+
base_errors: tuple[type[BaseException], ...] = (
|
|
30
|
+
ConnectionResetError,
|
|
31
|
+
BrokenPipeError,
|
|
32
|
+
ConnectionAbortedError,
|
|
33
|
+
ConnectionRefusedError,
|
|
34
|
+
TimeoutError,
|
|
35
|
+
EOFError,
|
|
36
|
+
OSError,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
import aio_pika.exceptions
|
|
41
|
+
|
|
42
|
+
aio_pika_errors: tuple[type[BaseException], ...] = (
|
|
43
|
+
aio_pika.exceptions.AMQPConnectionError,
|
|
44
|
+
aio_pika.exceptions.ChannelClosed,
|
|
45
|
+
aio_pika.exceptions.ConnectionClosed,
|
|
46
|
+
)
|
|
47
|
+
return aio_pika_errors + base_errors
|
|
48
|
+
except (ImportError, AttributeError):
|
|
49
|
+
return base_errors
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def build_ssl_context(ssl_config: SSLConfig) -> ssl.SSLContext | None:
|
|
53
|
+
"""Build stdlib ssl.SSLContext from SSLConfig.
|
|
54
|
+
|
|
55
|
+
Returns None if SSL is not enabled.
|
|
56
|
+
Shared with sync/connection.py — same logic.
|
|
57
|
+
"""
|
|
58
|
+
if not ssl_config.enabled:
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
cert_reqs_map = {
|
|
62
|
+
"CERT_REQUIRED": ssl.CERT_REQUIRED,
|
|
63
|
+
"CERT_OPTIONAL": ssl.CERT_OPTIONAL,
|
|
64
|
+
"CERT_NONE": ssl.CERT_NONE,
|
|
65
|
+
}
|
|
66
|
+
cert_reqs = cert_reqs_map.get(ssl_config.cert_reqs, ssl.CERT_REQUIRED)
|
|
67
|
+
|
|
68
|
+
# M13: disabling certificate verification makes the connection MITM-able —
|
|
69
|
+
# warn loudly (see sync/connection.py for rationale).
|
|
70
|
+
if cert_reqs == ssl.CERT_NONE:
|
|
71
|
+
import warnings
|
|
72
|
+
|
|
73
|
+
warnings.warn(
|
|
74
|
+
"SSLConfig(cert_reqs='CERT_NONE') disables TLS certificate and hostname "
|
|
75
|
+
"verification — the connection is encrypted but MITM-able. Use "
|
|
76
|
+
"'CERT_REQUIRED' (the default) with a proper ca_certs bundle in production.",
|
|
77
|
+
RuntimeWarning,
|
|
78
|
+
stacklevel=2,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
82
|
+
|
|
83
|
+
# Defense in depth: never negotiate below TLS 1.2.
|
|
84
|
+
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
|
|
85
|
+
|
|
86
|
+
# check_hostname must be disabled BEFORE setting verify_mode=CERT_NONE
|
|
87
|
+
if cert_reqs == ssl.CERT_NONE:
|
|
88
|
+
ctx.check_hostname = False
|
|
89
|
+
|
|
90
|
+
ctx.verify_mode = cert_reqs
|
|
91
|
+
|
|
92
|
+
if ssl_config.ca_certs:
|
|
93
|
+
ctx.load_verify_locations(ssl_config.ca_certs)
|
|
94
|
+
elif cert_reqs == ssl.CERT_REQUIRED:
|
|
95
|
+
# No explicit CA bundle configured — fall back to the system trust
|
|
96
|
+
# store so verification actually succeeds against broker certs
|
|
97
|
+
# signed by a well-known CA. Guarded so the explicit-ca_certs path
|
|
98
|
+
# above is unchanged.
|
|
99
|
+
try:
|
|
100
|
+
ctx.load_default_certs()
|
|
101
|
+
except Exception: # pragma: no cover — best effort, platform-dependent
|
|
102
|
+
try:
|
|
103
|
+
ctx.set_default_verify_paths()
|
|
104
|
+
except Exception: # pragma: no cover
|
|
105
|
+
pass
|
|
106
|
+
|
|
107
|
+
if ssl_config.certfile:
|
|
108
|
+
ctx.load_cert_chain(
|
|
109
|
+
certfile=ssl_config.certfile,
|
|
110
|
+
keyfile=ssl_config.keyfile,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
return ctx
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def make_aio_pika_connect_kwargs(
|
|
117
|
+
connection: ConnectionConfig,
|
|
118
|
+
security: SecurityConfig,
|
|
119
|
+
*,
|
|
120
|
+
host_override: str | None = None,
|
|
121
|
+
port_override: int | None = None,
|
|
122
|
+
) -> dict[str, Any]:
|
|
123
|
+
"""Build kwargs for aio_pika.connect_robust().
|
|
124
|
+
|
|
125
|
+
Returns a dict of keyword arguments.
|
|
126
|
+
Raises ImportError if aio-pika is not installed.
|
|
127
|
+
|
|
128
|
+
Note: aio-pika has no native ``blocked_connection_timeout`` knob (unlike
|
|
129
|
+
pika's ``ConnectionParameters.blocked_connection_timeout``). To honour
|
|
130
|
+
``ConnectionConfig.blocked_connection_timeout`` on the async side, call
|
|
131
|
+
:func:`install_blocked_connection_watchdog` on the returned connection
|
|
132
|
+
after ``connect_robust`` succeeds. That helper drives a timer task that
|
|
133
|
+
closes the connection when a ``connection.blocked`` alarm is not cleared
|
|
134
|
+
by ``connection.unblocked`` within the configured timeout, forcing a
|
|
135
|
+
reconnect instead of stalling publishes indefinitely.
|
|
136
|
+
"""
|
|
137
|
+
try:
|
|
138
|
+
import aio_pika # noqa: F401
|
|
139
|
+
except ImportError:
|
|
140
|
+
raise ImportError(
|
|
141
|
+
"aio-pika is required for async transport. Install it with: pip install rabbitkit[async]"
|
|
142
|
+
) from None
|
|
143
|
+
|
|
144
|
+
# Build URL — carry heartbeat as a query param. aio-pika/aiormq read heartbeat
|
|
145
|
+
# from the URL; passing it as a kwarg is not portable across versions. Without
|
|
146
|
+
# this the configured ConnectionConfig.heartbeat was silently dropped on async
|
|
147
|
+
# (the sync transport already honors it).
|
|
148
|
+
#
|
|
149
|
+
# URL-encode username/password so credentials containing special characters
|
|
150
|
+
# (e.g. ":", "@", "/", "+") don't corrupt the AMQP URL or leak as plaintext
|
|
151
|
+
# delimiters. ConnectionConfig.url (core/config.py) is NOT in this module's
|
|
152
|
+
# scope, so we rebuild a safe URL here.
|
|
153
|
+
# M13: resolve credentials via credentials_provider if set (rotation).
|
|
154
|
+
raw_user, raw_pwd = connection.resolve_credentials()
|
|
155
|
+
user = quote(raw_user, safe="")
|
|
156
|
+
pwd = quote(raw_pwd, safe="")
|
|
157
|
+
vhost = connection.vhost
|
|
158
|
+
if vhost == "/":
|
|
159
|
+
vhost = "%2F"
|
|
160
|
+
# M9: allow the caller (pool) to target a specific cluster node.
|
|
161
|
+
host = host_override if host_override is not None else connection.host
|
|
162
|
+
port = port_override if port_override is not None else connection.port
|
|
163
|
+
base_url = f"amqp://{user}:{pwd}@{host}:{port}/{vhost}"
|
|
164
|
+
sep = "&" if "?" in base_url else "?"
|
|
165
|
+
url = f"{base_url}{sep}heartbeat={connection.heartbeat}"
|
|
166
|
+
|
|
167
|
+
# H4: aio-pika's connect_robust uses a FIXED reconnect_interval — no
|
|
168
|
+
# exponential backoff. A fleet of consumers restarted together (e.g. a
|
|
169
|
+
# broker bounce under 200 pods) would otherwise retry in lockstep every
|
|
170
|
+
# `reconnect_backoff_base` seconds, hammering the recovering node in a
|
|
171
|
+
# thundering herd. We can't inject exponential backoff into connect_robust,
|
|
172
|
+
# but randomizing the interval PER PROCESS (full jitter over
|
|
173
|
+
# [base, min(base*2, backoff_max)]) de-synchronizes the herd so retries
|
|
174
|
+
# spread across the window instead of arriving as a spike. Each process
|
|
175
|
+
# picks its interval once at connect time; `random` needs no seeding for
|
|
176
|
+
# inter-process spread since PYTHONHASHSEED/os entropy differ per process.
|
|
177
|
+
base = connection.reconnect_backoff_base
|
|
178
|
+
jitter_span = max(0.0, min(base, connection.reconnect_backoff_max - base))
|
|
179
|
+
jittered_interval = base + random.uniform(0.0, jitter_span) # noqa: S311 — jitter, not crypto
|
|
180
|
+
kwargs: dict[str, Any] = {
|
|
181
|
+
"url": url,
|
|
182
|
+
"timeout": connection.socket_timeout,
|
|
183
|
+
"reconnect_interval": jittered_interval,
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
# SSL
|
|
187
|
+
ssl_context = build_ssl_context(security.ssl)
|
|
188
|
+
if ssl_context is not None:
|
|
189
|
+
kwargs["ssl_context"] = ssl_context
|
|
190
|
+
|
|
191
|
+
# Client properties
|
|
192
|
+
if connection.connection_name:
|
|
193
|
+
kwargs["client_properties"] = {
|
|
194
|
+
"connection_name": connection.connection_name,
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return kwargs
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
async def install_blocked_connection_watchdog(connection: Any, blocked_timeout: float) -> None:
|
|
201
|
+
"""Install a watchdog that closes *connection* when a blocked alarm lingers.
|
|
202
|
+
|
|
203
|
+
aio-pika has no native ``blocked_connection_timeout`` (I-11), so without
|
|
204
|
+
this a ``connection.blocked`` alarm from RabbitMQ (memory/disk pressure)
|
|
205
|
+
can stall publishes indefinitely while the connection stays "open". This
|
|
206
|
+
helper registers ``connection.connection_blocked`` /
|
|
207
|
+
``connection.connection_unblocked`` callbacks that drive a timer task:
|
|
208
|
+
|
|
209
|
+
- on ``blocked``: start (or replace) a task that sleeps *blocked_timeout*
|
|
210
|
+
then closes the connection (forcing ``connect_robust`` to reconnect).
|
|
211
|
+
- on ``unblocked``: cancel the pending timer so a transient alarm does not
|
|
212
|
+
tear down a recovered connection.
|
|
213
|
+
|
|
214
|
+
Safe to call with ``blocked_timeout <= 0`` (no-op) or on a connection
|
|
215
|
+
that does not expose the callback collections (logged at debug, no raise).
|
|
216
|
+
Must be called from the event loop that owns *connection*.
|
|
217
|
+
"""
|
|
218
|
+
if blocked_timeout <= 0:
|
|
219
|
+
return
|
|
220
|
+
|
|
221
|
+
blocked_cb_collection = getattr(connection, "connection_blocked", None)
|
|
222
|
+
unblocked_cb_collection = getattr(connection, "connection_unblocked", None)
|
|
223
|
+
if blocked_cb_collection is None or unblocked_cb_collection is None:
|
|
224
|
+
logger.debug(
|
|
225
|
+
"connection does not expose connection_blocked/connection_unblocked "
|
|
226
|
+
"callback collections; blocked-connection watchdog not installed"
|
|
227
|
+
)
|
|
228
|
+
return
|
|
229
|
+
|
|
230
|
+
loop = asyncio.get_event_loop()
|
|
231
|
+
state: dict[str, asyncio.Task[None] | None] = {"timer": None}
|
|
232
|
+
|
|
233
|
+
async def _on_blocked(*_args: Any) -> None:
|
|
234
|
+
# Replace any pending timer with a fresh one.
|
|
235
|
+
existing = state.get("timer")
|
|
236
|
+
if existing is not None and not existing.done():
|
|
237
|
+
existing.cancel()
|
|
238
|
+
logger.warning(
|
|
239
|
+
"Connection blocked by RabbitMQ; will close in %.1fs if not unblocked",
|
|
240
|
+
blocked_timeout,
|
|
241
|
+
)
|
|
242
|
+
state["timer"] = asyncio.ensure_future(_close_after(blocked_timeout))
|
|
243
|
+
|
|
244
|
+
async def _on_unblocked(*_args: Any) -> None:
|
|
245
|
+
existing = state.get("timer")
|
|
246
|
+
if existing is not None and not existing.done():
|
|
247
|
+
existing.cancel()
|
|
248
|
+
state["timer"] = None
|
|
249
|
+
logger.info("Connection unblocked; watchdog timer cancelled")
|
|
250
|
+
|
|
251
|
+
async def _close_after(delay: float) -> None:
|
|
252
|
+
try:
|
|
253
|
+
await asyncio.sleep(delay)
|
|
254
|
+
except asyncio.CancelledError:
|
|
255
|
+
return
|
|
256
|
+
logger.warning("Connection blocked for > %.1fs; closing to force reconnect", delay)
|
|
257
|
+
try:
|
|
258
|
+
close = connection.close
|
|
259
|
+
result = close()
|
|
260
|
+
if hasattr(result, "__await__"):
|
|
261
|
+
await result
|
|
262
|
+
except Exception: # pragma: no cover — best effort; connect_robust will retry
|
|
263
|
+
logger.debug("watchdog close raised", exc_info=True)
|
|
264
|
+
|
|
265
|
+
# aio-pika CallbackCollection.add_callback accepts a coroutine fn.
|
|
266
|
+
try:
|
|
267
|
+
blocked_cb_collection.add_callback(_on_blocked)
|
|
268
|
+
unblocked_cb_collection.add_callback(_on_unblocked)
|
|
269
|
+
except Exception: # pragma: no cover — defensive across aio-pika versions
|
|
270
|
+
logger.debug("Could not register blocked/unblocked watchdog callbacks", exc_info=True)
|
|
271
|
+
return
|
|
272
|
+
# Keep a reference so the timer is not GC'd and the callbacks are traceable.
|
|
273
|
+
connection._rabbitkit_blocked_watchdog = state
|
|
274
|
+
connection._rabbitkit_blocked_watchdog_loop = loop
|
rabbitkit/async_/pool.py
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"""Async connection and channel pools.
|
|
2
|
+
|
|
3
|
+
Minimal in 0.1.0 — internal performance utilities.
|
|
4
|
+
Do not oversell as a promised optimization layer.
|
|
5
|
+
|
|
6
|
+
Uses asyncio.Queue for channel pooling and dedicated
|
|
7
|
+
connections for publisher vs consumer separation.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import logging
|
|
14
|
+
import random
|
|
15
|
+
from contextlib import asynccontextmanager
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from rabbitkit.async_.connection import get_connection_errors, make_aio_pika_connect_kwargs
|
|
19
|
+
from rabbitkit.core.config import ConnectionConfig, PoolConfig, SecurityConfig
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AsyncChannelPool:
|
|
25
|
+
"""Async channel pool.
|
|
26
|
+
|
|
27
|
+
Manages a pool of aio-pika channels on a single connection.
|
|
28
|
+
Channels are acquired and released by callers.
|
|
29
|
+
|
|
30
|
+
``acquire_timeout`` controls how long to wait when all channels are
|
|
31
|
+
checked out. Raises ``asyncio.TimeoutError`` on exhaustion rather than
|
|
32
|
+
blocking forever (which would deadlock if a handler tries to publish).
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
connection: Any, # aio_pika.RobustConnection
|
|
38
|
+
pool_size: int = 10,
|
|
39
|
+
acquire_timeout: float = 10.0,
|
|
40
|
+
publisher_confirms: bool = True,
|
|
41
|
+
) -> None:
|
|
42
|
+
self._connection = connection
|
|
43
|
+
self._pool_size = pool_size
|
|
44
|
+
self._acquire_timeout = acquire_timeout
|
|
45
|
+
self._publisher_confirms = publisher_confirms
|
|
46
|
+
self._pool: asyncio.Queue[Any] = asyncio.Queue(maxsize=pool_size)
|
|
47
|
+
self._lock = asyncio.Lock()
|
|
48
|
+
self._created = 0
|
|
49
|
+
# Channels currently checked out by callers; closed in close_all() so they
|
|
50
|
+
# are not orphaned if a caller forgets to release (leak detection).
|
|
51
|
+
self._in_use: set[Any] = set()
|
|
52
|
+
|
|
53
|
+
async def acquire(self) -> Any:
|
|
54
|
+
"""Acquire a channel from the pool.
|
|
55
|
+
|
|
56
|
+
Creates a new channel if the pool is empty and under the size limit.
|
|
57
|
+
Waits up to ``acquire_timeout`` seconds when all channels are in use;
|
|
58
|
+
raises ``asyncio.TimeoutError`` if the wait expires, preventing
|
|
59
|
+
deadlocks when handlers publish while processing messages.
|
|
60
|
+
"""
|
|
61
|
+
try:
|
|
62
|
+
channel = self._pool.get_nowait()
|
|
63
|
+
if not channel.is_closed:
|
|
64
|
+
async with self._lock:
|
|
65
|
+
self._in_use.add(channel)
|
|
66
|
+
return channel
|
|
67
|
+
# I-6: a pooled channel was found closed — it still counted
|
|
68
|
+
# against _created when it was released, so decrement before
|
|
69
|
+
# discarding it (otherwise acquire() leaks a slot each time a
|
|
70
|
+
# closed-idle channel is pulled from the pool).
|
|
71
|
+
async with self._lock:
|
|
72
|
+
self._created = max(0, self._created - 1)
|
|
73
|
+
except asyncio.QueueEmpty:
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
# perf-M-2: create the channel OUTSIDE the lock (network round-trip) so
|
|
77
|
+
# concurrent acquire() calls don't serialize on channel creation during
|
|
78
|
+
# warmup/refill. The slot is reserved atomically under the lock (so we
|
|
79
|
+
# never over-create); the channel-open I/O happens outside the lock, and
|
|
80
|
+
# we re-acquire only to publish _in_use. If creation fails, the reserved
|
|
81
|
+
# slot is returned. The I-6 closed-idle decrement above stays under the
|
|
82
|
+
# lock.
|
|
83
|
+
async with self._lock:
|
|
84
|
+
if self._created < self._pool_size:
|
|
85
|
+
need_create = True
|
|
86
|
+
self._created += 1
|
|
87
|
+
else:
|
|
88
|
+
need_create = False
|
|
89
|
+
|
|
90
|
+
if need_create:
|
|
91
|
+
try:
|
|
92
|
+
channel = await self._connection.channel(publisher_confirms=self._publisher_confirms)
|
|
93
|
+
except BaseException:
|
|
94
|
+
# creation failed — give the reserved slot back.
|
|
95
|
+
async with self._lock:
|
|
96
|
+
self._created = max(0, self._created - 1)
|
|
97
|
+
raise
|
|
98
|
+
async with self._lock:
|
|
99
|
+
self._in_use.add(channel)
|
|
100
|
+
return channel
|
|
101
|
+
|
|
102
|
+
# Pool exhausted — wait with timeout to avoid deadlocks. R-timeout:
|
|
103
|
+
# ``asyncio.timeout`` (3.11+) replaces ``asyncio.wait_for`` to avoid
|
|
104
|
+
# the wrapper-task overhead.
|
|
105
|
+
logger.warning(
|
|
106
|
+
"Channel pool exhausted (pool_size=%d, created=%d). "
|
|
107
|
+
"Waiting up to %.1fs for a channel to be released. "
|
|
108
|
+
"Consider increasing PoolConfig.channel_pool_size.",
|
|
109
|
+
self._pool_size,
|
|
110
|
+
self._created,
|
|
111
|
+
self._acquire_timeout,
|
|
112
|
+
)
|
|
113
|
+
try:
|
|
114
|
+
async with asyncio.timeout(self._acquire_timeout):
|
|
115
|
+
channel = await self._pool.get()
|
|
116
|
+
except TimeoutError:
|
|
117
|
+
raise TimeoutError(
|
|
118
|
+
f"Timed out after {self._acquire_timeout}s waiting for a channel "
|
|
119
|
+
f"from the pool (pool_size={self._pool_size})."
|
|
120
|
+
) from None
|
|
121
|
+
if channel.is_closed:
|
|
122
|
+
async with self._lock:
|
|
123
|
+
self._created = max(0, self._created - 1)
|
|
124
|
+
return await self.acquire()
|
|
125
|
+
async with self._lock:
|
|
126
|
+
self._in_use.add(channel)
|
|
127
|
+
return channel
|
|
128
|
+
|
|
129
|
+
async def release(self, channel: Any) -> None:
|
|
130
|
+
"""Release a channel back to the pool."""
|
|
131
|
+
async with self._lock:
|
|
132
|
+
self._in_use.discard(channel)
|
|
133
|
+
if not channel.is_closed:
|
|
134
|
+
try:
|
|
135
|
+
self._pool.put_nowait(channel)
|
|
136
|
+
return
|
|
137
|
+
except asyncio.QueueFull:
|
|
138
|
+
pass
|
|
139
|
+
# Channel is closed or pool is full — discard
|
|
140
|
+
try:
|
|
141
|
+
if not channel.is_closed:
|
|
142
|
+
await channel.close()
|
|
143
|
+
except Exception:
|
|
144
|
+
pass
|
|
145
|
+
async with self._lock:
|
|
146
|
+
self._created = max(0, self._created - 1)
|
|
147
|
+
|
|
148
|
+
@asynccontextmanager
|
|
149
|
+
async def acquire_ctx(self) -> Any:
|
|
150
|
+
"""Async context manager for acquire/release — prevents leaks.
|
|
151
|
+
|
|
152
|
+
Usage::
|
|
153
|
+
|
|
154
|
+
async with pool.acquire_ctx() as ch:
|
|
155
|
+
await ch.publish(...)
|
|
156
|
+
"""
|
|
157
|
+
channel = await self.acquire()
|
|
158
|
+
try:
|
|
159
|
+
yield channel
|
|
160
|
+
finally:
|
|
161
|
+
await self.release(channel)
|
|
162
|
+
|
|
163
|
+
async def close_all(self) -> None:
|
|
164
|
+
"""Close all channels in the pool, including checked-out ones."""
|
|
165
|
+
async with self._lock:
|
|
166
|
+
in_use = list(self._in_use)
|
|
167
|
+
self._in_use.clear()
|
|
168
|
+
for channel in in_use:
|
|
169
|
+
try:
|
|
170
|
+
if not channel.is_closed:
|
|
171
|
+
await channel.close()
|
|
172
|
+
except Exception: # pragma: no cover — best effort
|
|
173
|
+
pass
|
|
174
|
+
async with self._lock:
|
|
175
|
+
self._created = max(0, self._created - 1)
|
|
176
|
+
while not self._pool.empty():
|
|
177
|
+
try:
|
|
178
|
+
channel = self._pool.get_nowait()
|
|
179
|
+
if not channel.is_closed:
|
|
180
|
+
await channel.close()
|
|
181
|
+
except (asyncio.QueueEmpty, Exception):
|
|
182
|
+
pass
|
|
183
|
+
async with self._lock:
|
|
184
|
+
self._created = 0
|
|
185
|
+
|
|
186
|
+
@property
|
|
187
|
+
def size(self) -> int:
|
|
188
|
+
"""Number of channels currently in the pool (available)."""
|
|
189
|
+
return self._pool.qsize()
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def created_count(self) -> int:
|
|
193
|
+
"""Total number of channels created."""
|
|
194
|
+
return self._created
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class AsyncConnectionPool:
|
|
198
|
+
"""Separate publisher/consumer connections with channel pools.
|
|
199
|
+
|
|
200
|
+
Provides dedicated connections for publishing and consuming to avoid
|
|
201
|
+
head-of-line blocking, and exposes ``AsyncChannelPool`` instances so
|
|
202
|
+
callers never share a single channel across concurrent operations.
|
|
203
|
+
|
|
204
|
+
Usage::
|
|
205
|
+
|
|
206
|
+
pool = AsyncConnectionPool(connection_config, security_config, pool_config)
|
|
207
|
+
await pool.connect()
|
|
208
|
+
|
|
209
|
+
async with pool.acquire_publisher_channel() as ch:
|
|
210
|
+
await ch.publish(...)
|
|
211
|
+
|
|
212
|
+
await pool.close_all()
|
|
213
|
+
"""
|
|
214
|
+
|
|
215
|
+
def __init__(
|
|
216
|
+
self,
|
|
217
|
+
connection_config: ConnectionConfig,
|
|
218
|
+
security_config: SecurityConfig,
|
|
219
|
+
pool_config: PoolConfig | None = None,
|
|
220
|
+
publisher_confirms: bool = True,
|
|
221
|
+
) -> None:
|
|
222
|
+
self._connection_config = connection_config
|
|
223
|
+
self._security_config = security_config
|
|
224
|
+
self._pool_config = pool_config or PoolConfig()
|
|
225
|
+
self._publisher_confirms = publisher_confirms
|
|
226
|
+
|
|
227
|
+
self._publisher_connection: Any | None = None
|
|
228
|
+
self._consumer_connection: Any | None = None
|
|
229
|
+
self._publisher_channel_pool: AsyncChannelPool | None = None
|
|
230
|
+
self._lock = asyncio.Lock()
|
|
231
|
+
self._prewarmed = False
|
|
232
|
+
|
|
233
|
+
async def connect(self) -> None:
|
|
234
|
+
"""Establish publisher and consumer connections eagerly."""
|
|
235
|
+
do_prewarm = False
|
|
236
|
+
async with self._lock:
|
|
237
|
+
if self._publisher_connection is None:
|
|
238
|
+
self._publisher_connection = await self._create_connection()
|
|
239
|
+
self._publisher_channel_pool = AsyncChannelPool(
|
|
240
|
+
self._publisher_connection,
|
|
241
|
+
pool_size=self._pool_config.channel_pool_size,
|
|
242
|
+
acquire_timeout=self._pool_config.channel_acquire_timeout,
|
|
243
|
+
publisher_confirms=self._publisher_confirms,
|
|
244
|
+
)
|
|
245
|
+
if self._consumer_connection is None:
|
|
246
|
+
self._consumer_connection = await self._create_connection()
|
|
247
|
+
if self._pool_config.prewarm_channels and not self._prewarmed:
|
|
248
|
+
self._prewarmed = True
|
|
249
|
+
do_prewarm = True
|
|
250
|
+
|
|
251
|
+
if do_prewarm and self._publisher_channel_pool is not None:
|
|
252
|
+
pool = self._publisher_channel_pool
|
|
253
|
+
channels = await asyncio.gather(
|
|
254
|
+
*(pool.acquire() for _ in range(self._pool_config.channel_pool_size)),
|
|
255
|
+
return_exceptions=True,
|
|
256
|
+
)
|
|
257
|
+
for ch in channels:
|
|
258
|
+
if not isinstance(ch, BaseException):
|
|
259
|
+
await pool.release(ch)
|
|
260
|
+
|
|
261
|
+
async def get_publisher_connection(self) -> Any:
|
|
262
|
+
"""Get a connection dedicated for publishing.
|
|
263
|
+
|
|
264
|
+
Creates the connection lazily on first call.
|
|
265
|
+
"""
|
|
266
|
+
async with self._lock:
|
|
267
|
+
if self._publisher_connection is None:
|
|
268
|
+
self._publisher_connection = await self._create_connection()
|
|
269
|
+
self._publisher_channel_pool = AsyncChannelPool(
|
|
270
|
+
self._publisher_connection,
|
|
271
|
+
pool_size=self._pool_config.channel_pool_size,
|
|
272
|
+
acquire_timeout=self._pool_config.channel_acquire_timeout,
|
|
273
|
+
publisher_confirms=self._publisher_confirms,
|
|
274
|
+
)
|
|
275
|
+
return self._publisher_connection
|
|
276
|
+
|
|
277
|
+
async def get_consumer_connection(self) -> Any:
|
|
278
|
+
"""Get a connection dedicated for consuming.
|
|
279
|
+
|
|
280
|
+
Creates the connection lazily on first call.
|
|
281
|
+
"""
|
|
282
|
+
async with self._lock:
|
|
283
|
+
if self._consumer_connection is None:
|
|
284
|
+
self._consumer_connection = await self._create_connection()
|
|
285
|
+
return self._consumer_connection
|
|
286
|
+
|
|
287
|
+
async def acquire_publisher_channel(self) -> Any:
|
|
288
|
+
"""Acquire a channel from the publisher channel pool."""
|
|
289
|
+
if self._publisher_channel_pool is None:
|
|
290
|
+
await self.get_publisher_connection()
|
|
291
|
+
assert self._publisher_channel_pool is not None
|
|
292
|
+
return await self._publisher_channel_pool.acquire()
|
|
293
|
+
|
|
294
|
+
async def release_publisher_channel(self, channel: Any) -> None:
|
|
295
|
+
"""Return a publisher channel to the pool."""
|
|
296
|
+
if self._publisher_channel_pool is not None:
|
|
297
|
+
await self._publisher_channel_pool.release(channel)
|
|
298
|
+
|
|
299
|
+
async def close_all(self) -> None:
|
|
300
|
+
"""Close all channel pools and connections."""
|
|
301
|
+
async with self._lock:
|
|
302
|
+
if self._publisher_channel_pool is not None:
|
|
303
|
+
await self._publisher_channel_pool.close_all()
|
|
304
|
+
self._publisher_channel_pool = None
|
|
305
|
+
|
|
306
|
+
for conn in [self._publisher_connection, self._consumer_connection]:
|
|
307
|
+
if conn is not None:
|
|
308
|
+
try:
|
|
309
|
+
if not conn.is_closed:
|
|
310
|
+
await conn.close()
|
|
311
|
+
except Exception as e:
|
|
312
|
+
logger.warning("Error closing connection: %s", e)
|
|
313
|
+
|
|
314
|
+
self._publisher_connection = None
|
|
315
|
+
self._consumer_connection = None
|
|
316
|
+
|
|
317
|
+
async def _create_connection(self) -> Any:
|
|
318
|
+
"""Create a new aio-pika connection."""
|
|
319
|
+
try:
|
|
320
|
+
import aio_pika
|
|
321
|
+
except ImportError:
|
|
322
|
+
raise ImportError(
|
|
323
|
+
"aio-pika is required for async transport. Install it with: pip install rabbitkit[async]"
|
|
324
|
+
) from None
|
|
325
|
+
|
|
326
|
+
# M9: cycle through cluster endpoints on the initial connect so a dead
|
|
327
|
+
# configured primary doesn't take the client down at startup. Once
|
|
328
|
+
# connect_robust succeeds it pins to that node for reconnects (aio-pika
|
|
329
|
+
# has no multi-host reconnect) — put a load balancer / DNS in front for
|
|
330
|
+
# per-reconnect failover across nodes.
|
|
331
|
+
endpoints = self._connection_config.cluster_endpoints()
|
|
332
|
+
|
|
333
|
+
# H-SRE3: connect_robust handles reconnects AFTER the first connection
|
|
334
|
+
# with a FIXED interval, so a fleet of clients starting at once thunder
|
|
335
|
+
# the broker. Apply an outer retry with full jitter for the INITIAL
|
|
336
|
+
# connect only; bounded so we never spin forever.
|
|
337
|
+
backoff = self._connection_config.reconnect_backoff_base
|
|
338
|
+
max_backoff = self._connection_config.reconnect_backoff_max
|
|
339
|
+
connection_errors = get_connection_errors()
|
|
340
|
+
max_attempts = 30
|
|
341
|
+
for attempt in range(1, max_attempts + 1):
|
|
342
|
+
host, port = endpoints[(attempt - 1) % len(endpoints)]
|
|
343
|
+
kwargs = make_aio_pika_connect_kwargs(
|
|
344
|
+
self._connection_config,
|
|
345
|
+
self._security_config,
|
|
346
|
+
host_override=host,
|
|
347
|
+
port_override=port,
|
|
348
|
+
)
|
|
349
|
+
try:
|
|
350
|
+
return await aio_pika.connect_robust(**kwargs)
|
|
351
|
+
except connection_errors as e:
|
|
352
|
+
if attempt == max_attempts:
|
|
353
|
+
raise
|
|
354
|
+
sleep_for = random.uniform(0.0, backoff) # noqa: S311
|
|
355
|
+
logger.warning(
|
|
356
|
+
"aio-pika initial connect failed (attempt %d), retrying in %.2fs: %s",
|
|
357
|
+
attempt,
|
|
358
|
+
sleep_for,
|
|
359
|
+
e,
|
|
360
|
+
)
|
|
361
|
+
await asyncio.sleep(sleep_for)
|
|
362
|
+
backoff = min(backoff * 2, max_backoff)
|
|
363
|
+
raise RuntimeError("unreachable") # pragma: no cover
|