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
rabbitkit/sync/batch.py
ADDED
|
@@ -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
|