rabbitkit 0.19.0__tar.gz → 0.19.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. {rabbitkit-0.19.0/src/rabbitkit.egg-info → rabbitkit-0.19.1}/PKG-INFO +1 -1
  2. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/pyproject.toml +1 -1
  3. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/_version.py +1 -1
  4. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/async_/pool.py +76 -5
  5. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/async_/transport.py +24 -89
  6. {rabbitkit-0.19.0 → rabbitkit-0.19.1/src/rabbitkit.egg-info}/PKG-INFO +1 -1
  7. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/LICENSE +0 -0
  8. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/README.md +0 -0
  9. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/setup.cfg +0 -0
  10. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/__init__.py +0 -0
  11. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/aio/__init__.py +0 -0
  12. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/async_/__init__.py +0 -0
  13. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/async_/batch.py +0 -0
  14. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/async_/broker.py +0 -0
  15. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/async_/connection.py +0 -0
  16. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/asyncapi/__init__.py +0 -0
  17. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/asyncapi/generator.py +0 -0
  18. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/asyncapi/schema.py +0 -0
  19. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/cli/__init__.py +0 -0
  20. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/cli/_utils.py +0 -0
  21. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/cli/commands/__init__.py +0 -0
  22. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/cli/commands/dlq.py +0 -0
  23. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/cli/commands/health.py +0 -0
  24. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/cli/commands/migrate.py +0 -0
  25. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/cli/commands/routes.py +0 -0
  26. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/cli/commands/run.py +0 -0
  27. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/cli/commands/shell.py +0 -0
  28. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/cli/commands/topology.py +0 -0
  29. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/concurrency.py +0 -0
  30. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/core/__init__.py +0 -0
  31. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/core/app.py +0 -0
  32. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/core/bulk.py +0 -0
  33. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/core/bulk_runner.py +0 -0
  34. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/core/config.py +0 -0
  35. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/core/env_config.py +0 -0
  36. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/core/errors.py +0 -0
  37. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/core/logging.py +0 -0
  38. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/core/message.py +0 -0
  39. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/core/path.py +0 -0
  40. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/core/pipeline.py +0 -0
  41. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/core/profiles.py +0 -0
  42. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/core/protocols.py +0 -0
  43. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/core/quorum.py +0 -0
  44. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/core/registry.py +0 -0
  45. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/core/retry_handoff.py +0 -0
  46. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/core/route.py +0 -0
  47. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/core/router.py +0 -0
  48. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/core/sanitizer.py +0 -0
  49. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/core/settlement.py +0 -0
  50. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/core/topology.py +0 -0
  51. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/core/topology_dispatch.py +0 -0
  52. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/core/types.py +0 -0
  53. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/dashboard/__init__.py +0 -0
  54. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/dashboard/app.py +0 -0
  55. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/di/__init__.py +0 -0
  56. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/di/context.py +0 -0
  57. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/di/depends.py +0 -0
  58. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/di/resolver.py +0 -0
  59. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/dlq.py +0 -0
  60. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/experimental/__init__.py +0 -0
  61. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/fastapi.py +0 -0
  62. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/health.py +0 -0
  63. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/highload/__init__.py +0 -0
  64. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/highload/async_acker.py +0 -0
  65. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/highload/backpressure.py +0 -0
  66. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/highload/batch.py +0 -0
  67. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/locking.py +0 -0
  68. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/management.py +0 -0
  69. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/middleware/__init__.py +0 -0
  70. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/middleware/base.py +0 -0
  71. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/middleware/circuit_breaker.py +0 -0
  72. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/middleware/compression.py +0 -0
  73. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/middleware/deduplication.py +0 -0
  74. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/middleware/error_classifier.py +0 -0
  75. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/middleware/exception.py +0 -0
  76. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/middleware/metrics.py +0 -0
  77. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/middleware/otel.py +0 -0
  78. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/middleware/rate_limit.py +0 -0
  79. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/middleware/retry.py +0 -0
  80. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/middleware/signing.py +0 -0
  81. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/middleware/timeout.py +0 -0
  82. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/py.typed +0 -0
  83. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/queue_metrics.py +0 -0
  84. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/results/__init__.py +0 -0
  85. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/results/backend.py +0 -0
  86. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/results/middleware.py +0 -0
  87. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/rpc.py +0 -0
  88. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/serialization/__init__.py +0 -0
  89. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/serialization/base.py +0 -0
  90. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/serialization/json.py +0 -0
  91. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/serialization/msgspec.py +0 -0
  92. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/serialization/pipeline.py +0 -0
  93. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/streams.py +0 -0
  94. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/sync/__init__.py +0 -0
  95. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/sync/batch.py +0 -0
  96. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/sync/broker.py +0 -0
  97. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/sync/connection.py +0 -0
  98. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/sync/pool.py +0 -0
  99. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/sync/transport.py +0 -0
  100. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/testing/__init__.py +0 -0
  101. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/testing/app.py +0 -0
  102. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/testing/broker.py +0 -0
  103. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit/testing/fixtures.py +0 -0
  104. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit.egg-info/SOURCES.txt +0 -0
  105. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit.egg-info/dependency_links.txt +0 -0
  106. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit.egg-info/entry_points.txt +0 -0
  107. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit.egg-info/requires.txt +0 -0
  108. {rabbitkit-0.19.0 → rabbitkit-0.19.1}/src/rabbitkit.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rabbitkit
