jararaca 0.2.37a12__py3-none-any.whl → 0.4.0a5__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.
Files changed (91) hide show
  1. README.md +121 -0
  2. jararaca/__init__.py +267 -15
  3. jararaca/__main__.py +4 -0
  4. jararaca/broker_backend/__init__.py +106 -0
  5. jararaca/broker_backend/mapper.py +25 -0
  6. jararaca/broker_backend/redis_broker_backend.py +168 -0
  7. jararaca/cli.py +840 -103
  8. jararaca/common/__init__.py +3 -0
  9. jararaca/core/__init__.py +3 -0
  10. jararaca/core/providers.py +4 -0
  11. jararaca/core/uow.py +55 -16
  12. jararaca/di.py +4 -0
  13. jararaca/files/entity.py.mako +4 -0
  14. jararaca/lifecycle.py +6 -2
  15. jararaca/messagebus/__init__.py +5 -1
  16. jararaca/messagebus/bus_message_controller.py +4 -0
  17. jararaca/messagebus/consumers/__init__.py +3 -0
  18. jararaca/messagebus/decorators.py +90 -85
  19. jararaca/messagebus/implicit_headers.py +49 -0
  20. jararaca/messagebus/interceptors/__init__.py +3 -0
  21. jararaca/messagebus/interceptors/aiopika_publisher_interceptor.py +95 -37
  22. jararaca/messagebus/interceptors/publisher_interceptor.py +42 -0
  23. jararaca/messagebus/message.py +31 -0
  24. jararaca/messagebus/publisher.py +47 -4
  25. jararaca/messagebus/worker.py +1615 -135
  26. jararaca/microservice.py +248 -36
  27. jararaca/observability/constants.py +7 -0
  28. jararaca/observability/decorators.py +177 -16
  29. jararaca/observability/fastapi_exception_handler.py +37 -0
  30. jararaca/observability/hooks.py +109 -0
  31. jararaca/observability/interceptor.py +8 -2
  32. jararaca/observability/providers/__init__.py +3 -0
  33. jararaca/observability/providers/otel.py +213 -18
  34. jararaca/persistence/base.py +40 -3
  35. jararaca/persistence/exports.py +4 -0
  36. jararaca/persistence/interceptors/__init__.py +3 -0
  37. jararaca/persistence/interceptors/aiosqa_interceptor.py +187 -23
  38. jararaca/persistence/interceptors/constants.py +5 -0
  39. jararaca/persistence/interceptors/decorators.py +50 -0
  40. jararaca/persistence/session.py +3 -0
  41. jararaca/persistence/sort_filter.py +4 -0
  42. jararaca/persistence/utilities.py +74 -32
  43. jararaca/presentation/__init__.py +3 -0
  44. jararaca/presentation/decorators.py +170 -82
  45. jararaca/presentation/exceptions.py +23 -0
  46. jararaca/presentation/hooks.py +4 -0
  47. jararaca/presentation/http_microservice.py +4 -0
  48. jararaca/presentation/server.py +120 -41
  49. jararaca/presentation/websocket/__init__.py +3 -0
  50. jararaca/presentation/websocket/base_types.py +4 -0
  51. jararaca/presentation/websocket/context.py +34 -4
  52. jararaca/presentation/websocket/decorators.py +8 -41
  53. jararaca/presentation/websocket/redis.py +280 -53
  54. jararaca/presentation/websocket/types.py +6 -2
  55. jararaca/presentation/websocket/websocket_interceptor.py +74 -23
  56. jararaca/reflect/__init__.py +3 -0
  57. jararaca/reflect/controller_inspect.py +81 -0
  58. jararaca/reflect/decorators.py +238 -0
  59. jararaca/reflect/metadata.py +76 -0
  60. jararaca/rpc/__init__.py +3 -0
  61. jararaca/rpc/http/__init__.py +101 -0
  62. jararaca/rpc/http/backends/__init__.py +14 -0
  63. jararaca/rpc/http/backends/httpx.py +43 -9
  64. jararaca/rpc/http/backends/otel.py +4 -0
  65. jararaca/rpc/http/decorators.py +378 -113
  66. jararaca/rpc/http/httpx.py +3 -0
  67. jararaca/scheduler/__init__.py +3 -0
  68. jararaca/scheduler/beat_worker.py +758 -0
  69. jararaca/scheduler/decorators.py +89 -28
  70. jararaca/scheduler/types.py +11 -0
  71. jararaca/tools/app_config/__init__.py +3 -0
  72. jararaca/tools/app_config/decorators.py +7 -19
  73. jararaca/tools/app_config/interceptor.py +10 -4
  74. jararaca/tools/typescript/__init__.py +3 -0
  75. jararaca/tools/typescript/decorators.py +120 -0
  76. jararaca/tools/typescript/interface_parser.py +1126 -189
  77. jararaca/utils/__init__.py +3 -0
  78. jararaca/utils/rabbitmq_utils.py +372 -0
  79. jararaca/utils/retry.py +148 -0
  80. jararaca-0.4.0a5.dist-info/LICENSE +674 -0
  81. jararaca-0.4.0a5.dist-info/LICENSES/GPL-3.0-or-later.txt +232 -0
  82. {jararaca-0.2.37a12.dist-info → jararaca-0.4.0a5.dist-info}/METADATA +14 -7
  83. jararaca-0.4.0a5.dist-info/RECORD +88 -0
  84. {jararaca-0.2.37a12.dist-info → jararaca-0.4.0a5.dist-info}/WHEEL +1 -1
  85. pyproject.toml +131 -0
  86. jararaca/messagebus/types.py +0 -30
  87. jararaca/scheduler/scheduler.py +0 -154
  88. jararaca/tools/metadata.py +0 -47
  89. jararaca-0.2.37a12.dist-info/RECORD +0 -63
  90. /jararaca-0.2.37a12.dist-info/LICENSE → /LICENSE +0 -0
  91. {jararaca-0.2.37a12.dist-info → jararaca-0.4.0a5.dist-info}/entry_points.txt +0 -0
@@ -1,16 +1,44 @@
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
1
5
  import asyncio
2
6
  import inspect
3
7
  import logging
8
+ import random
4
9
  import signal
10
+ import time
11
+ import uuid
12
+ from abc import ABC
5
13
  from contextlib import asynccontextmanager, suppress
6
- from dataclasses import dataclass
7
- from typing import Any, AsyncContextManager, AsyncGenerator, Type, get_origin
14
+ from dataclasses import dataclass, field
15
+ from datetime import UTC, datetime
16
+ from typing import (
17
+ Any,
18
+ AsyncContextManager,
19
+ AsyncGenerator,
20
+ Awaitable,
21
+ Optional,
22
+ Type,
23
+ get_origin,
24
+ )
25
+ from urllib.parse import parse_qs, urlparse
8
26
 
9
27
  import aio_pika
10
28
  import aio_pika.abc
11
29
  import uvloop
30
+ from aio_pika.exceptions import (
31
+ AMQPChannelError,
32
+ AMQPConnectionError,
33
+ AMQPError,
34
+ ChannelClosed,
35
+ ChannelNotFoundEntity,
36
+ ConnectionClosed,
37
+ )
12
38
  from pydantic import BaseModel
13
39
 
40
+ from jararaca.broker_backend import MessageBrokerBackend
41
+ from jararaca.broker_backend.mapper import get_message_broker_backend_from_url
14
42
  from jararaca.core.uow import UnitOfWorkContextProvider
15
43
  from jararaca.di import Container
16
44
  from jararaca.lifecycle import AppLifecycle
@@ -20,12 +48,26 @@ from jararaca.messagebus.bus_message_controller import (
20
48
  )
21
49
  from jararaca.messagebus.decorators import (
22
50
  MESSAGE_HANDLER_DATA_SET,
51
+ SCHEDULED_ACTION_DATA_SET,
23
52
  MessageBusController,
24
53
  MessageHandler,
25
54
  MessageHandlerData,
55
+ ScheduleDispatchData,
26
56
  )
27
- from jararaca.messagebus.types import Message, MessageOf
28
- from jararaca.microservice import MessageBusAppContext, Microservice
57
+ from jararaca.messagebus.implicit_headers import provide_implicit_headers
58
+ from jararaca.messagebus.message import Message, MessageOf
59
+ from jararaca.microservice import (
60
+ AppTransactionContext,
61
+ MessageBusTransactionData,
62
+ Microservice,
63
+ SchedulerTransactionData,
64
+ ShutdownState,
65
+ provide_shutdown_state,
66
+ providing_app_type,
67
+ )
68
+ from jararaca.scheduler.decorators import ScheduledActionData
69
+ from jararaca.utils.rabbitmq_utils import RabbitmqUtils
70
+ from jararaca.utils.retry import RetryConfig, retry_with_backoff
29
71
 
30
72
  logger = logging.getLogger(__name__)
31
73
 
@@ -33,9 +75,27 @@ logger = logging.getLogger(__name__)
33
75
  @dataclass
34
76
  class AioPikaWorkerConfig:
35
77
  url: str
36
- queue: str
37
78
  exchange: str
38
79
  prefetch_count: int
80
+ connection_retry_config: RetryConfig = field(
81
+ default_factory=lambda: RetryConfig(
82
+ max_retries=15,
83
+ initial_delay=1.0,
84
+ max_delay=60.0,
85
+ backoff_factor=2.0,
86
+ )
87
+ )
88
+ consumer_retry_config: RetryConfig = field(
89
+ default_factory=lambda: RetryConfig(
90
+ max_retries=15,
91
+ initial_delay=0.5,
92
+ max_delay=40.0,
93
+ backoff_factor=2.0,
94
+ )
95
+ )
96
+ # Connection health monitoring settings
97
+ connection_heartbeat_interval: float = 30.0 # seconds
98
+ connection_health_check_interval: float = 10.0 # seconds
39
99
 
40
100
 
41
101
  class AioPikaMessage(MessageOf[Message]):
@@ -76,79 +136,1047 @@ class MessageProcessingLocker:
76
136
  await asyncio.gather(*self.current_processing_messages_set)
77
137
 
78
138
 
