ergon-framework-python 0.1.1__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.1 → ergon_framework_python-0.1.2}/PKG-INFO +1 -1
  2. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/pyproject.toml +1 -1
  3. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/rabbitmq/async_connector.py +9 -0
  4. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/rabbitmq/async_service.py +121 -13
  5. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/rabbitmq/models.py +25 -2
  6. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/task/mixins/consumer.py +29 -0
  7. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon_framework_python.egg-info/PKG-INFO +1 -1
  8. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/LICENSE +0 -0
  9. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/README.md +0 -0
  10. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/setup.cfg +0 -0
  11. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/__init__.py +0 -0
  12. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/bootstrap/src/__project__/__init__.py +0 -0
  13. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/bootstrap/src/__project__/_observability/docker-compose.telemetry.yml +0 -0
  14. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/bootstrap/src/__project__/_observability/grafana.yaml +0 -0
  15. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/bootstrap/src/__project__/_observability/loki.yaml +0 -0
  16. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/bootstrap/src/__project__/_observability/otel-collector-config.yaml +0 -0
  17. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/bootstrap/src/__project__/_observability/prometheus.yaml +0 -0
  18. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/bootstrap/src/__project__/_observability/tempo.yaml +0 -0
  19. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/bootstrap/src/__project__/connectors/__init__.py +0 -0
  20. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/bootstrap/src/__project__/main.py +0 -0
  21. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/bootstrap/src/__project__/tasks/__init__.py +0 -0
  22. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/bootstrap/src/__project__/tasks/constants.py +0 -0
  23. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/bootstrap/src/__project__/tasks/example_task/__init__.py +0 -0
  24. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/bootstrap/src/__project__/tasks/example_task/config.py +0 -0
  25. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/bootstrap/src/__project__/tasks/example_task/exceptions.py +0 -0
  26. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/bootstrap/src/__project__/tasks/example_task/helpers.py +0 -0
  27. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/bootstrap/src/__project__/tasks/example_task/schemas.py +0 -0
  28. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/bootstrap/src/__project__/tasks/example_task/task.py +0 -0
  29. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/bootstrap/src/__project__/tasks/exceptions.py +0 -0
  30. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/bootstrap/src/__project__/tasks/helpers.py +0 -0
  31. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/bootstrap/src/__project__/tasks/schemas.py +0 -0
  32. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/bootstrap/src/__project__/tasks/settings.py +0 -0
  33. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/cli.py +0 -0
  34. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/__init__.py +0 -0
  35. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/connector.py +0 -0
  36. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/excel/__init__.py +0 -0
  37. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/excel/connector.py +0 -0
  38. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/excel/models.py +0 -0
  39. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/excel/service.py +0 -0
  40. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/pipefy/__init__.py +0 -0
  41. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/pipefy/async_connector.py +0 -0
  42. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/pipefy/async_service.py +0 -0
  43. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/pipefy/connector.py +0 -0
  44. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/pipefy/models.py +0 -0
  45. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/pipefy/service.py +0 -0
  46. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/pipefy/version.py +0 -0
  47. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/postgres/__init__.py +0 -0
  48. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/postgres/async_connector.py +0 -0
  49. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/postgres/async_service.py +0 -0
  50. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/postgres/models.py +0 -0
  51. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/rabbitmq/__init__.py +0 -0
  52. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/rabbitmq/connector.py +0 -0
  53. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/rabbitmq/helper.py +0 -0
  54. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/rabbitmq/service.py +0 -0
  55. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/sqs/__init__.py +0 -0
  56. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/sqs/async_connector.py +0 -0
  57. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/sqs/async_service.py +0 -0
  58. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/sqs/connector.py +0 -0
  59. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/sqs/models.py +0 -0
  60. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/sqs/service.py +0 -0
  61. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/transaction.py +0 -0
  62. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/py.typed +0 -0
  63. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/service/__init__.py +0 -0
  64. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/service/service.py +0 -0
  65. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/task/__init__.py +0 -0
  66. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/task/base.py +0 -0
  67. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/task/exceptions.py +0 -0
  68. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/task/helpers.py +0 -0
  69. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/task/manager.py +0 -0
  70. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/task/mixins/__init__.py +0 -0
  71. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/task/mixins/metrics.py +0 -0
  72. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/task/mixins/producer.py +0 -0
  73. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/task/policies.py +0 -0
  74. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/task/runner.py +0 -0
  75. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/task/utils.py +0 -0
  76. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/telemetry/__init__.py +0 -0
  77. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/telemetry/_resource.py +0 -0
  78. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/telemetry/logging.py +0 -0
  79. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/telemetry/metrics.py +0 -0
  80. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/telemetry/tracing.py +0 -0
  81. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/utils/__init__.py +0 -0
  82. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/utils/env.py +0 -0
  83. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon_framework_python.egg-info/SOURCES.txt +0 -0
  84. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon_framework_python.egg-info/dependency_links.txt +0 -0
  85. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon_framework_python.egg-info/entry_points.txt +0 -0
  86. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon_framework_python.egg-info/requires.txt +0 -0
  87. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon_framework_python.egg-info/top_level.txt +0 -0
  88. {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/tests/test_smoke.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ergon-framework-python
3
- Version: 0.1.1
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ergon-framework-python"
3
- version = "0.1.1"
3
+ version = "0.1.2"
4
4
  description = "Ergon internal task-oriented project bootstrapper"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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.1
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