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 CHANGED
@@ -10,4 +10,4 @@ __all__ = [
10
10
  "manager",
11
11
  ]
12
12
 
13
- __version__ = "0.1.6"
13
+ __version__ = "0.1.7"
@@ -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
- # Exception classes that signal the underlying channel is no longer usable.
28
- # We catch these on ack/nack and surface a typed DeadChannelError so the
29
- # consumer mixin can short-circuit cleanly instead of cascading further
30
- # failures (nack on the same dead channel).
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
- await channel.close()
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
- # Ensure the consume channel exists with the requested prefetch QoS.
369
- # The return value is unused here because declare_exchange/declare_queue
370
- # re-fetch the channel from the cache.
371
- await self._get_consume_channel(prefetch_count=config.prefetch_count)
372
-
373
- queues: list[tuple[str, AbstractQueue]] = []
374
- for subscription in config.resolved_subscriptions():
375
- queue = await self.declare_queue(
376
- subscription.queue_name,
377
- durable=config.durable,
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 key in binding.routing_keys:
387
- await self.bind_queue(queue, exchange, key)
388
- queues.append((subscription.queue_name, queue))
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 _DEAD_CHANNEL_EXCEPTIONS as exc:
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
- """Merge deliveries from multiple queue iterators into one batch."""
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
- async with AsyncExitStack() as stack:
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
- queue.iterator(no_ack=auto_ack),
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 stack.enter_async_context(iterator_context)
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
- while pending and len(buffer) < batch_size:
457
- done, _ = await asyncio.wait(pending, return_when=asyncio.FIRST_COMPLETED)
458
- for task in done:
459
- queue_name, iterator = pending.pop(task)
460
- try:
461
- message = task.result()
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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ergon-framework-python
3
- Version: 0.1.6
3
+ Version: 0.1.7
4
4
  Summary: Ergon internal task-oriented project bootstrapper
5
5
  Author-email: Ergondata Technologies <anza.vossos@protonmail.com>
6
6
  License-Expression: MIT
@@ -1,4 +1,4 @@
1
- ergon/__init__.py,sha256=dFNLM2h6C9VhsbgBVLleuYF0uggVlq1nnrxk1KSKB3k,218
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=By3lb7FnixpPmBPzni2az4gSefNYFcCgZyMS-scrcS4,30814
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.6.dist-info/licenses/LICENSE,sha256=bWCIhHzaadz_ecLdCdtwyMafeQ17s2d7_FSSuSuEcDs,1079
95
- ergon_framework_python-0.1.6.dist-info/METADATA,sha256=0ylSq7-H-BKek2GmW3dj74svbykQyIlAXJuXf9esyao,15677
96
- ergon_framework_python-0.1.6.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
97
- ergon_framework_python-0.1.6.dist-info/entry_points.txt,sha256=reZ934txb5OrtcNBgTGqCsXa1biUDQWubD2a9fGZynE,42
98
- ergon_framework_python-0.1.6.dist-info/top_level.txt,sha256=KSPt6N7RzsjU7PRAalWQ9Cul7YTUxbo84yAJ-y2vLm8,6
99
- ergon_framework_python-0.1.6.dist-info/RECORD,,
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,,