79
- class AioPikaMicroserviceConsumer:
139
+ class MessageBusConsumer(ABC):
140
+
141
+ async def consume(self) -> None:
142
+ raise NotImplementedError("consume method not implemented")
143
+
144
+ def shutdown(self) -> None: ...
145
+
146
+ async def close(self) -> None:
147
+ """Close all resources related to the consumer"""
148
+
149
+
150
+ class _WorkerShutdownState(ShutdownState):
151
+ def __init__(self, shutdown_event: asyncio.Event):
152
+ self.shutdown_event = shutdown_event
153
+
154
+ def request_shutdown(self) -> None:
155
+ self.shutdown_event.set()
156
+
157
+ def is_shutdown_requested(self) -> bool:
158
+ return self.shutdown_event.is_set()
159
+
160
+ async def wait_for_shutdown(self) -> None:
161
+ await self.shutdown_event.wait()
162
+
163
+
164
+ class AioPikaMicroserviceConsumer(MessageBusConsumer):
80
165
  def __init__(
81
166
  self,
167
+ broker_backend: MessageBrokerBackend,
82
168
  config: AioPikaWorkerConfig,
83
169
  message_handler_set: MESSAGE_HANDLER_DATA_SET,
170
+ scheduled_actions: SCHEDULED_ACTION_DATA_SET,
84
171
  uow_context_provider: UnitOfWorkContextProvider,
85
172
  ):
173
+
174
+ self.broker_backend = broker_backend
86
175
  self.config = config
87
176
  self.message_handler_set = message_handler_set
177
+ self.scheduled_actions = scheduled_actions
88
178
  self.incoming_map: dict[str, MessageHandlerData] = {}
89
179
  self.uow_context_provider = uow_context_provider
90
180
  self.shutdown_event = asyncio.Event()
181
+ self.shutdown_state = _WorkerShutdownState(self.shutdown_event)
91
182
  self.lock = asyncio.Lock()
92
183
  self.tasks: set[asyncio.Task[Any]] = set()
184
+ self.connection: aio_pika.abc.AbstractConnection | None = None
185
+ self.channels: dict[str, aio_pika.abc.AbstractChannel] = {}
186
+
187
+ # Connection resilience attributes
188
+ self.connection_healthy = False
189
+ self.connection_lock = asyncio.Lock()
190
+ self.consumer_tags: dict[str, str] = {} # Track consumer tags for cleanup
191
+ self.health_check_task: asyncio.Task[Any] | None = None
192
+
193
+ async def _verify_infrastructure(self) -> bool:
194
+ """
195
+ Verify that the required RabbitMQ infrastructure (exchanges, queues) exists.
196
+ Returns True if all required infrastructure is in place.
197
+ """
198
+ try:
199
+ async with self.connect() as connection:
200
+ # Create a main channel just for checking infrastructure
201
+ async with connection.channel() as main_channel:
202
+ # Get existing exchange and queues to verify infrastructure is in place
203
+ await RabbitmqUtils.get_main_exchange(
204
+ channel=main_channel,
205
+ exchange_name=self.config.exchange,
206
+ )
207
+ await RabbitmqUtils.get_dl_exchange(channel=main_channel)
208
+ await RabbitmqUtils.get_dl_queue(channel=main_channel)
209
+ return True
210
+ except (ChannelNotFoundEntity, ChannelClosed, AMQPError) as e:
211
+ logger.critical(
212
+ f"Required exchange or queue infrastructure not found. "
213
+ f"Please use the declare command first to create the required infrastructure. Error: {e}"
214
+ )
215
+ return False
216
+
217
+ async def _setup_message_handler_consumer(
218
+ self, handler: MessageHandlerData
219
+ ) -> bool:
220
+ """
221
+ Set up a consumer for a message handler with retry mechanism.
222
+ Returns True if successful, False otherwise.
223
+ """
224
+ queue_name = f"{handler.message_type.MESSAGE_TOPIC}.{handler.instance_callable.__module__}.{handler.instance_callable.__qualname__}"
225
+ routing_key = f"{handler.message_type.MESSAGE_TOPIC}.#"
226
+
227
+ async def setup_consumer() -> None:
228
+ # Create a channel using the context manager
229
+ async with self.create_channel(queue_name) as channel:
230
+ queue: aio_pika.abc.AbstractQueue = await RabbitmqUtils.get_queue(
231
+ channel=channel, queue_name=queue_name
232
+ )
233
+
234
+ # Configure consumer and get the consumer tag
235
+ consumer_tag = await queue.consume(
236
+ callback=MessageHandlerCallback(
237
+ consumer=self,
238
+ queue_name=queue_name,
239
+ routing_key=routing_key,
240
+ message_handler=handler,
241
+ ),
242
+ # no_ack=handler.spec.auto_ack,
243
+ )
244
+
245
+ # Store consumer tag for cleanup
246
+ self.consumer_tags[queue_name] = consumer_tag
247
+
248
+ logger.info(
249
+ "Consuming message handler %s on dedicated channel", queue_name
250
+ )
251
+
252
+ await self.shutdown_event.wait()
253
+
254
+ logger.warning(
255
+ "Shutdown event received, stopping consumer for %s", queue_name
256
+ )
257
+ await queue.cancel(consumer_tag)
258
+
259
+ try:
260
+ # Setup with retry
261
+ await retry_with_backoff(
262
+ setup_consumer,
263
+ retry_config=self.config.consumer_retry_config,
264
+ retry_exceptions=(
265
+ ChannelNotFoundEntity,
266
+ ChannelClosed,
267
+ AMQPError,
268
+ AMQPConnectionError,
269
+ AMQPChannelError,
270
+ ConnectionClosed,
271
+ ),
272
+ )
273
+ return True
274
+ except Exception as e:
275
+ logger.error(
276
+ f"Failed to setup consumer for queue '{queue_name}' after retries: {e}"
277
+ )
278
+ return False
279
+
280
+ async def _setup_scheduled_action_consumer(
281
+ self, scheduled_action: ScheduledActionData
282
+ ) -> bool:
283
+ """
284
+ Set up a consumer for a scheduled action with retry mechanism.
285
+ Returns True if successful, False otherwise.
286
+ """
287
+ queue_name = f"{scheduled_action.callable.__module__}.{scheduled_action.callable.__qualname__}"
288
+ routing_key = queue_name
289
+
290
+ async def setup_consumer() -> None:
291
+ # Create a channel using the context manager
292
+ async with self.create_channel(queue_name) as channel:
293
+ queue = await RabbitmqUtils.get_queue(
294
+ channel=channel, queue_name=queue_name
295
+ )
296
+
297
+ # Configure consumer and get the consumer tag
298
+ consumer_tag = await queue.consume(
299
+ callback=ScheduledMessageHandlerCallback(
300
+ consumer=self,
301
+ queue_name=queue_name,
302
+ routing_key=routing_key,
303
+ scheduled_action=scheduled_action,
304
+ ),
305
+ no_ack=True,
306
+ )
307
+
308
+ # Store consumer tag for cleanup
309
+ self.consumer_tags[queue_name] = consumer_tag
310
+
311
+ logger.debug("Consuming scheduler %s on dedicated channel", queue_name)
312
+
313
+ await self.shutdown_event.wait()
314
+
315
+ logger.warning(
316
+ "Shutdown event received, stopping consumer for %s", queue_name
317
+ )
318
+ await queue.cancel(consumer_tag)
319
+
320
+ try:
321
+ # Setup with retry
322
+ await retry_with_backoff(
323
+ setup_consumer,
324
+ retry_config=self.config.consumer_retry_config,
325
+ retry_exceptions=(
326
+ ChannelNotFoundEntity,
327
+ ChannelClosed,
328
+ AMQPError,
329
+ AMQPConnectionError,
330
+ AMQPChannelError,
331
+ ConnectionClosed,
332
+ ),
333
+ )
334
+ return True
335
+ except Exception as e:
336
+ logger.error(
337
+ f"Failed to setup consumer for scheduler queue '{queue_name}' after retries: {e}"
338
+ )
339
+ return False
93
340
 
94
341
  async def consume(self) -> None:
342
+ """
343
+ Main consume method that sets up all message handlers and scheduled actions with retry mechanisms.
344
+ """
345
+ # Establish initial connection
346
+ try:
347
+ async with self.connect() as connection:
348
+ self.connection_healthy = True
349
+
350
+ # Start connection health monitoring
351
+ self.health_check_task = asyncio.create_task(
352
+ self._monitor_connection_health(), name="ConnectionHealthMonitor"
353
+ )
354
+
355
+ # Verify infrastructure with retry
356
+ infra_check_success = await retry_with_backoff(
357
+ self._verify_infrastructure,
358
+ retry_config=self.config.connection_retry_config,
359
+ retry_exceptions=(Exception,),
360
+ )
361
+
362
+ if not infra_check_success:
363
+ logger.critical(
364
+ "Failed to verify RabbitMQ infrastructure. Shutting down."
365
+ )
366
+ self.shutdown_event.set()
367
+ return
368
+
369
+ async def wait_for(
370
+ type: str, name: str, coroutine: Awaitable[bool]
371
+ ) -> tuple[str, str, bool]:
372
+ return type, name, await coroutine
373
+
374
+ tasks: set[asyncio.Task[tuple[str, str, bool]]] = set()
375
+
376
+ # Setup message handlers
377
+ for handler in self.message_handler_set:
378
+ queue_name = f"{handler.message_type.MESSAGE_TOPIC}.{handler.instance_callable.__module__}.{handler.instance_callable.__qualname__}"
379
+ self.incoming_map[queue_name] = handler
380
+
381
+ tasks.add(
382
+ task := asyncio.create_task(
383
+ wait_for(
384
+ "message_handler",
385
+ queue_name,
386
+ self._setup_message_handler_consumer(handler),
387
+ ),
388
+ name=f"MessageHandler-{queue_name}-setup-consumer",
389
+ )
390
+ )
95
391
 
