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.
Files changed (95) hide show
  1. rabbitkit/__init__.py +201 -0
  2. rabbitkit/_version.py +3 -0
  3. rabbitkit/aio/__init__.py +31 -0
  4. rabbitkit/async_/__init__.py +9 -0
  5. rabbitkit/async_/batch.py +213 -0
  6. rabbitkit/async_/broker.py +1123 -0
  7. rabbitkit/async_/connection.py +274 -0
  8. rabbitkit/async_/pool.py +363 -0
  9. rabbitkit/async_/transport.py +877 -0
  10. rabbitkit/asyncapi/__init__.py +5 -0
  11. rabbitkit/asyncapi/generator.py +219 -0
  12. rabbitkit/asyncapi/schema.py +98 -0
  13. rabbitkit/cli/__init__.py +77 -0
  14. rabbitkit/cli/_utils.py +38 -0
  15. rabbitkit/cli/commands/__init__.py +0 -0
  16. rabbitkit/cli/commands/dlq.py +190 -0
  17. rabbitkit/cli/commands/health.py +34 -0
  18. rabbitkit/cli/commands/migrate.py +570 -0
  19. rabbitkit/cli/commands/routes.py +88 -0
  20. rabbitkit/cli/commands/run.py +144 -0
  21. rabbitkit/cli/commands/shell.py +72 -0
  22. rabbitkit/cli/commands/topology.py +346 -0
  23. rabbitkit/concurrency.py +451 -0
  24. rabbitkit/core/__init__.py +5 -0
  25. rabbitkit/core/app.py +323 -0
  26. rabbitkit/core/config.py +849 -0
  27. rabbitkit/core/env_config.py +251 -0
  28. rabbitkit/core/errors.py +199 -0
  29. rabbitkit/core/logging.py +261 -0
  30. rabbitkit/core/message.py +235 -0
  31. rabbitkit/core/path.py +53 -0
  32. rabbitkit/core/pipeline.py +1289 -0
  33. rabbitkit/core/protocols.py +349 -0
  34. rabbitkit/core/registry.py +284 -0
  35. rabbitkit/core/route.py +329 -0
  36. rabbitkit/core/router.py +142 -0
  37. rabbitkit/core/topology.py +261 -0
  38. rabbitkit/core/topology_dispatch.py +74 -0
  39. rabbitkit/core/types.py +324 -0
  40. rabbitkit/dashboard/__init__.py +5 -0
  41. rabbitkit/dashboard/app.py +212 -0
  42. rabbitkit/di/__init__.py +19 -0
  43. rabbitkit/di/context.py +193 -0
  44. rabbitkit/di/depends.py +42 -0
  45. rabbitkit/di/resolver.py +503 -0
  46. rabbitkit/dlq.py +320 -0
  47. rabbitkit/experimental/__init__.py +50 -0
  48. rabbitkit/fastapi.py +91 -0
  49. rabbitkit/health.py +654 -0
  50. rabbitkit/highload/__init__.py +10 -0
  51. rabbitkit/highload/backpressure.py +514 -0
  52. rabbitkit/highload/batch.py +448 -0
  53. rabbitkit/locking.py +277 -0
  54. rabbitkit/management.py +470 -0
  55. rabbitkit/middleware/__init__.py +27 -0
  56. rabbitkit/middleware/base.py +125 -0
  57. rabbitkit/middleware/circuit_breaker.py +131 -0
  58. rabbitkit/middleware/compression.py +267 -0
  59. rabbitkit/middleware/deduplication.py +651 -0
  60. rabbitkit/middleware/error_classifier.py +43 -0
  61. rabbitkit/middleware/exception.py +105 -0
  62. rabbitkit/middleware/metrics.py +440 -0
  63. rabbitkit/middleware/otel.py +203 -0
  64. rabbitkit/middleware/rate_limit.py +247 -0
  65. rabbitkit/middleware/retry.py +540 -0
  66. rabbitkit/middleware/signing.py +682 -0
  67. rabbitkit/middleware/timeout.py +291 -0
  68. rabbitkit/py.typed +0 -0
  69. rabbitkit/queue_metrics.py +174 -0
  70. rabbitkit/results/__init__.py +6 -0
  71. rabbitkit/results/backend.py +102 -0
  72. rabbitkit/results/middleware.py +123 -0
  73. rabbitkit/rpc.py +632 -0
  74. rabbitkit/serialization/__init__.py +25 -0
  75. rabbitkit/serialization/base.py +35 -0
  76. rabbitkit/serialization/json.py +122 -0
  77. rabbitkit/serialization/msgspec.py +136 -0
  78. rabbitkit/serialization/pipeline.py +255 -0
  79. rabbitkit/streams.py +139 -0
  80. rabbitkit/sync/__init__.py +11 -0
  81. rabbitkit/sync/batch.py +595 -0
  82. rabbitkit/sync/broker.py +996 -0
  83. rabbitkit/sync/connection.py +209 -0
  84. rabbitkit/sync/pool.py +262 -0
  85. rabbitkit/sync/transport.py +1085 -0
  86. rabbitkit/testing/__init__.py +20 -0
  87. rabbitkit/testing/app.py +99 -0
  88. rabbitkit/testing/broker.py +540 -0
  89. rabbitkit/testing/fixtures.py +56 -0
  90. rabbitkit-0.9.0.dist-info/METADATA +575 -0
  91. rabbitkit-0.9.0.dist-info/RECORD +95 -0
  92. rabbitkit-0.9.0.dist-info/WHEEL +5 -0
  93. rabbitkit-0.9.0.dist-info/entry_points.txt +2 -0
  94. rabbitkit-0.9.0.dist-info/licenses/LICENSE +21 -0
  95. rabbitkit-0.9.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,595 @@
