ergon-framework-python 0.1.0__tar.gz → 0.1.2__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 (88) hide show
  1. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/PKG-INFO +13 -13
  2. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/README.md +12 -12
  3. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/pyproject.toml +1 -1
  4. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/__init__.py +1 -1
  5. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/connector/rabbitmq/async_connector.py +9 -0
  6. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/connector/rabbitmq/async_service.py +121 -13
  7. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/connector/rabbitmq/models.py +25 -2
  8. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/task/mixins/consumer.py +29 -0
  9. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon_framework_python.egg-info/PKG-INFO +13 -13
  10. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/tests/test_smoke.py +1 -1
  11. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/LICENSE +0 -0
  12. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/setup.cfg +0 -0
  13. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/bootstrap/src/__project__/__init__.py +0 -0
  14. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/bootstrap/src/__project__/_observability/docker-compose.telemetry.yml +0 -0
  15. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/bootstrap/src/__project__/_observability/grafana.yaml +0 -0
  16. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/bootstrap/src/__project__/_observability/loki.yaml +0 -0
  17. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/bootstrap/src/__project__/_observability/otel-collector-config.yaml +0 -0
  18. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/bootstrap/src/__project__/_observability/prometheus.yaml +0 -0
  19. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/bootstrap/src/__project__/_observability/tempo.yaml +0 -0
  20. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/bootstrap/src/__project__/connectors/__init__.py +0 -0
  21. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/bootstrap/src/__project__/main.py +0 -0
  22. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/bootstrap/src/__project__/tasks/__init__.py +0 -0
  23. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/bootstrap/src/__project__/tasks/constants.py +0 -0
  24. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/bootstrap/src/__project__/tasks/example_task/__init__.py +0 -0
  25. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/bootstrap/src/__project__/tasks/example_task/config.py +0 -0
  26. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/bootstrap/src/__project__/tasks/example_task/exceptions.py +0 -0
  27. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/bootstrap/src/__project__/tasks/example_task/helpers.py +0 -0
  28. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/bootstrap/src/__project__/tasks/example_task/schemas.py +0 -0
  29. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/bootstrap/src/__project__/tasks/example_task/task.py +0 -0
  30. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/bootstrap/src/__project__/tasks/exceptions.py +0 -0
  31. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/bootstrap/src/__project__/tasks/helpers.py +0 -0
  32. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/bootstrap/src/__project__/tasks/schemas.py +0 -0
  33. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/bootstrap/src/__project__/tasks/settings.py +0 -0
  34. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/cli.py +0 -0
  35. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/connector/__init__.py +0 -0
  36. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/connector/connector.py +0 -0
  37. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/connector/excel/__init__.py +0 -0
  38. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/connector/excel/connector.py +0 -0
  39. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/connector/excel/models.py +0 -0
  40. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/connector/excel/service.py +0 -0
  41. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/connector/pipefy/__init__.py +0 -0
  42. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/connector/pipefy/async_connector.py +0 -0
  43. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/connector/pipefy/async_service.py +0 -0
  44. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/connector/pipefy/connector.py +0 -0
  45. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/connector/pipefy/models.py +0 -0
  46. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/connector/pipefy/service.py +0 -0
  47. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/connector/pipefy/version.py +0 -0
  48. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/connector/postgres/__init__.py +0 -0
  49. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/connector/postgres/async_connector.py +0 -0
  50. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/connector/postgres/async_service.py +0 -0
  51. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/connector/postgres/models.py +0 -0
  52. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/connector/rabbitmq/__init__.py +0 -0
  53. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/connector/rabbitmq/connector.py +0 -0
  54. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/connector/rabbitmq/helper.py +0 -0
  55. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/connector/rabbitmq/service.py +0 -0
  56. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/connector/sqs/__init__.py +0 -0
  57. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/connector/sqs/async_connector.py +0 -0
  58. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/connector/sqs/async_service.py +0 -0
  59. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/connector/sqs/connector.py +0 -0
  60. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/connector/sqs/models.py +0 -0
  61. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/connector/sqs/service.py +0 -0
  62. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/connector/transaction.py +0 -0
  63. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/py.typed +0 -0
  64. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/service/__init__.py +0 -0
  65. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/service/service.py +0 -0
  66. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/task/__init__.py +0 -0
  67. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/task/base.py +0 -0
  68. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/task/exceptions.py +0 -0
  69. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/task/helpers.py +0 -0
  70. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/task/manager.py +0 -0
  71. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/task/mixins/__init__.py +0 -0
  72. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/task/mixins/metrics.py +0 -0
  73. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/task/mixins/producer.py +0 -0
  74. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/task/policies.py +0 -0
  75. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/task/runner.py +0 -0
  76. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/task/utils.py +0 -0
  77. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/telemetry/__init__.py +0 -0
  78. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/telemetry/_resource.py +0 -0
  79. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/telemetry/logging.py +0 -0
  80. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/telemetry/metrics.py +0 -0
  81. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/telemetry/tracing.py +0 -0
  82. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/utils/__init__.py +0 -0
  83. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon/utils/env.py +0 -0
  84. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon_framework_python.egg-info/SOURCES.txt +0 -0
  85. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon_framework_python.egg-info/dependency_links.txt +0 -0
  86. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon_framework_python.egg-info/entry_points.txt +0 -0
  87. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/src/ergon_framework_python.egg-info/requires.txt +0 -0
  88. {ergon_framework_python-0.1.0 → ergon_framework_python-0.1.2}/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.0