96
- connection = await aio_pika.connect(self.config.url)
392
+ # Setup scheduled actions
393
+ for scheduled_action in self.scheduled_actions:
394
+ queue_name = f"{scheduled_action.callable.__module__}.{scheduled_action.callable.__qualname__}"
395
+ tasks.add(
396
+ task := asyncio.create_task(
397
+ wait_for(
398
+ "scheduled_action",
399
+ queue_name,
400
+ self._setup_scheduled_action_consumer(scheduled_action),
401
+ ),
402
+ name=f"ScheduledAction-{queue_name}-setup-consumer",
403
+ )
404
+ )
97
405
 
98
- channel = await connection.channel()
406
+ async def handle_task_results() -> None:
407
+ for task in asyncio.as_completed(tasks):
408
+ type, name, success = await task
409
+ if success:
410
+ logger.debug(
411
+ "Successfully set up %s consumer for %s", type, name
412
+ )
413
+ else:
414
+ logger.warning(
415
+ "Failed to set up %s consumer for %s, will not process messages from this queue",
416
+ type,
417
+ name,
418
+ )
419
+
420
+ handle_task_results_task = asyncio.create_task(
421
+ handle_task_results(), name="HandleSetupTaskResults"
422
+ )
423
+
424
+ # Wait for shutdown signal
425
+ await self.shutdown_event.wait()
426
+ logger.debug("Shutdown event received, stopping consumers")
427
+
428
+ await self.cancel_queue_consumers()
429
+
430
+ # Cancel health monitoring
431
+ if self.health_check_task:
432
+ self.health_check_task.cancel()
433
+ with suppress(asyncio.CancelledError):
434
+ await self.health_check_task
435
+
436
+ handle_task_results_task.cancel()
437
+ with suppress(asyncio.CancelledError):
438
+ await handle_task_results_task
439
+ for task in tasks:
440
+ if not task.done():
441
+ task.cancel()
442
+ with suppress(asyncio.CancelledError):
443
+ await task
444
+ logger.debug("Worker shutting down")
445
+ # Wait for all tasks to complete
446
+ await self.wait_all_tasks_done()
447
+
448
+ # Close all channels and the connection
449
+ await self.close_channels_and_connection()
450
+
451
+ except Exception as e:
452
+ logger.critical("Failed to establish initial connection to RabbitMQ: %s", e)
453
+ # Re-raise the exception so it can be caught by the caller
454
+ raise
455
+
456
+ async def cancel_queue_consumers(self) -> None:
457
+ """
458
+ Cancel all active queue consumers.
459
+ """
460
+ logger.debug("Cancelling all active queue consumers...")
461
+ for queue_name, channel in self.channels.items():
462
+ try:
463
+ if not channel.is_closed:
464
+ # Cancel consumer if we have its tag
465
+ if queue_name in self.consumer_tags:
466
+ try:
467
+ queue = await channel.get_queue(queue_name, ensure=False)
468
+ if queue:
469
+ await queue.cancel(self.consumer_tags[queue_name])
470
+ except Exception as cancel_error:
471
+ logger.warning(
472
+ "Error cancelling consumer for %s: %s",
473
+ queue_name,
474
+ cancel_error,
475
+ )
476
+ del self.consumer_tags[queue_name]
477
+ except Exception as e:
478
+ logger.warning("Error cancelling consumer for %s: %s", queue_name, e)
479
+
480
+ async def wait_all_tasks_done(self) -> None:
481
+ if not self.tasks:
482
+ return
483
+
484
+ logger.warning(
485
+ "Waiting for (%s) in-flight tasks to complete: %s",
486
+ len(self.tasks),
487
+ ", ".join((task.get_name()) for task in self.tasks),
488
+ )
489
+ # async with self.lock:
490
+ # Use gather with return_exceptions=True to ensure all tasks are awaited
491
+ # even if some raise exceptions
492
+ # results = await asyncio.gather(*self.tasks, return_exceptions=True)
493
+ pending_tasks = [task for task in self.tasks if not task.done()]
494
+ while len(pending_tasks) > 0:
495
+ if not pending_tasks:
496
+ break
497
+ await asyncio.wait(pending_tasks, return_when=asyncio.FIRST_COMPLETED)
498
+
499
+ pending_tasks = [task for task in pending_tasks if not task.done()]
500
+ if len(pending_tasks) > 0:
501
+ logger.warning(
502
+ "Waiting for (%s) in-flight tasks to complete: %s",
503
+ len(pending_tasks),
504
+ ", ".join((task.get_name()) for task in pending_tasks),
505
+ )
506
+ else:
507
+ logger.warning("All in-flight tasks have completed.")
508
+ # Log any exceptions that occurred
509
+ # for result in results:
510
+ # if isinstance(result, Exception):
511
+ # logger.error("Task raised an exception during shutdown: %s", result)
512
+
513
+ async def close_channels_and_connection(self) -> None:
514
+ """Close all channels and then the connection"""
515
+ logger.warning("Closing channels and connection...")
516
+ await self._cleanup_connection()
517
+
518
+ def shutdown(self) -> None:
519
+ """Signal for shutdown"""
520
+ logger.warning("Initiating graceful shutdown")
521
+ self.shutdown_event.set()
522
+
523
+ async def close(self) -> None:
524
+ """Implement MessageBusConsumer.close for cleanup"""
525
+ logger.warning("Closing consumer...")
526
+ self.shutdown()
527
+
528
+ # Cancel health monitoring
529
+ if self.health_check_task:
530
+ self.health_check_task.cancel()
531
+ with suppress(asyncio.CancelledError):
532
+ await self.health_check_task
533
+
534
+ await self.wait_all_tasks_done()
535
+ await self.close_channels_and_connection()
536
+
537
+ async def get_channel(self, queue_name: str) -> aio_pika.abc.AbstractChannel | None:
538
+ """
539
+ Get the channel for a specific queue, or None if not found.
540
+ This helps with error handling when a channel might have been closed.
541
+ """
542
+ if queue_name not in self.channels:
543
+ logger.warning("No channel found for queue %s", queue_name)
544
+ return None
545
+
546
+ try:
547
+ channel = self.channels[queue_name]
548
+ if channel.is_closed:
549
+ logger.warning("Channel for queue %s is closed", queue_name)
550
+ # Remove the closed channel
551
+ del self.channels[queue_name]
552
+
553
+ # Attempt to recreate the channel if connection is healthy
554
+ if (
555
+ self.connection
556
+ and not self.connection.is_closed
557
+ and self.connection_healthy
558
+ ):
559
+ try:
560
+ logger.debug("Creating new channel for %s", queue_name)
561
+ self.channels[queue_name] = await self.connection.channel()
562
+ await self.channels[queue_name].set_qos(
563
+ prefetch_count=self.config.prefetch_count
564
+ )
565
+ return self.channels[queue_name]
566
+ except Exception as e:
567
+ logger.error(
568
+ "Failed to recreate channel for %s: %s", queue_name, e
569
+ )
570
+ # Trigger shutdown if channel creation fails
571
+ self._trigger_shutdown()
572
+ return None
573
+ else:
574
+ # Connection is not healthy, trigger shutdown
575
+ self._trigger_shutdown()
576
+ return None
577
+ return channel
578
+ except Exception as e:
579
+ logger.error("Error accessing channel for queue %s: %s", queue_name, e)
580
+ # Trigger shutdown on any channel access error
581
+ self._trigger_shutdown()
582
+ return None
583
+
584
+ async def _establish_channel(self, queue_name: str) -> aio_pika.abc.AbstractChannel:
585
+ """
586
+ Creates a new channel for the specified queue with proper QoS settings.
587
+ """
588
+ if self.connection is None or self.connection.is_closed:
589
+ logger.warning(
590
+ "Cannot create channel for %s: connection is not available", queue_name
591
+ )
592
+ raise RuntimeError("Connection is not available")
99
593
 
594
+ logger.debug("Creating channel for queue %s", queue_name)
595
+ channel = await self.connection.channel()
100
596
  await channel.set_qos(prefetch_count=self.config.prefetch_count)