1
+ """SyncBatchPublisher — pipelined publisher confirms for the sync transport.
2
+
3
+ pika's ``BlockingChannel.basic_publish`` blocks per-confirm, ceiling-ing
4
+ confirmed sync publish at roughly 0.9k msg/s. pika's callback-based
5
+ ``SelectConnection`` CAN pipeline confirms: publish N messages back-to-back
6
+ on one channel and settle each caller as its ``Basic.Ack``/``Basic.Nack``
7
+ arrives, amortizing the confirm round-trip across the whole window.
8
+
9
+ STANDALONE-ONLY: this publisher is constructed and owned by the user — it is
10
+ NOT wired into ``SyncBroker`` (the broker's publish path keeps its
11
+ ``BlockingConnection`` semantics untouched). Use it directly::
12
+
13
+ from rabbitkit import SyncBatchPublisher
14
+ from rabbitkit.core.config import ConnectionConfig
15
+ from rabbitkit.core.types import MessageEnvelope
16
+
17
+ with SyncBatchPublisher(ConnectionConfig.from_url(url)) as pub:
18
+ outcome = pub.publish(MessageEnvelope(routing_key="q", body=b"{}"))
19
+ assert outcome.ok
20
+
21
+ Invariants (hard-won — do not weaken):
22
+
23
+ 1. ONE THREAD OWNS ONE PIKA CONNECTION. The ``SelectConnection`` lives
24
+ entirely on the dedicated daemon thread ``rabbitkit-sync-batch-io``;
25
+ caller threads never touch it directly — they enqueue work and wake the
26
+ ioloop via ``ioloop.add_callback_threadsafe`` (the one documented
27
+ thread-safe entry point).
28
+ 2. EVERY CALLER'S PENDING OUTCOME IS ALWAYS SETTLED (M17) — on ack, nack,
29
+ return, timeout, publish error, connection death, and shutdown. Never
30
+ silently dropped.
31
+ 3. CALLERS NEVER HANG: each ``publish()`` waits at most ``confirm_timeout``
32
+ (or its per-call override) and then gets a ``PublishStatus.TIMEOUT``
33
+ outcome; the slot is marked abandoned so a late confirm is a no-op.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import logging
39
+ import random
40
+ import threading
41
+ import time
42
+ from collections import deque
43
+ from typing import Any
44
+
45
+ from rabbitkit.core.config import ConnectionConfig, SecurityConfig, SocketConfig
46
+ from rabbitkit.core.types import MessageEnvelope, PublishOutcome, PublishStatus
47
+ from rabbitkit.sync.connection import make_pika_connection_params
48
+
49
+ logger = logging.getLogger(__name__)
50
+
51
+ #: Thread name of the dedicated SelectConnection I/O thread.
52
+ IO_THREAD_NAME = "rabbitkit-sync-batch-io"
53
+
54
+
55
+ class _Slot:
56
+ """One caller's pending publish: envelope + settlement rendezvous.
57
+
58
+ ``outcome`` transitions exactly once (first settlement wins) under the
59
+ publisher's lock; ``event`` is set only after ``outcome`` is assigned.
60
+ ``abandoned`` marks a slot whose caller already timed out, so a late
61
+ broker confirm becomes a no-op instead of settling into the void.
62
+ """
63
+
64
+ __slots__ = ("abandoned", "envelope", "event", "outcome")
65
+
66
+ def __init__(self, envelope: MessageEnvelope) -> None:
67
+ self.envelope = envelope
68
+ self.event = threading.Event()
69
+ self.outcome: PublishOutcome | None = None
70
+ self.abandoned = False
71
+
72
+
73
+ class SyncBatchPublisher:
74
+ """Pipelined-confirm publisher on a dedicated ``pika.SelectConnection``.
75
+
76
+ Thread-safe: any number of caller threads may ``publish()`` concurrently.
77
+ Each call blocks only for ITS OWN confirm (bounded by *confirm_timeout*),
78
+ while the I/O thread keeps the channel's confirm window full — confirms
79
+ for many in-flight messages are serviced concurrently instead of one
80
+ blocking round-trip per message.
81
+
82
+ Standalone-only (see module docstring): not wired into ``SyncBroker``.
83
+ """
84
+
85
+ def __init__(
86
+ self,
87
+ connection_config: ConnectionConfig | None = None,
88
+ socket_config: SocketConfig | None = None,
89
+ security_config: SecurityConfig | None = None,
90
+ confirm_timeout: float = 5.0,
91
+ ) -> None:
92
+ self._connection_config = connection_config or ConnectionConfig()
93
+ self._socket_config = socket_config or SocketConfig()
94
+ self._security_config = security_config or SecurityConfig()
95
+ self._confirm_timeout = float(confirm_timeout)
96
+
97
+ # RLock: settlement helpers are called both standalone and from
98
+ # within larger locked sections (fail-all, return matching).
99
+ self._lock = threading.RLock()
100
+ self._queue: deque[tuple[MessageEnvelope, _Slot]] = deque()
101
+ self._pending: dict[int, _Slot] = {} # delivery_tag → slot (publish order)
102
+ self._next_tag = 0 # mirrors pika's per-channel delivery-tag counter
103
+
104
+ self._connection: Any = None # pika.SelectConnection (I/O thread's)
105
+ self._channel: Any = None
106
+ self._pika: Any = None # the imported pika module (set on the I/O thread)
107
+
108
+ self._ready = threading.Event() # connected + channel in confirm mode
109
+ self._closing = threading.Event()
110
+ self._io_dead = threading.Event() # I/O thread has exited
111
+ self._closed = False
112
+ self._thread: threading.Thread | None = None
113
+ self._start_error: BaseException | None = None
114
+ self._connected_once = False # current SelectConnection reached confirm mode
115
+
116
+ # Bounded reconnect (same spirit as SyncTransport._ensure_connected:
117
+ # bounded attempts, full jitter, exponential backoff).
118
+ self.max_reconnect_attempts: int = 5
119
+
120
+ # ── lifecycle ─────────────────────────────────────────────────────────
121
+
122
+ def start(self, ready_timeout: float = 30.0) -> None:
123
+ """Spawn the I/O thread and block until the confirm channel is ready.
124
+
125
+ Raises ``TimeoutError`` after *ready_timeout* if the broker never
126
+ becomes reachable, or ``RuntimeError`` if the I/O thread gave up
127
+ (connect attempts exhausted). Idempotent while running.
128
+ """
129
+ with self._lock:
130
+ if self._closed:
131
+ raise RuntimeError("SyncBatchPublisher is closed")
132
+ if self._thread is not None:
133
+ return # already started
134
+ self._thread = threading.Thread(
135
+ target=self._io_loop, name=IO_THREAD_NAME, daemon=True
136
+ )
137
+ self._thread.start()
138
+
139
+ deadline = time.monotonic() + ready_timeout
140
+ while not self._ready.wait(timeout=0.02):
141
+ if self._io_dead.is_set():
142
+ err = self._start_error
143
+ self.close(timeout=1.0)
144
+ raise RuntimeError(
145
+ "SyncBatchPublisher failed to connect (attempts exhausted)"
146
+ ) from err
147
+ if time.monotonic() >= deadline:
148
+ self.close(timeout=1.0)
149
+ raise TimeoutError(
150
+ f"SyncBatchPublisher not ready within {ready_timeout}s"
151
+ )
152
+
153
+ def close(self, timeout: float = 10.0) -> None:
154
+ """Stop accepting publishes, drain briefly, fail stragglers, shut down.
155
+
156
+ Waits (bounded by *timeout*) for in-flight confirms to settle, then
157
+ fails any stragglers with ``PublishStatus.ERROR`` (M17: never silent),
158
+ stops the ioloop and joins the I/O thread. Idempotent.
159
+ """
160
+ with self._lock:
161
+ if self._closed:
162
+ return
163
+ self._closing.set()
164
+
165
+ deadline = time.monotonic() + timeout
166
+ # Bounded wait for unsettled confirms (skip if nothing is in flight).
167
+ while time.monotonic() < deadline:
168
+ with self._lock:
169
+ unsettled = any(s.outcome is None for s in self._pending.values()) or any(
170
+ s.outcome is None for _, s in self._queue
171
+ )
172
+ if not unsettled or self._io_dead.is_set():
173
+ break
174
+ time.sleep(0.005)
175
+
176
+ # Fail stragglers — every remaining slot gets a terminal outcome.
177
+ self._fail_all(RuntimeError("SyncBatchPublisher closed"))
178
+
179
+ connection = self._connection
180
+ if connection is not None:
181
+ try:
182
+ connection.ioloop.add_callback_threadsafe(self._shutdown_io)
183
+ except Exception:
184
+ # ioloop already stopped / connection already dead — the I/O
185
+ # thread exits via its _closing check.
186
+ logger.debug("close(): ioloop wake-up failed (already stopped)")
187
+
188
+ thread = self._thread
189
+ if thread is not None and thread is not threading.current_thread():
190
+ thread.join(timeout=max(0.0, deadline - time.monotonic()) + 1.0)
191
+
192
+ with self._lock:
193
+ self._closed = True
194
+ self._ready.clear()
195
+
196
+ def __enter__(self) -> SyncBatchPublisher:
197
+ self.start()
198
+ return self
199
+
200
+ def __exit__(self, *args: Any) -> None:
201
+ self.close()
202
+
203
+ # ── publish (caller threads) ──────────────────────────────────────────
204
+
205
+ def publish(
206
+ self, envelope: MessageEnvelope, timeout: float | None = None
207
+ ) -> PublishOutcome:
208
+ """Publish *envelope* and block until ITS confirm settles.
209
+
210
+ Returns CONFIRMED / NACKED / RETURNED per the broker's verdict,
211
+ TIMEOUT if no verdict arrived within *timeout* (default
212
+ ``confirm_timeout``), or ERROR if the publisher is closed,
213
+ disconnected, or the connection died with this message in flight.
214
+ Never raises for transport-level failures; never hangs.
215
+ """
216
+ wait = self._confirm_timeout if timeout is None else float(timeout)
217
+
218
+ with self._lock:
219
+ if self._closed or self._closing.is_set() or not self._ready.is_set():
220
+ return PublishOutcome(
221
+ status=PublishStatus.ERROR,
222
+ exchange=envelope.exchange,
223
+ routing_key=envelope.routing_key,
224
+ error=RuntimeError("SyncBatchPublisher is not running/connected"),
225
+ )
226
+ slot = _Slot(envelope)
227
+ self._queue.append((envelope, slot))
228
+ connection = self._connection
229
+
230
+ try:
231
+ # The ONLY thread-safe way to poke the SelectConnection: the
232
+ # drain itself runs on the I/O thread (invariant 1).
233
+ connection.ioloop.add_callback_threadsafe(self._drain)
234
+ except Exception as exc:
235
+ # Connection died between the ready-check and the wake-up — the
236
+ # close callback's fail-all may already have settled the slot;
237
+ # _settle is first-wins either way (invariant 2).
238
+ with self._lock:
239
+ try:
240
+ self._queue.remove((envelope, slot))
241
+ except ValueError:
242
+ pass # already drained/failed elsewhere
243
+ self._settle(slot, PublishStatus.ERROR, error=exc)
244
+
245
+ if not slot.event.wait(timeout=wait):
246
+ with self._lock:
247
+ if slot.outcome is None:
248
+ # Invariant 3: caller never hangs. Mark abandoned so the
249
+ # late confirm (if it ever arrives) is a no-op.
250
+ slot.abandoned = True
251
+ slot.outcome = PublishOutcome(
252
+ status=PublishStatus.TIMEOUT,
253
+ exchange=envelope.exchange,
254
+ routing_key=envelope.routing_key,
255
+ error=TimeoutError(
256
+ f"No publisher confirm within {wait}s"
257
+ ),
258
+ )
259
+ slot.event.set()
260
+
261
+ outcome = slot.outcome
262
+ if outcome is None: # pragma: no cover — event is only set after outcome
263
+ outcome = PublishOutcome(
264
+ status=PublishStatus.ERROR,
265
+ exchange=envelope.exchange,
266
+ routing_key=envelope.routing_key,
267
+ error=RuntimeError("publish slot settled without an outcome"),
268
+ )
269
+ return outcome
270
+
271
+ # ── settlement (any thread, lock-guarded) ─────────────────────────────
272
+
273
+ def _settle(
274
+ self,
275
+ slot: _Slot,
276
+ status: PublishStatus,
277
+ *,
278
+ error: BaseException | None = None,
279
+ delivery_tag: int | None = None,
280
+ ) -> None:
281
+ """Settle *slot* exactly once (first settlement wins)."""
282
+ with self._lock:
283
+ if slot.outcome is not None or slot.abandoned:
284
+ return # already settled, or caller timed out (late confirm no-op)
285
+ slot.outcome = PublishOutcome(
286
+ status=status,
287
+ delivery_tag=delivery_tag,
288
+ exchange=slot.envelope.exchange,
289
+ routing_key=slot.envelope.routing_key,
290
+ error=error,
291
+ )
292
+ slot.event.set()
293
+
294
+ def _fail_all(self, error: BaseException) -> None:
295
+ """Fail EVERY unsettled slot — in flight and still queued (M17)."""
296
+ with self._lock:
297
+ pending = list(self._pending.items())
298
+ self._pending.clear()
299
+ queued = [slot for _, slot in self._queue]
300
+ self._queue.clear()
301
+ for tag, slot in pending:
302
+ self._settle(slot, PublishStatus.ERROR, error=error, delivery_tag=tag)
303
+ for slot in queued:
304
+ self._settle(slot, PublishStatus.ERROR, error=error)
305
+
306
+ # ── I/O thread ────────────────────────────────────────────────────────
307
+
308
+ def _io_loop(self) -> None:
309
+ """Thread body: run the SelectConnection ioloop, reconnect bounded."""
310
+ try:
311
+ try:
312
+ import pika
313
+ except ImportError as exc:
314
+ self._start_error = ImportError(
315
+ "pika is required for SyncBatchPublisher. "
316
+ "Install it with: pip install rabbitkit[sync]"
317
+ )
318
+ self._start_error.__cause__ = exc
319
+ return
320
+
321
+ self._pika = pika
322
+ attempts = 0
323
+ backoff = self._connection_config.reconnect_backoff_base
324
+
325
+ while not self._closing.is_set():
326
+ self._connected_once = False
327
+ try:
328
+ params = make_pika_connection_params(
329
+ self._connection_config,
330
+ self._socket_config,
331
+ self._security_config,
332
+ )
333
+ connection = pika.SelectConnection(
334
+ parameters=params,
335
+ on_open_callback=self._on_connection_open,
336
+ on_open_error_callback=self._on_connection_open_error,
337
+ on_close_callback=self._on_connection_closed,
338
+ )
339
+ self._connection = connection
340
+ connection.ioloop.start() # returns after ioloop.stop()
341
+ except Exception as exc:
342
+ logger.warning("SyncBatchPublisher connection attempt failed: %s", exc)
343
+ self._start_error = exc
344
+
345
+ if self._closing.is_set():
346
+ break
347
+ if self._connected_once:
348
+ # This connection reached confirm mode — reset the budget.
349
+ attempts = 0
350
+ backoff = self._connection_config.reconnect_backoff_base
351
+ attempts += 1
352
+ if attempts > self.max_reconnect_attempts:
353
+ logger.critical(
354
+ "SyncBatchPublisher reconnect attempts exhausted after %d tries; giving up",
355
+ attempts - 1,
356
+ )
357
+ break
358
+ # Full jitter (H-SRE3 spirit): spread reconnects across clients.
359
+ sleep_for = random.uniform(0.0, backoff) # noqa: S311
360
+ logger.warning(
361
+ "SyncBatchPublisher reconnecting in %.2fs (attempt %d/%d)",
362
+ sleep_for,
363
+ attempts,
364
+ self.max_reconnect_attempts,
365
+ )
366
+ self._closing.wait(timeout=sleep_for) # interruptible by close()
367
+ backoff = min(backoff * 2, self._connection_config.reconnect_backoff_max)
368
+ finally:
369
+ # Thread exiting for ANY reason: nothing will ever settle these
370
+ # slots again — fail them now (invariant 2).
371
+ self._ready.clear()
372
+ self._fail_all(
373
+ self._start_error
374
+ if self._start_error is not None and not self._connected_once
375
+ else RuntimeError("SyncBatchPublisher I/O thread exited")
376
+ )
377
+ self._io_dead.set()
378
+
379
+ def _shutdown_io(self) -> None:
380
+ """Graceful shutdown, on the I/O thread (scheduled by close())."""
381
+ try:
382
+ if self._channel is not None and self._channel.is_open:
383
+ self._channel.close()
384
+ if self._connection is not None and self._connection.is_open:
385
+ # Triggers _on_connection_closed → ioloop.stop().
386
+ self._connection.close()
387
+ else:
388
+ self._connection.ioloop.stop()
389
+ except Exception: # pragma: no cover — best effort during shutdown
390
+ try:
391
+ self._connection.ioloop.stop()
392
+ except Exception:
393
+ logger.debug("shutdown: ioloop.stop() failed", exc_info=True)
394
+
395
+ # ── pika callbacks (I/O thread) ───────────────────────────────────────
396
+
397
+ def _on_connection_open(self, connection: Any) -> None:
398
+ connection.channel(on_open_callback=self._on_channel_open)
399
+
400
+ def _on_connection_open_error(self, connection: Any, error: Any) -> None:
401
+ logger.warning("SyncBatchPublisher failed to open connection: %s", error)
402
+ self._start_error = (
403
+ error if isinstance(error, BaseException) else RuntimeError(str(error))
404
+ )
405
+ connection.ioloop.stop()
406
+
407
+ def _on_connection_closed(self, connection: Any, reason: Any) -> None:
408
+ self._ready.clear()
409
+ self._channel = None
410
+ if not self._closing.is_set():
411
+ logger.warning("SyncBatchPublisher connection closed unexpectedly: %s", reason)
412
+ # M17: every unsettled outcome (in flight AND queued) gets ERROR —
413
+ # the confirms for these delivery tags will never arrive.
414
+ err = reason if isinstance(reason, BaseException) else RuntimeError(str(reason))
415
+ self._fail_all(err)
416
+ connection.ioloop.stop() # _io_loop decides whether to reconnect
417
+
418
+ def _on_channel_open(self, channel: Any) -> None:
419
+ self._channel = channel
420
+ channel.add_on_close_callback(self._on_channel_closed)
421
+ channel.add_on_return_callback(self._on_return)
422
+ # ack_nack_callback: Basic.Ack / Basic.Nack frames (pipelined
423
+ # confirms); callback: Confirm.SelectOk — confirm mode is active.
424
+ channel.confirm_delivery(
425
+ ack_nack_callback=self._on_delivery_confirmation,
426
+ callback=self._on_confirm_select_ok,
427
+ )
428
+
429
+ def _on_confirm_select_ok(self, _frame: Any) -> None:
430
+ with self._lock:
431
+ self._next_tag = 0 # delivery tags are per-channel
432
+ self._connected_once = True
433
+ self._ready.set()
434
+ logger.info("SyncBatchPublisher ready (confirm mode active)")
435
+ self._drain() # anything enqueued in the ready/connected race window
436
+
437
+ def _on_channel_closed(self, channel: Any, reason: Any) -> None:
438
+ """Channel died out from under us (connection may still be open)."""
439
+ if self._closing.is_set() or channel is not self._channel:
440
+ return # shutdown path / stale channel — handled elsewhere
441
+ logger.warning("SyncBatchPublisher channel closed unexpectedly: %s", reason)
442
+ self._ready.clear()
443
+ self._channel = None
444
+ err = reason if isinstance(reason, BaseException) else RuntimeError(str(reason))
445
+ self._fail_all(err)
446
+ # Simplest correct recovery: recycle the whole connection (tag
447
+ # counters and confirm mode are per-channel; a fresh connection via
448
+ # the reconnect loop re-establishes both).
449
+ try:
450
+ if self._connection is not None and self._connection.is_open:
451
+ self._connection.close()
452
+ except Exception: # pragma: no cover — best effort
453
+ logger.debug("channel-closed: connection.close() failed", exc_info=True)
454
+
455
+ def _on_delivery_confirmation(self, method_frame: Any) -> None:
456
+ """Basic.Ack / Basic.Nack — settle one tag, or all ≤ tag if multiple."""
457
+ method = method_frame.method
458
+ tag = int(method.delivery_tag)
459
+ multiple = bool(getattr(method, "multiple", False))
460
+ acked = isinstance(method, self._pika.spec.Basic.Ack)
461
+ status = PublishStatus.CONFIRMED if acked else PublishStatus.NACKED
462
+ error = (
463
+ None
464
+ if acked
465
+ else RuntimeError(f"Broker nacked delivery_tag={tag} (multiple={multiple})")
466
+ )
467
+
468
+ with self._lock:
469
+ if multiple:
470
+ tags = sorted(t for t in self._pending if t <= tag)
471
+ else:
472
+ tags = [tag] if tag in self._pending else []
473
+ settled = [(t, self._pending.pop(t)) for t in tags]
474
+
475
+ if not settled:
476
+ # Late confirm for an abandoned-and-reaped tag, or unknown tag.
477
+ logger.debug("Confirm for unknown delivery_tag=%s (late/reaped) — ignored", tag)
478
+ return
479
+ for t, slot in settled:
480
+ self._settle(slot, status, error=error, delivery_tag=t)
481
+
482
+ def _on_return(self, _channel: Any, method: Any, properties: Any, _body: bytes) -> None:
483
+ """Basic.Return — unroutable mandatory publish bounced by the broker.
484
+
485
+ pika delivers the Return BEFORE the corresponding Basic.Ack, so we
486
+ settle the slot RETURNED here and first-settlement-wins makes the
487
+ following Ack a no-op. Matched to the MOST RECENT unsettled publish
488
+ by (exchange, routing_key) and, when the broker echoed one, message_id.
489
+ """
490
+ msg_id = getattr(properties, "message_id", None)
491
+ with self._lock:
492
+ candidate: _Slot | None = None
493
+ candidate_tag: int | None = None
494
+ for t, slot in self._pending.items(): # insertion order = publish order
495
+ if slot.outcome is not None or slot.abandoned:
496
+ continue
497
+ env = slot.envelope
498
+ if env.exchange != method.exchange or env.routing_key != method.routing_key:
499
+ continue
500
+ if msg_id is not None and env.message_id != msg_id:
501
+ continue
502
+ candidate, candidate_tag = slot, t # keep last match = most recent
503
+ if candidate is None:
504
+ logger.warning(
505
+ "Basic.Return with no matching unsettled publish "
506
+ "(exchange=%r routing_key=%r message_id=%r) — ignored",
507
+ method.exchange,
508
+ method.routing_key,
509
+ msg_id,
510
+ )
511
+ return
512
+ # Leave the tag in _pending: the broker still sends the Ack for a
513
+ # returned message; _on_delivery_confirmation pops it (and its
514
+ # settle attempt no-ops — first settlement wins).
515
+ self._settle(
516
+ candidate,
517
+ PublishStatus.RETURNED,
518
+ error=RuntimeError(
519
+ f"Unroutable: reply_code={getattr(method, 'reply_code', None)} "
520
+ f"reply_text={getattr(method, 'reply_text', None)}"
521
+ ),
522
+ delivery_tag=candidate_tag,
523
+ )
524
+
525
+ # ── publishing (I/O thread) ───────────────────────────────────────────
526
+
527
+ def _drain(self) -> None:
528
+ """Drain the caller queue onto the channel (runs on the I/O thread)."""
529
+ while True:
530
+ with self._lock:
531
+ if not self._queue:
532
+ return
533
+ envelope, slot = self._queue.popleft()
534
+ channel = self._channel
535
+
536
+ if channel is None or not self._ready.is_set() or not channel.is_open:
537
+ self._settle(
538
+ slot,
539
+ PublishStatus.ERROR,
540
+ error=RuntimeError("SyncBatchPublisher is not connected"),
541
+ )
542
+ continue
543
+
544
+ try:
545
+ properties = self._build_properties(envelope)
546
+ except Exception as exc:
547
+ self._settle(slot, PublishStatus.ERROR, error=exc)
548
+ continue
549
+
550
+ with self._lock:
551
+ self._next_tag += 1
552
+ tag = self._next_tag
553
+ self._pending[tag] = slot
554
+
555
+ try:
556
+ channel.basic_publish(
557
+ exchange=envelope.exchange,
558
+ routing_key=envelope.routing_key,
559
+ body=envelope.body,
560
+ properties=properties,
561
+ mandatory=envelope.mandatory,
562
+ )
563
+ except Exception as exc:
564
+ with self._lock:
565
+ self._pending.pop(tag, None)
566
+ self._settle(slot, PublishStatus.ERROR, error=exc)
567
+ # Our tag counter may now disagree with pika's — never keep
568
+ # publishing on a desynced channel. Recycle it (the channel
569
+ # close callback fails any siblings and triggers reconnect).
570
+ try:
571
+ if channel.is_open:
572
+ channel.close()
573
+ except Exception: # pragma: no cover — best effort
574
+ logger.debug("drain: channel.close() failed", exc_info=True)
575
+ return
576
+
577
+ def _build_properties(self, envelope: MessageEnvelope) -> Any:
578
+ """Build pika.BasicProperties exactly like SyncTransport._publish_on_channel."""
579
+ properties = self._pika.BasicProperties(
580
+ message_id=envelope.message_id,
581
+ correlation_id=envelope.correlation_id,
582
+ reply_to=envelope.reply_to,
583
+ content_type=envelope.content_type,
584
+ content_encoding=envelope.content_encoding,
585
+ headers=envelope.headers or None,
586
+ delivery_mode=envelope.delivery_mode,
587
+ priority=envelope.priority,
588
+ expiration=envelope.expiration,
589
+ type=envelope.type,
590
+ user_id=envelope.user_id,
591
+ app_id=envelope.app_id,
592
+ )
593
+ if envelope.timestamp:
594
+ properties.timestamp = int(envelope.timestamp.timestamp())
595
+ return properties