ergon-framework-python 0.1.6__tar.gz → 0.1.8__tar.gz

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 (105) hide show
  1. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/PKG-INFO +1 -1
  2. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/pyproject.toml +1 -1
  3. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/__init__.py +1 -1
  4. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/connector/rabbitmq/async_service.py +163 -65
  5. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/task/runner.py +39 -16
  6. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon_framework_python.egg-info/PKG-INFO +1 -1
  7. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/tests/test_smoke.py +1 -1
  8. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/LICENSE +0 -0
  9. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/README.md +0 -0
  10. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/setup.cfg +0 -0
  11. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/bootstrap/src/__project__/__init__.py +0 -0
  12. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/bootstrap/src/__project__/_observability/docker-compose.telemetry.yml +0 -0
  13. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/bootstrap/src/__project__/_observability/grafana.yaml +0 -0
  14. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/bootstrap/src/__project__/_observability/loki.yaml +0 -0
  15. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/bootstrap/src/__project__/_observability/otel-collector-config.yaml +0 -0
  16. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/bootstrap/src/__project__/_observability/prometheus.yaml +0 -0
  17. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/bootstrap/src/__project__/_observability/tempo.yaml +0 -0
  18. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/bootstrap/src/__project__/connectors/__init__.py +0 -0
  19. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/bootstrap/src/__project__/main.py +0 -0
  20. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/bootstrap/src/__project__/tasks/__init__.py +0 -0
  21. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/bootstrap/src/__project__/tasks/constants.py +0 -0
  22. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/bootstrap/src/__project__/tasks/example_task/__init__.py +0 -0
  23. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/bootstrap/src/__project__/tasks/example_task/config.py +0 -0
  24. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/bootstrap/src/__project__/tasks/example_task/exceptions.py +0 -0
  25. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/bootstrap/src/__project__/tasks/example_task/helpers.py +0 -0
  26. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/bootstrap/src/__project__/tasks/example_task/schemas.py +0 -0
  27. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/bootstrap/src/__project__/tasks/example_task/task.py +0 -0
  28. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/bootstrap/src/__project__/tasks/exceptions.py +0 -0
  29. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/bootstrap/src/__project__/tasks/helpers.py +0 -0
  30. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/bootstrap/src/__project__/tasks/schemas.py +0 -0
  31. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/bootstrap/src/__project__/tasks/settings.py +0 -0
  32. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/cli.py +0 -0
  33. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/connector/__init__.py +0 -0
  34. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/connector/connector.py +0 -0
  35. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/connector/ergon_platform/__init__.py +0 -0
  36. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/connector/ergon_platform/_client.py +0 -0
  37. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/connector/ergon_platform/_operations.py +0 -0
  38. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/connector/ergon_platform/async_connector.py +0 -0
  39. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/connector/ergon_platform/connector.py +0 -0
  40. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/connector/ergon_platform/models.py +0 -0
  41. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/connector/ergon_platform/utils.py +0 -0
  42. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/connector/excel/__init__.py +0 -0
  43. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/connector/excel/connector.py +0 -0
  44. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/connector/excel/models.py +0 -0
  45. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/connector/excel/service.py +0 -0
  46. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/connector/nylas/__init__.py +0 -0
  47. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/connector/nylas/async_auth_service.py +0 -0
  48. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/connector/nylas/async_connector.py +0 -0
  49. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/connector/nylas/async_service.py +0 -0
  50. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/connector/nylas/auth_service.py +0 -0
  51. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/connector/nylas/connector.py +0 -0
  52. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/connector/nylas/models.py +0 -0
  53. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/connector/nylas/service.py +0 -0
  54. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/connector/nylas/utils.py +0 -0
  55. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/connector/pipefy/__init__.py +0 -0
  56. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/connector/pipefy/async_connector.py +0 -0
  57. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/connector/pipefy/async_service.py +0 -0
  58. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/connector/pipefy/connector.py +0 -0
  59. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/connector/pipefy/models.py +0 -0
  60. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/connector/pipefy/service.py +0 -0
  61. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/connector/pipefy/version.py +0 -0
  62. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/connector/postgres/__init__.py +0 -0
  63. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/connector/postgres/async_connector.py +0 -0
  64. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/connector/postgres/async_service.py +0 -0
  65. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/connector/postgres/models.py +0 -0
  66. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/connector/rabbitmq/__init__.py +0 -0
  67. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/connector/rabbitmq/async_connector.py +0 -0
  68. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/connector/rabbitmq/connector.py +0 -0
  69. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/connector/rabbitmq/helper.py +0 -0
  70. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/connector/rabbitmq/models.py +0 -0
  71. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/connector/rabbitmq/service.py +0 -0
  72. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/connector/sqs/__init__.py +0 -0
  73. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/connector/sqs/async_connector.py +0 -0
  74. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/connector/sqs/async_service.py +0 -0
  75. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/connector/sqs/connector.py +0 -0
  76. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/connector/sqs/models.py +0 -0
  77. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/connector/sqs/service.py +0 -0
  78. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/connector/transaction.py +0 -0
  79. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/py.typed +0 -0
  80. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/service/__init__.py +0 -0
  81. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/service/service.py +0 -0
  82. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/task/__init__.py +0 -0
  83. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/task/base.py +0 -0
  84. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/task/exceptions.py +0 -0
  85. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/task/helpers.py +0 -0
  86. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/task/liveness.py +0 -0
  87. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/task/manager.py +0 -0
  88. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/task/mixins/__init__.py +0 -0
  89. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/task/mixins/consumer.py +0 -0
  90. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/task/mixins/metrics.py +0 -0
  91. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/task/mixins/producer.py +0 -0
  92. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/task/policies.py +0 -0
  93. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/task/utils.py +0 -0
  94. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/telemetry/__init__.py +0 -0
  95. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/telemetry/_resource.py +0 -0
  96. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/telemetry/logging.py +0 -0
  97. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/telemetry/metrics.py +0 -0
  98. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/telemetry/tracing.py +0 -0
  99. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/utils/__init__.py +0 -0
  100. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon/utils/env.py +0 -0
  101. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon_framework_python.egg-info/SOURCES.txt +0 -0
  102. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon_framework_python.egg-info/dependency_links.txt +0 -0
  103. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon_framework_python.egg-info/entry_points.txt +0 -0
  104. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon_framework_python.egg-info/requires.txt +0 -0
  105. {ergon_framework_python-0.1.6 → ergon_framework_python-0.1.8}/src/ergon_framework_python.egg-info/top_level.txt +0 -0