597
+ logger.debug("Created channel for queue %s", queue_name)
598
+ return channel
599
+
600
+ @asynccontextmanager
601
+ async def create_channel(
602
+ self, queue_name: str
603
+ ) -> AsyncGenerator[aio_pika.abc.AbstractChannel, None]:
604
+ """
605
+ Create and yield a channel for the specified queue with retry mechanism.
606
+ This context manager ensures the channel is properly managed.
607
+ """
608
+ try:
609
+ # Create a new channel with retry
610
+ channel = await retry_with_backoff(
611
+ fn=lambda: self._establish_channel(queue_name),
612
+ retry_config=self.config.consumer_retry_config,
613
+ retry_exceptions=(
614
+ AMQPConnectionError,
615
+ AMQPChannelError,
616
+ ConnectionError,
617
+ ),
618
+ )
619
+
620
+ # Save in the channels dict for tracking
621
+ self.channels[queue_name] = channel
622
+ logger.debug("Created new channel for queue %s", queue_name)
623
+
624
+ try:
625
+ yield channel
626
+ finally:
627
+ # Don't close the channel here as it might be used later
628
+ # It will be closed during shutdown
629
+ pass
630
+ except aio_pika.exceptions.AMQPError as e:
631
+ logger.error(
632
+ "Error creating channel for queue %s after retries: %s", queue_name, e
633
+ )
634
+ raise
635
+
636
+ async def _establish_connection(self) -> aio_pika.abc.AbstractConnection:
637
+ """
638
+ Creates a new RabbitMQ connection with retry logic.
639
+ """
640
+ try:
641
+ logger.debug("Establishing connection to RabbitMQ")
642
+ connection = await aio_pika.connect(
643
+ self.config.url,
644
+ heartbeat=self.config.connection_heartbeat_interval,
645
+ )
646
+ logger.debug("Connected to RabbitMQ successfully")
647
+ return connection
648
+ except Exception as e:
649
+ logger.error("Failed to connect to RabbitMQ: %s", e)
650
+ raise
651
+
652
+ @asynccontextmanager
653
+ async def connect(self) -> AsyncGenerator[aio_pika.abc.AbstractConnection, None]:
654
+ """
655
+ Create and manage the main connection to RabbitMQ with automatic retry.
656
+ """
657
+ if self.connection is not None and not self.connection.is_closed:
658
+ logger.debug("Connection already exists, reusing existing connection")
659
+ try:
660
+ yield self.connection
661
+ finally:
662
+ # The existing connection will be handled by close_channels_and_connection
663
+ pass
664
+ return
665
+
666
+ try:
667
+ # Create a new connection with retry
668
+ self.connection = await retry_with_backoff(
669
+ self._establish_connection,
670
+ retry_config=self.config.connection_retry_config,
671
+ retry_exceptions=(
672
+ AMQPConnectionError,
673
+ ConnectionError,
674
+ OSError,
675
+ TimeoutError,
676
+ ),
677
+ )
678
+
679
+ try:
680
+ yield self.connection
681
+ finally:
682
+ # Don't close the connection here; it will be closed in close_channels_and_connection
683
+ pass
684
+ except Exception as e:
685
+ logger.error(
686
+ "Failed to establish connection to RabbitMQ after retries: %s", e
687
+ )
688
+ if self.connection:
689
+ try:
690
+ await self.connection.close()
691
+ except Exception as close_error:
692
+ logger.error(
693
+ "Error closing connection after connect failure: %s",
694
+ close_error,
695
+ )
696
+ self.connection = None
697
+ raise
698
+
699
+ @asynccontextmanager
700
+ async def get_channel_ctx(
701
+ self, queue_name: str
702
+ ) -> AsyncGenerator[aio_pika.abc.AbstractChannel, None]:
703
+ """
704
+ Get a channel for a specific queue as a context manager.
705
+ This is safer than using get_channel directly as it ensures proper error handling.
706
+ """
707
+ max_retries = 3
708
+ retry_delay = 1.0
709
+
710
+ for attempt in range(max_retries):
711
+ try:
712
+ channel = await self.get_channel(queue_name)
713
+ if channel is not None:
714
+ try:
715
+ yield channel
716
+ return
717
+ finally:
718
+ # We don't close the channel here as it's managed by the consumer
719
+ pass
720
+
721
+ # No channel available, check connection state
722
+ if (
723
+ self.connection
724
+ and not self.connection.is_closed
725
+ and self.connection_healthy
726
+ ):
727
+ # Try to create a new channel
728
+ async with self.create_channel(queue_name) as new_channel:
729
+ yield new_channel
730
+ return
731
+ else:
732
+ # Connection is not healthy, trigger shutdown
733
+ logger.error(
734
+ "Connection not healthy while getting channel for %s, triggering shutdown",
735
+ queue_name,
736
+ )
737
+ self._trigger_shutdown()
738
+ raise RuntimeError(
739
+ f"Cannot get channel for queue {queue_name}: connection is not healthy"
740
+ )
741
+
742
+ except Exception as e:
743
+ if attempt < max_retries - 1:
744
+ logger.warning(
745
+ "Error getting channel for %s, retrying: %s", queue_name, e
746
+ )
747
+ await asyncio.sleep(retry_delay)
748
+ retry_delay *= 2
749
+ else:
750
+ logger.error(
751
+ "Failed to get channel for %s after %s attempts: %s",
752
+ queue_name,
753
+ max_retries,
754
+ e,
755
+ )
756
+ raise
757
+
758
+ async def _monitor_connection_health(self) -> None:
759
+ """
760
+ Monitor connection health and trigger shutdown if connection is lost.
761
+ This runs as a background task.
762
+ """
763
+ while not self.shutdown_event.is_set():
764
+ try:
765
+ await asyncio.sleep(self.config.connection_health_check_interval)
766
+
767
+ if self.shutdown_event.is_set():
768
+ break
769
+
770
+ # Check connection health
771
+ if not await self._is_connection_healthy():
772
+ logger.error(
773
+ "Connection health check failed, initiating worker shutdown"
774
+ )
775
+ self.shutdown()
776
+ break
777
+
778
+ except asyncio.CancelledError:
779
+ logger.debug("Connection health monitoring cancelled")
780
+ break
781
+ except Exception as e:
782
+ logger.error("Error in connection health monitoring: %s", e)
783
+ await asyncio.sleep(5) # Wait before retrying
784
+
785
+ async def _is_connection_healthy(self) -> bool:
786
+ """
787
+ Check if the connection is healthy.
788
+ """
789
+ try:
790
+ if self.connection is None or self.connection.is_closed:
791
+ return False
792
+
793
+ # Try to create a temporary channel to test connection
794
+ async with self.connection.channel() as test_channel:
795
+ # If we can create a channel, connection is healthy
796
+ return True
797
+
798
+ except Exception as e:
799
+ logger.debug("Connection health check failed: %s", e)
800
+ return False
801
+
802
+ def _trigger_shutdown(self) -> None:
803
+ """
804
+ Trigger worker shutdown due to connection loss.
805
+ """
806
+ if not self.shutdown_event.is_set():
807
+ logger.error("Connection lost, initiating worker shutdown")
808
+ self.connection_healthy = False
809
+ self.shutdown()
810
+
811
+ async def _cleanup_connection(self) -> None:
812
+ """
813
+ Clean up existing connection and channels.
814
+ """
815
+ # Cancel existing consumers
816
+ for queue_name, channel in self.channels.items():
817
+ try:
818
+ if not channel.is_closed:
819
+ # Cancel consumer if we have its tag
820
+ if queue_name in self.consumer_tags:
821
+ try:
822
+ queue = await channel.get_queue(queue_name, ensure=False)
823
+ if queue:
824
+ await queue.cancel(self.consumer_tags[queue_name])
825
+ except Exception as cancel_error:
826
+ logger.warning(
827
+ "Error cancelling consumer for %s: %s",
828
+ queue_name,
829
+ cancel_error,
830
+ )
831
+ del self.consumer_tags[queue_name]
832
+ except Exception as e:
833
+ logger.warning("Error cancelling consumer for %s: %s", queue_name, e)
834
+
835
+ # Close channels
836
+ for queue_name, channel in self.channels.items():
837
+ try:
838
+ if not channel.is_closed:
839
+ await channel.close()
840
+ except Exception as e:
841
+ logger.warning("Error closing channel for %s: %s", queue_name, e)
842
+
843
+ self.channels.clear()
844
+
845
+ # Close connection
846
+ if self.connection and not self.connection.is_closed:
847
+ try:
848
+ await self.connection.close()
849
+ except Exception as e:
850
+ logger.warning("Error closing connection: %s", e)
851
+
852
+ self.connection = None
853
+ self.connection_healthy = False
854
+
855
+
856
+ def create_message_bus(
857
+ broker_url: str,
858
+ broker_backend: MessageBrokerBackend,
859
+ scheduled_actions: SCHEDULED_ACTION_DATA_SET,
860
+ message_handler_set: MESSAGE_HANDLER_DATA_SET,
861
+ uow_context_provider: UnitOfWorkContextProvider,
862
+ ) -> MessageBusConsumer:
863
+
864
+ parsed_url = urlparse(broker_url)
865
+
866
+ if parsed_url.scheme == "amqp" or parsed_url.scheme == "amqps":
867
+ assert parsed_url.query, "Query string must be set for AMQP URLs"
868
+
869
+ query_params: dict[str, list[str]] = parse_qs(parsed_url.query)
870
+
871
+ assert "exchange" in query_params, "Exchange must be set in the query string"
872
+ assert (
873
+ len(query_params["exchange"]) == 1
874
+ ), "Exchange must be set in the query string"
875
+ assert (
876
+ "prefetch_count" in query_params
877
+ ), "Prefetch count must be set in the query string"
878
+ assert (
879
+ len(query_params["prefetch_count"]) == 1
880
+ ), "Prefetch count must be set in the query string"
881
+ assert query_params["prefetch_count"][
882
+ 0
883
+ ].isdigit(), "Prefetch count must be an integer in the query string"
884
+ assert query_params["exchange"][0], "Exchange must be set in the query string"
885
+ assert query_params["prefetch_count"][
886
+ 0
887
+ ], "Prefetch count must be set in the query string"
888
+
889
+ exchange = query_params["exchange"][0]
890
+ prefetch_count = int(query_params["prefetch_count"][0])
891
+
892
+ # Parse optional retry configuration parameters
893
+ connection_retry_config = RetryConfig()
894
+ consumer_retry_config = RetryConfig(
895
+ max_retries=30, initial_delay=5, max_delay=60.0, backoff_factor=3.0
896
+ )
101
897
 
102
- main_x = await channel.declare_exchange(self.config.exchange, type="topic")
898
+ # Parse heartbeat and health check intervals
899
+ connection_heartbeat_interval = 30.0
900
+ connection_health_check_interval = 10.0
103
901
 
