ergon-framework-python 0.1.6__py3-none-any.whl → 0.1.7__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.
- ergon/__init__.py +1 -1
- ergon/connector/rabbitmq/async_service.py +134 -58
- {ergon_framework_python-0.1.6.dist-info → ergon_framework_python-0.1.7.dist-info}/METADATA +1 -1
- {ergon_framework_python-0.1.6.dist-info → ergon_framework_python-0.1.7.dist-info}/RECORD +8 -8
- {ergon_framework_python-0.1.6.dist-info → ergon_framework_python-0.1.7.dist-info}/WHEEL +0 -0
- {ergon_framework_python-0.1.6.dist-info → ergon_framework_python-0.1.7.dist-info}/entry_points.txt +0 -0
- {ergon_framework_python-0.1.6.dist-info → ergon_framework_python-0.1.7.dist-info}/licenses/LICENSE +0 -0
- {ergon_framework_python-0.1.6.dist-info → ergon_framework_python-0.1.7.dist-info}/top_level.txt +0 -0
ergon/__init__.py
CHANGED
|
@@ -4,7 +4,6 @@ import logging
|
|
|
4
4
|
import ssl as ssl_module
|
|
5
5
|
import time
|
|
6
6
|
from collections import deque
|
|
7
|
-
from contextlib import AsyncExitStack
|
|
8
7
|
from typing import Any, AsyncContextManager, Callable, Dict, List, Optional, cast
|
|
9
8
|
|
|
10
9
|
import aio_pika
|
|
@@ -24,15 +23,36 @@ from .models import AsyncRabbitmqClient, AsyncRabbitmqConsumerConfig, AsyncRabbi
|
|
|
24
23
|
logger = logging.getLogger(__name__)
|
|
25
24
|
|
|
26
25
|
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
|
|
26
|
+
# Invalid channel state always means the active transport disappeared. Broader
|
|
27
|
+
# AMQP connection hierarchies also contain permanent authentication/protocol
|
|
28
|
+
# errors, so consume-time recovery uses the classifier below instead.
|
|
29
|
+
_CHANNEL_INVALID_EXCEPTIONS: tuple[type[BaseException], ...] = (
|
|
30
|
+
aio_pika.exceptions.ChannelInvalidStateError,
|
|
31
|
+
aiormq.exceptions.ChannelInvalidStateError,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _is_recoverable_broker_interruption(exc: BaseException) -> bool:
|
|
36
|
+
if isinstance(exc, _CHANNEL_INVALID_EXCEPTIONS):
|
|
37
|
+
return True
|
|
38
|
+
if type(exc) is aiormq.exceptions.AMQPConnectionError:
|
|
39
|
+
return True
|
|
40
|
+
if type(exc) is aiormq.exceptions.ConnectionClosed:
|
|
41
|
+
reply_code = exc.args[0] if exc.args else None
|
|
42
|
+
return reply_code == 320
|
|
43
|
+
if type(exc) is aiormq.exceptions.ConnectionInternalError:
|
|
44
|
+
return True
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# Settlement can additionally discover a channel-level failure after delivery.
|
|
49
|
+
# Surface those as DeadChannelError so the consumer mixin does not attempt a
|
|
50
|
+
# second settlement on the same unusable channel.
|
|
31
51
|
_DEAD_CHANNEL_EXCEPTIONS: tuple[type[BaseException], ...] = (
|
|
52
|
+
aio_pika.exceptions.AMQPConnectionError,
|
|
53
|
+
*_CHANNEL_INVALID_EXCEPTIONS,
|
|
32
54
|
aio_pika.exceptions.MessageProcessError,
|
|
33
55
|
aio_pika.exceptions.ChannelClosed,
|
|
34
|
-
aio_pika.exceptions.ChannelInvalidStateError,
|
|
35
|
-
aiormq.exceptions.ChannelInvalidStateError,
|
|
36
56
|
)
|
|
37
57
|
|
|
38
58
|
# Same as above plus ``TimeoutError`` (raised by the async timeout context when an
|
|
@@ -193,7 +213,8 @@ class AsyncRabbitMQService:
|
|
|
193
213
|
return
|
|
194
214
|
try:
|
|
195
215
|
if not channel.is_closed:
|
|
196
|
-
|
|
216
|
+
async with timeout_after(min(1.0, self.client.channel_timeout)):
|
|
217
|
+
await channel.close()
|
|
197
218
|
logger.info("Closed dead consume channel (%s)", reason)
|
|
198
219
|
except Exception as exc: # noqa: BLE001 - best-effort teardown
|
|
199
220
|
logger.warning("Error closing consume channel during teardown (%s): %r", reason, exc)
|
|
@@ -365,27 +386,33 @@ class AsyncRabbitMQService:
|
|
|
365
386
|
self._consumer_state = "subscribing"
|
|
366
387
|
poll_completed = False
|
|
367
388
|
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
arguments=subscription.queue_arguments or None,
|
|
379
|
-
)
|
|
380
|
-
for binding in subscription.bindings:
|
|
381
|
-
exchange = await self.declare_exchange(
|
|
382
|
-
binding.exchange_name,
|
|
383
|
-
binding.exchange_type,
|
|
389
|
+
try:
|
|
390
|
+
# Ensure the consume channel exists with the requested prefetch QoS.
|
|
391
|
+
# The return value is unused here because declarations re-fetch the
|
|
392
|
+
# channel from the cache.
|
|
393
|
+
await self._get_consume_channel(prefetch_count=config.prefetch_count)
|
|
394
|
+
|
|
395
|
+
queues: list[tuple[str, AbstractQueue]] = []
|
|
396
|
+
for subscription in config.resolved_subscriptions():
|
|
397
|
+
queue = await self.declare_queue(
|
|
398
|
+
subscription.queue_name,
|
|
384
399
|
durable=config.durable,
|
|
400
|
+
arguments=subscription.queue_arguments or None,
|
|
385
401
|
)
|
|
386
|
-
for
|
|
387
|
-
await self.
|
|
388
|
-
|
|
402
|
+
for binding in subscription.bindings:
|
|
403
|
+
exchange = await self.declare_exchange(
|
|
404
|
+
binding.exchange_name,
|
|
405
|
+
binding.exchange_type,
|
|
406
|
+
durable=config.durable,
|
|
407
|
+
)
|
|
408
|
+
for key in binding.routing_keys:
|
|
409
|
+
await self.bind_queue(queue, exchange, key)
|
|
410
|
+
queues.append((subscription.queue_name, queue))
|
|
411
|
+
except Exception as exc:
|
|
412
|
+
if not _is_recoverable_broker_interruption(exc):
|
|
413
|
+
raise
|
|
414
|
+
await self._teardown_consume_channel(f"subscription setup interrupted: {exc!r}")
|
|
415
|
+
return []
|
|
389
416
|
|
|
390
417
|
buffer: List[Dict[str, Any]] = []
|
|
391
418
|
timeout = config.consume_timeout
|
|
@@ -404,7 +431,9 @@ class AsyncRabbitMQService:
|
|
|
404
431
|
)
|
|
405
432
|
except TimeoutError:
|
|
406
433
|
poll_completed = True
|
|
407
|
-
except
|
|
434
|
+
except Exception as exc:
|
|
435
|
+
if not _is_recoverable_broker_interruption(exc):
|
|
436
|
+
raise
|
|
408
437
|
# Broker cancelled this subscription mid-iteration; deterministically
|
|
409
438
|
# tear down (cancel consumers + close) the dead channel so the next
|
|
410
439
|
# call rebuilds against a fresh consumer, the broker redelivers what
|
|
@@ -435,15 +464,27 @@ class AsyncRabbitMQService:
|
|
|
435
464
|
batch_size: int,
|
|
436
465
|
auto_ack: bool,
|
|
437
466
|
) -> None:
|
|
438
|
-
"""
|
|
467
|
+
"""Wait for one delivery, then drain only messages already available.
|
|
468
|
+
|
|
469
|
+
``consume_timeout`` bounds the idle wait in :meth:`consume`; it must
|
|
470
|
+
not become a batch-assembly delay after the first delivery. Once any
|
|
471
|
+
message is buffered, give newly scheduled iterator reads one event-loop
|
|
472
|
+
turn to complete and return immediately if none are ready.
|
|
473
|
+
"""
|
|
439
474
|
pending: dict[asyncio.Task, tuple[str, Any]] = {}
|
|
440
|
-
|
|
475
|
+
iterator_contexts: list[AsyncContextManager[Any]] = []
|
|
476
|
+
try:
|
|
441
477
|
for queue_name, queue in queues:
|
|
442
478
|
iterator_context = cast(
|
|
443
479
|
AsyncContextManager[Any],
|
|
444
|
-
|
|
480
|
+
# The framework owns consumer teardown/resubscription.
|
|
481
|
+
# Letting aio-pika restore these short-lived per-fetch
|
|
482
|
+
# consumers can resurrect an iterator with no pending
|
|
483
|
+
# reader after a broker restart, creating a zombie tag.
|
|
484
|
+
queue.iterator(no_ack=auto_ack, robust=False),
|
|
445
485
|
)
|
|
446
|
-
iterator = await
|
|
486
|
+
iterator = await iterator_context.__aenter__()
|
|
487
|
+
iterator_contexts.append(iterator_context)
|
|
447
488
|
self._register_consumer_cancel_callback(iterator)
|
|
448
489
|
consumer_tag = getattr(iterator, "_consumer_tag", None) or getattr(iterator, "consumer_tag", None)
|
|
449
490
|
if consumer_tag:
|
|
@@ -451,33 +492,68 @@ class AsyncRabbitMQService:
|
|
|
451
492
|
pending[asyncio.create_task(anext(iterator))] = (queue_name, iterator)
|
|
452
493
|
|
|
453
494
|
self._active_consumer_tag = next(iter(self._active_consumer_tags.values()), None)
|
|
454
|
-
|
|
495
|
+
while pending and len(buffer) < batch_size:
|
|
496
|
+
if buffer:
|
|
497
|
+
done = {task for task in pending if task.done()}
|
|
498
|
+
if not done:
|
|
499
|
+
await asyncio.sleep(0)
|
|
500
|
+
done = {task for task in pending if task.done()}
|
|
501
|
+
if not done:
|
|
502
|
+
break
|
|
503
|
+
else:
|
|
504
|
+
done, _ = await asyncio.wait(
|
|
505
|
+
pending,
|
|
506
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
507
|
+
)
|
|
508
|
+
for task in done:
|
|
509
|
+
queue_name, iterator = pending.pop(task)
|
|
510
|
+
try:
|
|
511
|
+
message = task.result()
|
|
512
|
+
except StopAsyncIteration:
|
|
513
|
+
self._active_consumer_tags.pop(queue_name, None)
|
|
514
|
+
continue
|
|
515
|
+
|
|
516
|
+
message_dict = self._message_to_dict(message, queue_name=queue_name)
|
|
517
|
+
self._in_flight_message_ids.add(id(message))
|
|
518
|
+
self._last_fetch_ts = time.time()
|
|
519
|
+
self._consumer_state = "delivering"
|
|
520
|
+
if len(buffer) < batch_size:
|
|
521
|
+
buffer.append(message_dict)
|
|
522
|
+
else:
|
|
523
|
+
self._prefetched_messages.append(message_dict)
|
|
524
|
+
if len(buffer) < batch_size:
|
|
525
|
+
pending[asyncio.create_task(anext(iterator))] = (queue_name, iterator)
|
|
526
|
+
finally:
|
|
527
|
+
cleanup_timeout = min(1.0, self.client.channel_timeout)
|
|
528
|
+
cleanup_failed = False
|
|
529
|
+
for task in pending:
|
|
530
|
+
task.cancel()
|
|
531
|
+
if pending:
|
|
532
|
+
try:
|
|
533
|
+
async with timeout_after(cleanup_timeout):
|
|
534
|
+
await asyncio.gather(*pending, return_exceptions=True)
|
|
535
|
+
except TimeoutError:
|
|
536
|
+
cleanup_failed = True
|
|
537
|
+
logger.warning("Timed out cancelling RabbitMQ iterator reads")
|
|
538
|
+
cleanup_failed = await self._close_iterator_contexts(iterator_contexts) or cleanup_failed
|
|
539
|
+
if cleanup_failed:
|
|
540
|
+
await self._teardown_consume_channel("queue iterator cleanup failed")
|
|
541
|
+
|
|
542
|
+
async def _close_iterator_contexts(
|
|
543
|
+
self,
|
|
544
|
+
iterator_contexts: list[AsyncContextManager[Any]],
|
|
545
|
+
) -> bool:
|
|
546
|
+
"""Bound queue-iterator cleanup so a dead broker cannot stall a poll."""
|
|
547
|
+
cleanup_timeout = min(1.0, self.client.channel_timeout)
|
|
548
|
+
cleanup_failed = False
|
|
549
|
+
for iterator_context in reversed(iterator_contexts):
|
|
455
550
|
try:
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
except StopAsyncIteration:
|
|
463
|
-
self._active_consumer_tags.pop(queue_name, None)
|
|
464
|
-
continue
|
|
465
|
-
|
|
466
|
-
message_dict = self._message_to_dict(message, queue_name=queue_name)
|
|
467
|
-
self._in_flight_message_ids.add(id(message))
|
|
468
|
-
self._last_fetch_ts = time.time()
|
|
469
|
-
self._consumer_state = "delivering"
|
|
470
|
-
if len(buffer) < batch_size:
|
|
471
|
-
buffer.append(message_dict)
|
|
472
|
-
else:
|
|
473
|
-
self._prefetched_messages.append(message_dict)
|
|
474
|
-
if len(buffer) < batch_size:
|
|
475
|
-
pending[asyncio.create_task(anext(iterator))] = (queue_name, iterator)
|
|
476
|
-
finally:
|
|
477
|
-
for task in pending:
|
|
478
|
-
task.cancel()
|
|
479
|
-
if pending:
|
|
480
|
-
await asyncio.gather(*pending, return_exceptions=True)
|
|
551
|
+
async with timeout_after(cleanup_timeout):
|
|
552
|
+
await iterator_context.__aexit__(None, None, None)
|
|
553
|
+
except Exception as exc:
|
|
554
|
+
cleanup_failed = True
|
|
555
|
+
logger.warning("RabbitMQ queue iterator cleanup failed: %r", exc)
|
|
556
|
+
return cleanup_failed
|
|
481
557
|
|
|
482
558
|
def _register_consumer_cancel_callback(self, iterator: Any) -> None:
|
|
483
559
|
"""Best-effort registration of an on-cancel callback on the iterator's consumer.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
ergon/__init__.py,sha256=
|
|
1
|
+
ergon/__init__.py,sha256=CHVAlkPxdbdtbOV1yNyA5yEvPdy8cS9tKjNDgxlQyRA,218
|
|
2
2
|
ergon/cli.py,sha256=CF8PvOjIguR18a657aEBEyhFXUoSVtSRVXv5PfnuWtA,6021
|
|
3
3
|
ergon/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
4
|
ergon/bootstrap/src/__project__/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
@@ -58,7 +58,7 @@ ergon/connector/postgres/async_service.py,sha256=PdPnNElMKzLoov1as3Kcb_viuSyBaVE
|
|
|
58
58
|
ergon/connector/postgres/models.py,sha256=DL9wBdZ6QZPH1iVft71cTabnFUCH1Hg1RmG5f5E8578,1749
|
|
59
59
|
ergon/connector/rabbitmq/__init__.py,sha256=HOiQ2ZmrbgsW20g3HcHzT_PdZ0-M9oDj4Fwzg0N67Rw,817
|
|
60
60
|
ergon/connector/rabbitmq/async_connector.py,sha256=1ltA8wGcB91Sxq6xJcX0OeOTRd6co4cpq5drG-6gHAc,5175
|
|
61
|
-
ergon/connector/rabbitmq/async_service.py,sha256=
|
|
61
|
+
ergon/connector/rabbitmq/async_service.py,sha256=F4iIto5a7N_14_NimfeN1Qj6UjgoLBMHQrgsOxwCTes,34292
|
|
62
62
|
ergon/connector/rabbitmq/connector.py,sha256=vsSzbVuuE8rKkwBfPgD97qTsqADxJGALnUvrEwZIL5Q,1633
|
|
63
63
|
ergon/connector/rabbitmq/helper.py,sha256=xlQGx2SGR2DC1fi-5BMlCOvWydWEFkcN7fPJjlNvGLA,372
|
|
64
64
|
ergon/connector/rabbitmq/models.py,sha256=UxqAAHhArMOry0MLKBJEtAKBNdyHNZVAvqJkYhCv71Y,9654
|
|
@@ -91,9 +91,9 @@ ergon/telemetry/metrics.py,sha256=0T_CuNw9P5AG1U9k1-bNp1g7McyiNLUIt_zKyLOtrnA,28
|
|
|
91
91
|
ergon/telemetry/tracing.py,sha256=_ktiZWAgB1DU-uN91Mzr0iYNXxbCN21LnKiRo5SOJxY,4454
|
|
92
92
|
ergon/utils/__init__.py,sha256=M0_VCjMN7jvSPlRZLZHeFKTHAl1zu3BnAhjrzJjDfiI,57
|
|
93
93
|
ergon/utils/env.py,sha256=lji4xENvfHqg2GTGnzG95VKpxxM4f3DHIKKP61xQG08,621
|
|
94
|
-
ergon_framework_python-0.1.
|
|
95
|
-
ergon_framework_python-0.1.
|
|
96
|
-
ergon_framework_python-0.1.
|
|
97
|
-
ergon_framework_python-0.1.
|
|
98
|
-
ergon_framework_python-0.1.
|
|
99
|
-
ergon_framework_python-0.1.
|
|
94
|
+
ergon_framework_python-0.1.7.dist-info/licenses/LICENSE,sha256=bWCIhHzaadz_ecLdCdtwyMafeQ17s2d7_FSSuSuEcDs,1079
|
|
95
|
+
ergon_framework_python-0.1.7.dist-info/METADATA,sha256=QPW3jjA302RKd3D0pwg9kPj3WWSq0AkDZvP1yeOcAZg,15677
|
|
96
|
+
ergon_framework_python-0.1.7.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
97
|
+
ergon_framework_python-0.1.7.dist-info/entry_points.txt,sha256=reZ934txb5OrtcNBgTGqCsXa1biUDQWubD2a9fGZynE,42
|
|
98
|
+
ergon_framework_python-0.1.7.dist-info/top_level.txt,sha256=KSPt6N7RzsjU7PRAalWQ9Cul7YTUxbo84yAJ-y2vLm8,6
|
|
99
|
+
ergon_framework_python-0.1.7.dist-info/RECORD,,
|
|
File without changes
|
{ergon_framework_python-0.1.6.dist-info → ergon_framework_python-0.1.7.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{ergon_framework_python-0.1.6.dist-info → ergon_framework_python-0.1.7.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|
{ergon_framework_python-0.1.6.dist-info → ergon_framework_python-0.1.7.dist-info}/top_level.txt
RENAMED
|
File without changes
|