3
- Version: 0.19.0
3
+ Version: 0.19.1
4
4
  Summary: Production-grade RabbitMQ toolkit — sync/async, decorator routing, retry, compression, full configurability
5
5
  Author-email: Talaat Magdy <talaatmagdy75@gmail.com>
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "rabbitkit"
3
- version = "0.19.0"
3
+ version = "0.19.1"
4
4
  description = "Production-grade RabbitMQ toolkit — sync/async, decorator routing, retry, compression, full configurability"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,3 +1,3 @@
1
1
  """rabbitkit version — single source of truth."""
2
2
 
3
- __version__ = "0.19.0"
3
+ __version__ = "0.19.1"
@@ -13,6 +13,7 @@ import asyncio
13
13
  import contextlib
14
14
  import logging
15
15
  import random
16
+ import time
16
17
  from collections.abc import Callable
17
18
  from contextlib import asynccontextmanager
18
19
  from typing import Any
@@ -22,6 +23,9 @@ from rabbitkit.core.config import ConnectionConfig, PoolConfig, SecurityConfig
22
23
 
23
24
  logger = logging.getLogger(__name__)
24
25
 
26
+ #: Seconds between "Channel pool exhausted" warnings from one pool.
27
+ _EXHAUSTED_WARNING_INTERVAL = 60.0
28
+
25
29
 
26
30
  class AsyncChannelPool:
