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,877 @@
|
|
|
1
|
+
"""AsyncTransport — aio-pika transport adapter.
|
|
2
|
+
|
|
3
|
+
Uses aio_pika.connect_robust() for auto-reconnection.
|
|
4
|
+
aio-pika owns connection/channel/consumer restoration natively.
|
|
5
|
+
|
|
6
|
+
Architecture:
|
|
7
|
+
- Publisher connection: dedicated connection + AsyncChannelPool for concurrent
|
|
8
|
+
publishes without blocking consumer channels or topology operations.
|
|
9
|
+
- Consumer connection: dedicated connection for all subscribe operations.
|
|
10
|
+
Each queue gets its own channel (required for per-queue QoS).
|
|
11
|
+
- Topology connection: reuses consumer connection for declare/bind operations.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import contextlib
|
|
18
|
+
import logging
|
|
19
|
+
import uuid
|
|
20
|
+
from collections.abc import Awaitable, Callable
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from rabbitkit.async_.pool import AsyncConnectionPool
|
|
24
|
+
from rabbitkit.core.config import ConnectionConfig, PoolConfig, SecurityConfig
|
|
25
|
+
from rabbitkit.core.errors import ConfigurationError
|
|
26
|
+
from rabbitkit.core.message import RabbitMessage
|
|
27
|
+
from rabbitkit.core.topology import RabbitExchange, RabbitQueue
|
|
28
|
+
from rabbitkit.core.topology_dispatch import TopoAction, TopologyDispatcher
|
|
29
|
+
from rabbitkit.core.types import (
|
|
30
|
+
DIRECT_REPLY_TO_QUEUE,
|
|
31
|
+
MessageEnvelope,
|
|
32
|
+
PublishOutcome,
|
|
33
|
+
PublishStatus,
|
|
34
|
+
TopologyMode,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AsyncTransportImpl:
|
|
41
|
+
"""aio-pika-based async transport adapter.
|
|
42
|
+
|
|
43
|
+
Uses connect_robust() for transparent auto-reconnect.
|
|
44
|
+
Publisher and consumer traffic run on separate connections to prevent
|
|
45
|
+
head-of-line blocking and QoS interference.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
connection_config: ConnectionConfig | None = None,
|
|
51
|
+
security_config: SecurityConfig | None = None,
|
|
52
|
+
pool_config: PoolConfig | None = None,
|
|
53
|
+
topology_mode: TopologyMode = TopologyMode.AUTO_DECLARE,
|
|
54
|
+
confirm_delivery: bool = True,
|
|
55
|
+
confirm_timeout: float = 5.0,
|
|
56
|
+
on_topology_conflict: str = "raise",
|
|
57
|
+
) -> None:
|
|
58
|
+
self._connection_config = connection_config or ConnectionConfig()
|
|
59
|
+
self._security_config = security_config or SecurityConfig()
|
|
60
|
+
self._pool_config = pool_config or PoolConfig()
|
|
61
|
+
self._topology_mode = topology_mode
|
|
62
|
+
self._topo = TopologyDispatcher(topology_mode)
|
|
63
|
+
# M14: "raise" | "warn_continue" on a 406 topology-drift conflict.
|
|
64
|
+
self._on_topology_conflict = on_topology_conflict
|
|
65
|
+
self._confirm_delivery = confirm_delivery
|
|
66
|
+
# Per-publish timeout when publisher confirms are enabled. Without this a
|
|
67
|
+
# broker that never confirms would block the publish coroutine forever;
|
|
68
|
+
# on timeout we release/close the channel and return PublishStatus.TIMEOUT.
|
|
69
|
+
self._confirm_timeout = float(confirm_timeout)
|
|
70
|
+
|
|
71
|
+
self._conn_pool = AsyncConnectionPool(
|
|
72
|
+
self._connection_config,
|
|
73
|
+
self._security_config,
|
|
74
|
+
self._pool_config,
|
|
75
|
+
publisher_confirms=self._confirm_delivery,
|
|
76
|
+
)
|
|
77
|
+
self._connected = False
|
|
78
|
+
|
|
79
|
+
# Per-queue consumer channels: queue_name -> aio_pika channel
|
|
80
|
+
self._consumer_channels: dict[str, Any] = {}
|
|
81
|
+
self._consumer_tags: dict[str, str] = {} # queue_name -> consumer_tag
|
|
82
|
+
|
|
83
|
+
# The channel currently consuming DIRECT_REPLY_TO_QUEUE (set by
|
|
84
|
+
# consume(declare=False), cleared on cancel/disconnect). RabbitMQ's
|
|
85
|
+
# direct reply-to requires the reply consumer and the corresponding
|
|
86
|
+
# request publish to happen on the SAME channel (a publish on a
|
|
87
|
+
# different channel raises "PRECONDITION_FAILED - fast reply consumer
|
|
88
|
+
# does not exist") — publish() checks this to route RPC requests
|
|
89
|
+
# correctly without RPCClient needing to know about channels at all.
|
|
90
|
+
self._reply_to_channel: Any = None
|
|
91
|
+
|
|
92
|
+
# Shared topology channel (consumer connection)
|
|
93
|
+
self._topology_channel: Any | None = None
|
|
94
|
+
|
|
95
|
+
# Persistent no-confirm publish channel — reused across all fire-and-forget
|
|
96
|
+
# publishes when confirm_delivery=False. Eliminates per-publish pool
|
|
97
|
+
# acquire/release overhead; a single channel handles concurrent writes
|
|
98
|
+
# safely because aio-pika serialises AMQP frames at the connection level.
|
|
99
|
+
self._fast_publish_channel: Any | None = None
|
|
100
|
+
self._fast_channel_lock: asyncio.Lock = asyncio.Lock()
|
|
101
|
+
|
|
102
|
+
# H1: dedicated, always-confirmed channel for mandatory=True publishes,
|
|
103
|
+
# independent of confirm_delivery. Detecting an unroutable Basic.Return
|
|
104
|
+
# reliably requires BOTH publisher confirms AND on_return_raises=True —
|
|
105
|
+
# neither the fast channel (no confirms at all) nor the regular pool
|
|
106
|
+
# (confirms follow confirm_delivery, which may be False) guarantee that.
|
|
107
|
+
self._mandatory_publish_channel: Any | None = None
|
|
108
|
+
self._mandatory_channel_lock: asyncio.Lock = asyncio.Lock()
|
|
109
|
+
|
|
110
|
+
# Backpressure callbacks (FlowController registers here). Each is a
|
|
111
|
+
# zero-arg callable; aio-pika's blocked/unblocked frames are adapted.
|
|
112
|
+
self._blocked_callbacks: list[Callable[[], None]] = []
|
|
113
|
+
self._unblocked_callbacks: list[Callable[[], None]] = []
|
|
114
|
+
|
|
115
|
+
# Connection-churn metric hook (see on_reconnect) -- adapted from
|
|
116
|
+
# aio-pika's RobustConnection.reconnect_callbacks.
|
|
117
|
+
self._reconnect_callbacks: list[Callable[[], None]] = []
|
|
118
|
+
|
|
119
|
+
# L15: passive blocked-state tracking, independent of whether a
|
|
120
|
+
# FlowController is registered above -- health.broker_health_check
|
|
121
|
+
# reads this (via the is_blocked property) so a broker/disk/memory
|
|
122
|
+
# alarm is visible even when the caller never opted into FlowController.
|
|
123
|
+
self._blocked_state: bool = False
|
|
124
|
+
|
|
125
|
+
def on_reconnect(self, callback: Callable[[], None]) -> None:
|
|
126
|
+
"""Register a callback fired on every ``connect_robust`` re-connection
|
|
127
|
+
(connection-churn metric hook). Reconnects were logged but never
|
|
128
|
+
counted, so a flapping broker/network was invisible to metrics
|
|
129
|
+
alerting."""
|
|
130
|
+
self._reconnect_callbacks.append(callback)
|
|
131
|
+
|
|
132
|
+
def _aio_reconnected(self, *_args: Any) -> None:
|
|
133
|
+
for cb in list(self._reconnect_callbacks):
|
|
134
|
+
try:
|
|
135
|
+
cb()
|
|
136
|
+
except Exception: # pragma: no cover - never break the event loop
|
|
137
|
+
logger.exception("reconnect callback raised")
|
|
138
|
+
|
|
139
|
+
def on_blocked(self, callback: Callable[[], None]) -> None:
|
|
140
|
+
"""Register a connection-blocked callback (e.g. FlowController.on_blocked)."""
|
|
141
|
+
self._blocked_callbacks.append(callback)
|
|
142
|
+
|
|
143
|
+
def on_unblocked(self, callback: Callable[[], None]) -> None:
|
|
144
|
+
"""Register a connection-unblocked callback (e.g. FlowController.on_unblocked)."""
|
|
145
|
+
self._unblocked_callbacks.append(callback)
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def is_blocked(self) -> bool:
|
|
149
|
+
"""True if RabbitMQ has sent ``connection.blocked`` (L15) -- e.g. a
|
|
150
|
+
broker memory/disk alarm. Tracked passively regardless of whether
|
|
151
|
+
any ``on_blocked``/``on_unblocked`` callback is registered, so
|
|
152
|
+
``health.broker_health_check`` can see it even without an opt-in
|
|
153
|
+
``FlowController``."""
|
|
154
|
+
return self._blocked_state
|
|
155
|
+
|
|
156
|
+
def _aio_blocked(self, *_args: Any) -> None:
|
|
157
|
+
self._blocked_state = True
|
|
158
|
+
for cb in list(self._blocked_callbacks):
|
|
159
|
+
try:
|
|
160
|
+
cb()
|
|
161
|
+
except Exception: # pragma: no cover - never break the event loop
|
|
162
|
+
logger.exception("blocked callback raised")
|
|
163
|
+
|
|
164
|
+
def _aio_unblocked(self, *_args: Any) -> None:
|
|
165
|
+
self._blocked_state = False
|
|
166
|
+
for cb in list(self._unblocked_callbacks):
|
|
167
|
+
try:
|
|
168
|
+
cb()
|
|
169
|
+
except Exception: # pragma: no cover
|
|
170
|
+
logger.exception("unblocked callback raised")
|
|
171
|
+
|
|
172
|
+
async def connect(self) -> None:
|
|
173
|
+
"""Establish publisher and consumer connections."""
|
|
174
|
+
if self._connected:
|
|
175
|
+
return
|
|
176
|
+
|
|
177
|
+
await self._conn_pool.connect()
|
|
178
|
+
|
|
179
|
+
# Register connection blocked/unblocked callbacks (C-6) so a
|
|
180
|
+
# FlowController can throttle publishes when RabbitMQ raises an alarm.
|
|
181
|
+
pub_conn = await self._conn_pool.get_publisher_connection()
|
|
182
|
+
try:
|
|
183
|
+
pub_conn.connection_blocked.add_callback(self._aio_blocked)
|
|
184
|
+
pub_conn.connection_unblocked.add_callback(self._aio_unblocked)
|
|
185
|
+
except Exception: # pragma: no cover - older aio-pika may differ
|
|
186
|
+
logger.debug("Could not register blocked/unblocked callbacks")
|
|
187
|
+
|
|
188
|
+
# Connection-churn metric hook: connect_robust reconnects silently
|
|
189
|
+
# (well, logged) -- count them so a flapping broker/network is
|
|
190
|
+
# visible to metrics alerting. Both connections, since either can
|
|
191
|
+
# flap independently.
|
|
192
|
+
try:
|
|
193
|
+
pub_conn.reconnect_callbacks.add(self._aio_reconnected)
|
|
194
|
+
consumer_conn_for_cb = await self._conn_pool.get_consumer_connection()
|
|
195
|
+
if consumer_conn_for_cb is not pub_conn:
|
|
196
|
+
consumer_conn_for_cb.reconnect_callbacks.add(self._aio_reconnected)
|
|
197
|
+
except Exception: # pragma: no cover - older aio-pika may differ
|
|
198
|
+
logger.debug("Could not register reconnect callbacks")
|
|
199
|
+
|
|
200
|
+
# I-11: install a blocked-connection watchdog so a broker alarm that isn't
|
|
201
|
+
# cleared within blocked_connection_timeout closes the connection (forcing
|
|
202
|
+
# reconnect) — aio-pika has no native knob for this.
|
|
203
|
+
try:
|
|
204
|
+
from rabbitkit.async_.connection import install_blocked_connection_watchdog
|
|
205
|
+
|
|
206
|
+
await install_blocked_connection_watchdog(pub_conn, self._connection_config.blocked_connection_timeout)
|
|
207
|
+
except Exception: # pragma: no cover - best effort
|
|
208
|
+
logger.debug("Could not install blocked-connection watchdog")
|
|
209
|
+
|
|
210
|
+
# Open topology channel on consumer connection
|
|
211
|
+
consumer_conn = await self._conn_pool.get_consumer_connection()
|
|
212
|
+
self._topology_channel = await consumer_conn.channel()
|
|
213
|
+
|
|
214
|
+
self._connected = True
|
|
215
|
+
logger.info(
|
|
216
|
+
"Connected to RabbitMQ at %s:%d (async, pooled)",
|
|
217
|
+
self._connection_config.host,
|
|
218
|
+
self._connection_config.port,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
async def __aenter__(self) -> AsyncTransportImpl:
|
|
222
|
+
await self.connect()
|
|
223
|
+
return self
|
|
224
|
+
|
|
225
|
+
async def __exit__(self, *args: Any) -> None:
|
|
226
|
+
await self.disconnect()
|
|
227
|
+
|
|
228
|
+
async def disconnect(self) -> None:
|
|
229
|
+
"""Close all channels and connections."""
|
|
230
|
+
if not self._connected:
|
|
231
|
+
return
|
|
232
|
+
|
|
233
|
+
try:
|
|
234
|
+
# Close per-queue consumer channels
|
|
235
|
+
for ch in list(self._consumer_channels.values()):
|
|
236
|
+
try:
|
|
237
|
+
if not ch.is_closed:
|
|
238
|
+
await ch.close()
|
|
239
|
+
except Exception:
|
|
240
|
+
pass
|
|
241
|
+
self._consumer_channels.clear()
|
|
242
|
+
self._consumer_tags.clear()
|
|
243
|
+
self._reply_to_channel = None
|
|
244
|
+
|
|
245
|
+
# Close topology channel
|
|
246
|
+
if self._topology_channel is not None and not self._topology_channel.is_closed:
|
|
247
|
+
try:
|
|
248
|
+
await self._topology_channel.close()
|
|
249
|
+
except Exception:
|
|
250
|
+
pass
|
|
251
|
+
self._topology_channel = None
|
|
252
|
+
|
|
253
|
+
# Close fast publish channel (no-confirm persistent path)
|
|
254
|
+
if self._fast_publish_channel is not None and not self._fast_publish_channel.is_closed:
|
|
255
|
+
try:
|
|
256
|
+
await self._fast_publish_channel.close()
|
|
257
|
+
except Exception: # pragma: no cover — best effort close, network errors only
|
|
258
|
+
pass
|
|
259
|
+
self._fast_publish_channel = None
|
|
260
|
+
|
|
261
|
+
# Close the dedicated mandatory-publish channel (H1)
|
|
262
|
+
if self._mandatory_publish_channel is not None and not self._mandatory_publish_channel.is_closed:
|
|
263
|
+
try:
|
|
264
|
+
await self._mandatory_publish_channel.close()
|
|
265
|
+
except Exception: # pragma: no cover — best effort close, network errors only
|
|
266
|
+
pass
|
|
267
|
+
self._mandatory_publish_channel = None
|
|
268
|
+
|
|
269
|
+
await self._conn_pool.close_all()
|
|
270
|
+
except Exception as e:
|
|
271
|
+
logger.warning("Error during disconnect: %s", e)
|
|
272
|
+
finally:
|
|
273
|
+
self._connected = False
|
|
274
|
+
logger.info("Disconnected from RabbitMQ (async)")
|
|
275
|
+
|
|
276
|
+
def is_connected(self) -> bool:
|
|
277
|
+
"""Check if connected to RabbitMQ.
|
|
278
|
+
|
|
279
|
+
Reflects the real underlying robust-connection state rather than a
|
|
280
|
+
stale cached flag: if our cached flag is False we return False;
|
|
281
|
+
otherwise we inspect the robust connection's ``is_closed`` attribute
|
|
282
|
+
(guarded) so a connection that aio-pika has silently dropped is not
|
|
283
|
+
reported as healthy.
|
|
284
|
+
"""
|
|
285
|
+
if not self._connected:
|
|
286
|
+
return False
|
|
287
|
+
conn = self._conn_pool._publisher_connection
|
|
288
|
+
if conn is None:
|
|
289
|
+
return False
|
|
290
|
+
try:
|
|
291
|
+
# RobustConnection exposes ``is_closed``; True means fully closed.
|
|
292
|
+
if bool(getattr(conn, "is_closed", False)):
|
|
293
|
+
return False
|
|
294
|
+
except Exception: # pragma: no cover — defensive
|
|
295
|
+
return False
|
|
296
|
+
return True
|
|
297
|
+
|
|
298
|
+
@property
|
|
299
|
+
def has_open_channels(self) -> bool:
|
|
300
|
+
"""True if at least one consumer channel is open (readiness contract).
|
|
301
|
+
|
|
302
|
+
Mirrors ``SyncTransport.has_open_channels`` so ``broker_readiness`` can
|
|
303
|
+
detect a dead consumer channel on async transports (I-5 async side).
|
|
304
|
+
"""
|
|
305
|
+
if not self._consumer_channels:
|
|
306
|
+
return False
|
|
307
|
+
return all(not bool(getattr(ch, "is_closed", False)) for ch in self._consumer_channels.values())
|
|
308
|
+
|
|
309
|
+
@property
|
|
310
|
+
def is_reconnecting(self) -> bool:
|
|
311
|
+
"""Best-effort: True if the robust connection is mid-reconnect.
|
|
312
|
+
|
|
313
|
+
aio-pika does not expose a stable public attribute, so this is a
|
|
314
|
+
cheap, guarded heuristic (``reconnects`` counter / ``_reconnect_lock").
|
|
315
|
+
"""
|
|
316
|
+
conn = self._conn_pool._publisher_connection
|
|
317
|
+
if conn is None:
|
|
318
|
+
return False
|
|
319
|
+
try:
|
|
320
|
+
# Newer aio-pika tracks pending reconnects via a lock/event.
|
|
321
|
+
lock = getattr(conn, "_reconnect_lock", None)
|
|
322
|
+
if lock is not None and getattr(lock, "locked", lambda: False)():
|
|
323
|
+
return True
|
|
324
|
+
except Exception: # pragma: no cover
|
|
325
|
+
pass
|
|
326
|
+
return False
|
|
327
|
+
|
|
328
|
+
async def _ensure_connected(self) -> None:
|
|
329
|
+
"""Ensure connection is established."""
|
|
330
|
+
if self._connected:
|
|
331
|
+
return
|
|
332
|
+
await self.connect()
|
|
333
|
+
|
|
334
|
+
async def _get_fast_channel(self) -> Any:
|
|
335
|
+
"""Return the persistent no-confirm publish channel, (re)opening if needed.
|
|
336
|
+
|
|
337
|
+
Used exclusively by the fire-and-forget publish path (confirm_delivery=False).
|
|
338
|
+
A single channel is reused across all concurrent publishes; aio-pika
|
|
339
|
+
serialises AMQP frames at the transport level so concurrent writes are safe.
|
|
340
|
+
"""
|
|
341
|
+
ch = self._fast_publish_channel
|
|
342
|
+
if ch is not None and not ch.is_closed:
|
|
343
|
+
return ch
|
|
344
|
+
async with self._fast_channel_lock:
|
|
345
|
+
ch = self._fast_publish_channel
|
|
346
|
+
if ch is not None and not ch.is_closed: # pragma: no cover — concurrent path
|
|
347
|
+
return ch
|
|
348
|
+
conn = self._conn_pool._publisher_connection
|
|
349
|
+
if conn is None:
|
|
350
|
+
raise RuntimeError("Publisher connection is not available")
|
|
351
|
+
self._fast_publish_channel = await conn.channel(publisher_confirms=False)
|
|
352
|
+
return self._fast_publish_channel
|
|
353
|
+
|
|
354
|
+
async def _get_mandatory_channel(self) -> Any:
|
|
355
|
+
"""Return the dedicated always-confirmed channel for mandatory=True
|
|
356
|
+
publishes, (re)opening if needed.
|
|
357
|
+
|
|
358
|
+
H1: ``on_return_raises=True`` makes an unroutable ``Basic.Return``
|
|
359
|
+
raise ``aio_pika.exceptions.PublishError`` (caught by
|
|
360
|
+
:meth:`_publish_on_channel` and mapped to ``PublishStatus.RETURNED``)
|
|
361
|
+
instead of silently resolving the confirmation with the returned
|
|
362
|
+
message — indistinguishable from success otherwise. This channel is
|
|
363
|
+
used for every ``mandatory=True`` publish regardless of the broker's
|
|
364
|
+
``confirm_delivery`` setting, since reliable return detection needs
|
|
365
|
+
confirms + on_return_raises unconditionally.
|
|
366
|
+
"""
|
|
367
|
+
ch = self._mandatory_publish_channel
|
|
368
|
+
if ch is not None and not ch.is_closed:
|
|
369
|
+
return ch
|
|
370
|
+
async with self._mandatory_channel_lock:
|
|
371
|
+
ch = self._mandatory_publish_channel
|
|
372
|
+
if ch is not None and not ch.is_closed: # pragma: no cover — concurrent path
|
|
373
|
+
return ch
|
|
374
|
+
conn = self._conn_pool._publisher_connection
|
|
375
|
+
if conn is None:
|
|
376
|
+
raise RuntimeError("Publisher connection is not available")
|
|
377
|
+
self._mandatory_publish_channel = await conn.channel(
|
|
378
|
+
publisher_confirms=True, on_return_raises=True
|
|
379
|
+
)
|
|
380
|
+
return self._mandatory_publish_channel
|
|
381
|
+
|
|
382
|
+
def _build_aio_message(self, envelope: MessageEnvelope) -> Any:
|
|
383
|
+
"""Build an aio_pika.Message from a MessageEnvelope."""
|
|
384
|
+
import aio_pika
|
|
385
|
+
|
|
386
|
+
return aio_pika.Message(
|
|
387
|
+
body=envelope.body,
|
|
388
|
+
message_id=envelope.message_id,
|
|
389
|
+
correlation_id=envelope.correlation_id,
|
|
390
|
+
reply_to=envelope.reply_to,
|
|
391
|
+
content_type=envelope.content_type,
|
|
392
|
+
content_encoding=envelope.content_encoding,
|
|
393
|
+
headers=envelope.headers or None,
|
|
394
|
+
delivery_mode=aio_pika.DeliveryMode(envelope.delivery_mode),
|
|
395
|
+
priority=envelope.priority,
|
|
396
|
+
expiration=(int(envelope.expiration) / 1000 if envelope.expiration else None),
|
|
397
|
+
type=envelope.type,
|
|
398
|
+
user_id=envelope.user_id,
|
|
399
|
+
app_id=envelope.app_id,
|
|
400
|
+
timestamp=envelope.timestamp,
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
async def _publish_on_channel(self, channel: Any, envelope: MessageEnvelope) -> PublishOutcome:
|
|
404
|
+
"""Publish *envelope* on an already-acquired channel.
|
|
405
|
+
|
|
406
|
+
Used by BatchPublisher to publish many messages on one channel and
|
|
407
|
+
gather all confirms concurrently — one pool acquire/release for N
|
|
408
|
+
messages instead of N separate round-trips.
|
|
409
|
+
|
|
410
|
+
H1: a ``mandatory=True`` publish that the broker cannot route raises
|
|
411
|
+
``aio_pika.exceptions.PublishError`` when the channel has
|
|
412
|
+
``on_return_raises=True`` (only true for channels obtained via
|
|
413
|
+
:meth:`_get_mandatory_channel` — regular pool/fast channels default to
|
|
414
|
+
``on_return_raises=False`` and would otherwise resolve the confirmation
|
|
415
|
+
with the returned message instead of raising, indistinguishable from
|
|
416
|
+
success). Mapped to ``PublishStatus.RETURNED`` so callers keying off
|
|
417
|
+
``outcome.ok`` correctly treat it as a failed publish. A broker-side
|
|
418
|
+
``Basic.Nack`` (not a return) raises the more generic
|
|
419
|
+
``DeliveryError`` and maps to ``PublishStatus.NACKED``.
|
|
420
|
+
"""
|
|
421
|
+
import aio_pika.exceptions
|
|
422
|
+
|
|
423
|
+
message = self._build_aio_message(envelope)
|
|
424
|
+
exchange = (
|
|
425
|
+
await channel.get_exchange(envelope.exchange, ensure=False)
|
|
426
|
+
if envelope.exchange
|
|
427
|
+
else channel.default_exchange
|
|
428
|
+
)
|
|
429
|
+
try:
|
|
430
|
+
async with asyncio.timeout(self._confirm_timeout):
|
|
431
|
+
await exchange.publish(
|
|
432
|
+
message,
|
|
433
|
+
routing_key=envelope.routing_key,
|
|
434
|
+
mandatory=envelope.mandatory,
|
|
435
|
+
)
|
|
436
|
+
except TimeoutError as e:
|
|
437
|
+
# M17: do NOT close the channel here. This coroutine may be one of
|
|
438
|
+
# several concurrent calls sharing the SAME channel (BatchPublisher's
|
|
439
|
+
# _flush gathers N of these on one channel) — closing it the instant
|
|
440
|
+
# OUR OWN confirm-wait times out would kill every sibling publish
|
|
441
|
+
# still awaiting ITS OWN confirm on that channel, even ones that
|
|
442
|
+
# would have confirmed cleanly a moment later. Callers decide
|
|
443
|
+
# whether/when to close a channel that had a timeout, and do so
|
|
444
|
+
# only once they know no other publish is still using it (e.g.
|
|
445
|
+
# after their own single call, or after a whole gathered batch has
|
|
446
|
+
# fully resolved) — see callers of _publish_on_channel.
|
|
447
|
+
logger.warning("Publish confirm timed out after %.1fs", self._confirm_timeout)
|
|
448
|
+
return PublishOutcome(
|
|
449
|
+
status=PublishStatus.TIMEOUT,
|
|
450
|
+
exchange=envelope.exchange,
|
|
451
|
+
routing_key=envelope.routing_key,
|
|
452
|
+
error=e,
|
|
453
|
+
)
|
|
454
|
+
except aio_pika.exceptions.PublishError as e:
|
|
455
|
+
logger.warning(
|
|
456
|
+
"Publish returned as unroutable (mandatory=True, no matching binding): "
|
|
457
|
+
"exchange=%s routing_key=%s",
|
|
458
|
+
envelope.exchange,
|
|
459
|
+
envelope.routing_key,
|
|
460
|
+
)
|
|
461
|
+
return PublishOutcome(
|
|
462
|
+
status=PublishStatus.RETURNED,
|
|
463
|
+
exchange=envelope.exchange,
|
|
464
|
+
routing_key=envelope.routing_key,
|
|
465
|
+
error=e,
|
|
466
|
+
)
|
|
467
|
+
except aio_pika.exceptions.DeliveryError as e:
|
|
468
|
+
logger.warning(
|
|
469
|
+
"Publish nacked by broker: exchange=%s routing_key=%s",
|
|
470
|
+
envelope.exchange,
|
|
471
|
+
envelope.routing_key,
|
|
472
|
+
)
|
|
473
|
+
return PublishOutcome(
|
|
474
|
+
status=PublishStatus.NACKED,
|
|
475
|
+
exchange=envelope.exchange,
|
|
476
|
+
routing_key=envelope.routing_key,
|
|
477
|
+
error=e,
|
|
478
|
+
)
|
|
479
|
+
return PublishOutcome(
|
|
480
|
+
status=PublishStatus.CONFIRMED,
|
|
481
|
+
exchange=envelope.exchange,
|
|
482
|
+
routing_key=envelope.routing_key,
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
async def publish(self, envelope: MessageEnvelope) -> PublishOutcome:
|
|
486
|
+
"""Publish a message.
|
|
487
|
+
|
|
488
|
+
When confirm_delivery=False: uses a single persistent channel (no
|
|
489
|
+
acquire/release overhead, no broker ACK wait) for maximum throughput.
|
|
490
|
+
When confirm_delivery=True: uses the channel pool so each in-flight
|
|
491
|
+
confirm is isolated to its own channel slot.
|
|
492
|
+
|
|
493
|
+
A request with ``reply_to=DIRECT_REPLY_TO_QUEUE`` (RPCClient's direct
|
|
494
|
+
reply-to requests) bypasses both paths above and is routed onto
|
|
495
|
+
``self._reply_to_channel`` — the same channel that registered the
|
|
496
|
+
reply consumer — instead. RabbitMQ requires this exact channel
|
|
497
|
+
affinity for direct reply-to; publishing on a different channel raises
|
|
498
|
+
"PRECONDITION_FAILED - fast reply consumer does not exist".
|
|
499
|
+
|
|
500
|
+
H1: a ``mandatory=True`` envelope (that isn't a direct reply-to
|
|
501
|
+
request) always publishes via the dedicated always-confirmed channel
|
|
502
|
+
from :meth:`_get_mandatory_channel`, regardless of ``confirm_delivery``
|
|
503
|
+
— see that method's docstring for why neither the fast nor the regular
|
|
504
|
+
pool channel can reliably report an unroutable ``Basic.Return``.
|
|
505
|
+
"""
|
|
506
|
+
try:
|
|
507
|
+
if not self._connected:
|
|
508
|
+
await self._ensure_connected()
|
|
509
|
+
|
|
510
|
+
if envelope.reply_to == DIRECT_REPLY_TO_QUEUE and self._reply_to_channel is not None:
|
|
511
|
+
channel = self._reply_to_channel
|
|
512
|
+
if not channel.is_closed:
|
|
513
|
+
return await self._publish_on_channel(channel, envelope)
|
|
514
|
+
|
|
515
|
+
if envelope.mandatory:
|
|
516
|
+
channel = await self._get_mandatory_channel()
|
|
517
|
+
outcome = await self._publish_on_channel(channel, envelope)
|
|
518
|
+
# M17: this channel is a single persistent channel reused across
|
|
519
|
+
# ALL mandatory publishes — close it AFTER our own call resolves
|
|
520
|
+
# (not from inside _publish_on_channel) so a timeout doesn't risk
|
|
521
|
+
# yanking a channel a concurrent mandatory publish is still using.
|
|
522
|
+
# _get_mandatory_channel() lazily reopens on the next call.
|
|
523
|
+
if outcome.status == PublishStatus.TIMEOUT and not channel.is_closed:
|
|
524
|
+
with contextlib.suppress(Exception):
|
|
525
|
+
await channel.close()
|
|
526
|
+
return outcome
|
|
527
|
+
|
|
528
|
+
if not self._confirm_delivery:
|
|
529
|
+
# Fast path: persistent channel, no confirm wait, no pool overhead
|
|
530
|
+
message = self._build_aio_message(envelope)
|
|
531
|
+
channel = await self._get_fast_channel()
|
|
532
|
+
exchange = (
|
|
533
|
+
await channel.get_exchange(envelope.exchange, ensure=False)
|
|
534
|
+
if envelope.exchange
|
|
535
|
+
else channel.default_exchange
|
|
536
|
+
)
|
|
537
|
+
await exchange.publish(
|
|
538
|
+
message,
|
|
539
|
+
routing_key=envelope.routing_key,
|
|
540
|
+
mandatory=envelope.mandatory,
|
|
541
|
+
)
|
|
542
|
+
# M4: SENT, not CONFIRMED -- this channel has publisher_confirms=False,
|
|
543
|
+
# so nothing was actually acknowledged by the broker.
|
|
544
|
+
return PublishOutcome(
|
|
545
|
+
status=PublishStatus.SENT,
|
|
546
|
+
exchange=envelope.exchange,
|
|
547
|
+
routing_key=envelope.routing_key,
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
# Confirmed path: pool channel per publish so each confirm is isolated
|
|
551
|
+
channel = await self._conn_pool.acquire_publisher_channel()
|
|
552
|
+
try:
|
|
553
|
+
outcome = await self._publish_on_channel(channel, envelope)
|
|
554
|
+
# M17: close a timed-out channel before returning it to the pool
|
|
555
|
+
# so the pool doesn't hand out a possibly-wedged channel next.
|
|
556
|
+
# This channel is exclusively ours for this single publish (not
|
|
557
|
+
# shared concurrently), so closing here — after our own call has
|
|
558
|
+
# fully resolved — is safe.
|
|
559
|
+
if outcome.status == PublishStatus.TIMEOUT and not channel.is_closed:
|
|
560
|
+
with contextlib.suppress(Exception):
|
|
561
|
+
await channel.close()
|
|
562
|
+
return outcome
|
|
563
|
+
finally:
|
|
564
|
+
await self._conn_pool.release_publisher_channel(channel)
|
|
565
|
+
|
|
566
|
+
except Exception as e:
|
|
567
|
+
logger.error("Async publish failed: %s", e)
|
|
568
|
+
return PublishOutcome(
|
|
569
|
+
status=PublishStatus.ERROR,
|
|
570
|
+
exchange=envelope.exchange,
|
|
571
|
+
routing_key=envelope.routing_key,
|
|
572
|
+
error=e,
|
|
573
|
+
)
|
|
574
|
+
async def consume(
|
|
575
|
+
self,
|
|
576
|
+
queue: str,
|
|
577
|
+
callback: Callable[[RabbitMessage], Awaitable[None]],
|
|
578
|
+
prefetch: int = 10,
|
|
579
|
+
*,
|
|
580
|
+
no_ack: bool = False,
|
|
581
|
+
declare: bool = True,
|
|
582
|
+
) -> str:
|
|
583
|
+
"""Start consuming from a queue.
|
|
584
|
+
|
|
585
|
+
Each queue gets a dedicated channel so per-queue QoS settings do not
|
|
586
|
+
interfere with each other. Returns the consumer tag.
|
|
587
|
+
|
|
588
|
+
``no_ack=True`` starts a no-ack consumer: the broker auto-acks on
|
|
589
|
+
delivery, and the built ``RabbitMessage`` is not wired with settlement
|
|
590
|
+
functions (there is nothing to ack/nack/reject).
|
|
591
|
+
|
|
592
|
+
``declare=False`` skips the passive-declare check and instead obtains a
|
|
593
|
+
bare, undeclared ``Queue`` handle (``channel.get_queue(queue,
|
|
594
|
+
ensure=False)`` — constructs the wrapper locally with no AMQP frame
|
|
595
|
+
sent, unlike ``declare_queue(passive=True)``). Required for AMQP
|
|
596
|
+
pseudo-queues such as ``amq.rabbitmq.reply-to``: the broker rejects
|
|
597
|
+
*any* Queue.Declare against that name (even passive), yet basic_consume
|
|
598
|
+
against it is valid and is how RabbitMQ's direct-reply-to feature works.
|
|
599
|
+
Note: an undeclared queue is not tracked in ``RobustChannel``'s
|
|
600
|
+
internal registry, so unlike the ``declare=True`` path this consumer is
|
|
601
|
+
NOT automatically resumed by aio-pika after a ``connect_robust``
|
|
602
|
+
reconnect — acceptable for ``amq.rabbitmq.reply-to``, whose lifetime is
|
|
603
|
+
scoped to the connection anyway.
|
|
604
|
+
|
|
605
|
+
When ``declare=False`` and ``queue == DIRECT_REPLY_TO_QUEUE``, this
|
|
606
|
+
consumer's channel is also remembered as ``self._reply_to_channel`` so
|
|
607
|
+
:meth:`publish` can route matching requests onto the SAME channel —
|
|
608
|
+
required by RabbitMQ's direct reply-to (see :meth:`publish`).
|
|
609
|
+
"""
|
|
610
|
+
await self._ensure_connected()
|
|
611
|
+
|
|
612
|
+
# Dedicated channel per consumer queue for isolated QoS
|
|
613
|
+
consumer_conn = await self._conn_pool.get_consumer_connection()
|
|
614
|
+
channel = await consumer_conn.channel()
|
|
615
|
+
await channel.set_qos(prefetch_count=prefetch)
|
|
616
|
+
self._consumer_channels[queue] = channel
|
|
617
|
+
|
|
618
|
+
if not declare and queue == DIRECT_REPLY_TO_QUEUE:
|
|
619
|
+
self._reply_to_channel = channel
|
|
620
|
+
|
|
621
|
+
if declare:
|
|
622
|
+
# passive declare (not get_queue): RobustChannel only restores queues
|
|
623
|
+
# in its _queues registry, which declare_queue populates and get_queue
|
|
624
|
+
# does not. Without this, the consumer is silently NOT resumed after a
|
|
625
|
+
# connect_robust reconnect (the queue — and its consumer — are
|
|
626
|
+
# untracked).
|
|
627
|
+
q = await channel.declare_queue(queue, passive=True)
|
|
628
|
+
else:
|
|
629
|
+
# No AMQP frame sent — just a local Queue wrapper for `queue`. Some
|
|
630
|
+
# pseudo-queues (amq.rabbitmq.reply-to) reject any Queue.Declare.
|
|
631
|
+
q = await channel.get_queue(queue, ensure=False)
|
|
632
|
+
consumer_tag = f"rabbitkit.{uuid.uuid4()}"
|
|
633
|
+
|
|
634
|
+
async def on_message(message: Any) -> None:
|
|
635
|
+
rabbit_msg = self._build_message(message, no_ack=no_ack)
|
|
636
|
+
await callback(rabbit_msg)
|
|
637
|
+
|
|
638
|
+
await q.consume(on_message, consumer_tag=consumer_tag, no_ack=no_ack)
|
|
639
|
+
self._consumer_tags[queue] = consumer_tag
|
|
640
|
+
logger.info("Started consuming from queue '%s' with tag '%s' (async)", queue, consumer_tag)
|
|
641
|
+
return consumer_tag
|
|
642
|
+
|
|
643
|
+
async def declare_exchange(self, exchange: RabbitExchange) -> None:
|
|
644
|
+
"""Declare an exchange on the topology channel."""
|
|
645
|
+
action = self._topo.exchange_action(exchange)
|
|
646
|
+
if action is TopoAction.SKIP:
|
|
647
|
+
return
|
|
648
|
+
|
|
649
|
+
await self._ensure_connected()
|
|
650
|
+
assert self._topology_channel is not None
|
|
651
|
+
|
|
652
|
+
kwargs = exchange.to_declare_kwargs()
|
|
653
|
+
|
|
654
|
+
import aio_pika.exceptions
|
|
655
|
+
|
|
656
|
+
try:
|
|
657
|
+
if action is TopoAction.PASSIVE:
|
|
658
|
+
await self._topology_channel.get_exchange(kwargs["exchange"], ensure=True)
|
|
659
|
+
else:
|
|
660
|
+
await self._topology_channel.declare_exchange(
|
|
661
|
+
name=kwargs["exchange"],
|
|
662
|
+
type=kwargs.get("exchange_type", "direct"),
|
|
663
|
+
durable=kwargs.get("durable", True),
|
|
664
|
+
auto_delete=kwargs.get("auto_delete", False),
|
|
665
|
+
internal=kwargs.get("internal", False),
|
|
666
|
+
arguments=kwargs.get("arguments"),
|
|
667
|
+
)
|
|
668
|
+
except aio_pika.exceptions.ChannelPreconditionFailed as exc:
|
|
669
|
+
await self._handle_precondition_failed("exchange", kwargs["exchange"], exc)
|
|
670
|
+
|
|
671
|
+
async def declare_queue(self, queue: RabbitQueue) -> None:
|
|
672
|
+
"""Declare a queue on the topology channel."""
|
|
673
|
+
action = self._topo.queue_action(queue)
|
|
674
|
+
if action is TopoAction.SKIP:
|
|
675
|
+
return
|
|
676
|
+
|
|
677
|
+
await self._ensure_connected()
|
|
678
|
+
assert self._topology_channel is not None
|
|
679
|
+
|
|
680
|
+
kwargs = queue.to_declare_kwargs()
|
|
681
|
+
|
|
682
|
+
import aio_pika.exceptions
|
|
683
|
+
|
|
684
|
+
try:
|
|
685
|
+
if action is TopoAction.PASSIVE:
|
|
686
|
+
await self._topology_channel.get_queue(kwargs["queue"], ensure=True)
|
|
687
|
+
else:
|
|
688
|
+
await self._topology_channel.declare_queue(
|
|
689
|
+
name=kwargs["queue"],
|
|
690
|
+
durable=kwargs.get("durable", True),
|
|
691
|
+
exclusive=kwargs.get("exclusive", False),
|
|
692
|
+
auto_delete=kwargs.get("auto_delete", False),
|
|
693
|
+
arguments=kwargs.get("arguments"),
|
|
694
|
+
)
|
|
695
|
+
except aio_pika.exceptions.ChannelPreconditionFailed as exc:
|
|
696
|
+
await self._handle_precondition_failed("queue", kwargs["queue"], exc)
|
|
697
|
+
|
|
698
|
+
async def _handle_precondition_failed(self, kind: str, name: str, exc: BaseException) -> None:
|
|
699
|
+
"""M6: turn a 406 PRECONDITION_FAILED into a typed, actionable error.
|
|
700
|
+
|
|
701
|
+
Declaring a queue/exchange with arguments that conflict with an
|
|
702
|
+
existing one of the same name (e.g. an ops-created quorum queue
|
|
703
|
+
where rabbitkit's config declares classic, or a different TTL/DLX)
|
|
704
|
+
closes the channel with reply_code 406 --
|
|
705
|
+
``aio_pika.exceptions.ChannelPreconditionFailed`` specifically.
|
|
706
|
+
Previously this aborted startup with a low-level channel-closed
|
|
707
|
+
traceback giving no hint which queue/exchange or argument actually
|
|
708
|
+
conflicted.
|
|
709
|
+
|
|
710
|
+
M14: under ``SafetyConfig.on_topology_conflict="warn_continue"`` the
|
|
711
|
+
406 is logged and swallowed — a 406 (unlike a 404) proves the entity
|
|
712
|
+
exists, so rabbitkit continues with the EXISTING definition. The
|
|
713
|
+
conflict closed the topology channel, so we reopen it first.
|
|
714
|
+
"""
|
|
715
|
+
if self._on_topology_conflict == "warn_continue":
|
|
716
|
+
consumer_conn = await self._conn_pool.get_consumer_connection()
|
|
717
|
+
self._topology_channel = await consumer_conn.channel()
|
|
718
|
+
logger.warning(
|
|
719
|
+
"Topology drift on %s %r (broker: %s); on_topology_conflict='warn_continue' "
|
|
720
|
+
"— continuing with the EXISTING definition (rabbitkit's declaration was NOT "
|
|
721
|
+
"applied). Reconcile the %s or fix its rabbitkit config to silence this.",
|
|
722
|
+
kind,
|
|
723
|
+
name,
|
|
724
|
+
exc,
|
|
725
|
+
kind,
|
|
726
|
+
)
|
|
727
|
+
return
|
|
728
|
+
raise ConfigurationError(
|
|
729
|
+
f"Cannot declare {kind} {name!r}: it already exists with incompatible "
|
|
730
|
+
f"arguments (broker said: {exc}). This usually means it was created "
|
|
731
|
+
f"outside rabbitkit (e.g. ops tooling) with different arguments (e.g. "
|
|
732
|
+
f"quorum vs classic queue type, a different TTL, or a different "
|
|
733
|
+
f"dead-letter exchange). Either delete/reconcile the existing {kind}, "
|
|
734
|
+
f"adjust its rabbitkit definition to match, or use "
|
|
735
|
+
f"TopologyMode.PASSIVE_ONLY to skip declaration and just verify it exists."
|
|
736
|
+
) from exc
|
|
737
|
+
|
|
738
|
+
async def bind_queue(
|
|
739
|
+
self,
|
|
740
|
+
queue: str,
|
|
741
|
+
exchange: str,
|
|
742
|
+
routing_key: str,
|
|
743
|
+
arguments: dict[str, Any] | None = None,
|
|
744
|
+
) -> None:
|
|
745
|
+
"""Bind a queue to an exchange on the topology channel.
|
|
746
|
+
|
|
747
|
+
``arguments`` carries header-match criteria for HEADERS exchanges
|
|
748
|
+
(``x-match`` etc.) — without them a headers binding matches every
|
|
749
|
+
message (C4).
|
|
750
|
+
"""
|
|
751
|
+
if self._topo.binding_action() is TopoAction.SKIP:
|
|
752
|
+
return
|
|
753
|
+
|
|
754
|
+
await self._ensure_connected()
|
|
755
|
+
assert self._topology_channel is not None
|
|
756
|
+
|
|
757
|
+
q = await self._topology_channel.get_queue(queue, ensure=False)
|
|
758
|
+
ex = await self._topology_channel.get_exchange(exchange, ensure=False)
|
|
759
|
+
await q.bind(ex, routing_key=routing_key, arguments=arguments)
|
|
760
|
+
|
|
761
|
+
async def bind_exchange(
|
|
762
|
+
self,
|
|
763
|
+
destination: str,
|
|
764
|
+
source: str,
|
|
765
|
+
routing_key: str = "",
|
|
766
|
+
arguments: dict[str, Any] | None = None,
|
|
767
|
+
) -> None:
|
|
768
|
+
"""Bind an exchange to another exchange on the topology channel."""
|
|
769
|
+
if self._topo.binding_action() is TopoAction.SKIP:
|
|
770
|
+
return
|
|
771
|
+
|
|
772
|
+
await self._ensure_connected()
|
|
773
|
+
assert self._topology_channel is not None
|
|
774
|
+
|
|
775
|
+
dest_ex = await self._topology_channel.get_exchange(destination, ensure=False)
|
|
776
|
+
src_ex = await self._topology_channel.get_exchange(source, ensure=False)
|
|
777
|
+
await dest_ex.bind(src_ex, routing_key=routing_key, arguments=arguments)
|
|
778
|
+
|
|
779
|
+
async def cancel_consumer(self, consumer_tag: str) -> None:
|
|
780
|
+
"""Cancel a consumer by tag."""
|
|
781
|
+
if not self._connected:
|
|
782
|
+
return
|
|
783
|
+
|
|
784
|
+
for queue_name, tag in list(self._consumer_tags.items()):
|
|
785
|
+
if tag == consumer_tag:
|
|
786
|
+
channel = self._consumer_channels.get(queue_name)
|
|
787
|
+
if channel is not None:
|
|
788
|
+
try:
|
|
789
|
+
q = await channel.get_queue(queue_name, ensure=False)
|
|
790
|
+
await q.cancel(consumer_tag)
|
|
791
|
+
except Exception as e:
|
|
792
|
+
logger.warning("Failed to cancel consumer %s: %s", consumer_tag, e)
|
|
793
|
+
finally:
|
|
794
|
+
del self._consumer_tags[queue_name]
|
|
795
|
+
del self._consumer_channels[queue_name]
|
|
796
|
+
if channel is self._reply_to_channel:
|
|
797
|
+
self._reply_to_channel = None
|
|
798
|
+
break
|
|
799
|
+
|
|
800
|
+
# ── DLQ / inspection (DLQInspector protocol) ──────────────────────────
|
|
801
|
+
|
|
802
|
+
async def basic_get(self, queue: str) -> RabbitMessage | None:
|
|
803
|
+
"""Get a single message without subscribing.
|
|
804
|
+
|
|
805
|
+
Used by DLQInspector for peek/replay. Returns None if the queue is empty.
|
|
806
|
+
"""
|
|
807
|
+
await self._ensure_connected()
|
|
808
|
+
assert self._topology_channel is not None
|
|
809
|
+
q = await self._topology_channel.get_queue(queue, ensure=False)
|
|
810
|
+
aio_msg = await q.get(fail=False, no_ack=False)
|
|
811
|
+
if aio_msg is None:
|
|
812
|
+
return None
|
|
813
|
+
return self._build_message(aio_msg)
|
|
814
|
+
|
|
815
|
+
async def purge_queue(self, queue: str) -> int:
|
|
816
|
+
"""Purge all messages from a queue. Returns the number of messages purged."""
|
|
817
|
+
await self._ensure_connected()
|
|
818
|
+
assert self._topology_channel is not None
|
|
819
|
+
q = await self._topology_channel.get_queue(queue, ensure=False)
|
|
820
|
+
result = await q.purge()
|
|
821
|
+
return int(getattr(result, "message_count", 0))
|
|
822
|
+
|
|
823
|
+
# ── Internal ──────────────────────────────────────────────────────────
|
|
824
|
+
|
|
825
|
+
def _build_message(self, aio_message: Any, *, no_ack: bool = False) -> RabbitMessage:
|
|
826
|
+
"""Build RabbitMessage from aio-pika IncomingMessage.
|
|
827
|
+
|
|
828
|
+
``no_ack=True`` (delivery came from a no-ack consumer) skips wiring
|
|
829
|
+
settlement functions entirely — the broker already auto-acked the
|
|
830
|
+
delivery, and aio-pika's ``IncomingMessage.ack()``/``nack()``/``reject()``
|
|
831
|
+
raise ``TypeError`` on a no-ack message anyway.
|
|
832
|
+
"""
|
|
833
|
+
message = RabbitMessage(
|
|
834
|
+
body=aio_message.body,
|
|
835
|
+
headers=dict(aio_message.headers) if aio_message.headers else {},
|
|
836
|
+
message_id=aio_message.message_id,
|
|
837
|
+
correlation_id=aio_message.correlation_id,
|
|
838
|
+
reply_to=aio_message.reply_to,
|
|
839
|
+
content_type=aio_message.content_type,
|
|
840
|
+
content_encoding=aio_message.content_encoding,
|
|
841
|
+
type=aio_message.type,
|
|
842
|
+
app_id=aio_message.app_id,
|
|
843
|
+
priority=aio_message.priority,
|
|
844
|
+
# aio-pika decodes the wire's ms-string expiration into seconds
|
|
845
|
+
# (float) on IncomingMessage; re-encode to the ms-string convention
|
|
846
|
+
# RabbitMessage/MessageEnvelope.expiration use everywhere else (matches
|
|
847
|
+
# the raw string pika.BasicProperties.expiration carries unmodified),
|
|
848
|
+
# so a retry/DLQ-replay envelope built from this message round-trips
|
|
849
|
+
# correctly regardless of which transport received it.
|
|
850
|
+
expiration=(str(int(aio_message.expiration * 1000)) if aio_message.expiration is not None else None),
|
|
851
|
+
user_id=aio_message.user_id,
|
|
852
|
+
timestamp=aio_message.timestamp, # was never surfaced on consume
|
|
853
|
+
routing_key=aio_message.routing_key,
|
|
854
|
+
exchange=aio_message.exchange or "",
|
|
855
|
+
delivery_tag=aio_message.delivery_tag,
|
|
856
|
+
redelivered=aio_message.redelivered,
|
|
857
|
+
consumer_tag=aio_message.consumer_tag,
|
|
858
|
+
raw_message=aio_message,
|
|
859
|
+
)
|
|
860
|
+
|
|
861
|
+
if no_ack:
|
|
862
|
+
return message
|
|
863
|
+
|
|
864
|
+
async def ack_fn() -> None:
|
|
865
|
+
await aio_message.ack()
|
|
866
|
+
|
|
867
|
+
async def nack_fn(requeue: bool = True) -> None:
|
|
868
|
+
await aio_message.nack(requeue=requeue)
|
|
869
|
+
|
|
870
|
+
async def reject_fn(requeue: bool = False) -> None:
|
|
871
|
+
await aio_message.reject(requeue=requeue)
|
|
872
|
+
|
|
873
|
+
message._ack_async_fn = ack_fn
|
|
874
|
+
message._nack_async_fn = nack_fn
|
|
875
|
+
message._reject_async_fn = reject_fn
|
|
876
|
+
|
|
877
|
+
return message
|