ergon-framework-python 0.1.5__tar.gz → 0.1.7__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.5 → ergon_framework_python-0.1.7}/PKG-INFO +1 -1
  2. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/pyproject.toml +1 -1
  3. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/__init__.py +1 -1
  4. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/rabbitmq/async_service.py +134 -58
  5. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/task/runner.py +77 -8
  6. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon_framework_python.egg-info/PKG-INFO +1 -1
  7. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/tests/test_smoke.py +1 -1
  8. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/LICENSE +0 -0
  9. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/README.md +0 -0
  10. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/setup.cfg +0 -0
  11. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/bootstrap/src/__project__/__init__.py +0 -0
  12. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/bootstrap/src/__project__/_observability/docker-compose.telemetry.yml +0 -0
  13. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/bootstrap/src/__project__/_observability/grafana.yaml +0 -0
  14. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/bootstrap/src/__project__/_observability/loki.yaml +0 -0
  15. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/bootstrap/src/__project__/_observability/otel-collector-config.yaml +0 -0
  16. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/bootstrap/src/__project__/_observability/prometheus.yaml +0 -0
  17. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/bootstrap/src/__project__/_observability/tempo.yaml +0 -0
  18. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/bootstrap/src/__project__/connectors/__init__.py +0 -0
  19. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/bootstrap/src/__project__/main.py +0 -0
  20. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/bootstrap/src/__project__/tasks/__init__.py +0 -0
  21. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/bootstrap/src/__project__/tasks/constants.py +0 -0
  22. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/bootstrap/src/__project__/tasks/example_task/__init__.py +0 -0
  23. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/bootstrap/src/__project__/tasks/example_task/config.py +0 -0
  24. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/bootstrap/src/__project__/tasks/example_task/exceptions.py +0 -0
  25. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/bootstrap/src/__project__/tasks/example_task/helpers.py +0 -0
  26. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/bootstrap/src/__project__/tasks/example_task/schemas.py +0 -0
  27. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/bootstrap/src/__project__/tasks/example_task/task.py +0 -0
  28. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/bootstrap/src/__project__/tasks/exceptions.py +0 -0
  29. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/bootstrap/src/__project__/tasks/helpers.py +0 -0
  30. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/bootstrap/src/__project__/tasks/schemas.py +0 -0
  31. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/bootstrap/src/__project__/tasks/settings.py +0 -0
  32. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/cli.py +0 -0
  33. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/__init__.py +0 -0
  34. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/connector.py +0 -0
  35. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/ergon_platform/__init__.py +0 -0
  36. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/ergon_platform/_client.py +0 -0
  37. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/ergon_platform/_operations.py +0 -0
  38. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/ergon_platform/async_connector.py +0 -0
  39. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/ergon_platform/connector.py +0 -0
  40. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/ergon_platform/models.py +0 -0
  41. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/ergon_platform/utils.py +0 -0
  42. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/excel/__init__.py +0 -0
  43. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/excel/connector.py +0 -0
  44. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/excel/models.py +0 -0
  45. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/excel/service.py +0 -0
  46. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/nylas/__init__.py +0 -0
  47. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/nylas/async_auth_service.py +0 -0
  48. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/nylas/async_connector.py +0 -0
  49. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/nylas/async_service.py +0 -0
  50. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/nylas/auth_service.py +0 -0
  51. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/nylas/connector.py +0 -0
  52. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/nylas/models.py +0 -0
  53. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/nylas/service.py +0 -0
  54. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/nylas/utils.py +0 -0
  55. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/pipefy/__init__.py +0 -0
  56. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/pipefy/async_connector.py +0 -0
  57. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/pipefy/async_service.py +0 -0
  58. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/pipefy/connector.py +0 -0
  59. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/pipefy/models.py +0 -0
  60. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/pipefy/service.py +0 -0
  61. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/pipefy/version.py +0 -0
  62. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/postgres/__init__.py +0 -0
  63. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/postgres/async_connector.py +0 -0
  64. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/postgres/async_service.py +0 -0
  65. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/postgres/models.py +0 -0
  66. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/rabbitmq/__init__.py +0 -0
  67. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/rabbitmq/async_connector.py +0 -0
  68. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/rabbitmq/connector.py +0 -0
  69. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/rabbitmq/helper.py +0 -0
  70. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/rabbitmq/models.py +0 -0
  71. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/rabbitmq/service.py +0 -0
  72. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/sqs/__init__.py +0 -0
  73. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/sqs/async_connector.py +0 -0
  74. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/sqs/async_service.py +0 -0
  75. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/sqs/connector.py +0 -0
  76. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/sqs/models.py +0 -0
  77. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/sqs/service.py +0 -0
  78. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/transaction.py +0 -0
  79. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/py.typed +0 -0
  80. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/service/__init__.py +0 -0
  81. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/service/service.py +0 -0
  82. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/task/__init__.py +0 -0
  83. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/task/base.py +0 -0
  84. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/task/exceptions.py +0 -0
  85. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/task/helpers.py +0 -0
  86. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/task/liveness.py +0 -0
  87. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/task/manager.py +0 -0
  88. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/task/mixins/__init__.py +0 -0
  89. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/task/mixins/consumer.py +0 -0
  90. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/task/mixins/metrics.py +0 -0
  91. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/task/mixins/producer.py +0 -0
  92. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/task/policies.py +0 -0
  93. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/task/utils.py +0 -0
  94. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/telemetry/__init__.py +0 -0
  95. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/telemetry/_resource.py +0 -0
  96. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/telemetry/logging.py +0 -0
  97. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/telemetry/metrics.py +0 -0
  98. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/telemetry/tracing.py +0 -0
  99. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/utils/__init__.py +0 -0
  100. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/utils/env.py +0 -0
  101. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon_framework_python.egg-info/SOURCES.txt +0 -0
  102. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon_framework_python.egg-info/dependency_links.txt +0 -0
  103. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon_framework_python.egg-info/entry_points.txt +0 -0
  104. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon_framework_python.egg-info/requires.txt +0 -0
  105. {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/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.5
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,6 +1,6 @@
1
1
  [project]
2
2
  name = "ergon-framework-python"
3
- version = "0.1.5"
3
+ version = "0.1.7"
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.5"
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,4 +1,5 @@
1
1
  import asyncio
2
+ import inspect
2
3
  import logging as stdlib_logging
3
4
  import os
4
5
  import signal
@@ -6,6 +7,7 @@ import threading
6
7
  import time
7
8
  import traceback
8
9
  import uuid
10
+ from collections.abc import Callable
9
11
  from concurrent.futures import ProcessPoolExecutor
10
12
  from datetime import datetime
11
13
  from enum import IntEnum
@@ -41,6 +43,8 @@ class ExitCode(IntEnum):
41
43
 
42
44
  _shutdown_event = threading.Event()
43
45
  _shutdown_signal: int | None = None
46
+ _shutdown_callbacks: set[Callable[[], None]] = set()
47
+ _shutdown_callbacks_lock = threading.Lock()
44
48
  _supervisor_logger = stdlib_logging.getLogger(__name__)
45
49
 
46
50
 
@@ -48,6 +52,13 @@ def _signal_handler(signum, frame):
48
52
  global _shutdown_signal
49
53
  _shutdown_signal = signum
50
54
  _shutdown_event.set()
55
+ with _shutdown_callbacks_lock:
56
+ callbacks = tuple(_shutdown_callbacks)
57
+ for callback in callbacks:
58
+ try:
59
+ callback()
60
+ except Exception:
61
+ _supervisor_logger.exception("Async shutdown callback failed")
51
62
 
52
63
 
53
64
  def _install_signal_handlers():
@@ -55,6 +66,27 @@ def _install_signal_handlers():
55
66
  signal.signal(signal.SIGTERM, _signal_handler)
56
67
 
57
68
 
69
+ def _reset_shutdown_state() -> None:
70
+ global _shutdown_signal
71
+ _shutdown_event.clear()
72
+ _shutdown_signal = None
73
+ with _shutdown_callbacks_lock:
74
+ _shutdown_callbacks.clear()
75
+
76
+
77
+ def _register_shutdown_callback(callback: Callable[[], None]) -> None:
78
+ with _shutdown_callbacks_lock:
79
+ _shutdown_callbacks.add(callback)
80
+ shutdown_requested = _shutdown_event.is_set()
81
+ if shutdown_requested:
82
+ callback()
83
+
84
+
85
+ def _unregister_shutdown_callback(callback: Callable[[], None]) -> None:
86
+ with _shutdown_callbacks_lock:
87
+ _shutdown_callbacks.discard(callback)
88
+
89
+
58
90
  def is_shutdown_requested() -> bool:
59
91
  return _shutdown_event.is_set()
60
92
 
@@ -216,20 +248,31 @@ async def __run_task_async(
216
248
 
217
249
  instance = None
218
250
  supervisor = None
251
+ liveness_shutdown_requested = threading.Event()
252
+ connectors: dict[str, Any] = {}
253
+ services: dict[str, Any] = {}
254
+ loop = asyncio.get_running_loop()
255
+ runner_task = asyncio.current_task()
256
+
257
+ def request_signal_shutdown() -> None:
258
+ if runner_task is not None and not runner_task.done():
259
+ loop.call_soon_threadsafe(runner_task.cancel, "Process shutdown requested")
260
+
261
+ _register_shutdown_callback(request_signal_shutdown)
219
262
 
220
263
  try:
221
264
  with tracer.start_as_current_span( # type: ignore[attr-defined]
222
265
  f"{config.task.__name__}.run",
223
266
  attributes={"task.execution.id": task_exec_metadata["execution_id"]},
224
267
  ):
225
- connectors = {}
226
268
  for name, cfg in config.connectors.items():
227
269
  conn = cfg.connector(*cfg.args, **cfg.kwargs)
270
+ connectors[name] = conn
228
271
  if hasattr(conn, "init_async"):
229
272
  await conn.init_async() # type: ignore[attr-defined]
230
- connectors[name] = conn
231
273
 
232
- services = {name: cfg.service(*cfg.args, **cfg.kwargs) for name, cfg in config.services.items()}
274
+ for name, cfg in config.services.items():
275
+ services[name] = cfg.service(*cfg.args, **cfg.kwargs)
233
276
 
234
277
  instance = config.task(
235
278
  connectors=connectors,
@@ -242,10 +285,9 @@ async def __run_task_async(
242
285
  )
243
286
 
244
287
  if config.supervision.enabled and isinstance(instance, LivenessProvider):
245
- loop = asyncio.get_running_loop()
246
- runner_task = asyncio.current_task()
247
288
 
248
289
  def request_liveness_shutdown(reason: str) -> None:
290
+ liveness_shutdown_requested.set()
249
291
  if runner_task is not None and not runner_task.done():
250
292
  loop.call_soon_threadsafe(runner_task.cancel, f"Task unhealthy: {reason}")
251
293
 
@@ -266,11 +308,37 @@ async def __run_task_async(
266
308
  else:
267
309
  await instance.execute()
268
310
 
311
+ except asyncio.CancelledError:
312
+ if not is_shutdown_requested():
313
+ raise
269
314
  finally:
270
- if supervisor is not None:
315
+ _unregister_shutdown_callback(request_signal_shutdown)
316
+ if supervisor is not None and not liveness_shutdown_requested.is_set():
271
317
  supervisor.stop()
272
- if instance is not None:
273
- await instance.exit()
318
+ try:
319
+ if instance is not None:
320
+ await instance.exit()
321
+ finally:
322
+ try:
323
+ resources = [*connectors.values(), *services.values()]
324
+ for resource in reversed(resources):
325
+ close = getattr(resource, "close", None)
326
+ if close is None:
327
+ close = getattr(resource, "aclose", None)
328
+ if not callable(close):
329
+ continue
330
+ try:
331
+ result = close()
332
+ if inspect.isawaitable(result):
333
+ await result
334
+ except Exception:
335
+ _supervisor_logger.exception(
336
+ "Failed to close async task resource %s",
337
+ type(resource).__name__,
338
+ )
339
+ finally:
340
+ if supervisor is not None:
341
+ supervisor.stop()
274
342
 
275
343
 
276
344
  # =============================================================
@@ -424,6 +492,7 @@ def run_task(
424
492
  Returns POSIX-compatible exit code.
425
493
  """
426
494
 
495
+ _reset_shutdown_state()
427
496
  _install_signal_handlers()
428
497
  is_async = issubclass(config.task, BaseAsyncTask) # type: ignore[arg-type]
429
498
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ergon-framework-python
3
- Version: 0.1.5
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
@@ -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.5"
7
+ assert ergon.__version__ == "0.1.7"