@@ -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.8
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,6 +1,6 @@
1
1
  [project]
2
2
  name = "ergon-framework-python"
3
- version = "0.1.6"
3
+ version = "0.1.8"
4
4
  description = "Ergon internal task-oriented project bootstrapper"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -10,4 +10,4 @@ __all__ = [
10
10
  "manager",
11
11
  ]
12
12
 
13
- __version__ = "0.1.6"
13
+ __version__ = "0.1.8"
@@ -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
@@ -135,11 +155,33 @@ class AsyncRabbitMQService:
135
155
 
136
156
  connection = self._connection
137
157
  self._connection = None
138
- if connection is not None and not connection.is_closed:
139
- try:
158
+ await self._close_connection_safely(connection, reason)
159
+
160
+ async def _close_connection_safely(
161
+ self,
162
+ connection: Optional[AbstractRobustConnection],
163
+ reason: str,
164
+ ) -> None:
165
+ """Stop transport and robust-reconnect ownership within a fixed deadline.
166
+
167
+ ``RobustConnection.is_closed`` only describes its current transport. Its
168
+ background reconnection task can still be alive after the broker closes
169
+ that transport, so ``close()`` must always run for an owned connection.
170
+ """
171
+ if connection is None:
172
+ return
173
+ close_timeout = min(5.0, self.client.connect_timeout)
174
+ try:
175
+ async with timeout_after(close_timeout):
140
176
  await connection.close()
141
- except Exception as exc: # noqa: BLE001 - best-effort reset
142
- logger.warning("Error closing RabbitMQ connection during reset (%s): %r", reason, exc)
177
+ except TimeoutError:
178
+ logger.error(
179
+ "Timed out closing RabbitMQ connection after %.1fs (%s)",
180
+ close_timeout,
181
+ reason,
182
+ )
183
+ except Exception as exc: # noqa: BLE001 - best-effort reset
184
+ logger.warning("Error closing RabbitMQ connection (%s): %r", reason, exc)
143
185
 
144
186
  def _invalidate_consume_channel(self, reason: str = "explicit invalidation") -> None:
145
187
  """Drop cached consume channel + queue/exchange handles.
@@ -193,7 +235,8 @@ class AsyncRabbitMQService:
193
235
  return
194
236
  try:
195
237
  if not channel.is_closed:
196
- await channel.close()
238
+ async with timeout_after(min(1.0, self.client.channel_timeout)):
239
+ await channel.close()
197
240
  logger.info("Closed dead consume channel (%s)", reason)
198
241
  except Exception as exc: # noqa: BLE001 - best-effort teardown
199
242
  logger.warning("Error closing consume channel during teardown (%s): %r", reason, exc)
@@ -365,27 +408,33 @@ class AsyncRabbitMQService:
365
408
  self._consumer_state = "subscribing"
366
409
  poll_completed = False
367
410
 
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,
411
+ try:
412
+ # Ensure the consume channel exists with the requested prefetch QoS.
413
+ # The return value is unused here because declarations re-fetch the
414
+ # channel from the cache.
415
+ await self._get_consume_channel(prefetch_count=config.prefetch_count)
416
+
417
+ queues: list[tuple[str, AbstractQueue]] = []
418
+ for subscription in config.resolved_subscriptions():
419
+ queue = await self.declare_queue(
420
+ subscription.queue_name,
384
421
  durable=config.durable,
422
+ arguments=subscription.queue_arguments or None,
385
423
  )
386
- for key in binding.routing_keys:
387
- await self.bind_queue(queue, exchange, key)
388
- queues.append((subscription.queue_name, queue))
424
+ for binding in subscription.bindings:
425
+ exchange = await self.declare_exchange(
426
+ binding.exchange_name,
427
+ binding.exchange_type,
428
+ durable=config.durable,
429
+ )
430
+ for key in binding.routing_keys:
431
+ await self.bind_queue(queue, exchange, key)
432
+ queues.append((subscription.queue_name, queue))
433
+ except Exception as exc:
434
+ if not _is_recoverable_broker_interruption(exc):
435
+ raise
436
+ await self._teardown_consume_channel(f"subscription setup interrupted: {exc!r}")
437
+ return []
389
438
 
390
439
  buffer: List[Dict[str, Any]] = []
391
440
  timeout = config.consume_timeout
@@ -404,7 +453,9 @@ class AsyncRabbitMQService:
404
453
  )
405
454
  except TimeoutError:
406
455
  poll_completed = True
407
- except _DEAD_CHANNEL_EXCEPTIONS as exc:
456
+ except Exception as exc:
457
+ if not _is_recoverable_broker_interruption(exc):
458
+ raise
408
459
  # Broker cancelled this subscription mid-iteration; deterministically
409
460
  # tear down (cancel consumers + close) the dead channel so the next
410
461
  # call rebuilds against a fresh consumer, the broker redelivers what
@@ -435,15 +486,27 @@ class AsyncRabbitMQService:
435
486
  batch_size: int,
436
487
  auto_ack: bool,
437
488
  ) -> None:
438
- """Merge deliveries from multiple queue iterators into one batch."""
489
+ """Wait for one delivery, then drain only messages already available.
490
+
491
+ ``consume_timeout`` bounds the idle wait in :meth:`consume`; it must
492
+ not become a batch-assembly delay after the first delivery. Once any
493
+ message is buffered, give newly scheduled iterator reads one event-loop
494
+ turn to complete and return immediately if none are ready.
495
+ """
439
496
  pending: dict[asyncio.Task, tuple[str, Any]] = {}
440
- async with AsyncExitStack() as stack:
497
+ iterator_contexts: list[AsyncContextManager[Any]] = []
498
+ try:
441
499
  for queue_name, queue in queues:
442
500
  iterator_context = cast(
443
501
  AsyncContextManager[Any],
444
- queue.iterator(no_ack=auto_ack),
502
+ # The framework owns consumer teardown/resubscription.
503
+ # Letting aio-pika restore these short-lived per-fetch
504
+ # consumers can resurrect an iterator with no pending
505
+ # reader after a broker restart, creating a zombie tag.
506
+ queue.iterator(no_ack=auto_ack, robust=False),
445
507
  )
446
- iterator = await stack.enter_async_context(iterator_context)
508
+ iterator = await iterator_context.__aenter__()
509
+ iterator_contexts.append(iterator_context)
447
510
  self._register_consumer_cancel_callback(iterator)
448
511
  consumer_tag = getattr(iterator, "_consumer_tag", None) or getattr(iterator, "consumer_tag", None)
449
512
  if consumer_tag:
@@ -451,33 +514,68 @@ class AsyncRabbitMQService:
451
514
  pending[asyncio.create_task(anext(iterator))] = (queue_name, iterator)
452
515
 
453
516
  self._active_consumer_tag = next(iter(self._active_consumer_tags.values()), None)
454
-
517
+ while pending and len(buffer) < batch_size:
518
+ if buffer:
519
+ done = {task for task in pending if task.done()}
520
+ if not done:
521
+ await asyncio.sleep(0)
522
+ done = {task for task in pending if task.done()}
523
+ if not done:
524
+ break
525
+ else:
526
+ done, _ = await asyncio.wait(
527
+ pending,
528
+ return_when=asyncio.FIRST_COMPLETED,
529
+ )
530
+ for task in done:
531
+ queue_name, iterator = pending.pop(task)
532
+ try:
533
+ message = task.result()
534
+ except StopAsyncIteration:
535
+ self._active_consumer_tags.pop(queue_name, None)
536
+ continue
537
+
538
+ message_dict = self._message_to_dict(message, queue_name=queue_name)
539
+ self._in_flight_message_ids.add(id(message))
540
+ self._last_fetch_ts = time.time()
541
+ self._consumer_state = "delivering"
542
+ if len(buffer) < batch_size:
543
+ buffer.append(message_dict)
544
+ else:
545
+ self._prefetched_messages.append(message_dict)
546
+ if len(buffer) < batch_size:
547
+ pending[asyncio.create_task(anext(iterator))] = (queue_name, iterator)
548
+ finally:
549
+ cleanup_timeout = min(1.0, self.client.channel_timeout)
550
+ cleanup_failed = False
551
+ for task in pending:
552
+ task.cancel()
553
+ if pending:
554
+ try:
555
+ async with timeout_after(cleanup_timeout):
556
+ await asyncio.gather(*pending, return_exceptions=True)
557
+ except TimeoutError:
558
+ cleanup_failed = True
559
+ logger.warning("Timed out cancelling RabbitMQ iterator reads")
560
+ cleanup_failed = await self._close_iterator_contexts(iterator_contexts) or cleanup_failed
561
+ if cleanup_failed:
562
+ await self._teardown_consume_channel("queue iterator cleanup failed")
563
+
564
+ async def _close_iterator_contexts(
565
+ self,
566
+ iterator_contexts: list[AsyncContextManager[Any]],
567
+ ) -> bool:
568
+ """Bound queue-iterator cleanup so a dead broker cannot stall a poll."""
569
+ cleanup_timeout = min(1.0, self.client.channel_timeout)
570
+ cleanup_failed = False
571
+ for iterator_context in reversed(iterator_contexts):
455
572
  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)
573
+ async with timeout_after(cleanup_timeout):
574
+ await iterator_context.__aexit__(None, None, None)
575
+ except Exception as exc:
576
+ cleanup_failed = True
577
+ logger.warning("RabbitMQ queue iterator cleanup failed: %r", exc)
578
+ return cleanup_failed
481
579
 
482
580
  def _register_consumer_cancel_callback(self, iterator: Any) -> None:
483
581
  """Best-effort registration of an on-cancel callback on the iterator's consumer.