104
- dlx = await channel.declare_exchange("dlx", type="direct")
902
+ # Connection retry config parameters
903
+ if (
904
+ "connection_retry_max" in query_params
905
+ and query_params["connection_retry_max"][0].isdigit()
906
+ ):
907
+ connection_retry_config.max_retries = int(
908
+ query_params["connection_retry_max"][0]
909
+ )
910
+
911
+ if "connection_retry_delay" in query_params:
912
+ try:
913
+ connection_retry_config.initial_delay = float(
914
+ query_params["connection_retry_delay"][0]
915
+ )
916
+ except ValueError:
917
+ pass
918
+
919
+ if "connection_retry_max_delay" in query_params:
920
+ try:
921
+ connection_retry_config.max_delay = float(
922
+ query_params["connection_retry_max_delay"][0]
923
+ )
924
+ except ValueError:
925
+ pass
926
+
927
+ if "connection_retry_backoff" in query_params:
928
+ try:
929
+ connection_retry_config.backoff_factor = float(
930
+ query_params["connection_retry_backoff"][0]
931
+ )
932
+ except ValueError:
933
+ pass
934
+
935
+ # Consumer retry config parameters
936
+ if (
937
+ "consumer_retry_max" in query_params
938
+ and query_params["consumer_retry_max"][0].isdigit()
939
+ ):
940
+ consumer_retry_config.max_retries = int(
941
+ query_params["consumer_retry_max"][0]
942
+ )
943
+
944
+ if "consumer_retry_delay" in query_params:
945
+ try:
946
+ consumer_retry_config.initial_delay = float(
947
+ query_params["consumer_retry_delay"][0]
948
+ )
949
+ except ValueError:
950
+ pass
951
+
952
+ if "consumer_retry_max_delay" in query_params:
953
+ try:
954
+ consumer_retry_config.max_delay = float(
955
+ query_params["consumer_retry_max_delay"][0]
956
+ )
957
+ except ValueError:
958
+ pass
959
+
960
+ if "consumer_retry_backoff" in query_params:
961
+ try:
962
+ consumer_retry_config.backoff_factor = float(
963
+ query_params["consumer_retry_backoff"][0]
964
+ )
965
+ except ValueError:
966
+ pass
967
+
968
+ # Heartbeat and health check intervals
969
+ if "connection_heartbeat_interval" in query_params:
970
+ try:
971
+ connection_heartbeat_interval = float(
972
+ query_params["connection_heartbeat_interval"][0]
973
+ )
974
+ except ValueError:
975
+ pass
976
+
977
+ if "connection_health_check_interval" in query_params:
978
+ try:
979
+ connection_health_check_interval = float(
980
+ query_params["connection_health_check_interval"][0]
981
+ )
982
+ except ValueError:
983
+ pass
984
+
985
+ config = AioPikaWorkerConfig(
986
+ url=broker_url,
987
+ exchange=exchange,
988
+ prefetch_count=prefetch_count,
989
+ connection_retry_config=connection_retry_config,
990
+ consumer_retry_config=consumer_retry_config,
991
+ connection_heartbeat_interval=connection_heartbeat_interval,
992
+ connection_health_check_interval=connection_health_check_interval,
993
+ )
994
+
995
+ return AioPikaMicroserviceConsumer(
996
+ config=config,
997
+ broker_backend=broker_backend,
998
+ message_handler_set=message_handler_set,
999
+ scheduled_actions=scheduled_actions,
1000
+ uow_context_provider=uow_context_provider,
1001
+ )
1002
+
1003
+ raise ValueError(
1004
+ f"Unsupported broker URL scheme: {parsed_url.scheme}. Supported schemes are amqp and amqps"
1005
+ )
1006
+
1007
+
1008
+ class ScheduledMessageHandlerCallback:
1009
+ def __init__(
1010
+ self,
1011
+ consumer: AioPikaMicroserviceConsumer,
1012
+ queue_name: str,
1013
+ routing_key: str,
1014
+ scheduled_action: ScheduledActionData,
1015
+ ):
1016
+ self.consumer = consumer
1017
+ self.queue_name = queue_name
1018
+ self.routing_key = routing_key
1019
+ self.scheduled_action = scheduled_action
105
1020
 
106
- dlq = await channel.declare_queue("dlq")
1021
+ async def __call__(
1022
+ self, aio_pika_message: aio_pika.abc.AbstractIncomingMessage
1023
+ ) -> None:
107
1024
 
108
- await dlq.bind(dlx, routing_key="dlq")
1025
+ if self.consumer.shutdown_event.is_set():
1026
+ logger.debug(
1027
+ "Shutdown in progress. Requeuing scheduled message for %s",
1028
+ self.queue_name,
1029
+ )
1030
+ try:
1031
+ # Use channel context for requeuing
1032
+ await aio_pika_message.reject(requeue=True)
1033
+ except RuntimeError:
1034
+ logger.warning(
1035
+ "Could not requeue scheduled message during shutdown - channel not available"
1036
+ )
1037
+ except Exception as e:
1038
+ logger.error(
1039
+ "Failed to requeue scheduled message during shutdown: %s", e
1040
+ )
1041
+ return
109
1042
 
110
- for handler in self.message_handler_set:
1043
+ # Check if connection is healthy before processing
1044
+ if not self.consumer.connection_healthy:
1045
+ logger.warning(
1046
+ "Connection not healthy, requeuing scheduled message for %s",
1047
+ self.queue_name,
1048
+ )
1049
+ try:
1050
+ if not self.consumer.connection_healthy:
1051
+ # Still not healthy, requeue the message
1052
+ async with self.consumer.get_channel_ctx(self.queue_name):
1053
+ await aio_pika_message.reject(requeue=True)
1054
+ return
1055
+ except Exception as e:
1056
+ logger.error(
1057
+ "Failed to requeue scheduled message due to connection issues: %s",
1058
+ e,
1059
+ )
1060
+ return
111
1061
 