27
31
  """Async channel pool.
@@ -42,11 +46,14 @@ class AsyncChannelPool:
42
46
  publisher_confirms: bool = True,
43
47
  on_channel_opened: Callable[[], None] | None = None,
44
48
  on_channel_rebuilt: Callable[[], None] | None = None,
49
+ on_return_raises: bool = False,
45
50
  ) -> None:
46
51
  self._connection = connection
47
52
  self._pool_size = pool_size
48
53
  self._acquire_timeout = acquire_timeout
49
54
  self._publisher_confirms = publisher_confirms
55
+ self._on_return_raises = on_return_raises
56
+ self._exhausted_warned_at = -_EXHAUSTED_WARNING_INTERVAL
50
57
  self._pool: asyncio.Queue[Any] = asyncio.Queue(maxsize=pool_size)
51
58
  self._lock = asyncio.Lock()
52
59
  self._created = 0
@@ -111,7 +118,7 @@ class AsyncChannelPool:
111
118
  # turns the hang into a raise so ``acquire_publisher_channel``
112
119
  # can rebuild the connection.
113
120
  async with asyncio.timeout(self._acquire_timeout):
114
- channel = await self._connection.channel(publisher_confirms=self._publisher_confirms)
121
+ channel = await self._connection.channel(**self._channel_kwargs())
115
122
  except BaseException:
116
123
  # creation failed (or timed out) — give the reserved slot back.
117
124
  async with self._lock:
@@ -128,7 +135,15 @@ class AsyncChannelPool:
128
135
  # Pool exhausted — wait with timeout to avoid deadlocks. R-timeout:
129
136
  # ``asyncio.timeout`` (3.11+) replaces ``asyncio.wait_for`` to avoid
130
137
  # the wrapper-task overhead.
131
- logger.warning(
138
+ # Once a minute at WARNING, then DEBUG: under a burst every waiting
139
+ # publish lands here (2000 concurrent mandatory publishes logged 11874
140
+ # warnings), and a wait that runs out still raises TimeoutError below.
141
+ now = time.monotonic()
142
+ log = logger.debug
143
+ if now - self._exhausted_warned_at >= _EXHAUSTED_WARNING_INTERVAL:
144
+ self._exhausted_warned_at = now
145
+ log = logger.warning
146
+ log(
132
147
  "Channel pool exhausted (pool_size=%d, created=%d). "
133
148
  "Waiting up to %.1fs for a channel to be released. "
134
149
  "Consider increasing PoolConfig.channel_pool_size.",
@@ -152,6 +167,12 @@ class AsyncChannelPool:
152
167
  self._in_use.add(channel)
153
168
  return channel
154
169
 
170
+ def _channel_kwargs(self) -> dict[str, bool]:
171
+ kwargs = {"publisher_confirms": self._publisher_confirms}
172
+ if self._on_return_raises:
173
+ kwargs["on_return_raises"] = True
174
+ return kwargs
175
+
155
176
  async def release(self, channel: Any) -> None:
156
177
  """Release a channel back to the pool."""
157
178
  async with self._lock:
@@ -265,6 +286,9 @@ class AsyncConnectionPool:
265
286
  self._publisher_connection: Any | None = None
266
287
  self._consumer_connection: Any | None = None
267
288
  self._publisher_channel_pool: AsyncChannelPool | None = None
289
+ # mandatory=True publishes: confirmed channels that raise on Basic.Return,
290
+ # one publish per channel at a time — see acquire_mandatory_channel()
291
+ self._mandatory_channel_pool: AsyncChannelPool | None = None
268
292
  self._lock = asyncio.Lock()
269
293
  self._prewarmed = False
270
294
 
@@ -378,10 +402,12 @@ class AsyncConnectionPool:
378
402
  if self._publisher_connection is not stale:
379
403
  return # another worker already rebuilt it
380
404
  old_pool, self._publisher_channel_pool = self._publisher_channel_pool, None
405
+ old_mandatory, self._mandatory_channel_pool = self._mandatory_channel_pool, None
381
406
  old_conn, self._publisher_connection = self._publisher_connection, None
382
- if old_pool is not None:
383
- with contextlib.suppress(Exception):
384
- await asyncio.wait_for(old_pool.close_all(), timeout=timeout)
407
+ for stale_pool in (old_pool, old_mandatory):
408
+ if stale_pool is not None:
409
+ with contextlib.suppress(Exception):
410
+ await asyncio.wait_for(stale_pool.close_all(), timeout=timeout)
385
411
  if old_conn is not None and not old_conn.is_closed:
386
412
  with contextlib.suppress(Exception):
387
413
  await asyncio.wait_for(old_conn.close(), timeout=timeout)
@@ -398,12 +424,57 @@ class AsyncConnectionPool:
398
424
  if self._publisher_channel_pool is not None:
399
425
  await self._publisher_channel_pool.release(channel)
400
426
 
427
+ def _mandatory_pool(self) -> AsyncChannelPool:
428
+ if self._mandatory_channel_pool is None:
429
+ assert self._publisher_connection is not None
430
+ self._mandatory_channel_pool = AsyncChannelPool(
431
+ self._publisher_connection,
432
+ pool_size=self._pool_config.channel_pool_size,
433
+ acquire_timeout=self._pool_config.channel_acquire_timeout,
434
+ publisher_confirms=True,
435
+ on_return_raises=True,
436
+ on_channel_opened=self._on_channel_opened,
437
+ on_channel_rebuilt=self._on_channel_rebuilt,
438
+ )
439
+ return self._mandatory_channel_pool
440
+
441
+ async def acquire_mandatory_channel(self) -> Any:
442
+ """A channel for one ``mandatory=True`` publish: confirms on, and
443
+ ``on_return_raises=True`` so an unroutable return raises.
444
+
445
+ One publish per channel at a time, like the confirmed pool. A publish
446
+ the broker refuses (403, 404, 406: an unknown exchange, a forged
447
+ ``user_id``) closes its channel, and every operation still in flight
448
+ on that channel fails with it. One shared mandatory channel therefore
449
+ let one refused publish fail every concurrent innocent one. Closed
450
+ channels are discarded on release. Self-heals a wedged connection the
451
+ same way :meth:`acquire_publisher_channel` does.
452
+ """
453
+ if self._publisher_connection is None:
454
+ await self.get_publisher_connection()
455
+ try:
456
+ return await self._mandatory_pool().acquire()
457
+ except asyncio.CancelledError:
458
+ raise
459
+ except Exception as exc:
460
+ logger.warning("Mandatory channel acquire failed (%s); rebuilding connection", exc)
461
+ await self._rebuild_publisher_connection(stale=self._publisher_connection)
462
+ return await self._mandatory_pool().acquire()
463
+
464
+ async def release_mandatory_channel(self, channel: Any) -> None:
465
+ """Return a mandatory-publish channel to its pool (a closed one is discarded)."""
466
+ if self._mandatory_channel_pool is not None:
467
+ await self._mandatory_channel_pool.release(channel)
468
+
401
469
  async def close_all(self) -> None:
402
470
  """Close all channel pools and connections."""
403
471
  async with self._lock:
404
472
  if self._publisher_channel_pool is not None:
405
473
  await self._publisher_channel_pool.close_all()
406
474
  self._publisher_channel_pool = None
475
+ if self._mandatory_channel_pool is not None:
476
+ await self._mandatory_channel_pool.close_all()
477
+ self._mandatory_channel_pool = None
407
478
 
408
479
  for conn in [self._publisher_connection, self._consumer_connection]:
409
480
  if conn is not None:
@@ -176,21 +176,14 @@ class AsyncTransportImpl:
176
176
  self._fast_publish_channel: Any | None = None
177
177
  self._fast_channel_lock: asyncio.Lock = asyncio.Lock()
178
178
 
179
- # H1: dedicated, always-confirmed channel for mandatory=True publishes,
180
- # independent of confirm_delivery. Detecting an unroutable Basic.Return
181
- # reliably requires BOTH publisher confirms AND on_return_raises=True —
182
- # neither the fast channel (no confirms at all) nor the regular pool
183
- # (confirms follow confirm_delivery, which may be False) guarantee that.
184
- self._mandatory_publish_channel: Any | None = None
185
- # M3: in-flight mandatory publishes on the shared channel + a
186
- # deferred-recycle flag set by a confirm timeout (closed at zero).
187
- self._mandatory_in_flight = 0
188
- self._mandatory_channel_recycle = False
179
+ # H1: mandatory=True publishes use the connection pool's mandatory
180
+ # channels (acquire_mandatory_channel): always confirmed and
181
+ # on_return_raises=True, independent of confirm_delivery, one publish
182
+ # per channel so a refused publish fails only itself.
189
183
  # m3: bindings recorded for re-apply after robust reconnect (they are
190
184
  # not in RobustChannel's restoration registry — see bind_queue).
191
185
  self._recorded_bindings: list[tuple[str, str, str, str, dict[str, Any] | None]] = []
192
186
  self._binding_restore_tasks: set[Any] = set()
193
- self._mandatory_channel_lock: asyncio.Lock = asyncio.Lock()
194
187
 
195
188
  # Backpressure callbacks (FlowController registers here). Each is a
196
189
  # zero-arg callable; aio-pika's blocked/unblocked frames are adapted.
@@ -510,15 +503,7 @@ class AsyncTransportImpl:
510
503
  pass
511
504
  self._fast_publish_channel = None
512
505
 
513
- # Close the dedicated mandatory-publish channel (H1)
514
- if self._mandatory_publish_channel is not None and not self._mandatory_publish_channel.is_closed:
515
- try:
516
- await self._mandatory_publish_channel.close()
517
- except Exception: # pragma: no cover — best effort close, network errors only
518
- pass
519
- self._mandatory_publish_channel = None
520
-
521
- await self._conn_pool.close_all()
506
+ await self._conn_pool.close_all() # includes the mandatory-publish channels
522
507
  except Exception as e:
523
508
  logger.warning("Error during disconnect: %s", e)
524
509
  finally:
@@ -632,36 +617,6 @@ class AsyncTransportImpl:
632
617
  self._fire_channel_rebuilt()
633
618
  return self._fast_publish_channel
634
619
 
635
- async def _get_mandatory_channel(self) -> Any:
636
- """Return the dedicated always-confirmed channel for mandatory=True
637
- publishes, (re)opening if needed.
638
-
639
- H1: ``on_return_raises=True`` makes an unroutable ``Basic.Return``
640
- raise ``aio_pika.exceptions.PublishError`` (caught by
641
- :meth:`_publish_on_channel` and mapped to ``PublishStatus.RETURNED``)
642
- instead of silently resolving the confirmation with the returned
643
- message — indistinguishable from success otherwise. This channel is
644
- used for every ``mandatory=True`` publish regardless of the broker's
645
- ``confirm_delivery`` setting, since reliable return detection needs
646
- confirms + on_return_raises unconditionally.
647
- """
648
- ch = self._mandatory_publish_channel
649
- if ch is not None and not ch.is_closed:
650
- return ch
651
- async with self._mandatory_channel_lock:
652
- ch = self._mandatory_publish_channel
653
- if ch is not None and not ch.is_closed: # pragma: no cover — concurrent path
654
- return ch
655
- conn = self._conn_pool._publisher_connection
656
- if conn is None:
657
- raise RuntimeError("Publisher connection is not available")
658
- is_rebuild = ch is not None # replacing a closed/recycled channel
659
- self._mandatory_publish_channel = await conn.channel(publisher_confirms=True, on_return_raises=True)
660
- self._fire_channel_opened()
661
- if is_rebuild:
662
- self._fire_channel_rebuilt()
663
- return self._mandatory_publish_channel
664
-
665
620
  def _build_aio_message(self, envelope: MessageEnvelope) -> Any:
666
621
  """Build an aio_pika.Message from a MessageEnvelope.
