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,209 @@
|
|
|
1
|
+
"""Pika-specific connection parameter builders and error tuples.
|
|
2
|
+
|
|
3
|
+
This is where all pika imports live — core/ stays clean.
|
|
4
|
+
Provides helpers to build pika.ConnectionParameters from rabbitkit config objects.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import socket
|
|
11
|
+
import ssl
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from rabbitkit.core.config import ConnectionConfig, SecurityConfig, SocketConfig, SSLConfig
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ── Transport-specific connection errors ──────────────────────────────────
|
|
20
|
+
# These extend the core TRANSIENT_ERRORS for pika-specific exceptions.
|
|
21
|
+
# Lazy-loaded to avoid import errors when pika is not installed.
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_connection_errors() -> tuple[type[BaseException], ...]:
|
|
25
|
+
"""Get pika-specific connection error tuple.
|
|
26
|
+
|
|
27
|
+
Returns generic stdlib errors if 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 pika.exceptions
|
|
41
|
+
|
|
42
|
+
pika_errors: tuple[type[BaseException], ...] = (
|
|
43
|
+
pika.exceptions.StreamLostError,
|
|
44
|
+
pika.exceptions.AMQPConnectionError,
|
|
45
|
+
pika.exceptions.ConnectionClosedByBroker,
|
|
46
|
+
pika.exceptions.ChannelWrongStateError,
|
|
47
|
+
pika.exceptions.ChannelClosedByBroker,
|
|
48
|
+
pika.exceptions.AMQPChannelError,
|
|
49
|
+
)
|
|
50
|
+
return pika_errors + base_errors
|
|
51
|
+
except ImportError:
|
|
52
|
+
return base_errors
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def build_ssl_context(ssl_config: SSLConfig) -> ssl.SSLContext | None:
|
|
56
|
+
"""Build stdlib ssl.SSLContext from SSLConfig.
|
|
57
|
+
|
|
58
|
+
Returns None if SSL is not enabled.
|
|
59
|
+
"""
|
|
60
|
+
if not ssl_config.enabled:
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
# Determine cert_reqs
|
|
64
|
+
cert_reqs_map = {
|
|
65
|
+
"CERT_REQUIRED": ssl.CERT_REQUIRED,
|
|
66
|
+
"CERT_OPTIONAL": ssl.CERT_OPTIONAL,
|
|
67
|
+
"CERT_NONE": ssl.CERT_NONE,
|
|
68
|
+
}
|
|
69
|
+
cert_reqs = cert_reqs_map.get(ssl_config.cert_reqs, ssl.CERT_REQUIRED)
|
|
70
|
+
|
|
71
|
+
# M13: disabling certificate verification makes the connection
|
|
72
|
+
# MITM-able — warn loudly, since it's a copy-paste "make TLS errors go
|
|
73
|
+
# away" footgun that otherwise ships silently to production.
|
|
74
|
+
if cert_reqs == ssl.CERT_NONE:
|
|
75
|
+
import warnings
|
|
76
|
+
|
|
77
|
+
warnings.warn(
|
|
78
|
+
"SSLConfig(cert_reqs='CERT_NONE') disables TLS certificate and hostname "
|
|
79
|
+
"verification — the connection is encrypted but MITM-able. Use "
|
|
80
|
+
"'CERT_REQUIRED' (the default) with a proper ca_certs bundle in production.",
|
|
81
|
+
RuntimeWarning,
|
|
82
|
+
stacklevel=2,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
86
|
+
|
|
87
|
+
# Defense in depth: never negotiate below TLS 1.2.
|
|
88
|
+
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
|
|
89
|
+
|
|
90
|
+
# check_hostname must be disabled BEFORE setting verify_mode=CERT_NONE
|
|
91
|
+
# (Python 3.12+ raises ValueError otherwise)
|
|
92
|
+
if cert_reqs == ssl.CERT_NONE:
|
|
93
|
+
ctx.check_hostname = False
|
|
94
|
+
|
|
95
|
+
ctx.verify_mode = cert_reqs
|
|
96
|
+
|
|
97
|
+
if ssl_config.ca_certs:
|
|
98
|
+
ctx.load_verify_locations(ssl_config.ca_certs)
|
|
99
|
+
elif cert_reqs == ssl.CERT_REQUIRED:
|
|
100
|
+
# No explicit CA bundle configured — fall back to the system trust
|
|
101
|
+
# store so verification actually succeeds against broker certs
|
|
102
|
+
# signed by a well-known CA. Without this, CERT_REQUIRED + no ca_certs
|
|
103
|
+
# silently leaves the context with zero trusted CAs → every handshake
|
|
104
|
+
# fails. Guarded so the explicit-ca_certs path above is unchanged.
|
|
105
|
+
try:
|
|
106
|
+
ctx.load_default_certs()
|
|
107
|
+
except Exception: # pragma: no cover — best effort, platform-dependent
|
|
108
|
+
try:
|
|
109
|
+
ctx.set_default_verify_paths()
|
|
110
|
+
except Exception: # pragma: no cover
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
if ssl_config.certfile:
|
|
114
|
+
ctx.load_cert_chain(
|
|
115
|
+
certfile=ssl_config.certfile,
|
|
116
|
+
keyfile=ssl_config.keyfile,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
return ctx
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def make_pika_connection_params(
|
|
123
|
+
connection: ConnectionConfig,
|
|
124
|
+
socket_config: SocketConfig,
|
|
125
|
+
security: SecurityConfig,
|
|
126
|
+
) -> Any:
|
|
127
|
+
"""Build pika.ConnectionParameters with TCP tuning, SSL, heartbeat.
|
|
128
|
+
|
|
129
|
+
Returns a pika.ConnectionParameters object.
|
|
130
|
+
Raises ImportError if pika is not installed.
|
|
131
|
+
"""
|
|
132
|
+
try:
|
|
133
|
+
import pika
|
|
134
|
+
except ImportError:
|
|
135
|
+
raise ImportError("pika is required for sync transport. Install it with: pip install rabbitkit[sync]") from None
|
|
136
|
+
|
|
137
|
+
# SSL context
|
|
138
|
+
ssl_context = build_ssl_context(security.ssl)
|
|
139
|
+
ssl_options = None
|
|
140
|
+
if ssl_context is not None:
|
|
141
|
+
ssl_options = pika.SSLOptions(
|
|
142
|
+
context=ssl_context,
|
|
143
|
+
server_hostname=security.ssl.server_hostname or connection.host,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# Credentials (M13: resolve via credentials_provider if set, so a rotated
|
|
147
|
+
# secret is picked up on this (re)connect).
|
|
148
|
+
username, password = connection.resolve_credentials()
|
|
149
|
+
credentials = pika.PlainCredentials(
|
|
150
|
+
username=username,
|
|
151
|
+
password=password,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# Client properties
|
|
155
|
+
client_properties: dict[str, str] = {}
|
|
156
|
+
if connection.connection_name:
|
|
157
|
+
client_properties["connection_name"] = connection.connection_name
|
|
158
|
+
|
|
159
|
+
def _params_for(host: str, port: int) -> Any:
|
|
160
|
+
return pika.ConnectionParameters(
|
|
161
|
+
host=host,
|
|
162
|
+
port=port,
|
|
163
|
+
virtual_host=connection.vhost,
|
|
164
|
+
credentials=credentials,
|
|
165
|
+
heartbeat=connection.heartbeat,
|
|
166
|
+
socket_timeout=connection.socket_timeout,
|
|
167
|
+
blocked_connection_timeout=connection.blocked_connection_timeout,
|
|
168
|
+
ssl_options=ssl_options,
|
|
169
|
+
client_properties=client_properties if client_properties else None,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
endpoints = connection.cluster_endpoints()
|
|
173
|
+
if len(endpoints) == 1:
|
|
174
|
+
return _params_for(*endpoints[0])
|
|
175
|
+
# M9: pika.BlockingConnection accepts a LIST of ConnectionParameters and
|
|
176
|
+
# tries each in order until one connects — native cluster failover.
|
|
177
|
+
return [_params_for(host, port) for host, port in endpoints]
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def apply_socket_options(sock: socket.socket, config: SocketConfig) -> None:
|
|
181
|
+
"""Apply TCP_NODELAY, keepalive, buffer sizes to a socket.
|
|
182
|
+
|
|
183
|
+
Best-effort — not all options are universally guaranteed
|
|
184
|
+
depending on OS and backend internals.
|
|
185
|
+
"""
|
|
186
|
+
try:
|
|
187
|
+
# TCP_NODELAY — disable Nagle's algorithm
|
|
188
|
+
if config.tcp_nodelay:
|
|
189
|
+
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
|
190
|
+
|
|
191
|
+
# TCP keepalive
|
|
192
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
|
|
193
|
+
|
|
194
|
+
# Platform-specific keepalive options
|
|
195
|
+
if hasattr(socket, "TCP_KEEPIDLE"):
|
|
196
|
+
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, config.tcp_keepidle)
|
|
197
|
+
if hasattr(socket, "TCP_KEEPINTVL"):
|
|
198
|
+
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, config.tcp_keepintvl)
|
|
199
|
+
if hasattr(socket, "TCP_KEEPCNT"):
|
|
200
|
+
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, config.tcp_keepcnt)
|
|
201
|
+
|
|
202
|
+
# Buffer sizes
|
|
203
|
+
if config.tcp_sndbuf > 0:
|
|
204
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, config.tcp_sndbuf)
|
|
205
|
+
if config.tcp_rcvbuf > 0:
|
|
206
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, config.tcp_rcvbuf)
|
|
207
|
+
|
|
208
|
+
except OSError as e:
|
|
209
|
+
logger.warning("Failed to apply socket option: %s (best-effort, continuing)", e)
|
rabbitkit/sync/pool.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""Sync 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
|
+
Model A: one-connection-per-thread. Pools enforce this by assigning
|
|
7
|
+
dedicated connections per role (publisher vs consumer).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import queue
|
|
14
|
+
import threading
|
|
15
|
+
from contextlib import contextmanager
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from rabbitkit.core.config import ConnectionConfig, PoolConfig, SecurityConfig, SocketConfig
|
|
19
|
+
from rabbitkit.sync.connection import make_pika_connection_params
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SyncChannelPool:
|
|
25
|
+
"""Thread-safe channel pool.
|
|
26
|
+
|
|
27
|
+
Manages a pool of pika channels on a single connection.
|
|
28
|
+
Channels are acquired and released by callers.
|
|
29
|
+
|
|
30
|
+
``acquire_timeout`` bounds how long ``acquire()`` blocks when all channels
|
|
31
|
+
are checked out; it raises ``TimeoutError`` on expiry (mirrors the async
|
|
32
|
+
pool) rather than blocking forever — which would deadlock a worker that
|
|
33
|
+
tries to publish while processing.
|
|
34
|
+
|
|
35
|
+
Note: the default ``SyncTransport`` does not route publishes through this
|
|
36
|
+
pool (it uses a single dedicated publisher channel); the pool is kept as a
|
|
37
|
+
reusable utility for advanced/pooled usage and is covered by unit tests so
|
|
38
|
+
it is not silently dead code.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
connection: Any, # pika.BlockingConnection
|
|
44
|
+
pool_size: int = 10,
|
|
45
|
+
acquire_timeout: float = 10.0,
|
|
46
|
+
) -> None:
|
|
47
|
+
self._connection = connection
|
|
48
|
+
self._pool_size = pool_size
|
|
49
|
+
self._acquire_timeout = acquire_timeout
|
|
50
|
+
self._pool: queue.Queue[Any] = queue.Queue(maxsize=pool_size)
|
|
51
|
+
self._lock = threading.Lock()
|
|
52
|
+
self._created = 0
|
|
53
|
+
# Channels currently checked out by callers (leak detection / close_all).
|
|
54
|
+
self._in_use: set[Any] = set()
|
|
55
|
+
|
|
56
|
+
def acquire(self) -> Any:
|
|
57
|
+
"""Acquire a channel from the pool.
|
|
58
|
+
|
|
59
|
+
Creates a new channel if the pool is empty and under the size limit.
|
|
60
|
+
Blocks up to ``acquire_timeout`` if the pool is exhausted; raises
|
|
61
|
+
``TimeoutError`` on expiry so callers don't deadlock.
|
|
62
|
+
"""
|
|
63
|
+
try:
|
|
64
|
+
channel = self._pool.get_nowait()
|
|
65
|
+
if channel.is_open:
|
|
66
|
+
with self._lock:
|
|
67
|
+
self._in_use.add(channel)
|
|
68
|
+
return channel
|
|
69
|
+
# I-6: a pooled channel was found closed — it still counted
|
|
70
|
+
# against _created when it was released, so decrement before
|
|
71
|
+
# discarding it (otherwise acquire() leaks a slot each time a
|
|
72
|
+
# closed-idle channel is pulled from the pool).
|
|
73
|
+
with self._lock:
|
|
74
|
+
self._created = max(0, self._created - 1)
|
|
75
|
+
except queue.Empty:
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
# perf-M-2: create the channel OUTSIDE the lock (network round-trip) so
|
|
79
|
+
# concurrent acquire() calls don't serialize on channel creation during
|
|
80
|
+
# warmup/refill. The slot is reserved atomically under the lock (so we
|
|
81
|
+
# never over-create); the channel-open I/O happens outside the lock, and
|
|
82
|
+
# we re-acquire only to publish _in_use. If creation fails, the reserved
|
|
83
|
+
# slot is returned. The I-6 closed-idle decrement above stays under the
|
|
84
|
+
# lock.
|
|
85
|
+
with self._lock:
|
|
86
|
+
if self._created < self._pool_size:
|
|
87
|
+
need_create = True
|
|
88
|
+
self._created += 1
|
|
89
|
+
else:
|
|
90
|
+
need_create = False
|
|
91
|
+
|
|
92
|
+
if need_create:
|
|
93
|
+
try:
|
|
94
|
+
channel = self._connection.channel()
|
|
95
|
+
except BaseException:
|
|
96
|
+
with self._lock:
|
|
97
|
+
self._created = max(0, self._created - 1)
|
|
98
|
+
raise
|
|
99
|
+
with self._lock:
|
|
100
|
+
self._in_use.add(channel)
|
|
101
|
+
return channel
|
|
102
|
+
|
|
103
|
+
# Pool exhausted — block until one is released, bounded by acquire_timeout
|
|
104
|
+
logger.warning(
|
|
105
|
+
"SyncChannelPool exhausted (pool_size=%d, created=%d). "
|
|
106
|
+
"Waiting up to %.1fs for a channel to be released. "
|
|
107
|
+
"Consider increasing PoolConfig.channel_pool_size.",
|
|
108
|
+
self._pool_size,
|
|
109
|
+
self._created,
|
|
110
|
+
self._acquire_timeout,
|
|
111
|
+
)
|
|
112
|
+
try:
|
|
113
|
+
channel = self._pool.get(timeout=self._acquire_timeout)
|
|
114
|
+
except queue.Empty as e:
|
|
115
|
+
raise TimeoutError(
|
|
116
|
+
f"Timed out after {self._acquire_timeout}s waiting for a pooled "
|
|
117
|
+
"channel. Increase PoolConfig.channel_pool_size."
|
|
118
|
+
) from e
|
|
119
|
+
if not channel.is_open:
|
|
120
|
+
# Discard the stale channel and recurse to try again.
|
|
121
|
+
with self._lock:
|
|
122
|
+
self._created = max(0, self._created - 1)
|
|
123
|
+
return self.acquire()
|
|
124
|
+
with self._lock:
|
|
125
|
+
self._in_use.add(channel)
|
|
126
|
+
return channel
|
|
127
|
+
|
|
128
|
+
def release(self, channel: Any) -> None:
|
|
129
|
+
"""Release a channel back to the pool."""
|
|
130
|
+
with self._lock:
|
|
131
|
+
self._in_use.discard(channel)
|
|
132
|
+
if channel.is_open:
|
|
133
|
+
try:
|
|
134
|
+
self._pool.put_nowait(channel)
|
|
135
|
+
return
|
|
136
|
+
except queue.Full:
|
|
137
|
+
pass
|
|
138
|
+
# Channel is closed or pool is full — discard
|
|
139
|
+
try:
|
|
140
|
+
if channel.is_open:
|
|
141
|
+
channel.close()
|
|
142
|
+
except Exception:
|
|
143
|
+
pass
|
|
144
|
+
with self._lock:
|
|
145
|
+
self._created = max(0, self._created - 1)
|
|
146
|
+
|
|
147
|
+
@contextmanager
|
|
148
|
+
def acquire_ctx(self) -> Any:
|
|
149
|
+
"""Context manager for acquire/release — prevents leaks."""
|
|
150
|
+
channel = self.acquire()
|
|
151
|
+
try:
|
|
152
|
+
yield channel
|
|
153
|
+
finally:
|
|
154
|
+
self.release(channel)
|
|
155
|
+
|
|
156
|
+
def close_all(self) -> None:
|
|
157
|
+
"""Close all channels in the pool (including checked-out ones)."""
|
|
158
|
+
with self._lock:
|
|
159
|
+
in_use = list(self._in_use)
|
|
160
|
+
self._in_use.clear()
|
|
161
|
+
for channel in in_use:
|
|
162
|
+
try:
|
|
163
|
+
if channel.is_open:
|
|
164
|
+
channel.close()
|
|
165
|
+
except Exception: # pragma: no cover — best effort
|
|
166
|
+
pass
|
|
167
|
+
with self._lock:
|
|
168
|
+
self._created = max(0, self._created - 1)
|
|
169
|
+
while not self._pool.empty():
|
|
170
|
+
try:
|
|
171
|
+
channel = self._pool.get_nowait()
|
|
172
|
+
if channel.is_open:
|
|
173
|
+
channel.close()
|
|
174
|
+
except (queue.Empty, Exception):
|
|
175
|
+
pass
|
|
176
|
+
with self._lock:
|
|
177
|
+
self._created = 0
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def size(self) -> int:
|
|
181
|
+
"""Number of channels currently in the pool (available)."""
|
|
182
|
+
return self._pool.qsize()
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
def created_count(self) -> int:
|
|
186
|
+
"""Total number of channels created."""
|
|
187
|
+
return self._created
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class SyncConnectionPool:
|
|
191
|
+
"""Separate publisher/consumer connections.
|
|
192
|
+
|
|
193
|
+
Provides dedicated connections for publishing and consuming
|
|
194
|
+
to avoid head-of-line blocking.
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
def __init__(
|
|
198
|
+
self,
|
|
199
|
+
connection_config: ConnectionConfig,
|
|
200
|
+
socket_config: SocketConfig,
|
|
201
|
+
security_config: SecurityConfig,
|
|
202
|
+
pool_config: PoolConfig | None = None,
|
|
203
|
+
) -> None:
|
|
204
|
+
self._connection_config = connection_config
|
|
205
|
+
self._socket_config = socket_config
|
|
206
|
+
self._security_config = security_config
|
|
207
|
+
self._pool_config = pool_config or PoolConfig()
|
|
208
|
+
|
|
209
|
+
self._publisher_connections: list[Any] = []
|
|
210
|
+
self._consumer_connections: list[Any] = []
|
|
211
|
+
self._lock = threading.Lock()
|
|
212
|
+
|
|
213
|
+
def get_publisher_connection(self) -> Any:
|
|
214
|
+
"""Get a connection dedicated for publishing.
|
|
215
|
+
|
|
216
|
+
Creates the connection lazily on first call.
|
|
217
|
+
"""
|
|
218
|
+
with self._lock:
|
|
219
|
+
if not self._publisher_connections:
|
|
220
|
+
conn = self._create_connection()
|
|
221
|
+
self._publisher_connections.append(conn)
|
|
222
|
+
return self._publisher_connections[0]
|
|
223
|
+
|
|
224
|
+
def get_consumer_connection(self) -> Any:
|
|
225
|
+
"""Get a connection dedicated for consuming.
|
|
226
|
+
|
|
227
|
+
Creates the connection lazily on first call.
|
|
228
|
+
"""
|
|
229
|
+
with self._lock:
|
|
230
|
+
if not self._consumer_connections:
|
|
231
|
+
conn = self._create_connection()
|
|
232
|
+
self._consumer_connections.append(conn)
|
|
233
|
+
return self._consumer_connections[0]
|
|
234
|
+
|
|
235
|
+
def close_all(self) -> None:
|
|
236
|
+
"""Close all connections."""
|
|
237
|
+
with self._lock:
|
|
238
|
+
for conn in self._publisher_connections + self._consumer_connections:
|
|
239
|
+
try:
|
|
240
|
+
if conn.is_open:
|
|
241
|
+
conn.close()
|
|
242
|
+
except Exception as e:
|
|
243
|
+
logger.warning("Error closing connection: %s", e)
|
|
244
|
+
|
|
245
|
+
self._publisher_connections.clear()
|
|
246
|
+
self._consumer_connections.clear()
|
|
247
|
+
|
|
248
|
+
def _create_connection(self) -> Any:
|
|
249
|
+
"""Create a new pika connection."""
|
|
250
|
+
try:
|
|
251
|
+
import pika
|
|
252
|
+
except ImportError:
|
|
253
|
+
raise ImportError(
|
|
254
|
+
"pika is required for sync transport. Install it with: pip install rabbitkit[sync]"
|
|
255
|
+
) from None
|
|
256
|
+
|
|
257
|
+
params = make_pika_connection_params(
|
|
258
|
+
self._connection_config,
|
|
259
|
+
self._socket_config,
|
|
260
|
+
self._security_config,
|
|
261
|
+
)
|
|
262
|
+
return pika.BlockingConnection(params)
|