@@ -676,9 +774,9 @@ class AsyncRabbitMQService:
676
774
  logger.warning("Error closing %s: %r", attr_name, exc)
677
775
  setattr(self, attr_name, None)
678
776
 
679
- if self._connection is not None and not self._connection.is_closed:
680
- await self._connection.close()
681
- self._connection = None
777
+ connection = self._connection
778
+ self._connection = None
779
+ await self._close_connection_safely(connection, "service close")
682
780
 
683
781
  self._active_consumer_tag = None
684
782
  self._active_consumer_tags.clear()
@@ -106,15 +106,18 @@ class _TaskLivenessSupervisor:
106
106
  self,
107
107
  provider: LivenessProvider,
108
108
  policy: TaskSupervisionPolicy,
109
- request_shutdown,
110
- hard_exit=os._exit,
109
+ request_shutdown: Callable[[str], None],
110
+ hard_exit: Callable[[int], None] | None = None,
111
111
  ) -> None:
112
112
  self.provider = provider
113
113
  self.policy = policy
114
114
  self.request_shutdown = request_shutdown
115
- self.hard_exit = hard_exit
115
+ self.hard_exit = hard_exit or os._exit
116
116
  self._stop = threading.Event()
117
117
  self._thread: threading.Thread | None = None
118
+ self._shutdown_lock = threading.Lock()
119
+ self._shutdown_requested_at: float | None = None
120
+ self._shutdown_reason: str | None = None
118
121
 
