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.
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/PKG-INFO +1 -1
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/pyproject.toml +1 -1
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/rabbitmq/async_connector.py +9 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/rabbitmq/async_service.py +121 -13
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/rabbitmq/models.py +25 -2
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/task/mixins/consumer.py +29 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon_framework_python.egg-info/PKG-INFO +1 -1
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/LICENSE +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/README.md +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/setup.cfg +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/__init__.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/bootstrap/src/__project__/__init__.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/bootstrap/src/__project__/_observability/docker-compose.telemetry.yml +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/bootstrap/src/__project__/_observability/grafana.yaml +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/bootstrap/src/__project__/_observability/loki.yaml +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/bootstrap/src/__project__/_observability/otel-collector-config.yaml +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/bootstrap/src/__project__/_observability/prometheus.yaml +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/bootstrap/src/__project__/_observability/tempo.yaml +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/bootstrap/src/__project__/connectors/__init__.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/bootstrap/src/__project__/main.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/bootstrap/src/__project__/tasks/__init__.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/bootstrap/src/__project__/tasks/constants.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/bootstrap/src/__project__/tasks/example_task/__init__.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/bootstrap/src/__project__/tasks/example_task/config.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/bootstrap/src/__project__/tasks/example_task/exceptions.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/bootstrap/src/__project__/tasks/example_task/helpers.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/bootstrap/src/__project__/tasks/example_task/schemas.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/bootstrap/src/__project__/tasks/example_task/task.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/bootstrap/src/__project__/tasks/exceptions.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/bootstrap/src/__project__/tasks/helpers.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/bootstrap/src/__project__/tasks/schemas.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/bootstrap/src/__project__/tasks/settings.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/cli.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/__init__.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/connector.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/excel/__init__.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/excel/connector.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/excel/models.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/excel/service.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/pipefy/__init__.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/pipefy/async_connector.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/pipefy/async_service.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/pipefy/connector.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/pipefy/models.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/pipefy/service.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/pipefy/version.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/postgres/__init__.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/postgres/async_connector.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/postgres/async_service.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/postgres/models.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/rabbitmq/__init__.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/rabbitmq/connector.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/rabbitmq/helper.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/rabbitmq/service.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/sqs/__init__.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/sqs/async_connector.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/sqs/async_service.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/sqs/connector.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/sqs/models.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/sqs/service.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/transaction.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/py.typed +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/service/__init__.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/service/service.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/task/__init__.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/task/base.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/task/exceptions.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/task/helpers.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/task/manager.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/task/mixins/__init__.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/task/mixins/metrics.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/task/mixins/producer.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/task/policies.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/task/runner.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/task/utils.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/telemetry/__init__.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/telemetry/_resource.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/telemetry/logging.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/telemetry/metrics.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/telemetry/tracing.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/utils/__init__.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/utils/env.py +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon_framework_python.egg-info/SOURCES.txt +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon_framework_python.egg-info/dependency_links.txt +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon_framework_python.egg-info/entry_points.txt +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon_framework_python.egg-info/requires.txt +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon_framework_python.egg-info/top_level.txt +0 -0
- {ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/tests/test_smoke.py +0 -0
|
@@ -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.
|
|
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;
|
|
258
|
-
# the
|
|
259
|
-
# consumer
|
|
260
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
377
|
-
|
|
378
|
-
self.
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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:
|
{ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/rabbitmq/models.py
RENAMED
|
@@ -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(
|
|
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(
|
|
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")
|
{ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/task/mixins/consumer.py
RENAMED
|
@@ -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
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/__init__.py
RENAMED
|
File without changes
|
{ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/connector.py
RENAMED
|
File without changes
|
{ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/excel/__init__.py
RENAMED
|
File without changes
|
{ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/excel/connector.py
RENAMED
|
File without changes
|
{ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/excel/models.py
RENAMED
|
File without changes
|
{ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/excel/service.py
RENAMED
|
File without changes
|
{ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/pipefy/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/pipefy/models.py
RENAMED
|
File without changes
|
{ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/pipefy/service.py
RENAMED
|
File without changes
|
{ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/pipefy/version.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/postgres/models.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/rabbitmq/helper.py
RENAMED
|
File without changes
|
|
File without changes
|
{ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/sqs/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/sqs/connector.py
RENAMED
|
File without changes
|
{ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/sqs/models.py
RENAMED
|
File without changes
|
{ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/sqs/service.py
RENAMED
|
File without changes
|
{ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/connector/transaction.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/task/mixins/__init__.py
RENAMED
|
File without changes
|
{ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/task/mixins/metrics.py
RENAMED
|
File without changes
|
{ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/task/mixins/producer.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/telemetry/__init__.py
RENAMED
|
File without changes
|
{ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/telemetry/_resource.py
RENAMED
|
File without changes
|
{ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/telemetry/logging.py
RENAMED
|
File without changes
|
{ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/telemetry/metrics.py
RENAMED
|
File without changes
|
{ergon_framework_python-0.1.1 → ergon_framework_python-0.1.2}/src/ergon/telemetry/tracing.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|