jararaca 0.3.11a16__py3-none-any.whl → 0.3.12__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.
Potentially problematic release.
This version of jararaca might be problematic. Click here for more details.
- README.md +120 -0
- jararaca/__init__.py +106 -8
- jararaca/cli.py +216 -31
- jararaca/messagebus/worker.py +749 -272
- jararaca/microservice.py +42 -0
- jararaca/persistence/interceptors/aiosqa_interceptor.py +82 -73
- jararaca/persistence/interceptors/constants.py +1 -0
- jararaca/persistence/interceptors/decorators.py +45 -0
- jararaca/presentation/server.py +57 -11
- jararaca/presentation/websocket/redis.py +113 -7
- jararaca/reflect/metadata.py +1 -1
- jararaca/rpc/http/__init__.py +97 -0
- jararaca/rpc/http/backends/__init__.py +10 -0
- jararaca/rpc/http/backends/httpx.py +39 -9
- jararaca/rpc/http/decorators.py +302 -6
- jararaca/scheduler/beat_worker.py +550 -91
- jararaca/tools/typescript/__init__.py +0 -0
- jararaca/tools/typescript/decorators.py +95 -0
- jararaca/tools/typescript/interface_parser.py +699 -156
- jararaca-0.3.12.dist-info/LICENSE +674 -0
- {jararaca-0.3.11a16.dist-info → jararaca-0.3.12.dist-info}/METADATA +4 -3
- {jararaca-0.3.11a16.dist-info → jararaca-0.3.12.dist-info}/RECORD +26 -19
- {jararaca-0.3.11a16.dist-info → jararaca-0.3.12.dist-info}/WHEEL +1 -1
- pyproject.toml +86 -0
- /jararaca-0.3.11a16.dist-info/LICENSE → /LICENSE +0 -0
- {jararaca-0.3.11a16.dist-info → jararaca-0.3.12.dist-info}/entry_points.txt +0 -0
jararaca/messagebus/worker.py
CHANGED
|
@@ -23,7 +23,14 @@ from urllib.parse import parse_qs, urlparse
|
|
|
23
23
|
import aio_pika
|
|
24
24
|
import aio_pika.abc
|
|
25
25
|
import uvloop
|
|
26
|
-
from aio_pika.exceptions import
|
|
26
|
+
from aio_pika.exceptions import (
|
|
27
|
+
AMQPChannelError,
|
|
28
|
+
AMQPConnectionError,
|
|
29
|
+
AMQPError,
|
|
30
|
+
ChannelClosed,
|
|
31
|
+
ChannelNotFoundEntity,
|
|
32
|
+
ConnectionClosed,
|
|
33
|
+
)
|
|
27
34
|
from pydantic import BaseModel
|
|
28
35
|
|
|
29
36
|
from jararaca.broker_backend import MessageBrokerBackend
|
|
@@ -49,6 +56,8 @@ from jararaca.microservice import (
|
|
|
49
56
|
MessageBusTransactionData,
|
|
50
57
|
Microservice,
|
|
51
58
|
SchedulerTransactionData,
|
|
59
|
+
ShutdownState,
|
|
60
|
+
provide_shutdown_state,
|
|
52
61
|
)
|
|
53
62
|
from jararaca.scheduler.decorators import ScheduledActionData
|
|
54
63
|
from jararaca.utils.rabbitmq_utils import RabbitmqUtils
|
|
@@ -78,6 +87,18 @@ class AioPikaWorkerConfig:
|
|
|
78
87
|
backoff_factor=2.0,
|
|
79
88
|
)
|
|
80
89
|
)
|
|
90
|
+
# Connection health monitoring settings
|
|
91
|
+
connection_heartbeat_interval: float = 30.0 # seconds
|
|
92
|
+
connection_health_check_interval: float = 10.0 # seconds
|
|
93
|
+
reconnection_backoff_config: RetryConfig = field(
|
|
94
|
+
default_factory=lambda: RetryConfig(
|
|
95
|
+
max_retries=-1, # Infinite retries for reconnection
|
|
96
|
+
initial_delay=2.0,
|
|
97
|
+
max_delay=120.0,
|
|
98
|
+
backoff_factor=2.0,
|
|
99
|
+
jitter=True,
|
|
100
|
+
)
|
|
101
|
+
)
|
|
81
102
|
|
|
82
103
|
|
|
83
104
|
class AioPikaMessage(MessageOf[Message]):
|
|
@@ -129,6 +150,17 @@ class MessageBusConsumer(ABC):
|
|
|
129
150
|
"""Close all resources related to the consumer"""
|
|
130
151
|
|
|
131
152
|
|
|
153
|
+
class _WorkerShutdownState(ShutdownState):
|
|
154
|
+
def __init__(self, shutdown_event: asyncio.Event):
|
|
155
|
+
self.shutdown_event = shutdown_event
|
|
156
|
+
|
|
157
|
+
def request_shutdown(self) -> None:
|
|
158
|
+
self.shutdown_event.set()
|
|
159
|
+
|
|
160
|
+
def is_shutdown_requested(self) -> bool:
|
|
161
|
+
return self.shutdown_event.is_set()
|
|
162
|
+
|
|
163
|
+
|
|
132
164
|
class AioPikaMicroserviceConsumer(MessageBusConsumer):
|
|
133
165
|
def __init__(
|
|
134
166
|
self,
|
|
@@ -146,11 +178,21 @@ class AioPikaMicroserviceConsumer(MessageBusConsumer):
|
|
|
146
178
|
self.incoming_map: dict[str, MessageHandlerData] = {}
|
|
147
179
|
self.uow_context_provider = uow_context_provider
|
|
148
180
|
self.shutdown_event = asyncio.Event()
|
|
181
|
+
self.shutdown_state = _WorkerShutdownState(self.shutdown_event)
|
|
149
182
|
self.lock = asyncio.Lock()
|
|
150
183
|
self.tasks: set[asyncio.Task[Any]] = set()
|
|
151
184
|
self.connection: aio_pika.abc.AbstractConnection | None = None
|
|
152
185
|
self.channels: dict[str, aio_pika.abc.AbstractChannel] = {}
|
|
153
186
|
|
|
187
|
+
# Connection resilience attributes
|
|
188
|
+
self.connection_healthy = False
|
|
189
|
+
self.connection_lock = asyncio.Lock()
|
|
190
|
+
self.reconnection_event = asyncio.Event()
|
|
191
|
+
self.reconnection_in_progress = False
|
|
192
|
+
self.consumer_tags: dict[str, str] = {} # Track consumer tags for cleanup
|
|
193
|
+
self.health_check_task: asyncio.Task[Any] | None = None
|
|
194
|
+
self.reconnection_task: asyncio.Task[Any] | None = None
|
|
195
|
+
|
|
154
196
|
async def _verify_infrastructure(self) -> bool:
|
|
155
197
|
"""
|
|
156
198
|
Verify that the required RabbitMQ infrastructure (exchanges, queues) exists.
|
|
@@ -186,14 +228,18 @@ class AioPikaMicroserviceConsumer(MessageBusConsumer):
|
|
|
186
228
|
routing_key = f"{handler.message_type.MESSAGE_TOPIC}.#"
|
|
187
229
|
|
|
188
230
|
async def setup_consumer() -> None:
|
|
231
|
+
# Wait for connection to be healthy if reconnection is in progress
|
|
232
|
+
if self.reconnection_in_progress:
|
|
233
|
+
await self.reconnection_event.wait()
|
|
234
|
+
|
|
189
235
|
# Create a channel using the context manager
|
|
190
236
|
async with self.create_channel(queue_name) as channel:
|
|
191
237
|
queue = await RabbitmqUtils.get_queue(
|
|
192
238
|
channel=channel, queue_name=queue_name
|
|
193
239
|
)
|
|
194
240
|
|
|
195
|
-
# Configure consumer
|
|
196
|
-
await queue.consume(
|
|
241
|
+
# Configure consumer and get the consumer tag
|
|
242
|
+
consumer_tag = await queue.consume(
|
|
197
243
|
callback=MessageHandlerCallback(
|
|
198
244
|
consumer=self,
|
|
199
245
|
queue_name=queue_name,
|
|
@@ -203,6 +249,9 @@ class AioPikaMicroserviceConsumer(MessageBusConsumer):
|
|
|
203
249
|
no_ack=handler.spec.auto_ack,
|
|
204
250
|
)
|
|
205
251
|
|
|
252
|
+
# Store consumer tag for cleanup
|
|
253
|
+
self.consumer_tags[queue_name] = consumer_tag
|
|
254
|
+
|
|
206
255
|
logger.info(
|
|
207
256
|
f"Consuming message handler {queue_name} on dedicated channel"
|
|
208
257
|
)
|
|
@@ -212,7 +261,14 @@ class AioPikaMicroserviceConsumer(MessageBusConsumer):
|
|
|
212
261
|
await retry_with_backoff(
|
|
213
262
|
setup_consumer,
|
|
214
263
|
retry_config=self.config.consumer_retry_config,
|
|
215
|
-
retry_exceptions=(
|
|
264
|
+
retry_exceptions=(
|
|
265
|
+
ChannelNotFoundEntity,
|
|
266
|
+
ChannelClosed,
|
|
267
|
+
AMQPError,
|
|
268
|
+
AMQPConnectionError,
|
|
269
|
+
AMQPChannelError,
|
|
270
|
+
ConnectionClosed,
|
|
271
|
+
),
|
|
216
272
|
)
|
|
217
273
|
return True
|
|
218
274
|
except Exception as e:
|
|
@@ -232,14 +288,18 @@ class AioPikaMicroserviceConsumer(MessageBusConsumer):
|
|
|
232
288
|
routing_key = queue_name
|
|
233
289
|
|
|
234
290
|
async def setup_consumer() -> None:
|
|
291
|
+
# Wait for connection to be healthy if reconnection is in progress
|
|
292
|
+
if self.reconnection_in_progress:
|
|
293
|
+
await self.reconnection_event.wait()
|
|
294
|
+
|
|
235
295
|
# Create a channel using the context manager
|
|
236
296
|
async with self.create_channel(queue_name) as channel:
|
|
237
297
|
queue = await RabbitmqUtils.get_queue(
|
|
238
298
|
channel=channel, queue_name=queue_name
|
|
239
299
|
)
|
|
240
300
|
|
|
241
|
-
# Configure consumer
|
|
242
|
-
await queue.consume(
|
|
301
|
+
# Configure consumer and get the consumer tag
|
|
302
|
+
consumer_tag = await queue.consume(
|
|
243
303
|
callback=ScheduledMessageHandlerCallback(
|
|
244
304
|
consumer=self,
|
|
245
305
|
queue_name=queue_name,
|
|
@@ -249,6 +309,9 @@ class AioPikaMicroserviceConsumer(MessageBusConsumer):
|
|
|
249
309
|
no_ack=True,
|
|
250
310
|
)
|
|
251
311
|
|
|
312
|
+
# Store consumer tag for cleanup
|
|
313
|
+
self.consumer_tags[queue_name] = consumer_tag
|
|
314
|
+
|
|
252
315
|
logger.info(f"Consuming scheduler {queue_name} on dedicated channel")
|
|
253
316
|
|
|
254
317
|
try:
|
|
@@ -256,7 +319,14 @@ class AioPikaMicroserviceConsumer(MessageBusConsumer):
|
|
|
256
319
|
await retry_with_backoff(
|
|
257
320
|
setup_consumer,
|
|
258
321
|
retry_config=self.config.consumer_retry_config,
|
|
259
|
-
retry_exceptions=(
|
|
322
|
+
retry_exceptions=(
|
|
323
|
+
ChannelNotFoundEntity,
|
|
324
|
+
ChannelClosed,
|
|
325
|
+
AMQPError,
|
|
326
|
+
AMQPConnectionError,
|
|
327
|
+
AMQPChannelError,
|
|
328
|
+
ConnectionClosed,
|
|
329
|
+
),
|
|
260
330
|
)
|
|
261
331
|
return True
|
|
262
332
|
except Exception as e:
|
|
@@ -269,98 +339,107 @@ class AioPikaMicroserviceConsumer(MessageBusConsumer):
|
|
|
269
339
|
"""
|
|
270
340
|
Main consume method that sets up all message handlers and scheduled actions with retry mechanisms.
|
|
271
341
|
"""
|
|
272
|
-
#
|
|
273
|
-
|
|
274
|
-
self.
|
|
275
|
-
retry_config=self.config.connection_retry_config,
|
|
276
|
-
retry_exceptions=(Exception,),
|
|
277
|
-
)
|
|
342
|
+
# Establish initial connection
|
|
343
|
+
async with self.connect() as connection:
|
|
344
|
+
self.connection_healthy = True
|
|
278
345
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
346
|
+
# Start connection health monitoring
|
|
347
|
+
self.health_check_task = asyncio.create_task(
|
|
348
|
+
self._monitor_connection_health()
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
# Verify infrastructure with retry
|
|
352
|
+
infra_check_success = await retry_with_backoff(
|
|
353
|
+
self._verify_infrastructure,
|
|
354
|
+
retry_config=self.config.connection_retry_config,
|
|
355
|
+
retry_exceptions=(Exception,),
|
|
356
|
+
)
|
|
283
357
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
358
|
+
if not infra_check_success:
|
|
359
|
+
logger.critical(
|
|
360
|
+
"Failed to verify RabbitMQ infrastructure. Shutting down."
|
|
361
|
+
)
|
|
362
|
+
self.shutdown_event.set()
|
|
363
|
+
return
|
|
288
364
|
|
|
289
|
-
|
|
365
|
+
async def wait_for(
|
|
366
|
+
type: str, name: str, coroutine: Awaitable[bool]
|
|
367
|
+
) -> tuple[str, str, bool]:
|
|
368
|
+
return type, name, await coroutine
|
|
290
369
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
self.
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
370
|
+
tasks: set[asyncio.Task[tuple[str, str, bool]]] = set()
|
|
371
|
+
|
|
372
|
+
# Setup message handlers
|
|
373
|
+
for handler in self.message_handler_set:
|
|
374
|
+
queue_name = f"{handler.message_type.MESSAGE_TOPIC}.{handler.instance_callable.__module__}.{handler.instance_callable.__qualname__}"
|
|
375
|
+
self.incoming_map[queue_name] = handler
|
|
376
|
+
|
|
377
|
+
tasks.add(
|
|
378
|
+
task := asyncio.create_task(
|
|
379
|
+
wait_for(
|
|
380
|
+
"message_handler",
|
|
381
|
+
queue_name,
|
|
382
|
+
self._setup_message_handler_consumer(handler),
|
|
383
|
+
)
|
|
302
384
|
)
|
|
303
385
|
)
|
|
304
|
-
)
|
|
305
|
-
# task.add_done_callback(tasks.discard)
|
|
306
|
-
# success = await self._setup_message_handler_consumer(handler)
|
|
307
|
-
# if not success:
|
|
308
|
-
# logger.warning(
|
|
309
|
-
# f"Failed to set up consumer for {queue_name}, will not process messages from this queue"
|
|
310
|
-
# )
|
|
311
|
-
|
|
312
|
-
# Setup scheduled actions
|
|
313
|
-
for scheduled_action in self.scheduled_actions:
|
|
314
386
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
387
|
+
# Setup scheduled actions
|
|
388
|
+
for scheduled_action in self.scheduled_actions:
|
|
389
|
+
queue_name = f"{scheduled_action.callable.__module__}.{scheduled_action.callable.__qualname__}"
|
|
390
|
+
tasks.add(
|
|
391
|
+
task := asyncio.create_task(
|
|
392
|
+
wait_for(
|
|
393
|
+
"scheduled_action",
|
|
394
|
+
queue_name,
|
|
395
|
+
self._setup_scheduled_action_consumer(scheduled_action),
|
|
396
|
+
)
|
|
322
397
|
)
|
|
323
398
|
)
|
|
324
|
-
)
|
|
325
|
-
# task.add_done_callback(tasks.discard)
|
|
326
|
-
|
|
327
|
-
# success = await self._setup_scheduled_action_consumer(scheduled_action)
|
|
328
|
-
# if not success:
|
|
329
|
-
# queue_name = f"{scheduled_action.callable.__module__}.{scheduled_action.callable.__qualname__}"
|
|
330
|
-
# logger.warning(
|
|
331
|
-
# f"Failed to set up consumer for scheduled action {queue_name}, will not process scheduled tasks from this queue"
|
|
332
|
-
# )
|
|
333
|
-
|
|
334
|
-
async def handle_task_results() -> None:
|
|
335
|
-
for task in asyncio.as_completed(tasks):
|
|
336
|
-
type, name, success = await task
|
|
337
|
-
if success:
|
|
338
|
-
logger.info(f"Successfully set up {type} consumer for {name}")
|
|
339
|
-
else:
|
|
340
|
-
logger.warning(
|
|
341
|
-
f"Failed to set up {type} consumer for {name}, will not process messages from this queue"
|
|
342
|
-
)
|
|
343
399
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
400
|
+
async def handle_task_results() -> None:
|
|
401
|
+
for task in asyncio.as_completed(tasks):
|
|
402
|
+
type, name, success = await task
|
|
403
|
+
if success:
|
|
404
|
+
logger.info(f"Successfully set up {type} consumer for {name}")
|
|
405
|
+
else:
|
|
406
|
+
logger.warning(
|
|
407
|
+
f"Failed to set up {type} consumer for {name}, will not process messages from this queue"
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
handle_task_results_task = asyncio.create_task(handle_task_results())
|
|
411
|
+
|
|
412
|
+
# Wait for shutdown signal
|
|
413
|
+
await self.shutdown_event.wait()
|
|
414
|
+
logger.info("Shutdown event received, stopping consumers")
|
|
415
|
+
|
|
416
|
+
# Cancel health monitoring
|
|
417
|
+
if self.health_check_task:
|
|
418
|
+
self.health_check_task.cancel()
|
|
355
419
|
with suppress(asyncio.CancelledError):
|
|
356
|
-
await
|
|
357
|
-
logger.info("Worker shutting down")
|
|
420
|
+
await self.health_check_task
|
|
358
421
|
|
|
359
|
-
|
|
360
|
-
|
|
422
|
+
# Cancel reconnection task if running
|
|
423
|
+
if self.reconnection_task:
|
|
424
|
+
self.reconnection_task.cancel()
|
|
425
|
+
with suppress(asyncio.CancelledError):
|
|
426
|
+
await self.reconnection_task
|
|
361
427
|
|
|
362
|
-
|
|
363
|
-
|
|
428
|
+
handle_task_results_task.cancel()
|
|
429
|
+
with suppress(asyncio.CancelledError):
|
|
430
|
+
await handle_task_results_task
|
|
431
|
+
for task in tasks:
|
|
432
|
+
if not task.done():
|
|
433
|
+
task.cancel()
|
|
434
|
+
with suppress(asyncio.CancelledError):
|
|
435
|
+
await task
|
|
436
|
+
logger.info("Worker shutting down")
|
|
437
|
+
|
|
438
|
+
# Wait for all tasks to complete
|
|
439
|
+
await self.wait_all_tasks_done()
|
|
440
|
+
|
|
441
|
+
# Close all channels and the connection
|
|
442
|
+
await self.close_channels_and_connection()
|
|
364
443
|
|
|
365
444
|
async def wait_all_tasks_done(self) -> None:
|
|
366
445
|
if not self.tasks:
|
|
@@ -379,41 +458,8 @@ class AioPikaMicroserviceConsumer(MessageBusConsumer):
|
|
|
379
458
|
|
|
380
459
|
async def close_channels_and_connection(self) -> None:
|
|
381
460
|
"""Close all channels and then the connection"""
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
for queue_name, channel in self.channels.items():
|
|
385
|
-
try:
|
|
386
|
-
if not channel.is_closed:
|
|
387
|
-
logger.info(f"Closing channel for queue {queue_name}")
|
|
388
|
-
channel_close_tasks.append(channel.close())
|
|
389
|
-
else:
|
|
390
|
-
logger.info(f"Channel for queue {queue_name} already closed")
|
|
391
|
-
except Exception as e:
|
|
392
|
-
logger.error(
|
|
393
|
-
f"Error preparing to close channel for queue {queue_name}: {e}"
|
|
394
|
-
)
|
|
395
|
-
|
|
396
|
-
# Wait for all channels to close (if any)
|
|
397
|
-
if channel_close_tasks:
|
|
398
|
-
try:
|
|
399
|
-
await asyncio.gather(*channel_close_tasks, return_exceptions=True)
|
|
400
|
-
except Exception as e:
|
|
401
|
-
logger.error(f"Error during channel closures: {e}")
|
|
402
|
-
|
|
403
|
-
# Clear channels dictionary
|
|
404
|
-
self.channels.clear()
|
|
405
|
-
|
|
406
|
-
# Close the connection
|
|
407
|
-
if self.connection:
|
|
408
|
-
try:
|
|
409
|
-
if not self.connection.is_closed:
|
|
410
|
-
logger.info("Closing RabbitMQ connection")
|
|
411
|
-
await self.connection.close()
|
|
412
|
-
else:
|
|
413
|
-
logger.info("RabbitMQ connection already closed")
|
|
414
|
-
except Exception as e:
|
|
415
|
-
logger.error(f"Error closing RabbitMQ connection: {e}")
|
|
416
|
-
self.connection = None
|
|
461
|
+
logger.info("Closing channels and connection...")
|
|
462
|
+
await self._cleanup_connection()
|
|
417
463
|
|
|
418
464
|
def shutdown(self) -> None:
|
|
419
465
|
"""Signal for shutdown"""
|
|
@@ -422,7 +468,21 @@ class AioPikaMicroserviceConsumer(MessageBusConsumer):
|
|
|
422
468
|
|
|
423
469
|
async def close(self) -> None:
|
|
424
470
|
"""Implement MessageBusConsumer.close for cleanup"""
|
|
471
|
+
logger.info("Closing consumer...")
|
|
425
472
|
self.shutdown()
|
|
473
|
+
|
|
474
|
+
# Cancel health monitoring
|
|
475
|
+
if self.health_check_task:
|
|
476
|
+
self.health_check_task.cancel()
|
|
477
|
+
with suppress(asyncio.CancelledError):
|
|
478
|
+
await self.health_check_task
|
|
479
|
+
|
|
480
|
+
# Cancel reconnection task if running
|
|
481
|
+
if self.reconnection_task:
|
|
482
|
+
self.reconnection_task.cancel()
|
|
483
|
+
with suppress(asyncio.CancelledError):
|
|
484
|
+
await self.reconnection_task
|
|
485
|
+
|
|
426
486
|
await self.wait_all_tasks_done()
|
|
427
487
|
await self.close_channels_and_connection()
|
|
428
488
|
|
|
@@ -431,6 +491,16 @@ class AioPikaMicroserviceConsumer(MessageBusConsumer):
|
|
|
431
491
|
Get the channel for a specific queue, or None if not found.
|
|
432
492
|
This helps with error handling when a channel might have been closed.
|
|
433
493
|
"""
|
|
494
|
+
# If reconnection is in progress, wait for it to complete
|
|
495
|
+
if self.reconnection_in_progress:
|
|
496
|
+
try:
|
|
497
|
+
await asyncio.wait_for(self.reconnection_event.wait(), timeout=30.0)
|
|
498
|
+
except asyncio.TimeoutError:
|
|
499
|
+
logger.warning(
|
|
500
|
+
f"Timeout waiting for reconnection when getting channel for {queue_name}"
|
|
501
|
+
)
|
|
502
|
+
return None
|
|
503
|
+
|
|
434
504
|
if queue_name not in self.channels:
|
|
435
505
|
logger.warning(f"No channel found for queue {queue_name}")
|
|
436
506
|
return None
|
|
@@ -439,18 +509,38 @@ class AioPikaMicroserviceConsumer(MessageBusConsumer):
|
|
|
439
509
|
channel = self.channels[queue_name]
|
|
440
510
|
if channel.is_closed:
|
|
441
511
|
logger.warning(f"Channel for queue {queue_name} is closed")
|
|
442
|
-
#
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
512
|
+
# Remove the closed channel
|
|
513
|
+
del self.channels[queue_name]
|
|
514
|
+
|
|
515
|
+
# Attempt to recreate the channel if connection is healthy
|
|
516
|
+
if (
|
|
517
|
+
self.connection
|
|
518
|
+
and not self.connection.is_closed
|
|
519
|
+
and self.connection_healthy
|
|
520
|
+
):
|
|
521
|
+
try:
|
|
522
|
+
logger.info(f"Creating new channel for {queue_name}")
|
|
523
|
+
self.channels[queue_name] = await self.connection.channel()
|
|
524
|
+
await self.channels[queue_name].set_qos(
|
|
525
|
+
prefetch_count=self.config.prefetch_count
|
|
526
|
+
)
|
|
527
|
+
return self.channels[queue_name]
|
|
528
|
+
except Exception as e:
|
|
529
|
+
logger.error(
|
|
530
|
+
f"Failed to recreate channel for {queue_name}: {e}"
|
|
531
|
+
)
|
|
532
|
+
# Trigger reconnection if channel creation fails
|
|
533
|
+
self._trigger_reconnection()
|
|
534
|
+
return None
|
|
535
|
+
else:
|
|
536
|
+
# Connection is not healthy, trigger reconnection
|
|
537
|
+
self._trigger_reconnection()
|
|
538
|
+
return None
|
|
451
539
|
return channel
|
|
452
540
|
except Exception as e:
|
|
453
541
|
logger.error(f"Error accessing channel for queue {queue_name}: {e}")
|
|
542
|
+
# Trigger reconnection on any channel access error
|
|
543
|
+
self._trigger_reconnection()
|
|
454
544
|
return None
|
|
455
545
|
|
|
456
546
|
async def _establish_channel(self, queue_name: str) -> aio_pika.abc.AbstractChannel:
|
|
@@ -483,8 +573,8 @@ class AioPikaMicroserviceConsumer(MessageBusConsumer):
|
|
|
483
573
|
fn=lambda: self._establish_channel(queue_name),
|
|
484
574
|
retry_config=self.config.consumer_retry_config,
|
|
485
575
|
retry_exceptions=(
|
|
486
|
-
|
|
487
|
-
|
|
576
|
+
AMQPConnectionError,
|
|
577
|
+
AMQPChannelError,
|
|
488
578
|
ConnectionError,
|
|
489
579
|
),
|
|
490
580
|
)
|
|
@@ -511,7 +601,10 @@ class AioPikaMicroserviceConsumer(MessageBusConsumer):
|
|
|
511
601
|
"""
|
|
512
602
|
try:
|
|
513
603
|
logger.info("Establishing connection to RabbitMQ")
|
|
514
|
-
connection = await aio_pika.connect(
|
|
604
|
+
connection = await aio_pika.connect(
|
|
605
|
+
self.config.url,
|
|
606
|
+
heartbeat=self.config.connection_heartbeat_interval,
|
|
607
|
+
)
|
|
515
608
|
logger.info("Connected to RabbitMQ successfully")
|
|
516
609
|
return connection
|
|
517
610
|
except Exception as e:
|
|
@@ -538,7 +631,7 @@ class AioPikaMicroserviceConsumer(MessageBusConsumer):
|
|
|
538
631
|
self._establish_connection,
|
|
539
632
|
retry_config=self.config.connection_retry_config,
|
|
540
633
|
retry_exceptions=(
|
|
541
|
-
|
|
634
|
+
AMQPConnectionError,
|
|
542
635
|
ConnectionError,
|
|
543
636
|
OSError,
|
|
544
637
|
TimeoutError,
|
|
@@ -572,22 +665,254 @@ class AioPikaMicroserviceConsumer(MessageBusConsumer):
|
|
|
572
665
|
Get a channel for a specific queue as a context manager.
|
|
573
666
|
This is safer than using get_channel directly as it ensures proper error handling.
|
|
574
667
|
"""
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
668
|
+
max_retries = 3
|
|
669
|
+
retry_delay = 1.0
|
|
670
|
+
|
|
671
|
+
for attempt in range(max_retries):
|
|
672
|
+
try:
|
|
673
|
+
channel = await self.get_channel(queue_name)
|
|
674
|
+
if channel is not None:
|
|
675
|
+
try:
|
|
676
|
+
yield channel
|
|
677
|
+
return
|
|
678
|
+
finally:
|
|
679
|
+
# We don't close the channel here as it's managed by the consumer
|
|
680
|
+
pass
|
|
681
|
+
|
|
682
|
+
# No channel available, check connection state
|
|
683
|
+
if (
|
|
684
|
+
self.connection
|
|
685
|
+
and not self.connection.is_closed
|
|
686
|
+
and self.connection_healthy
|
|
687
|
+
):
|
|
688
|
+
# Try to create a new channel
|
|
689
|
+
async with self.create_channel(queue_name) as new_channel:
|
|
690
|
+
yield new_channel
|
|
691
|
+
return
|
|
692
|
+
else:
|
|
693
|
+
# Connection is not healthy, wait for reconnection
|
|
694
|
+
if self.reconnection_in_progress:
|
|
695
|
+
try:
|
|
696
|
+
await asyncio.wait_for(
|
|
697
|
+
self.reconnection_event.wait(), timeout=30.0
|
|
698
|
+
)
|
|
699
|
+
# Retry after reconnection
|
|
700
|
+
continue
|
|
701
|
+
except asyncio.TimeoutError:
|
|
702
|
+
logger.warning(
|
|
703
|
+
f"Timeout waiting for reconnection for queue {queue_name}"
|
|
704
|
+
)
|
|
705
|
+
|
|
706
|
+
# Still no connection, trigger reconnection
|
|
707
|
+
if not self.reconnection_in_progress:
|
|
708
|
+
self._trigger_reconnection()
|
|
709
|
+
|
|
710
|
+
if attempt < max_retries - 1:
|
|
711
|
+
logger.info(
|
|
712
|
+
f"Retrying channel access for {queue_name} in {retry_delay}s"
|
|
713
|
+
)
|
|
714
|
+
await asyncio.sleep(retry_delay)
|
|
715
|
+
retry_delay *= 2
|
|
716
|
+
else:
|
|
717
|
+
raise RuntimeError(
|
|
718
|
+
f"Cannot get channel for queue {queue_name}: no connection available after {max_retries} attempts"
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
except Exception as e:
|
|
722
|
+
if attempt < max_retries - 1:
|
|
723
|
+
logger.warning(
|
|
724
|
+
f"Error getting channel for {queue_name}, retrying: {e}"
|
|
725
|
+
)
|
|
726
|
+
await asyncio.sleep(retry_delay)
|
|
727
|
+
retry_delay *= 2
|
|
728
|
+
else:
|
|
729
|
+
logger.error(
|
|
730
|
+
f"Failed to get channel for {queue_name} after {max_retries} attempts: {e}"
|
|
731
|
+
)
|
|
732
|
+
raise
|
|
733
|
+
|
|
734
|
+
async def _monitor_connection_health(self) -> None:
|
|
735
|
+
"""
|
|
736
|
+
Monitor connection health and trigger reconnection if needed.
|
|
737
|
+
This runs as a background task.
|
|
738
|
+
"""
|
|
739
|
+
while not self.shutdown_event.is_set():
|
|
740
|
+
try:
|
|
741
|
+
await asyncio.sleep(self.config.connection_health_check_interval)
|
|
742
|
+
|
|
743
|
+
if self.shutdown_event.is_set():
|
|
744
|
+
break
|
|
745
|
+
|
|
746
|
+
# Check connection health
|
|
747
|
+
if not await self._is_connection_healthy():
|
|
748
|
+
logger.warning(
|
|
749
|
+
"Connection health check failed, triggering reconnection"
|
|
750
|
+
)
|
|
751
|
+
if not self.reconnection_in_progress:
|
|
752
|
+
self._trigger_reconnection()
|
|
753
|
+
|
|
754
|
+
except asyncio.CancelledError:
|
|
755
|
+
logger.info("Connection health monitoring cancelled")
|
|
756
|
+
break
|
|
757
|
+
except Exception as e:
|
|
758
|
+
logger.error(f"Error in connection health monitoring: {e}")
|
|
759
|
+
await asyncio.sleep(5) # Wait before retrying
|
|
760
|
+
|
|
761
|
+
async def _is_connection_healthy(self) -> bool:
|
|
762
|
+
"""
|
|
763
|
+
Check if the connection is healthy.
|
|
764
|
+
"""
|
|
765
|
+
try:
|
|
766
|
+
if self.connection is None or self.connection.is_closed:
|
|
767
|
+
return False
|
|
768
|
+
|
|
769
|
+
# Try to create a temporary channel to test connection
|
|
770
|
+
async with self.connection.channel() as test_channel:
|
|
771
|
+
# If we can create a channel, connection is healthy
|
|
772
|
+
return True
|
|
773
|
+
|
|
774
|
+
except Exception as e:
|
|
775
|
+
logger.debug(f"Connection health check failed: {e}")
|
|
776
|
+
return False
|
|
777
|
+
|
|
778
|
+
def _trigger_reconnection(self) -> None:
|
|
779
|
+
"""
|
|
780
|
+
Trigger reconnection process.
|
|
781
|
+
"""
|
|
782
|
+
if not self.reconnection_in_progress and not self.shutdown_event.is_set():
|
|
783
|
+
self.reconnection_in_progress = True
|
|
784
|
+
self.connection_healthy = False
|
|
785
|
+
self.reconnection_event.clear()
|
|
786
|
+
|
|
787
|
+
# Start reconnection task
|
|
788
|
+
self.reconnection_task = asyncio.create_task(self._handle_reconnection())
|
|
789
|
+
self.reconnection_task.add_done_callback(self._on_reconnection_done)
|
|
790
|
+
|
|
791
|
+
def _on_reconnection_done(self, task: asyncio.Task[Any]) -> None:
|
|
792
|
+
"""
|
|
793
|
+
Handle completion of reconnection task.
|
|
794
|
+
"""
|
|
795
|
+
self.reconnection_in_progress = False
|
|
796
|
+
if task.exception():
|
|
797
|
+
logger.error(f"Reconnection task failed: {task.exception()}")
|
|
585
798
|
else:
|
|
799
|
+
logger.info("Reconnection completed successfully")
|
|
800
|
+
|
|
801
|
+
async def _handle_reconnection(self) -> None:
|
|
802
|
+
"""
|
|
803
|
+
Handle the reconnection process with exponential backoff.
|
|
804
|
+
"""
|
|
805
|
+
logger.info("Starting reconnection process")
|
|
806
|
+
|
|
807
|
+
# Close existing connection and channels
|
|
808
|
+
await self._cleanup_connection()
|
|
809
|
+
|
|
810
|
+
reconnection_config = self.config.reconnection_backoff_config
|
|
811
|
+
attempt = 0
|
|
812
|
+
|
|
813
|
+
while not self.shutdown_event.is_set():
|
|
586
814
|
try:
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
815
|
+
attempt += 1
|
|
816
|
+
logger.info(f"Reconnection attempt {attempt}")
|
|
817
|
+
|
|
818
|
+
# Establish new connection
|
|
819
|
+
self.connection = await self._establish_connection()
|
|
820
|
+
self.connection_healthy = True
|
|
821
|
+
|
|
822
|
+
# Re-establish all consumers
|
|
823
|
+
await self._reestablish_consumers()
|
|
824
|
+
|
|
825
|
+
logger.info("Reconnection successful")
|
|
826
|
+
self.reconnection_event.set()
|
|
827
|
+
return
|
|
828
|
+
|
|
829
|
+
except Exception as e:
|
|
830
|
+
logger.error(f"Reconnection attempt {attempt} failed: {e}")
|
|
831
|
+
|
|
832
|
+
if self.shutdown_event.is_set():
|
|
833
|
+
break
|
|
834
|
+
|
|
835
|
+
# Calculate backoff delay
|
|
836
|
+
delay = reconnection_config.initial_delay * (
|
|
837
|
+
reconnection_config.backoff_factor ** (attempt - 1)
|
|
838
|
+
)
|
|
839
|
+
if reconnection_config.jitter:
|
|
840
|
+
jitter_amount = delay * 0.25
|
|
841
|
+
delay = delay + random.uniform(-jitter_amount, jitter_amount)
|
|
842
|
+
delay = max(delay, 0.1)
|
|
843
|
+
|
|
844
|
+
delay = min(delay, reconnection_config.max_delay)
|
|
845
|
+
|
|
846
|
+
logger.info(f"Retrying reconnection in {delay:.2f} seconds")
|
|
847
|
+
await asyncio.sleep(delay)
|
|
848
|
+
|
|
849
|
+
async def _cleanup_connection(self) -> None:
|
|
850
|
+
"""
|
|
851
|
+
Clean up existing connection and channels.
|
|
852
|
+
"""
|
|
853
|
+
# Cancel existing consumers
|
|
854
|
+
for queue_name, channel in self.channels.items():
|
|
855
|
+
try:
|
|
856
|
+
if not channel.is_closed:
|
|
857
|
+
# Cancel consumer if we have its tag
|
|
858
|
+
if queue_name in self.consumer_tags:
|
|
859
|
+
try:
|
|
860
|
+
queue = await channel.get_queue(queue_name, ensure=False)
|
|
861
|
+
if queue:
|
|
862
|
+
await queue.cancel(self.consumer_tags[queue_name])
|
|
863
|
+
except Exception as cancel_error:
|
|
864
|
+
logger.warning(
|
|
865
|
+
f"Error cancelling consumer for {queue_name}: {cancel_error}"
|
|
866
|
+
)
|
|
867
|
+
del self.consumer_tags[queue_name]
|
|
868
|
+
except Exception as e:
|
|
869
|
+
logger.warning(f"Error cancelling consumer for {queue_name}: {e}")
|
|
870
|
+
|
|
871
|
+
# Close channels
|
|
872
|
+
for queue_name, channel in self.channels.items():
|
|
873
|
+
try:
|
|
874
|
+
if not channel.is_closed:
|
|
875
|
+
await channel.close()
|
|
876
|
+
except Exception as e:
|
|
877
|
+
logger.warning(f"Error closing channel for {queue_name}: {e}")
|
|
878
|
+
|
|
879
|
+
self.channels.clear()
|
|
880
|
+
|
|
881
|
+
# Close connection
|
|
882
|
+
if self.connection and not self.connection.is_closed:
|
|
883
|
+
try:
|
|
884
|
+
await self.connection.close()
|
|
885
|
+
except Exception as e:
|
|
886
|
+
logger.warning(f"Error closing connection: {e}")
|
|
887
|
+
|
|
888
|
+
self.connection = None
|
|
889
|
+
self.connection_healthy = False
|
|
890
|
+
|
|
891
|
+
async def _reestablish_consumers(self) -> None:
|
|
892
|
+
"""
|
|
893
|
+
Re-establish all consumers after reconnection.
|
|
894
|
+
"""
|
|
895
|
+
logger.info("Re-establishing consumers after reconnection")
|
|
896
|
+
|
|
897
|
+
# Re-establish message handlers
|
|
898
|
+
for handler in self.message_handler_set:
|
|
899
|
+
queue_name = f"{handler.message_type.MESSAGE_TOPIC}.{handler.instance_callable.__module__}.{handler.instance_callable.__qualname__}"
|
|
900
|
+
try:
|
|
901
|
+
await self._setup_message_handler_consumer(handler)
|
|
902
|
+
logger.info(f"Re-established consumer for {queue_name}")
|
|
903
|
+
except Exception as e:
|
|
904
|
+
logger.error(f"Failed to re-establish consumer for {queue_name}: {e}")
|
|
905
|
+
|
|
906
|
+
# Re-establish scheduled actions
|
|
907
|
+
for scheduled_action in self.scheduled_actions:
|
|
908
|
+
queue_name = f"{scheduled_action.callable.__module__}.{scheduled_action.callable.__qualname__}"
|
|
909
|
+
try:
|
|
910
|
+
await self._setup_scheduled_action_consumer(scheduled_action)
|
|
911
|
+
logger.info(f"Re-established scheduler consumer for {queue_name}")
|
|
912
|
+
except Exception as e:
|
|
913
|
+
logger.error(
|
|
914
|
+
f"Failed to re-establish scheduler consumer for {queue_name}: {e}"
|
|
915
|
+
)
|
|
591
916
|
|
|
592
917
|
|
|
593
918
|
def create_message_bus(
|
|
@@ -632,6 +957,19 @@ def create_message_bus(
|
|
|
632
957
|
max_retries=30, initial_delay=5, max_delay=60.0, backoff_factor=3.0
|
|
633
958
|
)
|
|
634
959
|
|
|
960
|
+
# Parse optional reconnection configuration parameters
|
|
961
|
+
reconnection_backoff_config = RetryConfig(
|
|
962
|
+
max_retries=-1, # Infinite retries for reconnection
|
|
963
|
+
initial_delay=2.0,
|
|
964
|
+
max_delay=120.0,
|
|
965
|
+
backoff_factor=2.0,
|
|
966
|
+
jitter=True,
|
|
967
|
+
)
|
|
968
|
+
|
|
969
|
+
# Parse heartbeat and health check intervals
|
|
970
|
+
connection_heartbeat_interval = 30.0
|
|
971
|
+
connection_health_check_interval = 10.0
|
|
972
|
+
|
|
635
973
|
# Connection retry config parameters
|
|
636
974
|
if (
|
|
637
975
|
"connection_retry_max" in query_params
|
|
@@ -698,12 +1036,65 @@ def create_message_bus(
|
|
|
698
1036
|
except ValueError:
|
|
699
1037
|
pass
|
|
700
1038
|
|
|
1039
|
+
# Reconnection backoff config parameters
|
|
1040
|
+
if (
|
|
1041
|
+
"reconnection_retry_max" in query_params
|
|
1042
|
+
and query_params["reconnection_retry_max"][0].isdigit()
|
|
1043
|
+
):
|
|
1044
|
+
reconnection_backoff_config.max_retries = int(
|
|
1045
|
+
query_params["reconnection_retry_max"][0]
|
|
1046
|
+
)
|
|
1047
|
+
|
|
1048
|
+
if "reconnection_retry_delay" in query_params:
|
|
1049
|
+
try:
|
|
1050
|
+
reconnection_backoff_config.initial_delay = float(
|
|
1051
|
+
query_params["reconnection_retry_delay"][0]
|
|
1052
|
+
)
|
|
1053
|
+
except ValueError:
|
|
1054
|
+
pass
|
|
1055
|
+
|
|
1056
|
+
if "reconnection_retry_max_delay" in query_params:
|
|
1057
|
+
try:
|
|
1058
|
+
reconnection_backoff_config.max_delay = float(
|
|
1059
|
+
query_params["reconnection_retry_max_delay"][0]
|
|
1060
|
+
)
|
|
1061
|
+
except ValueError:
|
|
1062
|
+
pass
|
|
1063
|
+
|
|
1064
|
+
if "reconnection_retry_backoff" in query_params:
|
|
1065
|
+
try:
|
|
1066
|
+
reconnection_backoff_config.backoff_factor = float(
|
|
1067
|
+
query_params["reconnection_retry_backoff"][0]
|
|
1068
|
+
)
|
|
1069
|
+
except ValueError:
|
|
1070
|
+
pass
|
|
1071
|
+
|
|
1072
|
+
# Heartbeat and health check intervals
|
|
1073
|
+
if "connection_heartbeat_interval" in query_params:
|
|
1074
|
+
try:
|
|
1075
|
+
connection_heartbeat_interval = float(
|
|
1076
|
+
query_params["connection_heartbeat_interval"][0]
|
|
1077
|
+
)
|
|
1078
|
+
except ValueError:
|
|
1079
|
+
pass
|
|
1080
|
+
|
|
1081
|
+
if "connection_health_check_interval" in query_params:
|
|
1082
|
+
try:
|
|
1083
|
+
connection_health_check_interval = float(
|
|
1084
|
+
query_params["connection_health_check_interval"][0]
|
|
1085
|
+
)
|
|
1086
|
+
except ValueError:
|
|
1087
|
+
pass
|
|
1088
|
+
|
|
701
1089
|
config = AioPikaWorkerConfig(
|
|
702
1090
|
url=broker_url,
|
|
703
1091
|
exchange=exchange,
|
|
704
1092
|
prefetch_count=prefetch_count,
|
|
705
1093
|
connection_retry_config=connection_retry_config,
|
|
706
1094
|
consumer_retry_config=consumer_retry_config,
|
|
1095
|
+
connection_heartbeat_interval=connection_heartbeat_interval,
|
|
1096
|
+
connection_health_check_interval=connection_health_check_interval,
|
|
1097
|
+
reconnection_backoff_config=reconnection_backoff_config,
|
|
707
1098
|
)
|
|
708
1099
|
|
|
709
1100
|
return AioPikaMicroserviceConsumer(
|
|
@@ -754,6 +1145,25 @@ class ScheduledMessageHandlerCallback:
|
|
|
754
1145
|
)
|
|
755
1146
|
return
|
|
756
1147
|
|
|
1148
|
+
# Check if connection is healthy before processing
|
|
1149
|
+
if not self.consumer.connection_healthy:
|
|
1150
|
+
logger.warning(
|
|
1151
|
+
f"Connection not healthy, requeuing scheduled message for {self.queue_name}"
|
|
1152
|
+
)
|
|
1153
|
+
try:
|
|
1154
|
+
# Wait briefly for potential reconnection
|
|
1155
|
+
await asyncio.sleep(0.1)
|
|
1156
|
+
if not self.consumer.connection_healthy:
|
|
1157
|
+
# Still not healthy, requeue the message
|
|
1158
|
+
async with self.consumer.get_channel_ctx(self.queue_name):
|
|
1159
|
+
await aio_pika_message.reject(requeue=True)
|
|
1160
|
+
return
|
|
1161
|
+
except Exception as e:
|
|
1162
|
+
logger.error(
|
|
1163
|
+
f"Failed to requeue scheduled message due to connection issues: {e}"
|
|
1164
|
+
)
|
|
1165
|
+
return
|
|
1166
|
+
|
|
757
1167
|
async with self.consumer.lock:
|
|
758
1168
|
task = asyncio.create_task(self.handle_message(aio_pika_message))
|
|
759
1169
|
self.consumer.tasks.add(task)
|
|
@@ -789,6 +1199,21 @@ class ScheduledMessageHandlerCallback:
|
|
|
789
1199
|
logger.error(f"Failed to requeue message during shutdown: {e}")
|
|
790
1200
|
return
|
|
791
1201
|
|
|
1202
|
+
# Check connection health before processing
|
|
1203
|
+
if not self.consumer.connection_healthy:
|
|
1204
|
+
logger.warning(
|
|
1205
|
+
f"Connection not healthy, requeuing scheduled message for {self.queue_name}"
|
|
1206
|
+
)
|
|
1207
|
+
try:
|
|
1208
|
+
async with self.consumer.get_channel_ctx(self.queue_name):
|
|
1209
|
+
await aio_pika_message.reject(requeue=True)
|
|
1210
|
+
return
|
|
1211
|
+
except Exception as e:
|
|
1212
|
+
logger.error(
|
|
1213
|
+
f"Failed to requeue scheduled message due to connection issues: {e}"
|
|
1214
|
+
)
|
|
1215
|
+
return
|
|
1216
|
+
|
|
792
1217
|
sig = inspect.signature(self.scheduled_action.callable)
|
|
793
1218
|
if len(sig.parameters) == 1:
|
|
794
1219
|
|
|
@@ -832,18 +1257,19 @@ class ScheduledMessageHandlerCallback:
|
|
|
832
1257
|
args: tuple[Any, ...],
|
|
833
1258
|
kwargs: dict[str, Any],
|
|
834
1259
|
) -> None:
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
1260
|
+
with provide_shutdown_state(self.consumer.shutdown_state):
|
|
1261
|
+
async with self.consumer.uow_context_provider(
|
|
1262
|
+
AppTransactionContext(
|
|
1263
|
+
controller_member_reflect=scheduled_action.controller_member,
|
|
1264
|
+
transaction_data=SchedulerTransactionData(
|
|
1265
|
+
scheduled_to=datetime.now(UTC),
|
|
1266
|
+
cron_expression=scheduled_action.spec.cron,
|
|
1267
|
+
triggered_at=datetime.now(UTC),
|
|
1268
|
+
),
|
|
1269
|
+
)
|
|
1270
|
+
):
|
|
845
1271
|
|
|
846
|
-
|
|
1272
|
+
await scheduled_action.callable(*args, **kwargs)
|
|
847
1273
|
|
|
848
1274
|
|
|
849
1275
|
class MessageHandlerCallback:
|
|
@@ -880,6 +1306,23 @@ class MessageHandlerCallback:
|
|
|
880
1306
|
logger.error(f"Failed to requeue message during shutdown: {e}")
|
|
881
1307
|
return
|
|
882
1308
|
|
|
1309
|
+
# Check if connection is healthy before processing
|
|
1310
|
+
if not self.consumer.connection_healthy:
|
|
1311
|
+
logger.warning(
|
|
1312
|
+
f"Connection not healthy, requeuing message for {self.queue_name}"
|
|
1313
|
+
)
|
|
1314
|
+
try:
|
|
1315
|
+
# Wait briefly for potential reconnection
|
|
1316
|
+
await asyncio.sleep(0.1)
|
|
1317
|
+
if not self.consumer.connection_healthy:
|
|
1318
|
+
# Still not healthy, requeue the message
|
|
1319
|
+
async with self.consumer.get_channel_ctx(self.queue_name):
|
|
1320
|
+
await aio_pika_message.reject(requeue=True)
|
|
1321
|
+
return
|
|
1322
|
+
except Exception as e:
|
|
1323
|
+
logger.error(f"Failed to requeue message due to connection issues: {e}")
|
|
1324
|
+
return
|
|
1325
|
+
|
|
883
1326
|
async with self.consumer.lock:
|
|
884
1327
|
task = asyncio.create_task(self.handle_message(aio_pika_message))
|
|
885
1328
|
self.consumer.tasks.add(task)
|
|
@@ -944,8 +1387,11 @@ class MessageHandlerCallback:
|
|
|
944
1387
|
f"dead-lettering: {str(exception)}"
|
|
945
1388
|
)
|
|
946
1389
|
# Dead-letter the message after max retries
|
|
947
|
-
|
|
948
|
-
|
|
1390
|
+
try:
|
|
1391
|
+
async with self.consumer.get_channel_ctx(self.queue_name):
|
|
1392
|
+
await aio_pika_message.reject(requeue=False)
|
|
1393
|
+
except Exception as e:
|
|
1394
|
+
logger.error(f"Failed to dead-letter message {message_id}: {e}")
|
|
949
1395
|
return
|
|
950
1396
|
|
|
951
1397
|
# Calculate delay for this retry attempt
|
|
@@ -981,29 +1427,33 @@ class MessageHandlerCallback:
|
|
|
981
1427
|
)
|
|
982
1428
|
|
|
983
1429
|
# Acknowledge the current message since we'll handle retry ourselves
|
|
984
|
-
|
|
985
|
-
|
|
1430
|
+
try:
|
|
1431
|
+
async with self.consumer.get_channel_ctx(self.queue_name):
|
|
1432
|
+
await aio_pika_message.ack()
|
|
1433
|
+
except Exception as e:
|
|
1434
|
+
logger.error(
|
|
1435
|
+
f"Failed to acknowledge message {message_id} for retry: {e}"
|
|
1436
|
+
)
|
|
986
1437
|
return
|
|
987
1438
|
|
|
988
1439
|
# Standard reject without retry or with immediate requeue
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
1440
|
+
try:
|
|
1441
|
+
async with self.consumer.get_channel_ctx(self.queue_name):
|
|
1442
|
+
await aio_pika_message.reject(requeue=requeue)
|
|
1443
|
+
if requeue:
|
|
1444
|
+
logger.info(
|
|
1445
|
+
f"Message {message_id} ({self.queue_name}) requeued for immediate retry"
|
|
1446
|
+
)
|
|
1447
|
+
else:
|
|
1448
|
+
logger.info(
|
|
1449
|
+
f"Message {message_id} ({self.queue_name}) rejected without requeue"
|
|
1450
|
+
)
|
|
1451
|
+
except Exception as e:
|
|
1452
|
+
logger.error(f"Failed to reject message {message_id}: {e}")
|
|
999
1453
|
|
|
1000
|
-
except RuntimeError as e:
|
|
1001
|
-
logger.error(
|
|
1002
|
-
f"Error rejecting message {message_id} ({self.queue_name}): {e}"
|
|
1003
|
-
)
|
|
1004
1454
|
except Exception as e:
|
|
1005
1455
|
logger.exception(
|
|
1006
|
-
f"Unexpected error
|
|
1456
|
+
f"Unexpected error in handle_reject_message for {message_id} ({self.queue_name}): {e}"
|
|
1007
1457
|
)
|
|
1008
1458
|
|
|
1009
1459
|
async def _delayed_retry(
|
|
@@ -1018,7 +1468,7 @@ class MessageHandlerCallback:
|
|
|
1018
1468
|
|
|
1019
1469
|
Args:
|
|
1020
1470
|
aio_pika_message: The original message
|
|
1021
|
-
delay: Delay in seconds before
|
|
1471
|
+
delay: Delay in seconds before retrying
|
|
1022
1472
|
retry_count: The current retry count (after increment)
|
|
1023
1473
|
exception: The exception that caused the failure
|
|
1024
1474
|
"""
|
|
@@ -1043,28 +1493,46 @@ class MessageHandlerCallback:
|
|
|
1043
1493
|
if message_id in self.retry_state:
|
|
1044
1494
|
del self.retry_state[message_id]
|
|
1045
1495
|
|
|
1046
|
-
# Republish the message to the same queue
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1496
|
+
# Republish the message to the same queue with retry logic
|
|
1497
|
+
max_attempts = 3
|
|
1498
|
+
for attempt in range(max_attempts):
|
|
1499
|
+
try:
|
|
1500
|
+
async with self.consumer.get_channel_ctx(
|
|
1501
|
+
self.queue_name
|
|
1502
|
+
) as channel:
|
|
1503
|
+
exchange = await RabbitmqUtils.get_main_exchange(
|
|
1504
|
+
channel=channel,
|
|
1505
|
+
exchange_name=self.consumer.config.exchange,
|
|
1506
|
+
)
|
|
1052
1507
|
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1508
|
+
await exchange.publish(
|
|
1509
|
+
aio_pika.Message(
|
|
1510
|
+
body=message_body,
|
|
1511
|
+
headers=headers,
|
|
1512
|
+
message_id=message_id,
|
|
1513
|
+
content_type=aio_pika_message.content_type,
|
|
1514
|
+
content_encoding=aio_pika_message.content_encoding,
|
|
1515
|
+
delivery_mode=aio_pika_message.delivery_mode,
|
|
1516
|
+
),
|
|
1517
|
+
routing_key=self.routing_key,
|
|
1518
|
+
)
|
|
1064
1519
|
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1520
|
+
logger.info(
|
|
1521
|
+
f"Message {message_id} ({self.queue_name}) republished for retry {retry_count}"
|
|
1522
|
+
)
|
|
1523
|
+
return
|
|
1524
|
+
|
|
1525
|
+
except Exception as e:
|
|
1526
|
+
if attempt < max_attempts - 1:
|
|
1527
|
+
logger.warning(
|
|
1528
|
+
f"Failed to republish message {message_id} (attempt {attempt + 1}): {e}"
|
|
1529
|
+
)
|
|
1530
|
+
await asyncio.sleep(1.0 * (attempt + 1)) # Exponential backoff
|
|
1531
|
+
else:
|
|
1532
|
+
logger.error(
|
|
1533
|
+
f"Failed to republish message {message_id} after {max_attempts} attempts: {e}"
|
|
1534
|
+
)
|
|
1535
|
+
raise
|
|
1068
1536
|
|
|
1069
1537
|
except Exception as e:
|
|
1070
1538
|
logger.exception(
|
|
@@ -1133,83 +1601,92 @@ class MessageHandlerCallback:
|
|
|
1133
1601
|
incoming_message_spec = MessageHandler.get_message_incoming(handler)
|
|
1134
1602
|
assert incoming_message_spec is not None
|
|
1135
1603
|
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1604
|
+
with provide_shutdown_state(self.consumer.shutdown_state):
|
|
1605
|
+
async with self.consumer.uow_context_provider(
|
|
1606
|
+
AppTransactionContext(
|
|
1607
|
+
controller_member_reflect=handler_data.controller_member,
|
|
1608
|
+
transaction_data=MessageBusTransactionData(
|
|
1609
|
+
message=builded_message,
|
|
1610
|
+
topic=routing_key,
|
|
1611
|
+
),
|
|
1612
|
+
)
|
|
1613
|
+
):
|
|
1614
|
+
ctx: AsyncContextManager[Any]
|
|
1615
|
+
if incoming_message_spec.timeout is not None:
|
|
1616
|
+
ctx = asyncio.timeout(incoming_message_spec.timeout)
|
|
1617
|
+
else:
|
|
1618
|
+
ctx = none_context()
|
|
1619
|
+
async with ctx:
|
|
1620
|
+
try:
|
|
1621
|
+
with provide_bus_message_controller(
|
|
1622
|
+
AioPikaMessageBusController(aio_pika_message)
|
|
1623
|
+
):
|
|
1624
|
+
await handler(builded_message)
|
|
1625
|
+
if not incoming_message_spec.auto_ack:
|
|
1626
|
+
with suppress(aio_pika.MessageProcessError):
|
|
1627
|
+
# Use channel context for acknowledgement with retry
|
|
1628
|
+
try:
|
|
1629
|
+
async with self.consumer.get_channel_ctx(
|
|
1630
|
+
self.queue_name
|
|
1631
|
+
):
|
|
1632
|
+
await aio_pika_message.ack()
|
|
1633
|
+
except Exception as ack_error:
|
|
1634
|
+
logger.warning(
|
|
1635
|
+
f"Failed to acknowledge message {aio_pika_message.message_id or 'unknown'}: {ack_error}"
|
|
1636
|
+
)
|
|
1637
|
+
# Message will be redelivered if ack fails, which is acceptable
|
|
1638
|
+
except BaseException as base_exc:
|
|
1639
|
+
# Get message id for logging
|
|
1640
|
+
message_id = aio_pika_message.message_id or str(uuid.uuid4())
|
|
1641
|
+
|
|
1642
|
+
# Extract retry count from headers if available
|
|
1643
|
+
headers = aio_pika_message.headers or {}
|
|
1644
|
+
retry_count = int(str(headers.get("x-retry-count", 0)))
|
|
1645
|
+
|
|
1646
|
+
# Process exception handler if configured
|
|
1647
|
+
if incoming_message_spec.exception_handler is not None:
|
|
1648
|
+
try:
|
|
1649
|
+
incoming_message_spec.exception_handler(base_exc)
|
|
1650
|
+
except Exception as nested_exc:
|
|
1651
|
+
logger.exception(
|
|
1652
|
+
f"Error processing exception handler for message {message_id}: {base_exc} | {nested_exc}"
|
|
1653
|
+
)
|
|
1654
|
+
else:
|
|
1174
1655
|
logger.exception(
|
|
1175
|
-
f"Error processing
|
|
1656
|
+
f"Error processing message {message_id} on topic {routing_key}: {str(base_exc)}"
|
|
1176
1657
|
)
|
|
1177
|
-
else:
|
|
1178
|
-
logger.exception(
|
|
1179
|
-
f"Error processing message {message_id} on topic {routing_key}: {str(base_exc)}"
|
|
1180
|
-
)
|
|
1181
1658
|
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
else:
|
|
1197
|
-
# Message processed successfully, log and clean up any retry state
|
|
1198
|
-
message_id = aio_pika_message.message_id or str(uuid.uuid4())
|
|
1199
|
-
if message_id in self.retry_state:
|
|
1200
|
-
del self.retry_state[message_id]
|
|
1201
|
-
|
|
1202
|
-
# Log success with retry information if applicable
|
|
1203
|
-
headers = aio_pika_message.headers or {}
|
|
1204
|
-
if "x-retry-count" in headers:
|
|
1205
|
-
retry_count = int(str(headers.get("x-retry-count", 0)))
|
|
1206
|
-
logger.info(
|
|
1207
|
-
f"Message {message_id}#{self.queue_name} processed successfully after {retry_count} retries"
|
|
1208
|
-
)
|
|
1659
|
+
# Handle rejection with retry logic
|
|
1660
|
+
if incoming_message_spec.requeue_on_exception:
|
|
1661
|
+
# Use our retry with backoff mechanism
|
|
1662
|
+
await self.handle_reject_message(
|
|
1663
|
+
aio_pika_message,
|
|
1664
|
+
requeue=False, # Don't requeue directly, use our backoff mechanism
|
|
1665
|
+
retry_count=retry_count,
|
|
1666
|
+
exception=base_exc,
|
|
1667
|
+
)
|
|
1668
|
+
else:
|
|
1669
|
+
# Message shouldn't be retried, reject it
|
|
1670
|
+
await self.handle_reject_message(
|
|
1671
|
+
aio_pika_message, requeue=False, exception=base_exc
|
|
1672
|
+
)
|
|
1209
1673
|
else:
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1674
|
+
# Message processed successfully, log and clean up any retry state
|
|
1675
|
+
message_id = aio_pika_message.message_id or str(uuid.uuid4())
|
|
1676
|
+
if message_id in self.retry_state:
|
|
1677
|
+
del self.retry_state[message_id]
|
|
1678
|
+
|
|
1679
|
+
# Log success with retry information if applicable
|
|
1680
|
+
headers = aio_pika_message.headers or {}
|
|
1681
|
+
if "x-retry-count" in headers:
|
|
1682
|
+
retry_count = int(str(headers.get("x-retry-count", 0)))
|
|
1683
|
+
logger.info(
|
|
1684
|
+
f"Message {message_id}#{self.queue_name} processed successfully after {retry_count} retries"
|
|
1685
|
+
)
|
|
1686
|
+
else:
|
|
1687
|
+
logger.info(
|
|
1688
|
+
f"Message {message_id}#{self.queue_name} processed successfully"
|
|
1689
|
+
)
|
|
1213
1690
|
|
|
1214
1691
|
|
|
1215
1692
|
@asynccontextmanager
|