119
122
  def start(self) -> None:
120
123
  self._thread = threading.Thread(
@@ -129,13 +132,39 @@ class _TaskLivenessSupervisor:
129
132
  if self._thread is not None and self._thread is not threading.current_thread():
130
133
  self._thread.join(timeout=max(1.0, self.policy.check_interval * 2))
131
134
 
135
+ def arm_shutdown_deadline(self, reason: str) -> None:
136
+ """Force process exit if any cooperative shutdown or cleanup wedges."""
137
+ with self._shutdown_lock:
138
+ if self._shutdown_requested_at is not None:
139
+ return
140
+ self._shutdown_requested_at = time.monotonic()
141
+ self._shutdown_reason = reason
142
+ _supervisor_logger.warning(
143
+ "Task shutdown deadline armed for %.1fs (%s)",
144
+ self.policy.shutdown_grace,
145
+ reason,
146
+ )
147
+
132
148
  def _run(self) -> None:
133
149
  started_at = time.monotonic()
134
150
  unhealthy_since: float | None = None
135
- shutdown_requested_at: float | None = None
136
151
 
137
152
  while not self._stop.wait(self.policy.check_interval):
138
153
  now = time.monotonic()
154
+ with self._shutdown_lock:
155
+ shutdown_requested_at = self._shutdown_requested_at
156
+ shutdown_reason = self._shutdown_reason
157
+ if shutdown_requested_at is not None:
158
+ if now - shutdown_requested_at >= self.policy.shutdown_grace:
159
+ _supervisor_logger.critical(
160
+ "Task did not stop within %.1fs after shutdown began (%s); forcing exit",
161
+ self.policy.shutdown_grace,
162
+ shutdown_reason,
163
+ )
164
+ self._stop.set()
165
+ self.hard_exit(int(ExitCode.ERROR))
166
+ continue
167
+
139
168
  if now - started_at < self.policy.startup_grace:
140
169
  continue
141
170
 
@@ -157,22 +186,14 @@ class _TaskLivenessSupervisor:
157
186
  _supervisor_logger.error("Task liveness check failed: %s", reason)
158
187
  continue
159
188
 
160
- if shutdown_requested_at is None and now - unhealthy_since >= self.policy.unhealthy_grace:
161
- shutdown_requested_at = now
189
+ if now - unhealthy_since >= self.policy.unhealthy_grace:
162
190
  _supervisor_logger.critical(
163
191
  "Task remained unhealthy for %.1fs (%s); requesting process shutdown",
164
192
  now - unhealthy_since,
165
193
  reason,
166
194
  )
195
+ self.arm_shutdown_deadline(reason)
167
196
  self.request_shutdown(reason)
168
- continue
169
-
170
- if shutdown_requested_at is not None and now - shutdown_requested_at >= self.policy.shutdown_grace:
171
- _supervisor_logger.critical(
172
- "Task did not stop within %.1fs after liveness shutdown request; forcing exit",
173
- self.policy.shutdown_grace,
174
- )
175
- self.hard_exit(int(ExitCode.ERROR))
176
197
 
177
198
 
178
199
  # =============================================================
@@ -313,8 +334,10 @@ async def __run_task_async(
313
334
  raise
314
335
  finally:
315
336
  _unregister_shutdown_callback(request_signal_shutdown)
316
- if supervisor is not None and not liveness_shutdown_requested.is_set():
317
- supervisor.stop()
337
+ if supervisor is not None:
338
+ supervisor.arm_shutdown_deadline(
339
+ "liveness shutdown cleanup" if liveness_shutdown_requested.is_set() else "task cleanup"
340
+ )
318
341
  try:
319
342
  if instance is not None:
320
343
  await instance.exit()
@@ -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.8
4
4
  Summary: Ergon internal task-oriented project bootstrapper
5
5
  Author-email: Ergondata Technologies <anza.vossos@protonmail.com>
6
6
  License-Expression: MIT
@@ -4,4 +4,4 @@ import ergon
4
4
 
5
5
 
6
6
  def test_ergon_package_is_importable() -> None:
7
- assert ergon.__version__ == "0.1.6"
7
+ assert ergon.__version__ == "0.1.8"