667
622
 
@@ -697,8 +652,8 @@ class AsyncTransportImpl:
697
652
 
698
653
  H1: a ``mandatory=True`` publish that the broker cannot route raises
699
654
  ``aio_pika.exceptions.PublishError`` when the channel has
700
- ``on_return_raises=True`` (only true for channels obtained via
701
- :meth:`_get_mandatory_channel` — regular pool/fast channels default to
655
+ ``on_return_raises=True`` (only true for the connection pool's
656
+ mandatory channels — regular pool/fast channels default to
702
657
  ``on_return_raises=False`` and would otherwise resolve the confirmation
703
658
  with the returned message instead of raising, indistinguishable from
704
659
  success). Mapped to ``PublishStatus.RETURNED`` so callers keying off
@@ -817,10 +772,13 @@ class AsyncTransportImpl:
817
772
  "PRECONDITION_FAILED - fast reply consumer does not exist".
818
773
 
819
774
  H1: a ``mandatory=True`` envelope (that isn't a direct reply-to
820
- request) always publishes via the dedicated always-confirmed channel
821
- from :meth:`_get_mandatory_channel`, regardless of ``confirm_delivery``
822
- — see that method's docstring for why neither the fast nor the regular
823
- pool channel can reliably report an unroutable ``Basic.Return``.
775
+ request) always publishes on a mandatory channel of its own
776
+ (``AsyncConnectionPool.acquire_mandatory_channel``): confirmed and
777
+ ``on_return_raises=True`` regardless of ``confirm_delivery``, since
778
+ neither the fast nor the regular pool channel reliably reports an
779
+ unroutable ``Basic.Return``. One publish per channel: a publish the
780
+ broker refuses closes its channel and fails everything in flight on
781
+ it, so a shared channel let one refused publish fail its siblings.
824
782
 
