ergon-framework-python 0.1.0__py3-none-any.whl
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/__init__.py +13 -0
- ergon/bootstrap/src/__project__/__init__.py +0 -0
- ergon/bootstrap/src/__project__/_observability/docker-compose.telemetry.yml +124 -0
- ergon/bootstrap/src/__project__/_observability/grafana.yaml +17 -0
- ergon/bootstrap/src/__project__/_observability/loki.yaml +48 -0
- ergon/bootstrap/src/__project__/_observability/otel-collector-config.yaml +53 -0
- ergon/bootstrap/src/__project__/_observability/prometheus.yaml +11 -0
- ergon/bootstrap/src/__project__/_observability/tempo.yaml +24 -0
- ergon/bootstrap/src/__project__/connectors/__init__.py +0 -0
- ergon/bootstrap/src/__project__/main.py +9 -0
- ergon/bootstrap/src/__project__/tasks/__init__.py +0 -0
- ergon/bootstrap/src/__project__/tasks/constants.py +13 -0
- ergon/bootstrap/src/__project__/tasks/example_task/__init__.py +0 -0
- ergon/bootstrap/src/__project__/tasks/example_task/config.py +4 -0
- ergon/bootstrap/src/__project__/tasks/example_task/exceptions.py +4 -0
- ergon/bootstrap/src/__project__/tasks/example_task/helpers.py +4 -0
- ergon/bootstrap/src/__project__/tasks/example_task/schemas.py +5 -0
- ergon/bootstrap/src/__project__/tasks/example_task/task.py +1 -0
- ergon/bootstrap/src/__project__/tasks/exceptions.py +0 -0
- ergon/bootstrap/src/__project__/tasks/helpers.py +0 -0
- ergon/bootstrap/src/__project__/tasks/schemas.py +0 -0
- ergon/bootstrap/src/__project__/tasks/settings.py +5 -0
- ergon/cli.py +174 -0
- ergon/connector/__init__.py +64 -0
- ergon/connector/connector.py +97 -0
- ergon/connector/excel/__init__.py +18 -0
- ergon/connector/excel/connector.py +175 -0
- ergon/connector/excel/models.py +24 -0
- ergon/connector/excel/service.py +98 -0
- ergon/connector/pipefy/__init__.py +21 -0
- ergon/connector/pipefy/async_connector.py +48 -0
- ergon/connector/pipefy/async_service.py +907 -0
- ergon/connector/pipefy/connector.py +36 -0
- ergon/connector/pipefy/models.py +48 -0
- ergon/connector/pipefy/service.py +1016 -0
- ergon/connector/pipefy/version.py +1 -0
- ergon/connector/postgres/__init__.py +11 -0
- ergon/connector/postgres/async_connector.py +119 -0
- ergon/connector/postgres/async_service.py +116 -0
- ergon/connector/postgres/models.py +34 -0
- ergon/connector/rabbitmq/__init__.py +25 -0
- ergon/connector/rabbitmq/async_connector.py +120 -0
- ergon/connector/rabbitmq/async_service.py +417 -0
- ergon/connector/rabbitmq/connector.py +54 -0
- ergon/connector/rabbitmq/helper.py +14 -0
- ergon/connector/rabbitmq/models.py +92 -0
- ergon/connector/rabbitmq/service.py +199 -0
- ergon/connector/sqs/__init__.py +15 -0
- ergon/connector/sqs/async_connector.py +120 -0
- ergon/connector/sqs/async_service.py +246 -0
- ergon/connector/sqs/connector.py +120 -0
- ergon/connector/sqs/models.py +36 -0
- ergon/connector/sqs/service.py +219 -0
- ergon/connector/transaction.py +14 -0
- ergon/py.typed +0 -0
- ergon/service/__init__.py +5 -0
- ergon/service/service.py +17 -0
- ergon/task/__init__.py +13 -0
- ergon/task/base.py +222 -0
- ergon/task/exceptions.py +217 -0
- ergon/task/helpers.py +691 -0
- ergon/task/manager.py +85 -0
- ergon/task/mixins/__init__.py +13 -0
- ergon/task/mixins/consumer.py +858 -0
- ergon/task/mixins/metrics.py +457 -0
- ergon/task/mixins/producer.py +486 -0
- ergon/task/policies.py +229 -0
- ergon/task/runner.py +386 -0
- ergon/task/utils.py +64 -0
- ergon/telemetry/__init__.py +7 -0
- ergon/telemetry/_resource.py +13 -0
- ergon/telemetry/logging.py +370 -0
- ergon/telemetry/metrics.py +101 -0
- ergon/telemetry/tracing.py +152 -0
- ergon/utils/__init__.py +5 -0
- ergon/utils/env.py +26 -0
- ergon_framework_python-0.1.0.dist-info/METADATA +449 -0
- ergon_framework_python-0.1.0.dist-info/RECORD +82 -0
- ergon_framework_python-0.1.0.dist-info/WHEEL +5 -0
- ergon_framework_python-0.1.0.dist-info/entry_points.txt +2 -0
- ergon_framework_python-0.1.0.dist-info/licenses/LICENSE +21 -0
- ergon_framework_python-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import ssl as ssl_module
|
|
5
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
import aio_pika
|
|
8
|
+
import aio_pika.exceptions
|
|
9
|
+
import aiormq.exceptions
|
|
10
|
+
from aio_pika.abc import (
|
|
11
|
+
AbstractChannel,
|
|
12
|
+
AbstractExchange,
|
|
13
|
+
AbstractIncomingMessage,
|
|
14
|
+
AbstractQueue,
|
|
15
|
+
AbstractRobustConnection,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
from .models import AsyncRabbitmqClient, AsyncRabbitmqConsumerConfig, AsyncRabbitmqProducerConfig
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# Exception classes that signal the underlying channel is no longer usable.
|
|
24
|
+
# We catch these on ack/nack and surface a typed DeadChannelError so the
|
|
25
|
+
# consumer mixin can short-circuit cleanly instead of cascading further
|
|
26
|
+
# failures (nack on the same dead channel).
|
|
27
|
+
_DEAD_CHANNEL_EXCEPTIONS: tuple[type[BaseException], ...] = (
|
|
28
|
+
aio_pika.exceptions.MessageProcessError,
|
|
29
|
+
aio_pika.exceptions.ChannelClosed,
|
|
30
|
+
aio_pika.exceptions.ChannelInvalidStateError,
|
|
31
|
+
aiormq.exceptions.ChannelInvalidStateError,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AsyncRabbitMQService:
|
|
36
|
+
def __init__(self, client: AsyncRabbitmqClient) -> None:
|
|
37
|
+
self.client = client
|
|
38
|
+
|
|
39
|
+
self._connection: Optional[AbstractRobustConnection] = None
|
|
40
|
+
# Separate channels for consume and publish so a consumer-side outage
|
|
41
|
+
# (e.g. Basic.Cancel from broker after a force-recreate) cannot poison
|
|
42
|
+
# the publish path, and vice-versa.
|
|
43
|
+
self._consume_channel: Optional[AbstractChannel] = None
|
|
44
|
+
self._publish_channel: Optional[AbstractChannel] = None
|
|
45
|
+
# Caches keyed off the consume channel; invalidated whenever the
|
|
46
|
+
# consume channel is closed / cancelled so the next consume() rebuilds
|
|
47
|
+
# the subscription on a fresh channel and the broker redelivers any
|
|
48
|
+
# in-flight prefetch.
|
|
49
|
+
self._exchanges: Dict[str, AbstractExchange] = {}
|
|
50
|
+
self._queues: Dict[str, AbstractQueue] = {}
|
|
51
|
+
|
|
52
|
+
# ---------- Connection / Channel ----------
|
|
53
|
+
|
|
54
|
+
async def _get_connection(self) -> AbstractRobustConnection:
|
|
55
|
+
if self._connection is None or self._connection.is_closed:
|
|
56
|
+
url = self.client.get_url()
|
|
57
|
+
|
|
58
|
+
kwargs: Dict[str, Any] = {}
|
|
59
|
+
if self.client.ssl_enabled:
|
|
60
|
+
ctx = ssl_module.create_default_context()
|
|
61
|
+
if self.client.ssl_ca_certs:
|
|
62
|
+
ctx.load_verify_locations(self.client.ssl_ca_certs)
|
|
63
|
+
kwargs["ssl"] = True
|
|
64
|
+
kwargs["ssl_context"] = ctx
|
|
65
|
+
|
|
66
|
+
self._connection = await aio_pika.connect_robust(
|
|
67
|
+
url,
|
|
68
|
+
heartbeat=self.client.heartbeat,
|
|
69
|
+
**kwargs,
|
|
70
|
+
)
|
|
71
|
+
logger.info("Connected to RabbitMQ at %s", url.split("@")[-1] if "@" in url else url)
|
|
72
|
+
|
|
73
|
+
return self._connection
|
|
74
|
+
|
|
75
|
+
def _invalidate_consume_channel(self, reason: str = "explicit invalidation") -> None:
|
|
76
|
+
"""Drop cached consume channel + queue/exchange handles.
|
|
77
|
+
|
|
78
|
+
Called when the consume channel is closed by the broker (e.g. after
|
|
79
|
+
a Basic.Cancel during force-recreate) or when an ack/nack discovers
|
|
80
|
+
the channel is dead. The next ``consume()`` call will then rebuild
|
|
81
|
+
the subscription on a fresh channel, which causes the broker to
|
|
82
|
+
redeliver any messages that were stuck in the previous prefetch
|
|
83
|
+
buffer to the dead consumer tag.
|
|
84
|
+
"""
|
|
85
|
+
if self._consume_channel is None and not self._queues and not self._exchanges:
|
|
86
|
+
return
|
|
87
|
+
logger.warning("Invalidating consume channel cache (%s)", reason)
|
|
88
|
+
self._consume_channel = None
|
|
89
|
+
self._queues.clear()
|
|
90
|
+
self._exchanges.clear()
|
|
91
|
+
|
|
92
|
+
def _on_consume_channel_close(self, *args: Any, **kwargs: Any) -> None:
|
|
93
|
+
"""Callback registered with ``channel.add_close_callback``.
|
|
94
|
+
|
|
95
|
+
``aio_pika`` invokes close callbacks with varying signatures across
|
|
96
|
+
versions; accept ``*args, **kwargs`` to stay compatible.
|
|
97
|
+
"""
|
|
98
|
+
exc = args[1] if len(args) >= 2 else kwargs.get("exc")
|
|
99
|
+
reason = f"channel closed: {exc!r}" if exc is not None else "channel closed"
|
|
100
|
+
self._invalidate_consume_channel(reason)
|
|
101
|
+
|
|
102
|
+
async def _get_consume_channel(self, prefetch_count: Optional[int] = None) -> AbstractChannel:
|
|
103
|
+
if self._consume_channel is None or self._consume_channel.is_closed:
|
|
104
|
+
connection = await self._get_connection()
|
|
105
|
+
self._consume_channel = await connection.channel()
|
|
106
|
+
# ``add_close_callback`` is not part of the abstract interface
|
|
107
|
+
# but is provided by the concrete ``Channel`` class. Older
|
|
108
|
+
# aio_pika versions and test mocks may not expose it, so we
|
|
109
|
+
# access via getattr and skip silently when absent — the
|
|
110
|
+
# consume() path also self-heals via is_closed checks.
|
|
111
|
+
register_close = getattr(self._consume_channel, "add_close_callback", None)
|
|
112
|
+
if callable(register_close):
|
|
113
|
+
try:
|
|
114
|
+
register_close(self._on_consume_channel_close)
|
|
115
|
+
except TypeError:
|
|
116
|
+
logger.debug("Consume channel close-callback registration rejected; skipping")
|
|
117
|
+
else:
|
|
118
|
+
logger.debug("Consume channel does not support add_close_callback; skipping registration")
|
|
119
|
+
if prefetch_count is not None:
|
|
120
|
+
await self._consume_channel.set_qos(prefetch_count=prefetch_count)
|
|
121
|
+
logger.debug("Consume channel QoS set to prefetch_count=%d", prefetch_count)
|
|
122
|
+
|
|
123
|
+
return self._consume_channel
|
|
124
|
+
|
|
125
|
+
async def _get_publish_channel(self) -> AbstractChannel:
|
|
126
|
+
if self._publish_channel is None or self._publish_channel.is_closed:
|
|
127
|
+
connection = await self._get_connection()
|
|
128
|
+
self._publish_channel = await connection.channel()
|
|
129
|
+
|
|
130
|
+
return self._publish_channel
|
|
131
|
+
|
|
132
|
+
# Backwards-compatible alias preserved for any external callers / tests
|
|
133
|
+
# that referenced the original single-channel accessor. Defaults to the
|
|
134
|
+
# consume channel since that was the original behaviour for consume().
|
|
135
|
+
async def _get_channel(self, prefetch_count: Optional[int] = None) -> AbstractChannel:
|
|
136
|
+
return await self._get_consume_channel(prefetch_count=prefetch_count)
|
|
137
|
+
|
|
138
|
+
# ---------- Declarations ----------
|
|
139
|
+
|
|
140
|
+
async def declare_exchange(
|
|
141
|
+
self,
|
|
142
|
+
name: str,
|
|
143
|
+
exchange_type: str = "topic",
|
|
144
|
+
durable: bool = True,
|
|
145
|
+
) -> AbstractExchange:
|
|
146
|
+
if name in self._exchanges:
|
|
147
|
+
return self._exchanges[name]
|
|
148
|
+
|
|
149
|
+
channel = await self._get_consume_channel()
|
|
150
|
+
ex_type = aio_pika.ExchangeType(exchange_type)
|
|
151
|
+
exchange = await channel.declare_exchange(name, ex_type, durable=durable)
|
|
152
|
+
self._exchanges[name] = exchange
|
|
153
|
+
logger.debug("Declared exchange: name=%s type=%s durable=%s", name, exchange_type, durable)
|
|
154
|
+
return exchange
|
|
155
|
+
|
|
156
|
+
async def declare_queue(
|
|
157
|
+
self,
|
|
158
|
+
name: str,
|
|
159
|
+
durable: bool = True,
|
|
160
|
+
arguments: Optional[Dict[str, Any]] = None,
|
|
161
|
+
) -> AbstractQueue:
|
|
162
|
+
# Cache key includes ``arguments`` so two configs that target the same
|
|
163
|
+
# queue name but with different x-arguments (e.g. different DLX wiring)
|
|
164
|
+
# cannot silently share a cached handle. Without this, the second call
|
|
165
|
+
# would get the first declaration's queue object back regardless of its
|
|
166
|
+
# ``arguments`` payload — masking misconfiguration in tests and dev.
|
|
167
|
+
cache_key = self._queue_cache_key(name, arguments)
|
|
168
|
+
if cache_key in self._queues:
|
|
169
|
+
return self._queues[cache_key]
|
|
170
|
+
|
|
171
|
+
channel = await self._get_consume_channel()
|
|
172
|
+
queue = await channel.declare_queue(name, durable=durable, arguments=arguments)
|
|
173
|
+
self._queues[cache_key] = queue
|
|
174
|
+
logger.debug(
|
|
175
|
+
"Declared queue: name=%s durable=%s arguments=%s",
|
|
176
|
+
name,
|
|
177
|
+
durable,
|
|
178
|
+
arguments or {},
|
|
179
|
+
)
|
|
180
|
+
return queue
|
|
181
|
+
|
|
182
|
+
@staticmethod
|
|
183
|
+
def _queue_cache_key(name: str, arguments: Optional[Dict[str, Any]]) -> str:
|
|
184
|
+
if not arguments:
|
|
185
|
+
return name
|
|
186
|
+
# Sort for stable hashing regardless of insertion order; values are
|
|
187
|
+
# rendered via repr to handle non-hashable values (lists, dicts) that
|
|
188
|
+
# AMQP allows in the x-arguments table.
|
|
189
|
+
rendered = ",".join(f"{k}={arguments[k]!r}" for k in sorted(arguments))
|
|
190
|
+
return f"{name}#{rendered}"
|
|
191
|
+
|
|
192
|
+
async def bind_queue(
|
|
193
|
+
self,
|
|
194
|
+
queue: AbstractQueue,
|
|
195
|
+
exchange: AbstractExchange,
|
|
196
|
+
routing_key: str,
|
|
197
|
+
) -> None:
|
|
198
|
+
await queue.bind(exchange, routing_key=routing_key)
|
|
199
|
+
logger.debug("Bound queue=%s to exchange=%s with key=%s", queue.name, exchange.name, routing_key)
|
|
200
|
+
|
|
201
|
+
# ---------- Consume ----------
|
|
202
|
+
|
|
203
|
+
async def consume(
|
|
204
|
+
self,
|
|
205
|
+
config: AsyncRabbitmqConsumerConfig,
|
|
206
|
+
batch_size: int = 1,
|
|
207
|
+
) -> List[Dict[str, Any]]:
|
|
208
|
+
"""
|
|
209
|
+
Fetch up to batch_size messages from the configured queue.
|
|
210
|
+
|
|
211
|
+
Declares exchange/queue/bindings on first call, then iterates the
|
|
212
|
+
queue collecting messages until batch_size is reached or
|
|
213
|
+
consume_timeout elapses.
|
|
214
|
+
|
|
215
|
+
Returns raw message dicts without acknowledging — the caller
|
|
216
|
+
is responsible for ack/nack via the delivery_tag in metadata.
|
|
217
|
+
"""
|
|
218
|
+
# Ensure the consume channel exists with the requested prefetch QoS.
|
|
219
|
+
# The return value is unused here because declare_exchange/declare_queue
|
|
220
|
+
# re-fetch the channel from the cache.
|
|
221
|
+
await self._get_consume_channel(prefetch_count=config.prefetch_count)
|
|
222
|
+
|
|
223
|
+
if config.exchange_name:
|
|
224
|
+
exchange = await self.declare_exchange(
|
|
225
|
+
config.exchange_name,
|
|
226
|
+
config.exchange_type,
|
|
227
|
+
durable=config.durable,
|
|
228
|
+
)
|
|
229
|
+
else:
|
|
230
|
+
exchange = None
|
|
231
|
+
|
|
232
|
+
queue = await self.declare_queue(
|
|
233
|
+
config.queue_name,
|
|
234
|
+
durable=config.durable,
|
|
235
|
+
arguments=config.queue_arguments or None,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
if exchange is not None:
|
|
239
|
+
for key in config.binding_keys:
|
|
240
|
+
await self.bind_queue(queue, exchange, key)
|
|
241
|
+
|
|
242
|
+
buffer: List[Dict[str, Any]] = []
|
|
243
|
+
timeout = config.consume_timeout
|
|
244
|
+
|
|
245
|
+
try:
|
|
246
|
+
async with asyncio.timeout(timeout): # type: ignore[attr-defined]
|
|
247
|
+
async with queue.iterator(no_ack=config.auto_ack) as iterator:
|
|
248
|
+
self._register_consumer_cancel_callback(iterator)
|
|
249
|
+
async for message in iterator:
|
|
250
|
+
msg_dict = self._message_to_dict(message)
|
|
251
|
+
buffer.append(msg_dict)
|
|
252
|
+
if len(buffer) >= batch_size:
|
|
253
|
+
break
|
|
254
|
+
except TimeoutError:
|
|
255
|
+
pass
|
|
256
|
+
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}")
|
|
261
|
+
return buffer
|
|
262
|
+
finally:
|
|
263
|
+
# If the channel was closed during iteration, make sure we don't
|
|
264
|
+
# hand back a stale cache to the next caller.
|
|
265
|
+
if self._consume_channel is not None and self._consume_channel.is_closed:
|
|
266
|
+
self._invalidate_consume_channel("consume channel observed closed after iteration")
|
|
267
|
+
|
|
268
|
+
return buffer
|
|
269
|
+
|
|
270
|
+
def _register_consumer_cancel_callback(self, iterator: Any) -> None:
|
|
271
|
+
"""Best-effort registration of an on-cancel callback on the iterator's consumer.
|
|
272
|
+
|
|
273
|
+
``aio_pika`` does not expose a stable public API for this across
|
|
274
|
+
versions; we probe for known attributes and fall back silently.
|
|
275
|
+
The channel close callback in :meth:`_get_consume_channel` provides
|
|
276
|
+
the primary defence — this is belt-and-braces.
|
|
277
|
+
"""
|
|
278
|
+
candidate: Optional[Callable[[Callable[..., Any]], Any]] = None
|
|
279
|
+
for attr in ("add_on_cancel_callback", "add_close_callback"):
|
|
280
|
+
consumer_obj = getattr(iterator, "_consumer", None) or getattr(iterator, "consumer", None)
|
|
281
|
+
if consumer_obj is not None:
|
|
282
|
+
candidate = getattr(consumer_obj, attr, None)
|
|
283
|
+
if candidate is not None:
|
|
284
|
+
break
|
|
285
|
+
candidate = getattr(iterator, attr, None)
|
|
286
|
+
if candidate is not None:
|
|
287
|
+
break
|
|
288
|
+
|
|
289
|
+
if candidate is None:
|
|
290
|
+
return
|
|
291
|
+
|
|
292
|
+
def _on_cancel(*_args: Any, **_kwargs: Any) -> None:
|
|
293
|
+
self._invalidate_consume_channel("consumer cancelled by broker (Basic.Cancel)")
|
|
294
|
+
|
|
295
|
+
try:
|
|
296
|
+
candidate(_on_cancel)
|
|
297
|
+
except (TypeError, AttributeError):
|
|
298
|
+
logger.debug("Iterator consumer does not accept cancel callback; skipping")
|
|
299
|
+
|
|
300
|
+
@staticmethod
|
|
301
|
+
def _message_to_dict(message: AbstractIncomingMessage) -> Dict[str, Any]:
|
|
302
|
+
try:
|
|
303
|
+
body = json.loads(message.body.decode("utf-8"))
|
|
304
|
+
except Exception:
|
|
305
|
+
body = message.body
|
|
306
|
+
|
|
307
|
+
return {
|
|
308
|
+
"body": body,
|
|
309
|
+
"routing_key": message.routing_key or "",
|
|
310
|
+
"delivery_tag": message.delivery_tag,
|
|
311
|
+
"headers": dict(message.headers) if message.headers else {},
|
|
312
|
+
"content_type": message.content_type,
|
|
313
|
+
"message_id": message.message_id,
|
|
314
|
+
"correlation_id": message.correlation_id,
|
|
315
|
+
"_message": message,
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
# ---------- Publish ----------
|
|
319
|
+
|
|
320
|
+
async def publish(
|
|
321
|
+
self,
|
|
322
|
+
config: AsyncRabbitmqProducerConfig,
|
|
323
|
+
body: bytes,
|
|
324
|
+
routing_key: Optional[str] = None,
|
|
325
|
+
headers: Optional[Dict[str, Any]] = None,
|
|
326
|
+
) -> None:
|
|
327
|
+
channel = await self._get_publish_channel()
|
|
328
|
+
|
|
329
|
+
if config.exchange_name:
|
|
330
|
+
exchange = await self._declare_publish_exchange(
|
|
331
|
+
channel,
|
|
332
|
+
config.exchange_name,
|
|
333
|
+
config.exchange_type,
|
|
334
|
+
durable=config.durable,
|
|
335
|
+
)
|
|
336
|
+
else:
|
|
337
|
+
exchange = channel.default_exchange
|
|
338
|
+
|
|
339
|
+
rk = routing_key or config.routing_key
|
|
340
|
+
|
|
341
|
+
delivery_mode = (
|
|
342
|
+
aio_pika.DeliveryMode.PERSISTENT if config.delivery_mode == 2 else aio_pika.DeliveryMode.NOT_PERSISTENT
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
message = aio_pika.Message(
|
|
346
|
+
body=body,
|
|
347
|
+
content_type=config.content_type,
|
|
348
|
+
delivery_mode=delivery_mode,
|
|
349
|
+
headers=headers,
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
await exchange.publish(message, routing_key=rk)
|
|
353
|
+
logger.debug("Published message to exchange=%s routing_key=%s", config.exchange_name or "(default)", rk)
|
|
354
|
+
|
|
355
|
+
async def _declare_publish_exchange(
|
|
356
|
+
self,
|
|
357
|
+
channel: AbstractChannel,
|
|
358
|
+
name: str,
|
|
359
|
+
exchange_type: str,
|
|
360
|
+
durable: bool,
|
|
361
|
+
) -> AbstractExchange:
|
|
362
|
+
# Publish exchanges live on the publish channel and are not cached
|
|
363
|
+
# alongside consume-side declarations; declare-on-demand is cheap
|
|
364
|
+
# because RabbitMQ treats matching declarations as idempotent.
|
|
365
|
+
ex_type = aio_pika.ExchangeType(exchange_type)
|
|
366
|
+
return await channel.declare_exchange(name, ex_type, durable=durable)
|
|
367
|
+
|
|
368
|
+
# ---------- Ack / Nack ----------
|
|
369
|
+
|
|
370
|
+
async def ack(self, message: AbstractIncomingMessage) -> None:
|
|
371
|
+
# Imported lazily to avoid a circular import between
|
|
372
|
+
# ergon.connector and ergon.task at package init time.
|
|
373
|
+
from ...task import exceptions as task_exceptions
|
|
374
|
+
|
|
375
|
+
try:
|
|
376
|
+
await message.ack()
|
|
377
|
+
except _DEAD_CHANNEL_EXCEPTIONS as exc:
|
|
378
|
+
self._invalidate_consume_channel(f"ack failed: {exc!r}")
|
|
379
|
+
raise task_exceptions.AckOnDeadChannelError(
|
|
380
|
+
delivery_tag=getattr(message, "delivery_tag", None),
|
|
381
|
+
queue=getattr(message, "routing_key", None),
|
|
382
|
+
cause=exc,
|
|
383
|
+
) from exc
|
|
384
|
+
|
|
385
|
+
async def nack(self, message: AbstractIncomingMessage, requeue: bool = True) -> None:
|
|
386
|
+
from ...task import exceptions as task_exceptions
|
|
387
|
+
|
|
388
|
+
try:
|
|
389
|
+
await message.nack(requeue=requeue)
|
|
390
|
+
except _DEAD_CHANNEL_EXCEPTIONS as exc:
|
|
391
|
+
self._invalidate_consume_channel(f"nack failed: {exc!r}")
|
|
392
|
+
raise task_exceptions.NackOnDeadChannelError(
|
|
393
|
+
delivery_tag=getattr(message, "delivery_tag", None),
|
|
394
|
+
queue=getattr(message, "routing_key", None),
|
|
395
|
+
cause=exc,
|
|
396
|
+
) from exc
|
|
397
|
+
|
|
398
|
+
# ---------- Lifecycle ----------
|
|
399
|
+
|
|
400
|
+
async def close(self) -> None:
|
|
401
|
+
self._exchanges.clear()
|
|
402
|
+
self._queues.clear()
|
|
403
|
+
|
|
404
|
+
for attr_name in ("_consume_channel", "_publish_channel"):
|
|
405
|
+
channel = getattr(self, attr_name)
|
|
406
|
+
if channel is not None and not channel.is_closed:
|
|
407
|
+
try:
|
|
408
|
+
await channel.close()
|
|
409
|
+
except Exception as exc:
|
|
410
|
+
logger.warning("Error closing %s: %r", attr_name, exc)
|
|
411
|
+
setattr(self, attr_name, None)
|
|
412
|
+
|
|
413
|
+
if self._connection is not None and not self._connection.is_closed:
|
|
414
|
+
await self._connection.close()
|
|
415
|
+
self._connection = None
|
|
416
|
+
|
|
417
|
+
logger.info("RabbitMQ connection closed")
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from typing import Dict, Generator, List, Optional
|
|
2
|
+
|
|
3
|
+
from ..connector import Connector
|
|
4
|
+
from ..transaction import Transaction
|
|
5
|
+
from .models import RabbitmqClient
|
|
6
|
+
from .service import RabbitMQService
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RabbitMQConnector(Connector):
|
|
10
|
+
service: RabbitMQService
|
|
11
|
+
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
client: RabbitmqClient,
|
|
15
|
+
default_queue: Optional[str] = None,
|
|
16
|
+
auto_ack: bool = False,
|
|
17
|
+
) -> None:
|
|
18
|
+
self.service = RabbitMQService(client)
|
|
19
|
+
|
|
20
|
+
self.default_queue = default_queue or client.queue_name
|
|
21
|
+
self.auto_ack = auto_ack
|
|
22
|
+
|
|
23
|
+
# Mantém generators por (queue_name, auto_ack)
|
|
24
|
+
self._iterators: Dict[tuple, Generator[dict, None, None]] = {}
|
|
25
|
+
|
|
26
|
+
def fetch_transactions(
|
|
27
|
+
self,
|
|
28
|
+
batch_size: int = 1,
|
|
29
|
+
queue_name: Optional[str] = None,
|
|
30
|
+
auto_ack: bool = False,
|
|
31
|
+
metadata: Optional[dict] = None,
|
|
32
|
+
*args,
|
|
33
|
+
**kwargs,
|
|
34
|
+
) -> List[Transaction]:
|
|
35
|
+
"""
|
|
36
|
+
Busca até `batch_size` mensagens da fila indicada, transformando
|
|
37
|
+
cada uma em um Transaction do ergon.
|
|
38
|
+
"""
|
|
39
|
+
queue_items = self.service.consume(queue_name=queue_name, auto_ack=auto_ack, batch_size=batch_size)
|
|
40
|
+
|
|
41
|
+
if not queue_items:
|
|
42
|
+
return []
|
|
43
|
+
|
|
44
|
+
return [Transaction(id=str(queue_item["delivery_tag"]), payload=queue_item) for queue_item in queue_items]
|
|
45
|
+
|
|
46
|
+
def dispatch_transactions(
|
|
47
|
+
self,
|
|
48
|
+
transactions: List[Transaction],
|
|
49
|
+
*args,
|
|
50
|
+
**kwargs,
|
|
51
|
+
) -> None:
|
|
52
|
+
"""Publish transactions as messages to RabbitMQ."""
|
|
53
|
+
for txn in transactions:
|
|
54
|
+
self.service.publish(txn.payload)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import uuid
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def headers_generator(
|
|
7
|
+
id: uuid.UUID, content_type: Optional[str] = None, source: Optional[str] = None
|
|
8
|
+
) -> Dict[str, Any]:
|
|
9
|
+
return {
|
|
10
|
+
"content-type": content_type or "application/json",
|
|
11
|
+
"correlation_id": str(id),
|
|
12
|
+
"source": source,
|
|
13
|
+
"timestamp": int(time.time()),
|
|
14
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from typing import Any, Literal, Optional
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel, Field
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class RabbitmqClient(BaseModel):
|
|
8
|
+
host: str = Field(default="localhost", description="The RabbitMQ host")
|
|
9
|
+
port: int = Field(default=5672, description="The RabbitMQ port")
|
|
10
|
+
username: str = Field(description="The RabbitMQ username")
|
|
11
|
+
password: str = Field(description="The RabbitMQ password")
|
|
12
|
+
queue_name: str = Field(default="minha fila", description="The RabbitMQ queue name")
|
|
13
|
+
prefetch_count: int = Field(default=10, description="The number of prefetched messages")
|
|
14
|
+
virtual_host: Optional[str] = Field(default="/", description="The RabbitMQ vhost")
|
|
15
|
+
connection_attempts: int = Field(default=3, description="The RabbitMQ connection retry attempts")
|
|
16
|
+
socket_timeout: float = Field(default=999, description="Socket timeout in seconds")
|
|
17
|
+
heartbeat: Optional[float] = Field(default=600, description="Heartbeat interval in seconds")
|
|
18
|
+
blocked_connection_timeout: Optional[float] = Field(
|
|
19
|
+
default=None, description="Timeout when connection is blocked by the broker"
|
|
20
|
+
)
|
|
21
|
+
ssl_enabled: bool = Field(default=False, description="Enable SSL/TLS")
|
|
22
|
+
ssl_ca_certs: Optional[str] = Field(default=None, description="Path to CA certificate when using SSL")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RabbitmqProducerMessage(BaseModel):
|
|
26
|
+
queue_name: str = Field(description="The RabbitMQ queue name")
|
|
27
|
+
durable: bool = Field(default=True, description="True for queue persists if broker restart")
|
|
28
|
+
body: dict = Field(default={"message": "Hello World!"}, description="The body of the message")
|
|
29
|
+
delivery_mode: Literal[1, 2] = Field(
|
|
30
|
+
default=2, description="1 for not persistent and 2 for persistent (It needs durable True)"
|
|
31
|
+
)
|
|
32
|
+
content_type: str = Field(default="application/json", description="Content type of the message")
|
|
33
|
+
priority: int = Field(default=0, description="The priority of the message 0 for default")
|
|
34
|
+
timestamp: int = Field(default_factory=lambda: int(time.time()), description="The timestamp of the message")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class RabbitmqConsumerMessage(BaseModel):
|
|
38
|
+
queue_name: str = Field(description="The RabbitMQ queue name")
|
|
39
|
+
auto_ack: bool = Field(default=True, description="True for automatic acknowledgement")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ---------------------------------------------------------
|
|
43
|
+
# ASYNC MODELS (aio-pika)
|
|
44
|
+
# ---------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class AsyncRabbitmqClient(BaseModel):
|
|
48
|
+
url: Optional[str] = Field(default=None, description="Full AMQP URL (takes precedence over individual params)")
|
|
49
|
+
host: str = Field(default="localhost", description="RabbitMQ host")
|
|
50
|
+
port: int = Field(default=5672, description="RabbitMQ port")
|
|
51
|
+
username: str = Field(default="guest", description="RabbitMQ username")
|
|
52
|
+
password: str = Field(default="guest", description="RabbitMQ password")
|
|
53
|
+
virtual_host: str = Field(default="/", description="RabbitMQ virtual host")
|
|
54
|
+
heartbeat: int = Field(default=600, description="Heartbeat interval in seconds")
|
|
55
|
+
connection_attempts: int = Field(default=3, description="Connection retry attempts")
|
|
56
|
+
ssl_enabled: bool = Field(default=False, description="Enable SSL/TLS")
|
|
57
|
+
ssl_ca_certs: Optional[str] = Field(default=None, description="Path to CA certificate when using SSL")
|
|
58
|
+
|
|
59
|
+
def get_url(self) -> str:
|
|
60
|
+
if self.url:
|
|
61
|
+
return self.url
|
|
62
|
+
return f"amqp://{self.username}:{self.password}@{self.host}:{self.port}/{self.virtual_host}"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class AsyncRabbitmqConsumerConfig(BaseModel):
|
|
66
|
+
queue_name: str = Field(description="Queue to consume from")
|
|
67
|
+
exchange_name: str = Field(default="", description="Exchange name (empty for default exchange)")
|
|
68
|
+
exchange_type: str = Field(default="topic", description="Exchange type: topic, direct, fanout, headers")
|
|
69
|
+
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")
|
|
71
|
+
durable: bool = Field(default=True, description="Durable exchange and queue declarations")
|
|
72
|
+
auto_ack: bool = Field(default=False, description="Automatically acknowledge messages on delivery")
|
|
73
|
+
consume_timeout: float = Field(default=2.0, description="Max seconds to wait per fetch call")
|
|
74
|
+
queue_arguments: dict[str, Any] = Field(
|
|
75
|
+
default_factory=dict,
|
|
76
|
+
description=(
|
|
77
|
+
"Extra AMQP arguments forwarded to queue.declare (the AMQP `x-arguments` table). "
|
|
78
|
+
"Use for dead-lettering (`x-dead-letter-exchange`, `x-dead-letter-routing-key`), "
|
|
79
|
+
"TTL (`x-message-ttl`), max length, etc. Note: changing these on an existing queue "
|
|
80
|
+
"will cause RabbitMQ to reject re-declaration with PRECONDITION_FAILED; the queue "
|
|
81
|
+
"must be recreated or the args set via a broker-side Policy."
|
|
82
|
+
),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class AsyncRabbitmqProducerConfig(BaseModel):
|
|
87
|
+
exchange_name: str = Field(default="", description="Exchange to publish to (empty for default exchange)")
|
|
88
|
+
exchange_type: str = Field(default="topic", description="Exchange type: topic, direct, fanout, headers")
|
|
89
|
+
routing_key: str = Field(default="", description="Default routing key for published messages")
|
|
90
|
+
durable: bool = Field(default=True, description="Durable exchange declaration")
|
|
91
|
+
delivery_mode: Literal[1, 2] = Field(default=2, description="1 for transient, 2 for persistent")
|
|
92
|
+
content_type: str = Field(default="application/json", description="Content type of published messages")
|