ergon-framework-python 0.1.5__tar.gz → 0.1.7__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/PKG-INFO +1 -1
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/pyproject.toml +1 -1
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/__init__.py +1 -1
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/rabbitmq/async_service.py +134 -58
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/task/runner.py +77 -8
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon_framework_python.egg-info/PKG-INFO +1 -1
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/tests/test_smoke.py +1 -1
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/LICENSE +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/README.md +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/setup.cfg +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/bootstrap/src/__project__/__init__.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/bootstrap/src/__project__/_observability/docker-compose.telemetry.yml +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/bootstrap/src/__project__/_observability/grafana.yaml +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/bootstrap/src/__project__/_observability/loki.yaml +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/bootstrap/src/__project__/_observability/otel-collector-config.yaml +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/bootstrap/src/__project__/_observability/prometheus.yaml +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/bootstrap/src/__project__/_observability/tempo.yaml +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/bootstrap/src/__project__/connectors/__init__.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/bootstrap/src/__project__/main.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/bootstrap/src/__project__/tasks/__init__.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/bootstrap/src/__project__/tasks/constants.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/bootstrap/src/__project__/tasks/example_task/__init__.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/bootstrap/src/__project__/tasks/example_task/config.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/bootstrap/src/__project__/tasks/example_task/exceptions.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/bootstrap/src/__project__/tasks/example_task/helpers.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/bootstrap/src/__project__/tasks/example_task/schemas.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/bootstrap/src/__project__/tasks/example_task/task.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/bootstrap/src/__project__/tasks/exceptions.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/bootstrap/src/__project__/tasks/helpers.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/bootstrap/src/__project__/tasks/schemas.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/bootstrap/src/__project__/tasks/settings.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/cli.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/__init__.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/connector.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/ergon_platform/__init__.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/ergon_platform/_client.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/ergon_platform/_operations.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/ergon_platform/async_connector.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/ergon_platform/connector.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/ergon_platform/models.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/ergon_platform/utils.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/excel/__init__.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/excel/connector.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/excel/models.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/excel/service.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/nylas/__init__.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/nylas/async_auth_service.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/nylas/async_connector.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/nylas/async_service.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/nylas/auth_service.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/nylas/connector.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/nylas/models.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/nylas/service.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/nylas/utils.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/pipefy/__init__.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/pipefy/async_connector.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/pipefy/async_service.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/pipefy/connector.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/pipefy/models.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/pipefy/service.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/pipefy/version.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/postgres/__init__.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/postgres/async_connector.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/postgres/async_service.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/postgres/models.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/rabbitmq/__init__.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/rabbitmq/async_connector.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/rabbitmq/connector.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/rabbitmq/helper.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/rabbitmq/models.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/rabbitmq/service.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/sqs/__init__.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/sqs/async_connector.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/sqs/async_service.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/sqs/connector.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/sqs/models.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/sqs/service.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/transaction.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/py.typed +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/service/__init__.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/service/service.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/task/__init__.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/task/base.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/task/exceptions.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/task/helpers.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/task/liveness.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/task/manager.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/task/mixins/__init__.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/task/mixins/consumer.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/task/mixins/metrics.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/task/mixins/producer.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/task/policies.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/task/utils.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/telemetry/__init__.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/telemetry/_resource.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/telemetry/logging.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/telemetry/metrics.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/telemetry/tracing.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/utils/__init__.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/utils/env.py +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon_framework_python.egg-info/SOURCES.txt +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon_framework_python.egg-info/dependency_links.txt +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon_framework_python.egg-info/entry_points.txt +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon_framework_python.egg-info/requires.txt +0 -0
- {ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon_framework_python.egg-info/top_level.txt +0 -0
|
@@ -4,7 +4,6 @@ import logging
|
|
|
4
4
|
import ssl as ssl_module
|
|
5
5
|
import time
|
|
6
6
|
from collections import deque
|
|
7
|
-
from contextlib import AsyncExitStack
|
|
8
7
|
from typing import Any, AsyncContextManager, Callable, Dict, List, Optional, cast
|
|
9
8
|
|
|
10
9
|
import aio_pika
|
|
@@ -24,15 +23,36 @@ from .models import AsyncRabbitmqClient, AsyncRabbitmqConsumerConfig, AsyncRabbi
|
|
|
24
23
|
logger = logging.getLogger(__name__)
|
|
25
24
|
|
|
26
25
|
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
|
|
26
|
+
# Invalid channel state always means the active transport disappeared. Broader
|
|
27
|
+
# AMQP connection hierarchies also contain permanent authentication/protocol
|
|
28
|
+
# errors, so consume-time recovery uses the classifier below instead.
|
|
29
|
+
_CHANNEL_INVALID_EXCEPTIONS: tuple[type[BaseException], ...] = (
|
|
30
|
+
aio_pika.exceptions.ChannelInvalidStateError,
|
|
31
|
+
aiormq.exceptions.ChannelInvalidStateError,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _is_recoverable_broker_interruption(exc: BaseException) -> bool:
|
|
36
|
+
if isinstance(exc, _CHANNEL_INVALID_EXCEPTIONS):
|
|
37
|
+
return True
|
|
38
|
+
if type(exc) is aiormq.exceptions.AMQPConnectionError:
|
|
39
|
+
return True
|
|
40
|
+
if type(exc) is aiormq.exceptions.ConnectionClosed:
|
|
41
|
+
reply_code = exc.args[0] if exc.args else None
|
|
42
|
+
return reply_code == 320
|
|
43
|
+
if type(exc) is aiormq.exceptions.ConnectionInternalError:
|
|
44
|
+
return True
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# Settlement can additionally discover a channel-level failure after delivery.
|
|
49
|
+
# Surface those as DeadChannelError so the consumer mixin does not attempt a
|
|
50
|
+
# second settlement on the same unusable channel.
|
|
31
51
|
_DEAD_CHANNEL_EXCEPTIONS: tuple[type[BaseException], ...] = (
|
|
52
|
+
aio_pika.exceptions.AMQPConnectionError,
|
|
53
|
+
*_CHANNEL_INVALID_EXCEPTIONS,
|
|
32
54
|
aio_pika.exceptions.MessageProcessError,
|
|
33
55
|
aio_pika.exceptions.ChannelClosed,
|
|
34
|
-
aio_pika.exceptions.ChannelInvalidStateError,
|
|
35
|
-
aiormq.exceptions.ChannelInvalidStateError,
|
|
36
56
|
)
|
|
37
57
|
|
|
38
58
|
# Same as above plus ``TimeoutError`` (raised by the async timeout context when an
|
|
@@ -193,7 +213,8 @@ class AsyncRabbitMQService:
|
|
|
193
213
|
return
|
|
194
214
|
try:
|
|
195
215
|
if not channel.is_closed:
|
|
196
|
-
|
|
216
|
+
async with timeout_after(min(1.0, self.client.channel_timeout)):
|
|
217
|
+
await channel.close()
|
|
197
218
|
logger.info("Closed dead consume channel (%s)", reason)
|
|
198
219
|
except Exception as exc: # noqa: BLE001 - best-effort teardown
|
|
199
220
|
logger.warning("Error closing consume channel during teardown (%s): %r", reason, exc)
|
|
@@ -365,27 +386,33 @@ class AsyncRabbitMQService:
|
|
|
365
386
|
self._consumer_state = "subscribing"
|
|
366
387
|
poll_completed = False
|
|
367
388
|
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
arguments=subscription.queue_arguments or None,
|
|
379
|
-
)
|
|
380
|
-
for binding in subscription.bindings:
|
|
381
|
-
exchange = await self.declare_exchange(
|
|
382
|
-
binding.exchange_name,
|
|
383
|
-
binding.exchange_type,
|
|
389
|
+
try:
|
|
390
|
+
# Ensure the consume channel exists with the requested prefetch QoS.
|
|
391
|
+
# The return value is unused here because declarations re-fetch the
|
|
392
|
+
# channel from the cache.
|
|
393
|
+
await self._get_consume_channel(prefetch_count=config.prefetch_count)
|
|
394
|
+
|
|
395
|
+
queues: list[tuple[str, AbstractQueue]] = []
|
|
396
|
+
for subscription in config.resolved_subscriptions():
|
|
397
|
+
queue = await self.declare_queue(
|
|
398
|
+
subscription.queue_name,
|
|
384
399
|
durable=config.durable,
|
|
400
|
+
arguments=subscription.queue_arguments or None,
|
|
385
401
|
)
|
|
386
|
-
for
|
|
387
|
-
await self.
|
|
388
|
-
|
|
402
|
+
for binding in subscription.bindings:
|
|
403
|
+
exchange = await self.declare_exchange(
|
|
404
|
+
binding.exchange_name,
|
|
405
|
+
binding.exchange_type,
|
|
406
|
+
durable=config.durable,
|
|
407
|
+
)
|
|
408
|
+
for key in binding.routing_keys:
|
|
409
|
+
await self.bind_queue(queue, exchange, key)
|
|
410
|
+
queues.append((subscription.queue_name, queue))
|
|
411
|
+
except Exception as exc:
|
|
412
|
+
if not _is_recoverable_broker_interruption(exc):
|
|
413
|
+
raise
|
|
414
|
+
await self._teardown_consume_channel(f"subscription setup interrupted: {exc!r}")
|
|
415
|
+
return []
|
|
389
416
|
|
|
390
417
|
buffer: List[Dict[str, Any]] = []
|
|
391
418
|
timeout = config.consume_timeout
|
|
@@ -404,7 +431,9 @@ class AsyncRabbitMQService:
|
|
|
404
431
|
)
|
|
405
432
|
except TimeoutError:
|
|
406
433
|
poll_completed = True
|
|
407
|
-
except
|
|
434
|
+
except Exception as exc:
|
|
435
|
+
if not _is_recoverable_broker_interruption(exc):
|
|
436
|
+
raise
|
|
408
437
|
# Broker cancelled this subscription mid-iteration; deterministically
|
|
409
438
|
# tear down (cancel consumers + close) the dead channel so the next
|
|
410
439
|
# call rebuilds against a fresh consumer, the broker redelivers what
|
|
@@ -435,15 +464,27 @@ class AsyncRabbitMQService:
|
|
|
435
464
|
batch_size: int,
|
|
436
465
|
auto_ack: bool,
|
|
437
466
|
) -> None:
|
|
438
|
-
"""
|
|
467
|
+
"""Wait for one delivery, then drain only messages already available.
|
|
468
|
+
|
|
469
|
+
``consume_timeout`` bounds the idle wait in :meth:`consume`; it must
|
|
470
|
+
not become a batch-assembly delay after the first delivery. Once any
|
|
471
|
+
message is buffered, give newly scheduled iterator reads one event-loop
|
|
472
|
+
turn to complete and return immediately if none are ready.
|
|
473
|
+
"""
|
|
439
474
|
pending: dict[asyncio.Task, tuple[str, Any]] = {}
|
|
440
|
-
|
|
475
|
+
iterator_contexts: list[AsyncContextManager[Any]] = []
|
|
476
|
+
try:
|
|
441
477
|
for queue_name, queue in queues:
|
|
442
478
|
iterator_context = cast(
|
|
443
479
|
AsyncContextManager[Any],
|
|
444
|
-
|
|
480
|
+
# The framework owns consumer teardown/resubscription.
|
|
481
|
+
# Letting aio-pika restore these short-lived per-fetch
|
|
482
|
+
# consumers can resurrect an iterator with no pending
|
|
483
|
+
# reader after a broker restart, creating a zombie tag.
|
|
484
|
+
queue.iterator(no_ack=auto_ack, robust=False),
|
|
445
485
|
)
|
|
446
|
-
iterator = await
|
|
486
|
+
iterator = await iterator_context.__aenter__()
|
|
487
|
+
iterator_contexts.append(iterator_context)
|
|
447
488
|
self._register_consumer_cancel_callback(iterator)
|
|
448
489
|
consumer_tag = getattr(iterator, "_consumer_tag", None) or getattr(iterator, "consumer_tag", None)
|
|
449
490
|
if consumer_tag:
|
|
@@ -451,33 +492,68 @@ class AsyncRabbitMQService:
|
|
|
451
492
|
pending[asyncio.create_task(anext(iterator))] = (queue_name, iterator)
|
|
452
493
|
|
|
453
494
|
self._active_consumer_tag = next(iter(self._active_consumer_tags.values()), None)
|
|
454
|
-
|
|
495
|
+
while pending and len(buffer) < batch_size:
|
|
496
|
+
if buffer:
|
|
497
|
+
done = {task for task in pending if task.done()}
|
|
498
|
+
if not done:
|
|
499
|
+
await asyncio.sleep(0)
|
|
500
|
+
done = {task for task in pending if task.done()}
|
|
501
|
+
if not done:
|
|
502
|
+
break
|
|
503
|
+
else:
|
|
504
|
+
done, _ = await asyncio.wait(
|
|
505
|
+
pending,
|
|
506
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
507
|
+
)
|
|
508
|
+
for task in done:
|
|
509
|
+
queue_name, iterator = pending.pop(task)
|
|
510
|
+
try:
|
|
511
|
+
message = task.result()
|
|
512
|
+
except StopAsyncIteration:
|
|
513
|
+
self._active_consumer_tags.pop(queue_name, None)
|
|
514
|
+
continue
|
|
515
|
+
|
|
516
|
+
message_dict = self._message_to_dict(message, queue_name=queue_name)
|
|
517
|
+
self._in_flight_message_ids.add(id(message))
|
|
518
|
+
self._last_fetch_ts = time.time()
|
|
519
|
+
self._consumer_state = "delivering"
|
|
520
|
+
if len(buffer) < batch_size:
|
|
521
|
+
buffer.append(message_dict)
|
|
522
|
+
else:
|
|
523
|
+
self._prefetched_messages.append(message_dict)
|
|
524
|
+
if len(buffer) < batch_size:
|
|
525
|
+
pending[asyncio.create_task(anext(iterator))] = (queue_name, iterator)
|
|
526
|
+
finally:
|
|
527
|
+
cleanup_timeout = min(1.0, self.client.channel_timeout)
|
|
528
|
+
cleanup_failed = False
|
|
529
|
+
for task in pending:
|
|
530
|
+
task.cancel()
|
|
531
|
+
if pending:
|
|
532
|
+
try:
|
|
533
|
+
async with timeout_after(cleanup_timeout):
|
|
534
|
+
await asyncio.gather(*pending, return_exceptions=True)
|
|
535
|
+
except TimeoutError:
|
|
536
|
+
cleanup_failed = True
|
|
537
|
+
logger.warning("Timed out cancelling RabbitMQ iterator reads")
|
|
538
|
+
cleanup_failed = await self._close_iterator_contexts(iterator_contexts) or cleanup_failed
|
|
539
|
+
if cleanup_failed:
|
|
540
|
+
await self._teardown_consume_channel("queue iterator cleanup failed")
|
|
541
|
+
|
|
542
|
+
async def _close_iterator_contexts(
|
|
543
|
+
self,
|
|
544
|
+
iterator_contexts: list[AsyncContextManager[Any]],
|
|
545
|
+
) -> bool:
|
|
546
|
+
"""Bound queue-iterator cleanup so a dead broker cannot stall a poll."""
|
|
547
|
+
cleanup_timeout = min(1.0, self.client.channel_timeout)
|
|
548
|
+
cleanup_failed = False
|
|
549
|
+
for iterator_context in reversed(iterator_contexts):
|
|
455
550
|
try:
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
except StopAsyncIteration:
|
|
463
|
-
self._active_consumer_tags.pop(queue_name, None)
|
|
464
|
-
continue
|
|
465
|
-
|
|
466
|
-
message_dict = self._message_to_dict(message, queue_name=queue_name)
|
|
467
|
-
self._in_flight_message_ids.add(id(message))
|
|
468
|
-
self._last_fetch_ts = time.time()
|
|
469
|
-
self._consumer_state = "delivering"
|
|
470
|
-
if len(buffer) < batch_size:
|
|
471
|
-
buffer.append(message_dict)
|
|
472
|
-
else:
|
|
473
|
-
self._prefetched_messages.append(message_dict)
|
|
474
|
-
if len(buffer) < batch_size:
|
|
475
|
-
pending[asyncio.create_task(anext(iterator))] = (queue_name, iterator)
|
|
476
|
-
finally:
|
|
477
|
-
for task in pending:
|
|
478
|
-
task.cancel()
|
|
479
|
-
if pending:
|
|
480
|
-
await asyncio.gather(*pending, return_exceptions=True)
|
|
551
|
+
async with timeout_after(cleanup_timeout):
|
|
552
|
+
await iterator_context.__aexit__(None, None, None)
|
|
553
|
+
except Exception as exc:
|
|
554
|
+
cleanup_failed = True
|
|
555
|
+
logger.warning("RabbitMQ queue iterator cleanup failed: %r", exc)
|
|
556
|
+
return cleanup_failed
|
|
481
557
|
|
|
482
558
|
def _register_consumer_cancel_callback(self, iterator: Any) -> None:
|
|
483
559
|
"""Best-effort registration of an on-cancel callback on the iterator's consumer.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
+
import inspect
|
|
2
3
|
import logging as stdlib_logging
|
|
3
4
|
import os
|
|
4
5
|
import signal
|
|
@@ -6,6 +7,7 @@ import threading
|
|
|
6
7
|
import time
|
|
7
8
|
import traceback
|
|
8
9
|
import uuid
|
|
10
|
+
from collections.abc import Callable
|
|
9
11
|
from concurrent.futures import ProcessPoolExecutor
|
|
10
12
|
from datetime import datetime
|
|
11
13
|
from enum import IntEnum
|
|
@@ -41,6 +43,8 @@ class ExitCode(IntEnum):
|
|
|
41
43
|
|
|
42
44
|
_shutdown_event = threading.Event()
|
|
43
45
|
_shutdown_signal: int | None = None
|
|
46
|
+
_shutdown_callbacks: set[Callable[[], None]] = set()
|
|
47
|
+
_shutdown_callbacks_lock = threading.Lock()
|
|
44
48
|
_supervisor_logger = stdlib_logging.getLogger(__name__)
|
|
45
49
|
|
|
46
50
|
|
|
@@ -48,6 +52,13 @@ def _signal_handler(signum, frame):
|
|
|
48
52
|
global _shutdown_signal
|
|
49
53
|
_shutdown_signal = signum
|
|
50
54
|
_shutdown_event.set()
|
|
55
|
+
with _shutdown_callbacks_lock:
|
|
56
|
+
callbacks = tuple(_shutdown_callbacks)
|
|
57
|
+
for callback in callbacks:
|
|
58
|
+
try:
|
|
59
|
+
callback()
|
|
60
|
+
except Exception:
|
|
61
|
+
_supervisor_logger.exception("Async shutdown callback failed")
|
|
51
62
|
|
|
52
63
|
|
|
53
64
|
def _install_signal_handlers():
|
|
@@ -55,6 +66,27 @@ def _install_signal_handlers():
|
|
|
55
66
|
signal.signal(signal.SIGTERM, _signal_handler)
|
|
56
67
|
|
|
57
68
|
|
|
69
|
+
def _reset_shutdown_state() -> None:
|
|
70
|
+
global _shutdown_signal
|
|
71
|
+
_shutdown_event.clear()
|
|
72
|
+
_shutdown_signal = None
|
|
73
|
+
with _shutdown_callbacks_lock:
|
|
74
|
+
_shutdown_callbacks.clear()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _register_shutdown_callback(callback: Callable[[], None]) -> None:
|
|
78
|
+
with _shutdown_callbacks_lock:
|
|
79
|
+
_shutdown_callbacks.add(callback)
|
|
80
|
+
shutdown_requested = _shutdown_event.is_set()
|
|
81
|
+
if shutdown_requested:
|
|
82
|
+
callback()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _unregister_shutdown_callback(callback: Callable[[], None]) -> None:
|
|
86
|
+
with _shutdown_callbacks_lock:
|
|
87
|
+
_shutdown_callbacks.discard(callback)
|
|
88
|
+
|
|
89
|
+
|
|
58
90
|
def is_shutdown_requested() -> bool:
|
|
59
91
|
return _shutdown_event.is_set()
|
|
60
92
|
|
|
@@ -216,20 +248,31 @@ async def __run_task_async(
|
|
|
216
248
|
|
|
217
249
|
instance = None
|
|
218
250
|
supervisor = None
|
|
251
|
+
liveness_shutdown_requested = threading.Event()
|
|
252
|
+
connectors: dict[str, Any] = {}
|
|
253
|
+
services: dict[str, Any] = {}
|
|
254
|
+
loop = asyncio.get_running_loop()
|
|
255
|
+
runner_task = asyncio.current_task()
|
|
256
|
+
|
|
257
|
+
def request_signal_shutdown() -> None:
|
|
258
|
+
if runner_task is not None and not runner_task.done():
|
|
259
|
+
loop.call_soon_threadsafe(runner_task.cancel, "Process shutdown requested")
|
|
260
|
+
|
|
261
|
+
_register_shutdown_callback(request_signal_shutdown)
|
|
219
262
|
|
|
220
263
|
try:
|
|
221
264
|
with tracer.start_as_current_span( # type: ignore[attr-defined]
|
|
222
265
|
f"{config.task.__name__}.run",
|
|
223
266
|
attributes={"task.execution.id": task_exec_metadata["execution_id"]},
|
|
224
267
|
):
|
|
225
|
-
connectors = {}
|
|
226
268
|
for name, cfg in config.connectors.items():
|
|
227
269
|
conn = cfg.connector(*cfg.args, **cfg.kwargs)
|
|
270
|
+
connectors[name] = conn
|
|
228
271
|
if hasattr(conn, "init_async"):
|
|
229
272
|
await conn.init_async() # type: ignore[attr-defined]
|
|
230
|
-
connectors[name] = conn
|
|
231
273
|
|
|
232
|
-
|
|
274
|
+
for name, cfg in config.services.items():
|
|
275
|
+
services[name] = cfg.service(*cfg.args, **cfg.kwargs)
|
|
233
276
|
|
|
234
277
|
instance = config.task(
|
|
235
278
|
connectors=connectors,
|
|
@@ -242,10 +285,9 @@ async def __run_task_async(
|
|
|
242
285
|
)
|
|
243
286
|
|
|
244
287
|
if config.supervision.enabled and isinstance(instance, LivenessProvider):
|
|
245
|
-
loop = asyncio.get_running_loop()
|
|
246
|
-
runner_task = asyncio.current_task()
|
|
247
288
|
|
|
248
289
|
def request_liveness_shutdown(reason: str) -> None:
|
|
290
|
+
liveness_shutdown_requested.set()
|
|
249
291
|
if runner_task is not None and not runner_task.done():
|
|
250
292
|
loop.call_soon_threadsafe(runner_task.cancel, f"Task unhealthy: {reason}")
|
|
251
293
|
|
|
@@ -266,11 +308,37 @@ async def __run_task_async(
|
|
|
266
308
|
else:
|
|
267
309
|
await instance.execute()
|
|
268
310
|
|
|
311
|
+
except asyncio.CancelledError:
|
|
312
|
+
if not is_shutdown_requested():
|
|
313
|
+
raise
|
|
269
314
|
finally:
|
|
270
|
-
|
|
315
|
+
_unregister_shutdown_callback(request_signal_shutdown)
|
|
316
|
+
if supervisor is not None and not liveness_shutdown_requested.is_set():
|
|
271
317
|
supervisor.stop()
|
|
272
|
-
|
|
273
|
-
|
|
318
|
+
try:
|
|
319
|
+
if instance is not None:
|
|
320
|
+
await instance.exit()
|
|
321
|
+
finally:
|
|
322
|
+
try:
|
|
323
|
+
resources = [*connectors.values(), *services.values()]
|
|
324
|
+
for resource in reversed(resources):
|
|
325
|
+
close = getattr(resource, "close", None)
|
|
326
|
+
if close is None:
|
|
327
|
+
close = getattr(resource, "aclose", None)
|
|
328
|
+
if not callable(close):
|
|
329
|
+
continue
|
|
330
|
+
try:
|
|
331
|
+
result = close()
|
|
332
|
+
if inspect.isawaitable(result):
|
|
333
|
+
await result
|
|
334
|
+
except Exception:
|
|
335
|
+
_supervisor_logger.exception(
|
|
336
|
+
"Failed to close async task resource %s",
|
|
337
|
+
type(resource).__name__,
|
|
338
|
+
)
|
|
339
|
+
finally:
|
|
340
|
+
if supervisor is not None:
|
|
341
|
+
supervisor.stop()
|
|
274
342
|
|
|
275
343
|
|
|
276
344
|
# =============================================================
|
|
@@ -424,6 +492,7 @@ def run_task(
|
|
|
424
492
|
Returns POSIX-compatible exit code.
|
|
425
493
|
"""
|
|
426
494
|
|
|
495
|
+
_reset_shutdown_state()
|
|
427
496
|
_install_signal_handlers()
|
|
428
497
|
is_async = issubclass(config.task, BaseAsyncTask) # type: ignore[arg-type]
|
|
429
498
|
|
|
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.5 → ergon_framework_python-0.1.7}/src/ergon/connector/__init__.py
RENAMED
|
File without changes
|
{ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/connector.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
|
{ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/excel/__init__.py
RENAMED
|
File without changes
|
{ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/excel/connector.py
RENAMED
|
File without changes
|
{ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/excel/models.py
RENAMED
|
File without changes
|
{ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/excel/service.py
RENAMED
|
File without changes
|
{ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/nylas/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/nylas/connector.py
RENAMED
|
File without changes
|
{ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/nylas/models.py
RENAMED
|
File without changes
|
{ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/nylas/service.py
RENAMED
|
File without changes
|
{ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/nylas/utils.py
RENAMED
|
File without changes
|
{ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/pipefy/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/pipefy/models.py
RENAMED
|
File without changes
|
{ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/pipefy/service.py
RENAMED
|
File without changes
|
{ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/pipefy/version.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/postgres/models.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/rabbitmq/helper.py
RENAMED
|
File without changes
|
{ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/rabbitmq/models.py
RENAMED
|
File without changes
|
|
File without changes
|
{ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/sqs/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/sqs/connector.py
RENAMED
|
File without changes
|
{ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/sqs/models.py
RENAMED
|
File without changes
|
{ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/connector/sqs/service.py
RENAMED
|
File without changes
|
{ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/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
|
|
File without changes
|
{ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/task/mixins/__init__.py
RENAMED
|
File without changes
|
{ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/task/mixins/consumer.py
RENAMED
|
File without changes
|
{ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/task/mixins/metrics.py
RENAMED
|
File without changes
|
{ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/task/mixins/producer.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/telemetry/__init__.py
RENAMED
|
File without changes
|
{ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/telemetry/_resource.py
RENAMED
|
File without changes
|
{ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/telemetry/logging.py
RENAMED
|
File without changes
|
{ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/src/ergon/telemetry/metrics.py
RENAMED
|
File without changes
|
{ergon_framework_python-0.1.5 → ergon_framework_python-0.1.7}/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
|