825
783
  The confirmed (pooled-channel) path is handled by
826
784
  :meth:`_publish_confirmed`, OUTSIDE this method's own try/except
@@ -835,36 +793,7 @@ class AsyncTransportImpl:
835
793
  if not channel.is_closed:
836
794
  return await self._publish_on_channel(channel, envelope)
837
795
 
838
- if envelope.mandatory:
839
- channel = await self._get_mandatory_channel()
840
- # M3 (architect review): this single persistent channel is
841
- # shared by ALL concurrent mandatory publishes. Closing it the
842
- # instant OUR publish times out cascades channel-closed errors
843
- # into every sibling still awaiting its own confirm (spurious
844
- # NACKED/ERROR → caller retries → duplicates). Ref-count
845
- # in-flight publishes and recycle the channel only when the
846
- # last one resolves; _get_mandatory_channel() lazily reopens.
847
- self._mandatory_in_flight += 1
848
- try:
849
- outcome = await self._publish_on_channel(channel, envelope)
850
- finally:
851
- self._mandatory_in_flight -= 1
852
- if outcome.status == PublishStatus.TIMEOUT:
853
- self._mandatory_channel_recycle = True
854
- current = self._mandatory_publish_channel
855
- if (
856
- self._mandatory_channel_recycle
857
- and self._mandatory_in_flight == 0
858
- and current is not None
859
- and channel is current
860
- and not current.is_closed
861
- ):
862
- self._mandatory_channel_recycle = False
863
- with contextlib.suppress(Exception):
864
- await current.close()
865
- return outcome
866
-
867
- if not self._confirm_delivery:
796
+ if not envelope.mandatory and not self._confirm_delivery:
868
797
  # Fast path: persistent channel, no confirm wait, no pool overhead
