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,448 @@
|
|
|
1
|
+
"""Batch publish and multi-ack — high-throughput helpers.
|
|
2
|
+
|
|
3
|
+
``BatchPublisher`` buffers outgoing envelopes and flushes them as a batch,
|
|
4
|
+
optionally confirming delivery after flush.
|
|
5
|
+
|
|
6
|
+
``BatchAcker`` accumulates delivery tags and issues a single
|
|
7
|
+
``ack(max_tag, multiple=True)`` when the batch fills or is flushed.
|
|
8
|
+
|
|
9
|
+
Both are channel-scoped — never cross channels.
|
|
10
|
+
|
|
11
|
+
NOTE (I-7): do NOT mix the sync and async APIs on a single instance. The sync
|
|
12
|
+
``add``/``flush``/``close`` use a ``threading.Lock`` and a ``threading.Timer``;
|
|
13
|
+
the async ``add_async``/``flush_async``/``close_async`` use an ``asyncio.Lock``
|
|
14
|
+
and an ``asyncio.Task``. Each API path cancels the *other* path's timer/task on
|
|
15
|
+
close so a stray leftover does not fire after shutdown, but the buffer is not
|
|
16
|
+
safe to mutate concurrently from both worlds at once. Pick one mode per
|
|
17
|
+
instance.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
import logging
|
|
24
|
+
import threading
|
|
25
|
+
from collections.abc import Callable
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from rabbitkit.core.config import BatchAckConfig, BatchPublishConfig
|
|
29
|
+
from rabbitkit.core.types import MessageEnvelope
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ── BatchPublisher ───────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class BatchPublisher:
|
|
38
|
+
"""Buffer outgoing envelopes and flush as a batch.
|
|
39
|
+
|
|
40
|
+
When ``flush_interval_ms > 0`` (default 50 ms), a background timer
|
|
41
|
+
fires periodically to flush any buffered envelopes even if
|
|
42
|
+
``batch_size`` has not been reached. The timer starts lazily on the
|
|
43
|
+
first call to ``add()`` and is cancelled by ``close()`` / ``close_async()``.
|
|
44
|
+
|
|
45
|
+
``max_in_flight`` is reserved for future async-confirm support and has
|
|
46
|
+
no runtime effect in the current synchronous-confirm model.
|
|
47
|
+
|
|
48
|
+
NOTE (throughput): this is a *buffering/timing* helper, not wire-level
|
|
49
|
+
batching. ``flush`` publishes each buffered envelope via ``publish_fn``, so
|
|
50
|
+
if ``publish_fn`` awaits a confirm per message the confirms do not pipeline —
|
|
51
|
+
you get ergonomics, not extra throughput. For high-volume confirmed
|
|
52
|
+
publishing, pipeline confirms yourself (publish many, then await) or use the
|
|
53
|
+
transactional outbox; for safety-critical events, always use the outbox.
|
|
54
|
+
|
|
55
|
+
Usage::
|
|
56
|
+
|
|
57
|
+
bp = BatchPublisher(
|
|
58
|
+
config=BatchPublishConfig(batch_size=50, flush_interval_ms=100),
|
|
59
|
+
publish_fn=transport.publish,
|
|
60
|
+
confirm_fn=transport.wait_for_confirms, # optional
|
|
61
|
+
)
|
|
62
|
+
bp.add(envelope1)
|
|
63
|
+
bp.add(envelope2)
|
|
64
|
+
...
|
|
65
|
+
bp.flush() # publishes all buffered
|
|
66
|
+
bp.close() # flush remaining + cancel timer
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
publish_fn: Callable[[MessageEnvelope], Any],
|
|
72
|
+
config: BatchPublishConfig | None = None,
|
|
73
|
+
confirm_fn: Callable[[], Any] | None = None,
|
|
74
|
+
) -> None:
|
|
75
|
+
self._config = config or BatchPublishConfig()
|
|
76
|
+
self._publish_fn = publish_fn
|
|
77
|
+
self._confirm_fn = confirm_fn
|
|
78
|
+
self._buffer: list[MessageEnvelope] = []
|
|
79
|
+
self._lock = threading.Lock()
|
|
80
|
+
self._timer: threading.Timer | None = None
|
|
81
|
+
self._closed = False
|
|
82
|
+
self._flush_task: asyncio.Task[None] | None = None
|
|
83
|
+
# I-7: an asyncio.Lock guards _buffer/_flush_task mutations in the async
|
|
84
|
+
# path so the background interval loop and add_async/flush_async/close_async
|
|
85
|
+
# cannot race and lose messages. Lazily created inside the event loop.
|
|
86
|
+
self._async_lock: asyncio.Lock | None = None
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def pending(self) -> int:
|
|
90
|
+
"""Number of envelopes buffered but not yet flushed."""
|
|
91
|
+
return len(self._buffer)
|
|
92
|
+
|
|
93
|
+
def _ensure_async_lock(self) -> asyncio.Lock:
|
|
94
|
+
if self._async_lock is None:
|
|
95
|
+
self._async_lock = asyncio.Lock()
|
|
96
|
+
return self._async_lock
|
|
97
|
+
|
|
98
|
+
# ── Timer helpers ────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
def _schedule_timer(self) -> None:
|
|
101
|
+
# MH-1: defensive — only arm a new timer when none is already running
|
|
102
|
+
# so a concurrent add()/timer-callback cannot double-schedule an orphan
|
|
103
|
+
# timer that fires forever (leaking daemon threads).
|
|
104
|
+
if self._timer is None and self._config.flush_interval_ms > 0 and not self._closed:
|
|
105
|
+
interval = self._config.flush_interval_ms / 1000.0
|
|
106
|
+
self._timer = threading.Timer(interval, self._timer_callback)
|
|
107
|
+
self._timer.daemon = True
|
|
108
|
+
self._timer.start()
|
|
109
|
+
|
|
110
|
+
def _timer_callback(self) -> None:
|
|
111
|
+
# I-7: clear the timer slot under the lock so concurrent flush()/add()
|
|
112
|
+
# see a consistent None and cannot double-schedule.
|
|
113
|
+
with self._lock:
|
|
114
|
+
self._timer = None
|
|
115
|
+
self.flush()
|
|
116
|
+
# MH-1: reschedule UNDER the lock with a None-guard. A concurrent add()
|
|
117
|
+
# that armed a timer during flush() (outside the lock) leaves
|
|
118
|
+
# self._timer set, so we skip rescheduling here instead of starting an
|
|
119
|
+
# orphan timer that fires forever.
|
|
120
|
+
with self._lock:
|
|
121
|
+
if self._timer is None and self._config.flush_interval_ms > 0 and not self._closed:
|
|
122
|
+
self._schedule_timer()
|
|
123
|
+
|
|
124
|
+
# ── Sync API ─────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
def add(self, envelope: MessageEnvelope) -> None:
|
|
127
|
+
"""Add an envelope to the batch buffer.
|
|
128
|
+
|
|
129
|
+
Auto-flushes when ``batch_size`` is reached. Starts the interval
|
|
130
|
+
timer on the first call when ``flush_interval_ms > 0``.
|
|
131
|
+
"""
|
|
132
|
+
with self._lock:
|
|
133
|
+
self._buffer.append(envelope)
|
|
134
|
+
should_flush = len(self._buffer) >= self._config.batch_size
|
|
135
|
+
if self._timer is None and self._config.flush_interval_ms > 0 and not self._closed:
|
|
136
|
+
self._schedule_timer()
|
|
137
|
+
if should_flush:
|
|
138
|
+
self.flush()
|
|
139
|
+
|
|
140
|
+
def flush(self) -> int:
|
|
141
|
+
"""Publish all buffered envelopes.
|
|
142
|
+
|
|
143
|
+
Returns the number of envelopes published.
|
|
144
|
+
"""
|
|
145
|
+
with self._lock:
|
|
146
|
+
if not self._buffer:
|
|
147
|
+
return 0
|
|
148
|
+
count = len(self._buffer)
|
|
149
|
+
batch = list(self._buffer)
|
|
150
|
+
self._buffer.clear()
|
|
151
|
+
|
|
152
|
+
for envelope in batch:
|
|
153
|
+
self._publish_fn(envelope)
|
|
154
|
+
|
|
155
|
+
if self._confirm_fn is not None:
|
|
156
|
+
self._confirm_fn()
|
|
157
|
+
|
|
158
|
+
logger.debug("Batch-published %d envelopes", count)
|
|
159
|
+
return count
|
|
160
|
+
|
|
161
|
+
def close(self) -> int:
|
|
162
|
+
"""Flush remaining envelopes, cancel the interval timer, and clean up.
|
|
163
|
+
|
|
164
|
+
Returns the number of envelopes flushed.
|
|
165
|
+
"""
|
|
166
|
+
self._closed = True
|
|
167
|
+
if self._timer is not None:
|
|
168
|
+
self._timer.cancel()
|
|
169
|
+
self._timer = None
|
|
170
|
+
# I-7: also cancel a stray async interval task so closing via the sync
|
|
171
|
+
# API does not leave the async loop running.
|
|
172
|
+
if self._flush_task is not None:
|
|
173
|
+
self._flush_task.cancel()
|
|
174
|
+
self._flush_task = None
|
|
175
|
+
return self.flush()
|
|
176
|
+
|
|
177
|
+
# ── Async API ────────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
async def _interval_loop_async(self) -> None:
|
|
180
|
+
interval = self._config.flush_interval_ms / 1000.0
|
|
181
|
+
try:
|
|
182
|
+
while True:
|
|
183
|
+
await asyncio.sleep(interval)
|
|
184
|
+
await self.flush_async()
|
|
185
|
+
except asyncio.CancelledError:
|
|
186
|
+
pass
|
|
187
|
+
|
|
188
|
+
async def add_async(self, envelope: MessageEnvelope) -> None:
|
|
189
|
+
"""Async: add envelope; auto-flush at batch_size.
|
|
190
|
+
|
|
191
|
+
Starts the async interval loop on the first call when
|
|
192
|
+
``flush_interval_ms > 0``.
|
|
193
|
+
"""
|
|
194
|
+
# I-7: guard _buffer / _flush_task mutations with the async lock so a
|
|
195
|
+
# concurrent interval-loop flush_async cannot interleave with this add
|
|
196
|
+
# and lose/duplicate buffered envelopes.
|
|
197
|
+
should_flush = False
|
|
198
|
+
async with self._ensure_async_lock():
|
|
199
|
+
self._buffer.append(envelope)
|
|
200
|
+
if len(self._buffer) >= self._config.batch_size:
|
|
201
|
+
should_flush = True
|
|
202
|
+
elif self._config.flush_interval_ms > 0 and self._flush_task is None:
|
|
203
|
+
self._flush_task = asyncio.create_task(self._interval_loop_async())
|
|
204
|
+
if should_flush:
|
|
205
|
+
await self.flush_async()
|
|
206
|
+
|
|
207
|
+
async def flush_async(self) -> int:
|
|
208
|
+
"""Async: publish all buffered envelopes."""
|
|
209
|
+
# I-7: take the batch snapshot under the async lock so concurrent
|
|
210
|
+
# flush_async / add_async calls cannot both grab the same buffer.
|
|
211
|
+
async with self._ensure_async_lock():
|
|
212
|
+
if not self._buffer:
|
|
213
|
+
return 0
|
|
214
|
+
count = len(self._buffer)
|
|
215
|
+
batch = list(self._buffer)
|
|
216
|
+
self._buffer.clear()
|
|
217
|
+
|
|
218
|
+
for envelope in batch:
|
|
219
|
+
result = self._publish_fn(envelope)
|
|
220
|
+
if hasattr(result, "__await__"):
|
|
221
|
+
await result
|
|
222
|
+
|
|
223
|
+
if self._confirm_fn is not None:
|
|
224
|
+
result = self._confirm_fn()
|
|
225
|
+
if hasattr(result, "__await__"):
|
|
226
|
+
await result
|
|
227
|
+
|
|
228
|
+
logger.debug("Async batch-published %d envelopes", count)
|
|
229
|
+
return count
|
|
230
|
+
|
|
231
|
+
async def close_async(self) -> int:
|
|
232
|
+
"""Async: cancel the interval loop, flush remaining, and clean up."""
|
|
233
|
+
self._closed = True
|
|
234
|
+
async with self._ensure_async_lock():
|
|
235
|
+
if self._flush_task is not None:
|
|
236
|
+
self._flush_task.cancel()
|
|
237
|
+
self._flush_task = None
|
|
238
|
+
# I-7: also cancel a stray sync timer so closing via the async API does
|
|
239
|
+
# not leave the sync timer firing after shutdown.
|
|
240
|
+
if self._timer is not None:
|
|
241
|
+
self._timer.cancel()
|
|
242
|
+
self._timer = None
|
|
243
|
+
return await self.flush_async()
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# ── BatchAcker ───────────────────────────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
class BatchAcker:
|
|
250
|
+
"""Accumulate delivery tags and ack in batches.
|
|
251
|
+
|
|
252
|
+
Uses ``multiple=True`` on the maximum delivery tag in the batch.
|
|
253
|
+
|
|
254
|
+
When ``flush_interval_ms > 0`` (default 200 ms), a background timer
|
|
255
|
+
fires periodically to ack any buffered tags even if ``batch_size`` has
|
|
256
|
+
not been reached. The timer starts lazily on the first call to
|
|
257
|
+
``add()`` and is cancelled by ``close()`` / ``close_async()``.
|
|
258
|
+
|
|
259
|
+
**Ownership rules:**
|
|
260
|
+
- Channel-scoped — NEVER cross channels
|
|
261
|
+
- Handlers MUST NOT call ``msg.ack()`` when BatchAcker is active
|
|
262
|
+
- Compatible with AUTO and NACK_ON_ERROR policies only
|
|
263
|
+
|
|
264
|
+
Usage (sync / pika) — ``ack_fn`` MUST NOT be a raw ``channel.basic_ack``.
|
|
265
|
+
The interval timer fires ``flush()`` from a background
|
|
266
|
+
``threading.Timer`` thread, not pika's connection I/O thread; pika
|
|
267
|
+
channel methods are not thread-safe, so a direct ``channel.basic_ack``
|
|
268
|
+
reference here is a real cross-thread violation waiting to happen the
|
|
269
|
+
first time the timer (rather than ``add()``) triggers the flush.
|
|
270
|
+
Marshal onto the I/O thread instead, e.g. via
|
|
271
|
+
``connection.add_callback_threadsafe``::
|
|
272
|
+
|
|
273
|
+
def safe_ack(delivery_tag: int, multiple: bool = False) -> None:
|
|
274
|
+
connection.add_callback_threadsafe(
|
|
275
|
+
lambda: channel.basic_ack(delivery_tag=delivery_tag, multiple=multiple)
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
ba = BatchAcker(
|
|
279
|
+
config=BatchAckConfig(batch_size=50, flush_interval_ms=200),
|
|
280
|
+
ack_fn=safe_ack,
|
|
281
|
+
)
|
|
282
|
+
ba.add(delivery_tag=1)
|
|
283
|
+
ba.add(delivery_tag=2)
|
|
284
|
+
...
|
|
285
|
+
ba.flush() # ack(max_tag, multiple=True)
|
|
286
|
+
ba.close() # flush remaining + cancel timer
|
|
287
|
+
|
|
288
|
+
The async path (``add_async``/``flush_async``) has no such hazard —
|
|
289
|
+
``_interval_loop_async`` schedules via ``asyncio.create_task`` on the
|
|
290
|
+
same event loop the aio-pika channel already runs on, so an aio-pika
|
|
291
|
+
``channel.basic_ack`` (a coroutine function) can be passed directly as
|
|
292
|
+
``ack_fn``.
|
|
293
|
+
"""
|
|
294
|
+
|
|
295
|
+
def __init__(
|
|
296
|
+
self,
|
|
297
|
+
ack_fn: Callable[..., Any],
|
|
298
|
+
config: BatchAckConfig | None = None,
|
|
299
|
+
) -> None:
|
|
300
|
+
self._config = config or BatchAckConfig()
|
|
301
|
+
self._ack_fn = ack_fn
|
|
302
|
+
self._tags: list[int] = []
|
|
303
|
+
self._max_tag: int = 0 # O(1) max tracking (perf)
|
|
304
|
+
self._lock = threading.Lock()
|
|
305
|
+
self._timer: threading.Timer | None = None
|
|
306
|
+
self._closed = False
|
|
307
|
+
self._flush_task: asyncio.Task[None] | None = None
|
|
308
|
+
# I-7: asyncio.Lock guarding _tags/_flush_task in the async path.
|
|
309
|
+
# Lazily created inside the event loop.
|
|
310
|
+
self._async_lock: asyncio.Lock | None = None
|
|
311
|
+
|
|
312
|
+
@property
|
|
313
|
+
def pending(self) -> int:
|
|
314
|
+
"""Number of delivery tags buffered."""
|
|
315
|
+
return len(self._tags)
|
|
316
|
+
|
|
317
|
+
def _ensure_async_lock(self) -> asyncio.Lock:
|
|
318
|
+
if self._async_lock is None:
|
|
319
|
+
self._async_lock = asyncio.Lock()
|
|
320
|
+
return self._async_lock
|
|
321
|
+
|
|
322
|
+
# ── Timer helpers ────────────────────────────────────────────────────
|
|
323
|
+
|
|
324
|
+
def _schedule_timer(self) -> None:
|
|
325
|
+
# MH-1: defensive — only arm a new timer when none is already running
|
|
326
|
+
# so a concurrent add()/timer-callback cannot double-schedule an orphan
|
|
327
|
+
# timer that fires forever (leaking daemon threads).
|
|
328
|
+
if self._timer is None and self._config.flush_interval_ms > 0 and not self._closed:
|
|
329
|
+
interval = self._config.flush_interval_ms / 1000.0
|
|
330
|
+
self._timer = threading.Timer(interval, self._timer_callback)
|
|
331
|
+
self._timer.daemon = True
|
|
332
|
+
self._timer.start()
|
|
333
|
+
|
|
334
|
+
def _timer_callback(self) -> None:
|
|
335
|
+
# I-7: clear the timer slot under the lock for consistency.
|
|
336
|
+
with self._lock:
|
|
337
|
+
self._timer = None
|
|
338
|
+
self.flush()
|
|
339
|
+
# MH-1: reschedule UNDER the lock with a None-guard so a concurrent
|
|
340
|
+
# add() that armed a timer during flush() doesn't leave us starting an
|
|
341
|
+
# orphan timer that fires forever.
|
|
342
|
+
with self._lock:
|
|
343
|
+
if self._timer is None and self._config.flush_interval_ms > 0 and not self._closed:
|
|
344
|
+
self._schedule_timer()
|
|
345
|
+
|
|
346
|
+
# ── Sync API ─────────────────────────────────────────────────────────
|
|
347
|
+
|
|
348
|
+
def add(self, delivery_tag: int) -> None:
|
|
349
|
+
"""Add a delivery tag to the batch.
|
|
350
|
+
|
|
351
|
+
Auto-flushes when ``batch_size`` is reached. Starts the interval
|
|
352
|
+
timer on the first call when ``flush_interval_ms > 0``.
|
|
353
|
+
"""
|
|
354
|
+
with self._lock:
|
|
355
|
+
self._tags.append(delivery_tag)
|
|
356
|
+
if delivery_tag > self._max_tag:
|
|
357
|
+
self._max_tag = delivery_tag
|
|
358
|
+
should_flush = len(self._tags) >= self._config.batch_size
|
|
359
|
+
if self._timer is None and self._config.flush_interval_ms > 0 and not self._closed:
|
|
360
|
+
self._schedule_timer()
|
|
361
|
+
if should_flush:
|
|
362
|
+
self.flush()
|
|
363
|
+
|
|
364
|
+
def flush(self) -> int:
|
|
365
|
+
"""Ack all buffered tags using the max tag with multiple=True.
|
|
366
|
+
|
|
367
|
+
Returns the number of tags acked.
|
|
368
|
+
"""
|
|
369
|
+
with self._lock:
|
|
370
|
+
if not self._tags:
|
|
371
|
+
return 0
|
|
372
|
+
count = len(self._tags)
|
|
373
|
+
max_tag = max(self._tags)
|
|
374
|
+
self._tags.clear()
|
|
375
|
+
|
|
376
|
+
self._ack_fn(max_tag, multiple=True)
|
|
377
|
+
logger.debug("Batch-acked %d messages (max_tag=%d)", count, max_tag)
|
|
378
|
+
return count
|
|
379
|
+
|
|
380
|
+
def close(self) -> int:
|
|
381
|
+
"""Flush remaining tags, cancel the interval timer, and clean up."""
|
|
382
|
+
self._closed = True
|
|
383
|
+
if self._timer is not None:
|
|
384
|
+
self._timer.cancel()
|
|
385
|
+
self._timer = None
|
|
386
|
+
# I-7: also cancel a stray async interval task.
|
|
387
|
+
if self._flush_task is not None:
|
|
388
|
+
self._flush_task.cancel()
|
|
389
|
+
self._flush_task = None
|
|
390
|
+
return self.flush()
|
|
391
|
+
|
|
392
|
+
# ── Async API ────────────────────────────────────────────────────────
|
|
393
|
+
|
|
394
|
+
async def _interval_loop_async(self) -> None:
|
|
395
|
+
interval = self._config.flush_interval_ms / 1000.0
|
|
396
|
+
try:
|
|
397
|
+
while True:
|
|
398
|
+
await asyncio.sleep(interval)
|
|
399
|
+
await self.flush_async()
|
|
400
|
+
except asyncio.CancelledError:
|
|
401
|
+
pass
|
|
402
|
+
|
|
403
|
+
async def add_async(self, delivery_tag: int) -> None:
|
|
404
|
+
"""Async: add tag; auto-flush at batch_size.
|
|
405
|
+
|
|
406
|
+
Starts the async interval loop on the first call when
|
|
407
|
+
``flush_interval_ms > 0``.
|
|
408
|
+
"""
|
|
409
|
+
# I-7: guard _tags / _flush_task mutations with the async lock.
|
|
410
|
+
should_flush = False
|
|
411
|
+
async with self._ensure_async_lock():
|
|
412
|
+
self._tags.append(delivery_tag)
|
|
413
|
+
if len(self._tags) >= self._config.batch_size:
|
|
414
|
+
should_flush = True
|
|
415
|
+
elif self._config.flush_interval_ms > 0 and self._flush_task is None:
|
|
416
|
+
self._flush_task = asyncio.create_task(self._interval_loop_async())
|
|
417
|
+
if should_flush:
|
|
418
|
+
await self.flush_async()
|
|
419
|
+
|
|
420
|
+
async def flush_async(self) -> int:
|
|
421
|
+
"""Async: ack all buffered tags."""
|
|
422
|
+
# I-7: take the batch snapshot under the async lock.
|
|
423
|
+
async with self._ensure_async_lock():
|
|
424
|
+
if not self._tags:
|
|
425
|
+
return 0
|
|
426
|
+
count = len(self._tags)
|
|
427
|
+
max_tag = max(self._tags)
|
|
428
|
+
self._tags.clear()
|
|
429
|
+
|
|
430
|
+
result = self._ack_fn(max_tag, multiple=True)
|
|
431
|
+
if hasattr(result, "__await__"):
|
|
432
|
+
await result
|
|
433
|
+
|
|
434
|
+
logger.debug("Async batch-acked %d messages (max_tag=%d)", count, max_tag)
|
|
435
|
+
return count
|
|
436
|
+
|
|
437
|
+
async def close_async(self) -> int:
|
|
438
|
+
"""Async: cancel the interval loop, flush remaining, and clean up."""
|
|
439
|
+
self._closed = True
|
|
440
|
+
async with self._ensure_async_lock():
|
|
441
|
+
if self._flush_task is not None:
|
|
442
|
+
self._flush_task.cancel()
|
|
443
|
+
self._flush_task = None
|
|
444
|
+
# I-7: also cancel a stray sync timer.
|
|
445
|
+
if self._timer is not None:
|
|
446
|
+
self._timer.cancel()
|
|
447
|
+
self._timer = None
|
|
448
|
+
return await self.flush_async()
|