3
+ Version: 0.1.2
4
4
  Summary: Ergon internal task-oriented project bootstrapper
5
5
  Author-email: Ergondata Technologies <anza.vossos@protonmail.com>
6
6
  License-Expression: MIT
@@ -55,7 +55,7 @@ For local development, install in editable mode:
55
55
  pip install -e /path/to/ergon-framework/sdks/python
56
56
  ```
57
57
 
58
- 📖 **[Full Getting Started Guide](docs/getting-started.md)** — Complete setup, project configuration, and task registration
58
+ 📖 **[Full Getting Started Guide](https://github.com/danzanellovossos/ergon-framework/blob/main/sdks/python/docs/getting-started.md)** — Complete setup, project configuration, and task registration
59
59
 
60
60
  ---
61
61
 
@@ -419,7 +419,7 @@ my-project/
419
419
  └── helpers.py # Task-specific utilities
420
420
  ```
421
421
 
422
- 📖 **[Full Project Structure Guide](docs/project-structure.md)** — Detailed documentation on organizing connectors, tasks, and shared modules.
422
+ 📖 **[Full Project Structure Guide](https://github.com/danzanellovossos/ergon-framework/blob/main/sdks/python/docs/project-structure.md)** — Detailed documentation on organizing connectors, tasks, and shared modules.
423
423
 
424
424
  ---
425
425
 
@@ -427,23 +427,23 @@ my-project/
427
427
 
428
428
  ### Python SDK Guides
429
429
 
430
- - **[Getting Started](docs/getting-started.md)** — Installation, project setup, and running your first task
431
- - **[CLI Reference](docs/cli.md)** — Commands, options, and exit codes
432
- - **[Project Structure Guide](docs/project-structure.md)** — How to organize connectors, tasks, and shared modules
430
+ - **[Getting Started](https://github.com/danzanellovossos/ergon-framework/blob/main/sdks/python/docs/getting-started.md)** — Installation, project setup, and running your first task
431
+ - **[CLI Reference](https://github.com/danzanellovossos/ergon-framework/blob/main/sdks/python/docs/cli.md)** — Commands, options, and exit codes
432
+ - **[Project Structure Guide](https://github.com/danzanellovossos/ergon-framework/blob/main/sdks/python/docs/project-structure.md)** — How to organize connectors, tasks, and shared modules
433
433
 
434
434
  ### Framework Concepts
435
435
 
436
- - **[Framework Architecture](../../docs/architecture.md)** — Full system specification and design philosophy
437
- - **[Transaction Abstraction](../../docs/modules/1.transaction.md)** — Understanding atomicity rules
438
- - **[Task Module](../../docs/modules/2.task.md)** — Mixins, lifecycles, and execution modes
439
- - **[Connector Module](../../docs/modules/3.connector.md)** — Building integration boundaries
440
- - **[Service Module](../../docs/modules/4.service.md)** — Protocol engineering and reliability
441
- - **[Telemetry Module](../../docs/modules/5.telemetry.md)** — Configuring OTel logs, metrics, and traces
436
+ - **[Framework Architecture](https://github.com/danzanellovossos/ergon-framework/blob/main/docs/architecture.md)** — Full system specification and design philosophy
437
+ - **[Transaction Abstraction](https://github.com/danzanellovossos/ergon-framework/blob/main/docs/modules/1.transaction.md)** — Understanding atomicity rules
438
+ - **[Task Module](https://github.com/danzanellovossos/ergon-framework/blob/main/docs/modules/2.task.md)** — Mixins, lifecycles, and execution modes
439
+ - **[Connector Module](https://github.com/danzanellovossos/ergon-framework/blob/main/docs/modules/3.connector.md)** — Building integration boundaries
440
+ - **[Service Module](https://github.com/danzanellovossos/ergon-framework/blob/main/docs/modules/4.service.md)** — Protocol engineering and reliability
441
+ - **[Telemetry Module](https://github.com/danzanellovossos/ergon-framework/blob/main/docs/modules/5.telemetry.md)** — Configuring OTel logs, metrics, and traces
442
442
 
443
443
  ---
444
444
 
445
445
  ## License
446
446
 
447
- This project is licensed under the [MIT License](../../LICENSE).
447
+ This project is licensed under the [MIT License](https://github.com/danzanellovossos/ergon-framework/blob/main/LICENSE).
448
448
 
449
449
  <br/>
@@ -20,7 +20,7 @@ For local development, install in editable mode:
20
20
  pip install -e /path/to/ergon-framework/sdks/python
21
21
  ```
22
22
 
23
- 📖 **[Full Getting Started Guide](docs/getting-started.md)** — Complete setup, project configuration, and task registration
23
+ 📖 **[Full Getting Started Guide](https://github.com/danzanellovossos/ergon-framework/blob/main/sdks/python/docs/getting-started.md)** — Complete setup, project configuration, and task registration
24
24
 
25
25
  ---
26
26
 
@@ -384,7 +384,7 @@ my-project/
384
384
  └── helpers.py # Task-specific utilities
385
385
  ```
386
386
 
387
- 📖 **[Full Project Structure Guide](docs/project-structure.md)** — Detailed documentation on organizing connectors, tasks, and shared modules.
387
+ 📖 **[Full Project Structure Guide](https://github.com/danzanellovossos/ergon-framework/blob/main/sdks/python/docs/project-structure.md)** — Detailed documentation on organizing connectors, tasks, and shared modules.
388
388
 
389
389
  ---
390
390
 
@@ -392,23 +392,23 @@ my-project/
392
392
 
393
393
  ### Python SDK Guides
394
394
 
395
- - **[Getting Started](docs/getting-started.md)** — Installation, project setup, and running your first task
396
- - **[CLI Reference](docs/cli.md)** — Commands, options, and exit codes
397
- - **[Project Structure Guide](docs/project-structure.md)** — How to organize connectors, tasks, and shared modules
395
+ - **[Getting Started](https://github.com/danzanellovossos/ergon-framework/blob/main/sdks/python/docs/getting-started.md)** — Installation, project setup, and running your first task
396
+ - **[CLI Reference](https://github.com/danzanellovossos/ergon-framework/blob/main/sdks/python/docs/cli.md)** — Commands, options, and exit codes
397
+ - **[Project Structure Guide](https://github.com/danzanellovossos/ergon-framework/blob/main/sdks/python/docs/project-structure.md)** — How to organize connectors, tasks, and shared modules
398
398
 
399
399
  ### Framework Concepts
400
400
 
401
- - **[Framework Architecture](../../docs/architecture.md)** — Full system specification and design philosophy
402
- - **[Transaction Abstraction](../../docs/modules/1.transaction.md)** — Understanding atomicity rules
403
- - **[Task Module](../../docs/modules/2.task.md)** — Mixins, lifecycles, and execution modes
404
- - **[Connector Module](../../docs/modules/3.connector.md)** — Building integration boundaries
405
- - **[Service Module](../../docs/modules/4.service.md)** — Protocol engineering and reliability
406
- - **[Telemetry Module](../../docs/modules/5.telemetry.md)** — Configuring OTel logs, metrics, and traces
401
+ - **[Framework Architecture](https://github.com/danzanellovossos/ergon-framework/blob/main/docs/architecture.md)** — Full system specification and design philosophy
402
+ - **[Transaction Abstraction](https://github.com/danzanellovossos/ergon-framework/blob/main/docs/modules/1.transaction.md)** — Understanding atomicity rules
403
+ - **[Task Module](https://github.com/danzanellovossos/ergon-framework/blob/main/docs/modules/2.task.md)** — Mixins, lifecycles, and execution modes
404
+ - **[Connector Module](https://github.com/danzanellovossos/ergon-framework/blob/main/docs/modules/3.connector.md)** — Building integration boundaries
405
+ - **[Service Module](https://github.com/danzanellovossos/ergon-framework/blob/main/docs/modules/4.service.md)** — Protocol engineering and reliability
406
+ - **[Telemetry Module](https://github.com/danzanellovossos/ergon-framework/blob/main/docs/modules/5.telemetry.md)** — Configuring OTel logs, metrics, and traces
407
407
 
408
408
  ---
409
409
 
410
410
  ## License
411
411
 
412
- This project is licensed under the [MIT License](../../LICENSE).
412
+ This project is licensed under the [MIT License](https://github.com/danzanellovossos/ergon-framework/blob/main/LICENSE).
413
413
 
414
414
  <br/>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ergon-framework-python"
3
- version = "0.1.0"
3
+ version = "0.1.2"
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.0"
13
+ __version__ = "0.1.1"
@@ -116,5 +116,14 @@ class AsyncRabbitMQConnector(AsyncConnector):
116
116
  raise ValueError(f"Cannot nack transaction {transaction.id}: no raw message in metadata")
117
117
  await self.service.nack(raw_message, requeue=requeue)
118
118
 
119
+ def health(self) -> Dict[str, Any]:
120
+ """Consumer liveness snapshot (last fetch/ack, active tag, channel state).
121
+
122
+ Intended for wiring a service-level health check that can detect a
123
+ wedged or zombie consumer rather than silently running after a broker
124
+ cancel.
125
+ """
126
+ return self.service.health()
127
+
119
128
  async def close(self) -> None:
120
129
  await self.service.close()
@@ -2,6 +2,7 @@ import asyncio
2
2
  import json
3
3
  import logging
4
4
  import ssl as ssl_module
5
+ import time
5
6
  from typing import Any, Callable, Dict, List, Optional
6
7
 
7
8
  import aio_pika
@@ -31,6 +32,15 @@ _DEAD_CHANNEL_EXCEPTIONS: tuple[type[BaseException], ...] = (
31
32
  aiormq.exceptions.ChannelInvalidStateError,
32
33
  )
33
34
 
35
+ # Same as above plus ``TimeoutError`` (raised by ``asyncio.timeout`` when an
36
+ # ack/nack stalls on a half-open socket). A stalled ack is functionally a dead
37
+ # channel: we tear down and let the broker redeliver instead of blocking until
38
+ # the heartbeat eventually fires.
39
+ _DEAD_CHANNEL_TIMEOUT_EXCEPTIONS: tuple[type[BaseException], ...] = (
40
+ *_DEAD_CHANNEL_EXCEPTIONS,
41
+ TimeoutError,
42
+ )
43
+
34
44
 
35
45
  class AsyncRabbitMQService:
36
46
  def __init__(self, client: AsyncRabbitmqClient) -> None:
@@ -49,6 +59,13 @@ class AsyncRabbitMQService:
49
59
  self._exchanges: Dict[str, AbstractExchange] = {}
50
60
  self._queues: Dict[str, AbstractQueue] = {}
51
61
 
62
+ # Liveness signals so services can wire a real health check instead of
63
+ # silently running with a zombie/dead consumer. Updated on every
64
+ # successful fetch/ack and reset when the consume channel is torn down.
65
+ self._last_fetch_ts: Optional[float] = None
66
+ self._last_ack_ts: Optional[float] = None
67
+ self._active_consumer_tag: Optional[str] = None
68
+
52
69
  # ---------- Connection / Channel ----------
53
70
 
54
71
  async def _get_connection(self) -> AbstractRobustConnection:
@@ -89,6 +106,61 @@ class AsyncRabbitMQService:
89
106
  self._queues.clear()
90
107
  self._exchanges.clear()
91
108
 
109
+ async def _teardown_consume_channel(self, reason: str = "explicit teardown") -> None:
110
+ """Deterministically tear down the consume channel.
111
+
112
+ Unlike :meth:`_invalidate_consume_channel` (which only drops Python
113
+ references), this snapshots the live channel, clears the cache, then
114
+ explicitly ``close()``-es the channel. Closing the channel:
115
+
116
+ * drops every consumer registered on it at the broker — this is what
117
+ eliminates the *zombie consumer* left behind by a broker-initiated
118
+ ``Basic.Cancel`` that the per-fetch iterator could not cancel
119
+ cleanly; and
120
+ * for a ``RobustChannel`` removes it from aio_pika's reconnection set,
121
+ so the robust layer does not silently restore the dead consumer on
122
+ the next reconnect.
123
+
124
+ It also prevents the channel leak: previously the dropped channel
125
+ object was orphaned without ever being closed, so its broker-side
126
+ channel lingered and ``ChannelCount`` climbed over time.
127
+ """
128
+ channel = self._consume_channel
129
+ self._invalidate_consume_channel(reason)
130
+ self._active_consumer_tag = None
131
+ await self._close_channel_safely(channel, reason)
132
+
133
+ async def _close_channel_safely(self, channel: Optional[AbstractChannel], reason: str) -> None:
134
+ """Best-effort close of a (possibly already-dead) channel."""
135
+ if channel is None:
136
+ return
137
+ try:
138
+ if not channel.is_closed:
139
+ await channel.close()
140
+ logger.info("Closed dead consume channel (%s)", reason)
141
+ except Exception as exc: # noqa: BLE001 - best-effort teardown
142
+ logger.warning("Error closing consume channel during teardown (%s): %r", reason, exc)
143
+
144
+ def _schedule_teardown(self, reason: str) -> None:
145
+ """Tear down the consume channel from a sync callback context.
146
+
147
+ ``add_close_callback`` / consumer-cancel callbacks are synchronous, so
148
+ we cannot ``await``. We invalidate the cache synchronously (so the next
149
+ consume never sees a stale channel) and, when a running event loop is
150
+ available, schedule the explicit ``close()`` as a task to drop the dead
151
+ channel's consumers at the broker and avoid the channel leak.
152
+ """
153
+ channel = self._consume_channel
154
+ self._invalidate_consume_channel(reason)
155
+ self._active_consumer_tag = None
156
+ if channel is None or channel.is_closed:
157
+ return
158
+ try:
159
+ loop = asyncio.get_running_loop()
160
+ except RuntimeError:
161
+ return
162
+ loop.create_task(self._close_channel_safely(channel, reason))
163
+
92
164
  def _on_consume_channel_close(self, *args: Any, **kwargs: Any) -> None:
93
165
  """Callback registered with ``channel.add_close_callback``.
94
166
 
@@ -97,7 +169,7 @@ class AsyncRabbitMQService:
97
169
  """
98
170
  exc = args[1] if len(args) >= 2 else kwargs.get("exc")
99
171
  reason = f"channel closed: {exc!r}" if exc is not None else "channel closed"
100
- self._invalidate_consume_channel(reason)
172
+ self._schedule_teardown(reason)
101
173
 
102
174
  async def _get_consume_channel(self, prefetch_count: Optional[int] = None) -> AbstractChannel:
103
175
  if self._consume_channel is None or self._consume_channel.is_closed:
@@ -246,24 +318,32 @@ class AsyncRabbitMQService:
246
318
  async with asyncio.timeout(timeout): # type: ignore[attr-defined]
247
319
  async with queue.iterator(no_ack=config.auto_ack) as iterator:
248
320
  self._register_consumer_cancel_callback(iterator)
321
+ # Surface the active consumer tag for liveness diagnostics;
322
+ # attribute name varies across aio_pika versions.
323
+ self._active_consumer_tag = getattr(iterator, "_consumer_tag", None) or getattr(
324
+ iterator, "consumer_tag", None
325
+ )
249
326
  async for message in iterator:
250
327
  msg_dict = self._message_to_dict(message)
251
328
  buffer.append(msg_dict)
329
+ self._last_fetch_ts = time.time()
252
330
  if len(buffer) >= batch_size:
253
331
  break
254
332
  except TimeoutError:
255
333
  pass
256
334
  except _DEAD_CHANNEL_EXCEPTIONS as exc:
257
- # Broker cancelled this subscription mid-iteration; invalidate
258
- # the cached channel so the next call rebuilds against a fresh
259
- # consumer and the broker redelivers what we had prefetched.
260
- self._invalidate_consume_channel(f"consume aborted: {exc!r}")
335
+ # Broker cancelled this subscription mid-iteration; deterministically
336
+ # tear down (cancel consumers + close) the dead channel so the next
337
+ # call rebuilds against a fresh consumer, the broker redelivers what
338
+ # we had prefetched, and no zombie consumer / leaked channel is left
339
+ # behind.
340
+ await self._teardown_consume_channel(f"consume aborted: {exc!r}")
261
341
  return buffer
262
342
  finally:
263
343
  # If the channel was closed during iteration, make sure we don't
264
344
  # hand back a stale cache to the next caller.
265
345
  if self._consume_channel is not None and self._consume_channel.is_closed:
266
- self._invalidate_consume_channel("consume channel observed closed after iteration")
346
+ await self._teardown_consume_channel("consume channel observed closed after iteration")
267
347
 
268
348
  return buffer
269
349
 
@@ -290,7 +370,7 @@ class AsyncRabbitMQService:
290
370
  return
291
371
 
292
372
  def _on_cancel(*_args: Any, **_kwargs: Any) -> None:
293
- self._invalidate_consume_channel("consumer cancelled by broker (Basic.Cancel)")
373
+ self._schedule_teardown("consumer cancelled by broker (Basic.Cancel)")
294
374
 
295
375
  try:
296
376
  candidate(_on_cancel)
@@ -373,9 +453,13 @@ class AsyncRabbitMQService:
373
453
  from ...task import exceptions as task_exceptions
374
454
 
375
455
  try:
376
- await message.ack()
377
- except _DEAD_CHANNEL_EXCEPTIONS as exc:
378
- self._invalidate_consume_channel(f"ack failed: {exc!r}")
456
+ # Bound the ack so a half-open socket is detected in seconds rather
457
+ # than blocking until the (much longer) heartbeat timeout fires.
458
+ async with asyncio.timeout(self.client.ack_timeout): # type: ignore[attr-defined]
459
+ await message.ack()
460
+ self._last_ack_ts = time.time()
461
+ except _DEAD_CHANNEL_TIMEOUT_EXCEPTIONS as exc:
462
+ await self._teardown_consume_channel(f"ack failed: {exc!r}")
379
463
  raise task_exceptions.AckOnDeadChannelError(
380
464
  delivery_tag=getattr(message, "delivery_tag", None),
381
465
  queue=getattr(message, "routing_key", None),
@@ -386,15 +470,39 @@ class AsyncRabbitMQService:
386
470
  from ...task import exceptions as task_exceptions
387
471
 
388
472
  try:
389
- await message.nack(requeue=requeue)
390
- except _DEAD_CHANNEL_EXCEPTIONS as exc:
391
- self._invalidate_consume_channel(f"nack failed: {exc!r}")
473
+ async with asyncio.timeout(self.client.ack_timeout): # type: ignore[attr-defined]
474
+ await message.nack(requeue=requeue)
475
+ except _DEAD_CHANNEL_TIMEOUT_EXCEPTIONS as exc:
476
+ await self._teardown_consume_channel(f"nack failed: {exc!r}")
392
477
  raise task_exceptions.NackOnDeadChannelError(
393
478
  delivery_tag=getattr(message, "delivery_tag", None),
394
479
  queue=getattr(message, "routing_key", None),
395
480
  cause=exc,
396
481
  ) from exc
397
482
 
483
+ # ---------- Health / Liveness ----------
484
+
485
+ def health(self) -> Dict[str, Any]:
486
+ """Snapshot of consumer liveness for external health checks.
487
+
488
+ Exposes the timestamps of the last successful fetch and ack, the active
489
+ consumer tag, and connection/channel state so a service can detect a
490
+ wedged or zombie consumer (e.g. no successful ack within N seconds)
491
+ instead of silently running for hours after a broker cancel.
492
+ """
493
+ now = time.time()
494
+ connection_open = self._connection is not None and not self._connection.is_closed
495
+ consume_channel_open = self._consume_channel is not None and not self._consume_channel.is_closed
496
+ return {
497
+ "connection_open": connection_open,
498
+ "consume_channel_open": consume_channel_open,
499
+ "active_consumer_tag": self._active_consumer_tag,
500
+ "last_fetch_ts": self._last_fetch_ts,
501
+ "last_ack_ts": self._last_ack_ts,
502
+ "seconds_since_last_fetch": (now - self._last_fetch_ts) if self._last_fetch_ts is not None else None,
503
+ "seconds_since_last_ack": (now - self._last_ack_ts) if self._last_ack_ts is not None else None,
504
+ }
505
+
398
506
  # ---------- Lifecycle ----------
399
507
 
400
508
  async def close(self) -> None:
@@ -51,7 +51,22 @@ class AsyncRabbitmqClient(BaseModel):
51
51
  username: str = Field(default="guest", description="RabbitMQ username")
52
52
  password: str = Field(default="guest", description="RabbitMQ password")
53
53
  virtual_host: str = Field(default="/", description="RabbitMQ virtual host")
54
- heartbeat: int = Field(default=600, description="Heartbeat interval in seconds")
54
+ heartbeat: int = Field(
55
+ default=60,
56
+ description=(
57
+ "Heartbeat interval in seconds. Kept low (default 60) so a half-open "
58
+ "socket is detected within ~2x heartbeat instead of blocking for many "
59
+ "minutes. AmazonMQ/RabbitMQ negotiate the lower of client/server values."
60
+ ),
61
+ )
62
+ ack_timeout: float = Field(
63
+ default=30,
64
+ description=(
65
+ "Max seconds to wait for an ack/nack RPC before treating the channel as "
66
+ "dead. Bounds recovery so a stalled ack on a half-open connection fails "
67
+ "fast (broker redelivers) rather than wedging the consumer."
68
+ ),
69
+ )
55
70
  connection_attempts: int = Field(default=3, description="Connection retry attempts")
56
71
  ssl_enabled: bool = Field(default=False, description="Enable SSL/TLS")
57
72
  ssl_ca_certs: Optional[str] = Field(default=None, description="Path to CA certificate when using SSL")
@@ -67,7 +82,15 @@ class AsyncRabbitmqConsumerConfig(BaseModel):
67
82
  exchange_name: str = Field(default="", description="Exchange name (empty for default exchange)")
68
83
  exchange_type: str = Field(default="topic", description="Exchange type: topic, direct, fanout, headers")
69
84
  binding_keys: list[str] = Field(default=["#"], description="Routing key patterns for queue binding")
70
- prefetch_count: int = Field(default=10, description="Number of unacknowledged messages allowed")
85
+ prefetch_count: int = Field(
86
+ default=10,
87
+ description=(
88
+ "Number of unacknowledged messages allowed. Recommended: set equal to the "
89
+ "consumer loop concurrency. A prefetch larger than concurrency lets idle "
90
+ "sibling messages sit unacked until they age past the broker "
91
+ "consumer_timeout, which triggers a Basic.Cancel and consumer recovery."
92
+ ),
93
+ )
71
94
  durable: bool = Field(default=True, description="Durable exchange and queue declarations")
72
95
  auto_ack: bool = Field(default=False, description="Automatically acknowledge messages on delivery")
73
96
  consume_timeout: float = Field(default=2.0, description="Max seconds to wait per fetch call")
@@ -45,6 +45,34 @@ def _wrap_handler_failure(result: Any) -> exceptions.TransactionException:
45
45
  )
46
46
 
47
47
 
48
+ def _warn_if_prefetch_exceeds_concurrency(conn: Any, policy: policies.ConsumerPolicy, task_name: str) -> None:
49
+ """Warn when a broker consumer holds more unacked messages than it can process.
50
+
51
+ A prefetch larger than the loop concurrency lets idle sibling messages sit
52
+ unacked until they age past the broker ``consumer_timeout``, which triggers
53
+ a ``Basic.Cancel`` and the whole consumer-recovery path. The connector
54
+ cannot see the loop concurrency and the policy cannot see the connector's
55
+ prefetch, so the check lives here where both are visible. Best-effort:
56
+ silently skips connectors that do not expose a consumer config.
57
+ """
58
+ consumer_config = getattr(conn, "_consumer_config", None)
59
+ prefetch = getattr(consumer_config, "prefetch_count", None)
60
+ if not isinstance(prefetch, int):
61
+ return
62
+ concurrency = policy.loop.concurrency.value
63
+ if prefetch > concurrency:
64
+ logger.warning(
65
+ "[%s] prefetch_count=%d exceeds loop concurrency=%d: up to %d messages will be "
66
+ "held unacked while only %d are processed concurrently. Idle siblings can age past "
67
+ "the broker consumer_timeout and trigger a Basic.Cancel. Set prefetch_count == concurrency.",
68
+ task_name,
69
+ prefetch,
70
+ concurrency,
71
+ prefetch,
72
+ concurrency,
73
+ )
74
+
75
+
48
76
  class ConsumerMixin(ABC):
49
77
  name: str
50
78
  connectors: dict[str, connector.Connector]
@@ -702,6 +730,7 @@ class AsyncConsumerMixin(ABC):
702
730
  logger.debug(f"Consume loop running with loop policy: {policy.loop.model_dump_json(indent=2)}")
703
731
 
704
732
  conn = self._resolve_connector(policy.fetch.connector_name)
733
+ _warn_if_prefetch_exceeds_concurrency(conn, policy, getattr(self, "name", self.__class__.__name__))
705
734
 
706
735
  ctx = otel_context.Context()
707
736
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ergon-framework-python
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Ergon internal task-oriented project bootstrapper
5
5
  Author-email: Ergondata Technologies <anza.vossos@protonmail.com>
6
6
  License-Expression: MIT
@@ -55,7 +55,7 @@ For local development, install in editable mode:
55
55
  pip install -e /path/to/ergon-framework/sdks/python
56
56
  ```
57
57
 
58
- 📖 **[Full Getting Started Guide](docs/getting-started.md)** — Complete setup, project configuration, and task registration
58
+ 📖 **[Full Getting Started Guide](https://github.com/danzanellovossos/ergon-framework/blob/main/sdks/python/docs/getting-started.md)** — Complete setup, project configuration, and task registration
59
59
 
60
60
  ---
61
61
 
@@ -419,7 +419,7 @@ my-project/
419
419
  └── helpers.py # Task-specific utilities
420
420
  ```
421
421
 
422
- 📖 **[Full Project Structure Guide](docs/project-structure.md)** — Detailed documentation on organizing connectors, tasks, and shared modules.
422
+ 📖 **[Full Project Structure Guide](https://github.com/danzanellovossos/ergon-framework/blob/main/sdks/python/docs/project-structure.md)** — Detailed documentation on organizing connectors, tasks, and shared modules.
423
423
 
424
424
  ---
425
425
 
@@ -427,23 +427,23 @@ my-project/
427
427
 
428
428
  ### Python SDK Guides
429
429
 
430
- - **[Getting Started](docs/getting-started.md)** — Installation, project setup, and running your first task
431
- - **[CLI Reference](docs/cli.md)** — Commands, options, and exit codes
432
- - **[Project Structure Guide](docs/project-structure.md)** — How to organize connectors, tasks, and shared modules
430
+ - **[Getting Started](https://github.com/danzanellovossos/ergon-framework/blob/main/sdks/python/docs/getting-started.md)** — Installation, project setup, and running your first task
431
+ - **[CLI Reference](https://github.com/danzanellovossos/ergon-framework/blob/main/sdks/python/docs/cli.md)** — Commands, options, and exit codes
432
+ - **[Project Structure Guide](https://github.com/danzanellovossos/ergon-framework/blob/main/sdks/python/docs/project-structure.md)** — How to organize connectors, tasks, and shared modules
433
433
 
434
434
  ### Framework Concepts
435
435
 
436
- - **[Framework Architecture](../../docs/architecture.md)** — Full system specification and design philosophy
437
- - **[Transaction Abstraction](../../docs/modules/1.transaction.md)** — Understanding atomicity rules
438
- - **[Task Module](../../docs/modules/2.task.md)** — Mixins, lifecycles, and execution modes
439
- - **[Connector Module](../../docs/modules/3.connector.md)** — Building integration boundaries
440
- - **[Service Module](../../docs/modules/4.service.md)** — Protocol engineering and reliability
441
- - **[Telemetry Module](../../docs/modules/5.telemetry.md)** — Configuring OTel logs, metrics, and traces
436
+ - **[Framework Architecture](https://github.com/danzanellovossos/ergon-framework/blob/main/docs/architecture.md)** — Full system specification and design philosophy
437
+ - **[Transaction Abstraction](https://github.com/danzanellovossos/ergon-framework/blob/main/docs/modules/1.transaction.md)** — Understanding atomicity rules
438
+ - **[Task Module](https://github.com/danzanellovossos/ergon-framework/blob/main/docs/modules/2.task.md)** — Mixins, lifecycles, and execution modes
439
+ - **[Connector Module](https://github.com/danzanellovossos/ergon-framework/blob/main/docs/modules/3.connector.md)** — Building integration boundaries
440
+ - **[Service Module](https://github.com/danzanellovossos/ergon-framework/blob/main/docs/modules/4.service.md)** — Protocol engineering and reliability
441
+ - **[Telemetry Module](https://github.com/danzanellovossos/ergon-framework/blob/main/docs/modules/5.telemetry.md)** — Configuring OTel logs, metrics, and traces
442
442
 
443
443
  ---
444
444
 
445
445
  ## License
446
446
 
447
- This project is licensed under the [MIT License](../../LICENSE).
447
+ This project is licensed under the [MIT License](https://github.com/danzanellovossos/ergon-framework/blob/main/LICENSE).
448
448
 
449
449
  <br/>
@@ -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.0"
7
+ assert ergon.__version__ == "0.1.1"