869
798
  message = self._build_aio_message(envelope)
870
799
  channel = await self._get_fast_channel()
@@ -917,17 +846,23 @@ class AsyncTransportImpl:
917
846
  which discards it since it's closed). No wait/recovery, or a
918
847
  second failure, returns ERROR — never raises.
919
848
  """
849
+ pool = self._conn_pool
850
+ acquire, release = (
851
+ (pool.acquire_mandatory_channel, pool.release_mandatory_channel)
852
+ if envelope.mandatory
853
+ else (pool.acquire_publisher_channel, pool.release_publisher_channel)
854
+ )
920
855
  last_exc: Exception | None = None
921
856
  for attempt in range(2):
922
857
  channel = None
923
858
  try:
924
- channel = await self._conn_pool.acquire_publisher_channel()
859
+ channel = await acquire()
925
860
  outcome = await self._publish_on_channel(channel, envelope)
926
861
  except Exception as e:
927
862
  last_exc = e
928
863
  if channel is not None:
929
864
  with contextlib.suppress(Exception):
930
- await self._conn_pool.release_publisher_channel(channel)
865
+ await release(channel)
931
866
  if attempt == 0 and _is_connection_error(e) and await self._wait_for_recovery():
932
867
  logger.warning(
933
868
  "Publish hit a connection error, retrying once after recovery: exchange=%s routing_key=%s",
@@ -952,7 +887,7 @@ class AsyncTransportImpl:
952
887
  with contextlib.suppress(Exception):
953
888
  await channel.close()
954
889
  with contextlib.suppress(Exception):
955
- await self._conn_pool.release_publisher_channel(channel)
890
+ await release(channel)
956
891
  return outcome
957
892
 
958
893
  return PublishOutcome( # pragma: no cover — defensive; the loop always returns above
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rabbitkit
3
- Version: 0.19.0
3
+ Version: 0.19.1
4
4
  Summary: Production-grade RabbitMQ toolkit — sync/async, decorator routing, retry, compression, full configurability
5
5
  Author-email: Talaat Magdy <talaatmagdy75@gmail.com>
6
6
  License-Expression: MIT
File without changes
File without changes
File without changes