112
- queue_name = f"{self.config.exchange}.{handler.message_type.MESSAGE_TOPIC}.{handler.callable.__module__}.{handler.callable.__qualname__}"
113
- routing_key = (
114
- f"{self.config.exchange}.{handler.message_type.MESSAGE_TOPIC}.#"
1062
+ async with self.consumer.lock:
1063
+ task = asyncio.create_task(
1064
+ self.handle_message(aio_pika_message),
1065
+ name=f"ScheduledAction-{self.queue_name}-handle-message-{aio_pika_message.message_id}",
115
1066
  )
1067
+ self.consumer.tasks.add(task)
1068
+ task.add_done_callback(self.handle_message_consume_done)
116
1069
 
117
- self.incoming_map[queue_name] = handler
1070
+ def handle_message_consume_done(self, task: asyncio.Task[Any]) -> None:
1071
+ self.consumer.tasks.discard(task)
1072
+ if task.cancelled():
1073
+ logger.warning("Scheduled task for %s was cancelled", self.queue_name)
1074
+ return
118
1075
 
119
- queue = await channel.declare_queue(
120
- queue_name,
121
- arguments={
122
- "x-dead-letter-exchange": "dlx",
123
- "x-dead-letter-routing-key": "dlq",
124
- },
1076
+ if (error := task.exception()) is not None:
1077
+ logger.exception(
1078
+ "Error processing scheduled action %s", self.queue_name, exc_info=error
125
1079
  )
126
1080
 
127
- await queue.bind(exchange=self.config.exchange, routing_key=routing_key)
1081
+ async def handle_message(
1082
+ self, aio_pika_message: aio_pika.abc.AbstractIncomingMessage
1083
+ ) -> None:
128
1084
 
129
- await queue.consume(
130
- callback=MessageHandlerCallback(
131
- consumer=self,
132
- queue_name=queue_name,
133
- routing_key=routing_key,
134
- message_handler=handler,
1085
+ if self.consumer.shutdown_event.is_set():
1086
+ logger.debug(
1087
+ "Shutdown event set. Requeuing message for %s", self.queue_name
1088
+ )
1089
+ try:
1090
+ # Use channel context for requeuing
1091
+ async with self.consumer.get_channel_ctx(self.queue_name):
1092
+ await aio_pika_message.reject(requeue=True)
1093
+ return
1094
+ except RuntimeError:
1095
+ logger.warning(
1096
+ "Could not requeue message during shutdown - channel not available"
1097
+ )
1098
+ except Exception as e:
1099
+ logger.error("Failed to requeue message during shutdown: %s", e)
1100
+ return
1101
+
1102
+ # Check connection health before processing
1103
+ if not self.consumer.connection_healthy:
1104
+ logger.warning(
1105
+ "Connection not healthy, requeuing scheduled message for %s",
1106
+ self.queue_name,
1107
+ )
1108
+ try:
1109
+ async with self.consumer.get_channel_ctx(self.queue_name):
1110
+ await aio_pika_message.reject(requeue=True)
1111
+ return
1112
+ except Exception as e:
1113
+ logger.error(
1114
+ "Failed to requeue scheduled message due to connection issues: %s",
1115
+ e,
1116
+ )
1117
+ return
1118
+
1119
+ sig = inspect.signature(self.scheduled_action.callable)
1120
+ if len(sig.parameters) == 1:
1121
+
1122
+ task = asyncio.create_task(
1123
+ self.run_with_context(
1124
+ self.scheduled_action,
1125
+ (ScheduleDispatchData(int(aio_pika_message.body.decode("utf-8"))),),
1126
+ {},
135
1127
  ),
136
- no_ack=handler.spec.auto_ack,
1128
+ name=f"ScheduledAction-{self.queue_name}-handle-message-{aio_pika_message.message_id}",
137
1129
  )
138
1130
 
139
- print(f"Consuming {queue_name}")
1131
+ elif len(sig.parameters) == 0:
1132
+ task = asyncio.create_task(
1133
+ self.run_with_context(
1134
+ self.scheduled_action,
1135
+ (),
1136
+ {},
1137
+ ),
1138
+ name=f"ScheduledAction-{self.queue_name}-handle-message-{aio_pika_message.message_id}",
1139
+ )
1140
+ else:
1141
+ logger.warning(
1142
+ "Scheduled action '%s' must have exactly one parameter of type ScheduleDispatchData or no parameters"
1143
+ % self.queue_name
1144
+ )
1145
+ return
140
1146
 
141
- await self.shutdown_event.wait()
142
- logger.info("Worker shutting down")
1147
+ self.consumer.tasks.add(task)
1148
+ task.add_done_callback(self.handle_message_consume_done)
143
1149
 
144
- await self.wait_all_tasks_done()
1150
+ try:
1151
+ await task
1152
+ except Exception as e:
1153
+
1154
+ logger.exception(
1155
+ "Error processing scheduled action %s: %s", self.queue_name, e
1156
+ )
145
1157
 
146
- await channel.close()
147
- await connection.close()
1158
+ async def run_with_context(
1159
+ self,
1160
+ scheduled_action: ScheduledActionData,
1161
+ args: tuple[Any, ...],
1162
+ kwargs: dict[str, Any],
1163
+ ) -> None:
148
1164
 
149
- async def wait_all_tasks_done(self) -> None:
150
- async with self.lock:
151
- await asyncio.gather(*self.tasks)
1165
+ with provide_shutdown_state(self.consumer.shutdown_state):
1166
+ async with self.consumer.uow_context_provider(
1167
+ AppTransactionContext(
1168
+ controller_member_reflect=scheduled_action.controller_member,
1169
+ transaction_data=SchedulerTransactionData(
1170
+ task_name=scheduled_action.spec.name
1171
+ or scheduled_action.callable.__qualname__,
1172
+ scheduled_to=datetime.now(UTC),
1173
+ cron_expression=scheduled_action.spec.cron,
1174
+ triggered_at=datetime.now(UTC),
1175
+ ),
1176
+ )
1177
+ ):
1178
+
1179
+ await scheduled_action.callable(*args, **kwargs)
152
1180
 
153
1181
 
154
1182
  class MessageHandlerCallback:
@@ -164,25 +1192,62 @@ class MessageHandlerCallback:
164
1192
  self.queue_name = queue_name
165
1193
  self.routing_key = routing_key
166
1194
  self.message_handler = message_handler
1195
+ self.retry_state: dict[str, dict[str, Any]] = {}
167
1196
 
168
1197
  async def message_consumer(
169
1198
  self, aio_pika_message: aio_pika.abc.AbstractIncomingMessage
170
1199
  ) -> None:
171
1200
  if self.consumer.shutdown_event.is_set():
1201
+ logger.debug(
1202
+ "Shutdown in progress. Requeuing message for %s", self.queue_name
1203
+ )
1204
+ try:
1205
+ # Use channel context for requeuing
1206
+ async with self.consumer.get_channel_ctx(self.queue_name):
1207
+ await aio_pika_message.reject(requeue=True)
1208
+ except RuntimeError:
1209
+ logger.warning(
1210
+ "Could not requeue message during shutdown - channel not available"
1211
+ )
1212
+ except Exception as e:
1213
+ logger.error("Failed to requeue message during shutdown: %s", e)
172
1214
  return
173
1215
 
1216
+ # Check if connection is healthy before processing
1217
+ if not self.consumer.connection_healthy:
1218
+ logger.warning(
1219
+ "Connection not healthy, requeuing message for %s", self.queue_name
1220
+ )
1221
+ try:
1222
+ if not self.consumer.connection_healthy:
1223
+ # Still not healthy, requeue the message
1224
+ async with self.consumer.get_channel_ctx(self.queue_name):
1225
+ await aio_pika_message.reject(requeue=True)
1226
+ return
1227
+ except Exception as e:
1228
+ logger.error(
1229
+ "Failed to requeue message due to connection issues: %s", e
1230
+ )
1231
+ return
1232
+
174
1233
  async with self.consumer.lock:
175
- task = asyncio.create_task(self.handle_message(aio_pika_message))
1234
+ task = asyncio.create_task(
1235
+ self.handle_message(aio_pika_message),
1236
+ name=f"MessageHandler-{self.queue_name}-handle-message-{aio_pika_message.message_id}",
1237
+ )
176
1238
  self.consumer.tasks.add(task)
177
1239
  task.add_done_callback(self.handle_message_consume_done)
178
1240
 
179
1241
  def handle_message_consume_done(self, task: asyncio.Task[Any]) -> None:
180
1242
  self.consumer.tasks.discard(task)
181
1243
  if task.cancelled():
1244
+ logger.warning("Task for queue %s was cancelled", self.queue_name)
182
1245
  return
183
1246
 
184
1247
  if (error := task.exception()) is not None:
185
- logger.exception("Error processing message", exc_info=error)
1248
+ logger.exception(
1249
+ "Error processing message for queue %s", self.queue_name, exc_info=error
1250
+ )
186
1251
 
187
1252
  async def __call__(
188
1253
  self, aio_pika_message: aio_pika.abc.AbstractIncomingMessage
@@ -193,14 +1258,242 @@ class MessageHandlerCallback:
193
1258
  self,
194
1259
  aio_pika_message: aio_pika.abc.AbstractIncomingMessage,
195
1260
  requeue: bool = False,
1261
+ retry_count: int = 0,
1262
+ exception: Optional[BaseException] = None,
196
1263
  ) -> None:
197
- if self.message_handler.spec.auto_ack is False:
198
- await aio_pika_message.reject(requeue=requeue)
199
- elif requeue:
200
- logger.warning(
201
- f"Message {aio_pika_message.message_id} ({self.queue_name}) cannot be requeued because auto_ack is enabled"
1264
+ """
1265
+ Handle rejecting a message, with support for retry with exponential backoff.
1266
+
1267
+ Args:
1268
+ aio_pika_message: The message to reject
1269
+ requeue: Whether to requeue the message directly (True) or handle with retry logic (False)
1270
+ retry_count: The current retry count for this message
1271
+ exception: The exception that caused the rejection, if any
1272
+ """
1273
+ message_id = aio_pika_message.message_id or str(uuid.uuid4())
1274
+
1275
+ try:
1276
+ # Check if we should retry with backoff
1277
+ if (
1278
+ not requeue
1279
+ and self.message_handler.spec.nack_on_exception
1280
+ and exception is not None
1281
+ ):
1282
+ # Get retry config from consumer
1283
+ retry_config = self.consumer.config.consumer_retry_config
1284
+
1285
+ # Check if we reached max retries
1286
+ if retry_count >= retry_config.max_retries:
1287
+ logger.warning(
1288
+ "Message %s (%s) failed after %s retries, dead-lettering: %s",
1289
+ message_id,
1290
+ self.queue_name,
1291
+ retry_count,
1292
+ str(exception),
1293
+ )
1294
+ # Dead-letter the message after max retries
1295
+ try:
1296
+
1297
+ await aio_pika_message.reject(requeue=False)
1298
+ except Exception as e:
1299
+ logger.error(
1300
+ "Failed to dead-letter message %s: %s", message_id, e
1301
+ )
1302
+ return
1303
+
1304
+ # Calculate delay for this retry attempt
1305
+ delay = retry_config.initial_delay * (
1306
+ retry_config.backoff_factor**retry_count
1307
+ )
1308
+ if retry_config.jitter:
1309
+ jitter_amount = delay * 0.25
1310
+ delay = delay + random.uniform(-jitter_amount, jitter_amount)
1311
+ delay = max(
1312
+ delay, 0.1
1313
+ ) # Ensure delay doesn't go negative due to jitter
1314
+
1315
+ delay = min(delay, retry_config.max_delay)
1316
+
1317
+ logger.warning(
1318
+ "Message %s (%s) failed with %s, retry %s/%s scheduled in %.2fs",
1319
+ message_id,
1320
+ self.queue_name,
1321
+ str(exception),
1322
+ retry_count + 1,
1323
+ retry_config.max_retries,
1324
+ delay,
1325
+ )
1326
+
1327
+ # Store retry state for this message
1328
+ self.retry_state[message_id] = {
1329
+ "retry_count": retry_count + 1,
1330
+ "last_exception": exception,
1331
+ "next_retry": time.time() + delay,
1332
+ }
1333
+
1334
+ # Schedule retry after delay
1335
+ asyncio.create_task(
1336
+ self._delayed_retry(
1337
+ aio_pika_message, delay, retry_count + 1, exception
1338
+ ),
1339
+ name=f"MessageHandler-{self.queue_name}-delayed-retry-{message_id}",
1340
+ )
1341
+
1342
+ # Acknowledge the current message since we'll handle retry ourselves
1343
+ try:
1344
+ await aio_pika_message.ack()
1345
+ except Exception as e:
1346
+ logger.error(
1347
+ "Failed to acknowledge message %s for retry: %s", message_id, e
1348
+ )
1349
+ return
1350
+
1351
+ # Standard reject without retry or with immediate requeue
1352
+ try:
1353
+ async with self.consumer.get_channel_ctx(self.queue_name):
1354
+ await aio_pika_message.reject(requeue=requeue)
1355
+ if requeue:
1356
+ logger.warning(
1357
+ "Message %s (%s) requeued for immediate retry",
1358
+ message_id,
1359
+ self.queue_name,
1360
+ )
1361
+ else:
1362
+ logger.warning(
1363
+ "Message %s (%s) rejected without requeue",
1364
+ message_id,
1365
+ self.queue_name,
1366
+ )
1367
+ except Exception as e:
1368
+ logger.error("Failed to reject message %s: %s", message_id, e)
1369
+
1370
+ except Exception as e:
1371
+ logger.exception(
1372
+ "Unexpected error in handle_reject_message for %s (%s): %s",
1373
+ message_id,
1374
+ self.queue_name,
1375
+ e,
1376
+ )
1377
+
1378
+ async def _delayed_retry(
1379
+ self,
1380
+ aio_pika_message: aio_pika.abc.AbstractIncomingMessage,
1381
+ delay: float,
1382
+ retry_count: int,
1383
+ exception: Optional[BaseException],
1384
+ ) -> None:
1385
+ """
1386
+ Handle delayed retry of a message after exponential backoff delay.
1387
+
1388
+ Args:
1389
+ aio_pika_message: The original message
1390
+ delay: Delay in seconds before retrying
1391
+ retry_count: The current retry count (after increment)
1392
+ exception: The exception that caused the failure
1393
+ """
1394
+ message_id = aio_pika_message.message_id or str(uuid.uuid4())
1395
+
1396
+ try:
1397
+ # Wait for the backoff delay
1398
+ await self._wait_delay_or_shutdown(delay)
1399
+
1400
+ # Get message body and properties for republishing
1401
+ message_body = aio_pika_message.body
1402
+ headers = (
1403
+ aio_pika_message.headers.copy() if aio_pika_message.headers else {}
202
1404
  )
203
1405
 
1406
+ # Add retry information to headers
1407
+ headers["x-retry-count"] = retry_count
1408
+ if exception:
1409
+ headers["x-last-error"] = str(exception)
1410
+
1411
+ # Clean up retry state
1412
+ if message_id in self.retry_state:
1413
+ del self.retry_state[message_id]
1414
+
1415
+ # Republish the message to the same queue with retry logic
1416
+ max_attempts = 3
1417
+ for attempt in range(max_attempts):
1418
+ try:
1419
+ async with self.consumer.get_channel_ctx(
1420
+ self.queue_name
1421
+ ) as channel:
1422
+ exchange = await RabbitmqUtils.get_main_exchange(
1423
+ channel=channel,
1424
+ exchange_name=self.consumer.config.exchange,
1425
+ )
1426
+
1427
+ await exchange.publish(
1428
+ aio_pika.Message(
1429
+ body=message_body,
1430
+ headers=headers,
1431
+ message_id=message_id,
1432
+ content_type=aio_pika_message.content_type,
1433
+ content_encoding=aio_pika_message.content_encoding,
1434
+ delivery_mode=aio_pika_message.delivery_mode,
1435
+ ),
1436
+ routing_key=self.routing_key,
1437
+ )
1438
+
1439
+ logger.warning(
1440
+ "Message %s (%s) republished for retry %s",
1441
+ message_id,
1442
+ self.queue_name,
1443
+ retry_count,
1444
+ )
1445
+ return
1446
+
1447
+ except Exception as e:
1448
+ if attempt < max_attempts - 1:
1449
+ logger.warning(
1450
+ "Failed to republish message %s (attempt %s): %s",
1451
+ message_id,
1452
+ attempt + 1,
1453
+ e,
1454
+ )
1455
+ await asyncio.sleep(1.0 * (attempt + 1)) # Exponential backoff
1456
+ else:
1457
+ logger.error(
1458
+ "Failed to republish message %s after %s attempts: %s",
1459
+ message_id,
1460
+ max_attempts,
1461
+ e,
1462
+ )
1463
+ raise
1464
+
1465
+ except Exception as e:
1466
+ logger.exception(
1467
+ "Failed to execute delayed retry for message %s (%s): %s",
1468
+ message_id,
1469
+ self.queue_name,
1470
+ e,
1471
+ )
1472
+ # If we fail to republish, try to dead-letter the original message
1473
+ try:
1474
+ if message_id in self.retry_state:
1475
+ del self.retry_state[message_id]
1476
+ except Exception:
1477
+ pass
1478
+
1479
+ async def _wait_delay_or_shutdown(self, delay: float) -> None:
1480
+ """
1481
+ Wait for the specified delay or exit early if shutdown is initiated.
1482
+
1483
+ Args:
1484
+ delay: Delay in seconds to wait
1485
+ """
1486
+
1487
+ wait_cor = asyncio.create_task(asyncio.sleep(delay), name="delayed-retry-wait")
1488
+ wait_shutdown_cor = asyncio.create_task(
1489
+ self.consumer.shutdown_event.wait(), name="delayed-retry-shutdown-wait"
1490
+ )
1491
+
1492
+ await asyncio.wait(
1493
+ [wait_cor, wait_shutdown_cor],
1494
+ return_when=asyncio.FIRST_COMPLETED,
1495
+ )
1496
+
204
1497
  async def handle_message(
205
1498
  self, aio_pika_message: aio_pika.abc.AbstractIncomingMessage
206
1499
  ) -> None:
@@ -212,15 +1505,9 @@ class MessageHandlerCallback:
212
1505
  await self.handle_reject_message(aio_pika_message)
213
1506
  return
214
1507
 
215
- handler_data = self.consumer.incoming_map.get(routing_key)
216
-
217
- if handler_data is None:
218
- logger.warning("No handler found for topic '%s'" % routing_key)
219
- await self.handle_reject_message(aio_pika_message)
220
-
221
- return
1508
+ handler_data = self.message_handler
222
1509
 
223
- handler = handler_data.callable
1510
+ handler = handler_data.instance_callable
224
1511
 
225
1512
  sig = inspect.signature(handler)
226
1513
 
@@ -260,51 +1547,123 @@ class MessageHandlerCallback:
260
1547
 
261
1548
  builded_message = AioPikaMessage(aio_pika_message, message_type)
262
1549
 
263
- incoming_message_spec = MessageHandler.get_message_incoming(handler)
1550
+ incoming_message_spec = MessageHandler.get_last(handler)
264
1551
  assert incoming_message_spec is not None
265
1552
 
266
- async with self.consumer.uow_context_provider(
267
- MessageBusAppContext(
268
- message=builded_message,
269
- topic=routing_key,
270
- )
1553
+ with provide_implicit_headers(aio_pika_message.headers), provide_shutdown_state(
1554
+ self.consumer.shutdown_state
271
1555
  ):
272
- ctx: AsyncContextManager[Any]
273
- if incoming_message_spec.timeout is not None:
274
- ctx = asyncio.timeout(incoming_message_spec.timeout)
275
- else:
276
- ctx = none_context()
277
- async with ctx:
278
- try:
279
- with provide_bus_message_controller(
280
- AioPikaMessageBusController(aio_pika_message)
281
- ):
282
- await handler(builded_message)
283
- if not incoming_message_spec.auto_ack:
284
- with suppress(aio_pika.MessageProcessError):
285
- await aio_pika_message.ack()
286
- except BaseException as base_exc:
287
- if incoming_message_spec.exception_handler is not None:
288
- try:
289
- incoming_message_spec.exception_handler(base_exc)
290
- except Exception as nested_exc:
1556
+ async with self.consumer.uow_context_provider(
1557
+ AppTransactionContext(
1558
+ controller_member_reflect=handler_data.controller_member,
1559
+ transaction_data=MessageBusTransactionData(
1560
+ message_type=message_type,
1561
+ message=builded_message,
1562
+ topic=routing_key,
1563
+ ),
1564
+ )
1565
+ ):
1566
+ maybe_timeout_ctx: AsyncContextManager[Any]
1567
+ if incoming_message_spec.timeout is not None:
1568
+ maybe_timeout_ctx = asyncio.timeout(incoming_message_spec.timeout)
1569
+ else:
1570
+ maybe_timeout_ctx = none_context()
1571
+
1572
+ start_time = time.perf_counter()
1573
+ async with maybe_timeout_ctx:
1574
+ try:
1575
+ with provide_bus_message_controller(
1576
+ AioPikaMessageBusController(aio_pika_message)
1577
+ ):
1578
+ await handler(builded_message)
1579
+ with suppress(aio_pika.MessageProcessError):
1580
+ # Use channel context for acknowledgement with retry
1581
+ try:
1582
+ await aio_pika_message.ack()
1583
+ except Exception as ack_error:
1584
+ logger.warning(
1585
+ "Failed to acknowledge message %s: %s",
1586
+ aio_pika_message.message_id or "unknown",
1587
+ ack_error,
1588
+ )
1589
+ successfully = True
1590
+ except BaseException as base_exc:
1591
+ successfully = False
1592
+ # Get message id for logging
1593
+ message_id = aio_pika_message.message_id or "unknown"
1594
+
1595
+ # Extract retry count from headers if available
1596
+ headers = aio_pika_message.headers or {}
1597
+ retry_count = int(str(headers.get("x-retry-count", 0)))
1598
+
1599
+ # Process exception handler if configured
1600
+ if incoming_message_spec.exception_handler is not None:
1601
+ try:
1602
+ incoming_message_spec.exception_handler(base_exc)
1603
+ except Exception as nested_exc:
1604
+ logger.exception(
1605
+ "Error processing exception handler for message %s: %s | %s",
1606
+ message_id,
1607
+ base_exc,
1608
+ nested_exc,
1609
+ )
1610
+ else:
291
1611
  logger.exception(
292
- f"Error processing exception handler: {base_exc} | {nested_exc}"
1612
+ "Error processing message %s on topic %s: %s",
1613
+ message_id,
1614
+ routing_key,
1615
+ str(base_exc),
293
1616
  )
294
- else:
295
- logger.exception(
296
- f"Error processing message on topic {routing_key}"
1617
+
1618
+ # Handle rejection with retry logic
1619
+ if incoming_message_spec.nack_on_exception:
1620
+ await self.handle_reject_message(
1621
+ aio_pika_message,
1622
+ requeue=False, # Don't requeue directly, use our backoff mechanism
1623
+ retry_count=retry_count,
1624
+ exception=base_exc,
1625
+ )
1626
+ else:
1627
+ # Message shouldn't be retried, reject it
1628
+ await self.handle_reject_message(
1629
+ aio_pika_message, requeue=False, exception=base_exc
1630
+ )
1631
+
1632
+ elapsed_time = time.perf_counter() - start_time
1633
+ # Message processed successfully, log and clean up any retry state
1634
+ message_id = aio_pika_message.message_id or str(uuid.uuid4())
1635
+ if message_id in self.retry_state:
1636
+ del self.retry_state[message_id]
1637
+
1638
+ # Log success with retry information if applicable
1639
+ headers = aio_pika_message.headers or {}
1640
+ traceparent = headers.get("traceparent")
1641
+ trace_info = (
1642
+ f" [traceparent={str(traceparent)}]" if traceparent else ""
1643
+ )
1644
+
1645
+ if "x-retry-count" in headers:
1646
+ retry_count = int(str(headers.get("x-retry-count", 0)))
1647
+ logger.debug(
1648
+ "Message %s#%s processed "
1649
+ + ("successfully" if successfully else "with errors")
1650
+ + " after %s retries in %.4fs%s",
1651
+ message_id,
1652
+ self.queue_name,
1653
+ retry_count,
1654
+ elapsed_time,
1655
+ trace_info,
297
1656
  )
298
- if incoming_message_spec.requeue_on_exception:
299
- await self.handle_reject_message(aio_pika_message, requeue=True)
300
1657
  else:
301
- await self.handle_reject_message(
302
- aio_pika_message, requeue=False
1658
+ logger.debug(
1659
+ "Message %s#%s processed "
1660
+ + ("successfully" if successfully else "with errors")
1661
+ + " in %.4fs%s",
1662
+ message_id,
1663
+ self.queue_name,
1664
+ elapsed_time,
1665
+ trace_info,
303
1666
  )
304
- else:
305
- logger.info(
306
- f"Message {aio_pika_message.message_id}#{self.queue_name} processed successfully"
307
- )
308
1667
 
309
1668
 
310
1669
  @asynccontextmanager
@@ -313,9 +1672,18 @@ async def none_context() -> AsyncGenerator[None, None]:
313
1672
 
314
1673
 
315
1674
  class MessageBusWorker:
316
- def __init__(self, app: Microservice, config: AioPikaWorkerConfig) -> None:
1675
+ def __init__(
1676
+ self,
1677
+ app: Microservice,
1678
+ broker_url: str,
1679
+ backend_url: str,
1680
+ handler_names: set[str] | None = None,
1681
+ ) -> None:
317
1682
  self.app = app
318
- self.config = config
1683
+ self.backend_url = backend_url
1684
+ self.broker_url = broker_url
1685
+ self.handler_names = handler_names
1686
+
319
1687
  self.container = Container(app)
320
1688
  self.lifecycle = AppLifecycle(app, self.container)
321
1689
 
@@ -323,69 +1691,146 @@ class MessageBusWorker:
323
1691
  app=app, container=self.container
324
1692
  )
325
1693
 
326
- self._consumer: AioPikaMicroserviceConsumer | None = None
1694
+ self._consumer: MessageBusConsumer | None = None
327
1695
 
328
1696
  @property
329
- def consumer(self) -> AioPikaMicroserviceConsumer:
1697
+ def consumer(self) -> MessageBusConsumer:
330
1698
  if self._consumer is None:
331
1699
  raise RuntimeError("Consumer not started")
332
1700
  return self._consumer
333
1701
 
334
1702
  async def start_async(self) -> None:
335
1703
  all_message_handlers_set: MESSAGE_HANDLER_DATA_SET = set()
336
- async with self.lifecycle():
337
- for instance_type in self.app.controllers:
338
- controller = MessageBusController.get_messagebus(instance_type)
339
-
340
- if controller is None:
341
- continue
1704
+ all_scheduled_actions_set: SCHEDULED_ACTION_DATA_SET = set()
1705
+ with providing_app_type("worker"):
1706
+ async with self.lifecycle():
1707
+ for instance_class in self.app.controllers:
1708
+ controller = MessageBusController.get_last(instance_class)
342
1709
 
343
- instance: Any = self.container.get_by_type(instance_type)
344
-
345
- factory = controller.get_messagebus_factory()
346
- handlers = factory(instance)
1710
+ if controller is None:
1711
+ continue
347
1712
 
348
- message_handler_data_map: dict[str, MessageHandlerData] = {}
1713
+ instance: Any = self.container.get_by_type(instance_class)
1714
+
1715
+ factory = controller.get_messagebus_factory()
1716
+ handlers, schedulers = factory(instance)
1717
+
1718
+ message_handler_data_map: dict[str, MessageHandlerData] = {}
1719
+ all_scheduled_actions_set.update(schedulers)
1720
+ for handler_data in handlers:
1721
+ message_type = handler_data.spec.message_type
1722
+ topic = message_type.MESSAGE_TOPIC
1723
+
1724
+ # Filter handlers by name if specified
1725
+ if (
1726
+ self.handler_names is not None
1727
+ and handler_data.spec.name is not None
1728
+ ):
1729
+ if handler_data.spec.name not in self.handler_names:
1730
+ continue
1731
+ elif (
1732
+ self.handler_names is not None
1733
+ and handler_data.spec.name is None
1734
+ ):
1735
+ # Skip handlers without names when filtering is requested
1736
+ continue
1737
+
1738
+ if (
1739
+ topic in message_handler_data_map
1740
+ and message_type.MESSAGE_TYPE == "task"
1741
+ ):
1742
+ logger.warning(
1743
+ "Task handler for topic '%s' already registered. Skipping"
1744
+ % topic
1745
+ )
1746
+ continue
1747
+ message_handler_data_map[topic] = handler_data
1748
+ all_message_handlers_set.add(handler_data)
349
1749
 
350
- for handler_data in handlers:
351
- message_type = handler_data.spec.message_type
352
- topic = message_type.MESSAGE_TOPIC
353
- if (
354
- topic in message_handler_data_map
355
- and message_type.MESSAGE_TYPE == "task"
356
- ):
357
- logger.warning(
358
- "Task handler for topic '%s' already registered. Skipping"
359
- % topic
360
- )
361
- continue
362
- message_handler_data_map[topic] = handler_data
363
- all_message_handlers_set.add(handler_data)
1750
+ broker_backend = get_message_broker_backend_from_url(
1751
+ url=self.backend_url
1752
+ )
364
1753
 
365
- consumer = self._consumer = AioPikaMicroserviceConsumer(
366
- config=self.config,
367
- message_handler_set=all_message_handlers_set,
368
- uow_context_provider=self.uow_context_provider,
369
- )
1754
+ consumer = self._consumer = create_message_bus(
1755
+ broker_url=self.broker_url,
1756
+ broker_backend=broker_backend,
1757
+ scheduled_actions=all_scheduled_actions_set,
1758
+ message_handler_set=all_message_handlers_set,
1759
+ uow_context_provider=self.uow_context_provider,
1760
+ )
370
1761
 
371
- await consumer.consume()
1762
+ await consumer.consume()
372
1763
 
373
1764
  def start_sync(self) -> None:
374
1765
 
375
1766
  def on_shutdown(loop: asyncio.AbstractEventLoop) -> None:
376
- logger.info("Shutting down")
377
- self.consumer.shutdown_event.set()
1767
+ logger.warning("Shutting down - signal received")
1768
+ # Schedule the shutdown to run in the event loop
1769
+ asyncio.create_task(
1770
+ self._graceful_shutdown(), name="Worker-Graceful-Shutdown"
1771
+ )
1772
+ # wait until the shutdown is complete
378
1773
 
379
1774
  with asyncio.Runner(loop_factory=uvloop.new_event_loop) as runner:
380
- runner.get_loop().add_signal_handler(
381
- signal.SIGINT, on_shutdown, runner.get_loop()
382
- )
383
- runner.run(self.start_async())
1775
+ loop = runner.get_loop()
1776
+ loop.add_signal_handler(signal.SIGINT, on_shutdown, loop)
1777
+ # Add graceful shutdown handler for SIGTERM as well
1778
+ loop.add_signal_handler(signal.SIGTERM, on_shutdown, loop)
1779
+ try:
1780
+ runner.run(self.start_async())
1781
+ except Exception as e:
1782
+ logger.critical("Worker failed to start due to connection error: %s", e)
1783
+ # Exit with error code 1 to indicate startup failure
1784
+ import sys
1785
+
1786
+ sys.exit(1)
1787
+
1788
+ async def _graceful_shutdown(self) -> None:
1789
+ """Handles graceful shutdown process"""
1790
+ logger.warning("Initiating graceful shutdown sequence")
1791
+ # Use the comprehensive close method that handles shutdown, task waiting and connection cleanup
1792
+
1793
+ self.consumer.shutdown()
1794
+ logger.warning("Graceful shutdown completed")
384
1795
 
385
1796
 
386
1797
  class AioPikaMessageBusController(BusMessageController):
387
1798
  def __init__(self, aio_pika_message: aio_pika.abc.AbstractIncomingMessage):
388
1799
  self.aio_pika_message = aio_pika_message
1800
+ # We access consumer callback through context if available
1801
+ self._callback: Optional[MessageHandlerCallback] = None
1802
+
1803
+ def _get_callback(self) -> MessageHandlerCallback:
1804
+ """
1805
+ Find the callback associated with this message.
1806
+ This allows us to access the retry mechanisms.
1807
+ """
1808
+ if self._callback is None:
1809
+ # Get the context from current frame's locals
1810
+ frame = inspect.currentframe()
1811
+ if frame is not None:
1812
+ try:
1813
+ caller_frame = frame.f_back
1814
+ if caller_frame is not None:
1815
+ # Check for context with handler callback
1816
+ callback_ref = None
1817
+ # Look for handler_message call context
1818
+ while caller_frame is not None:
1819
+ if "self" in caller_frame.f_locals:
1820
+ self_obj = caller_frame.f_locals["self"]
1821
+ if isinstance(self_obj, MessageHandlerCallback):
1822
+ callback_ref = self_obj
1823
+ break
1824
+ caller_frame = caller_frame.f_back
1825
+ # Save callback reference if we found it
1826
+ self._callback = callback_ref
1827
+ finally:
1828
+ del frame # Avoid reference cycles
1829
+
1830
+ if self._callback is None:
1831
+ raise RuntimeError("Could not find callback context for message retry")
1832
+
1833
+ return self._callback
389
1834
 
390
1835
  async def ack(self) -> None:
391
1836
  await self.aio_pika_message.ack()
@@ -397,7 +1842,42 @@ class AioPikaMessageBusController(BusMessageController):
397
1842
  await self.aio_pika_message.reject()
398
1843
 
399
1844
  async def retry(self) -> None:
400
- await self.aio_pika_message.reject(requeue=True)
1845
+ """
1846
+ Retry the message immediately by rejecting with requeue flag.
1847
+ This doesn't use the exponential backoff mechanism.
1848
+ """
1849
+ callback = self._get_callback()
1850
+ await callback.handle_reject_message(self.aio_pika_message, requeue=True)
401
1851
 
402
1852
  async def retry_later(self, delay: int) -> None:
403
- raise NotImplementedError("Not implemented")
1853
+ """
1854
+ Retry the message after a specified delay using the exponential backoff mechanism.
1855
+
1856
+ Args:
1857
+ delay: Minimum delay in seconds before retrying
1858
+ """
1859
+ try:
1860
+ callback = self._get_callback()
1861
+
1862
+ # Get current retry count from message headers
1863
+ headers = self.aio_pika_message.headers or {}
1864
+ retry_count = int(str(headers.get("x-retry-count", 0)))
1865
+
1866
+ # Handle retry with explicit delay
1867
+ asyncio.create_task(
1868
+ callback._delayed_retry(
1869
+ self.aio_pika_message,
1870
+ float(delay),
1871
+ retry_count + 1,
1872
+ None, # No specific exception
1873
+ ),
1874
+ name=f"MessageHandler-{callback.queue_name}-delayed-retry-{self.aio_pika_message.message_id or 'unknown'}-{int(time.time())}",
1875
+ )
1876
+
1877
+ # Acknowledge the current message since we'll republish
1878
+ await self.aio_pika_message.ack()
1879
+
1880
+ except Exception as e:
1881
+ logger.exception("Failed to schedule retry_later: %s", e)
1882
+ # Fall back to immediate retry
1883
+ await self.aio_pika_message.reject(requeue=True)