jararaca 0.3.11a16__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 (88) hide show
  1. README.md +121 -0
  2. jararaca/__init__.py +184 -12
  3. jararaca/__main__.py +4 -0
  4. jararaca/broker_backend/__init__.py +4 -0
  5. jararaca/broker_backend/mapper.py +4 -0
  6. jararaca/broker_backend/redis_broker_backend.py +9 -3
  7. jararaca/cli.py +272 -47
  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 +41 -7
  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 +4 -0
  16. jararaca/messagebus/bus_message_controller.py +4 -0
  17. jararaca/messagebus/consumers/__init__.py +3 -0
  18. jararaca/messagebus/decorators.py +33 -67
  19. jararaca/messagebus/implicit_headers.py +49 -0
  20. jararaca/messagebus/interceptors/__init__.py +3 -0
  21. jararaca/messagebus/interceptors/aiopika_publisher_interceptor.py +13 -4
  22. jararaca/messagebus/interceptors/publisher_interceptor.py +4 -0
  23. jararaca/messagebus/message.py +4 -0
  24. jararaca/messagebus/publisher.py +6 -0
  25. jararaca/messagebus/worker.py +850 -383
  26. jararaca/microservice.py +110 -1
  27. jararaca/observability/constants.py +7 -0
  28. jararaca/observability/decorators.py +170 -13
  29. jararaca/observability/fastapi_exception_handler.py +37 -0
  30. jararaca/observability/hooks.py +109 -0
  31. jararaca/observability/interceptor.py +4 -0
  32. jararaca/observability/providers/__init__.py +3 -0
  33. jararaca/observability/providers/otel.py +202 -11
  34. jararaca/persistence/base.py +38 -2
  35. jararaca/persistence/exports.py +4 -0
  36. jararaca/persistence/interceptors/__init__.py +3 -0
  37. jararaca/persistence/interceptors/aiosqa_interceptor.py +86 -73
  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 +50 -20
  43. jararaca/presentation/__init__.py +3 -0
  44. jararaca/presentation/decorators.py +88 -86
  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 +97 -45
  49. jararaca/presentation/websocket/__init__.py +3 -0
  50. jararaca/presentation/websocket/base_types.py +4 -0
  51. jararaca/presentation/websocket/context.py +4 -0
  52. jararaca/presentation/websocket/decorators.py +8 -41
  53. jararaca/presentation/websocket/redis.py +280 -53
  54. jararaca/presentation/websocket/types.py +4 -0
  55. jararaca/presentation/websocket/websocket_interceptor.py +46 -19
  56. jararaca/reflect/__init__.py +3 -0
  57. jararaca/reflect/controller_inspect.py +16 -10
  58. jararaca/reflect/decorators.py +238 -0
  59. jararaca/reflect/metadata.py +34 -25
  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 +521 -105
  69. jararaca/scheduler/decorators.py +15 -22
  70. jararaca/scheduler/types.py +4 -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 +6 -2
  74. jararaca/tools/typescript/__init__.py +3 -0
  75. jararaca/tools/typescript/decorators.py +120 -0
  76. jararaca/tools/typescript/interface_parser.py +1074 -173
  77. jararaca/utils/__init__.py +3 -0
  78. jararaca/utils/rabbitmq_utils.py +65 -39
  79. jararaca/utils/retry.py +10 -3
  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.3.11a16.dist-info → jararaca-0.4.0a5.dist-info}/METADATA +11 -7
  83. jararaca-0.4.0a5.dist-info/RECORD +88 -0
  84. {jararaca-0.3.11a16.dist-info → jararaca-0.4.0a5.dist-info}/WHEEL +1 -1
  85. pyproject.toml +131 -0
  86. jararaca-0.3.11a16.dist-info/RECORD +0 -74
  87. /jararaca-0.3.11a16.dist-info/LICENSE → /LICENSE +0 -0
  88. {jararaca-0.3.11a16.dist-info → jararaca-0.4.0a5.dist-info}/entry_points.txt +0 -0
@@ -1,11 +1,15 @@
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
1
5
  import asyncio
2
6
  import contextlib
3
7
  import logging
4
8
  import signal
5
9
  import time
6
10
  from abc import ABC, abstractmethod
11
+ from dataclasses import dataclass, field
7
12
  from datetime import UTC, datetime
8
- from types import FrameType
9
13
  from typing import Any
10
14
  from urllib.parse import parse_qs
11
15
 
@@ -14,16 +18,24 @@ import croniter
14
18
  import urllib3
15
19
  import urllib3.util
16
20
  import uvloop
17
- from aio_pika import connect_robust
18
- from aio_pika.abc import AbstractChannel, AbstractRobustConnection
21
+ from aio_pika import connect
22
+ from aio_pika.abc import AbstractChannel, AbstractConnection
23
+ from aio_pika.exceptions import (
24
+ AMQPChannelError,
25
+ AMQPConnectionError,
26
+ AMQPError,
27
+ ChannelClosed,
28
+ ConnectionClosed,
29
+ )
19
30
  from aio_pika.pool import Pool
31
+ from aiormq.exceptions import ChannelInvalidStateError
20
32
 
21
33
  from jararaca.broker_backend import MessageBrokerBackend
22
34
  from jararaca.broker_backend.mapper import get_message_broker_backend_from_url
23
35
  from jararaca.core.uow import UnitOfWorkContextProvider
24
36
  from jararaca.di import Container
25
37
  from jararaca.lifecycle import AppLifecycle
26
- from jararaca.microservice import Microservice
38
+ from jararaca.microservice import Microservice, providing_app_type
27
39
  from jararaca.scheduler.decorators import (
28
40
  ScheduledAction,
29
41
  ScheduledActionData,
@@ -31,6 +43,7 @@ from jararaca.scheduler.decorators import (
31
43
  )
32
44
  from jararaca.scheduler.types import DelayedMessageData
33
45
  from jararaca.utils.rabbitmq_utils import RabbitmqUtils
46
+ from jararaca.utils.retry import RetryConfig, retry_with_backoff
34
47
 
35
48
  logger = logging.getLogger(__name__)
36
49
 
@@ -101,17 +114,26 @@ class _MessageBrokerDispatcher(ABC):
101
114
 
102
115
  class _RabbitMQBrokerDispatcher(_MessageBrokerDispatcher):
103
116
 
104
- def __init__(self, url: str) -> None:
117
+ def __init__(
118
+ self,
119
+ url: str,
120
+ config: "BeatWorkerConfig | None" = None,
121
+ shutdown_event: asyncio.Event | None = None,
122
+ ) -> None:
105
123
  self.url = url
124
+ self.config = config or BeatWorkerConfig()
125
+ self.connection_healthy = False
126
+ self.shutdown_event = shutdown_event or asyncio.Event()
127
+ self.health_check_task: asyncio.Task[Any] | None = None
106
128
 
107
- self.conn_pool: "Pool[AbstractRobustConnection]" = Pool(
129
+ self.conn_pool: "Pool[AbstractConnection]" = Pool(
108
130
  self._create_connection,
109
- max_size=10,
131
+ max_size=self.config.max_pool_size,
110
132
  )
111
133
 
112
134
  self.channel_pool: "Pool[AbstractChannel]" = Pool(
113
135
  self._create_channel,
114
- max_size=10,
136
+ max_size=self.config.max_pool_size,
115
137
  )
116
138
 
117
139
  splitted = urllib3.util.parse_url(url)
@@ -130,88 +152,290 @@ class _RabbitMQBrokerDispatcher(_MessageBrokerDispatcher):
130
152
 
131
153
  self.exchange = str(query_params["exchange"][0])
132
154
 
133
- async def _create_connection(self) -> AbstractRobustConnection:
155
+ async def _create_connection(self) -> AbstractConnection:
134
156
  """
135
- Create a connection to the RabbitMQ server.
136
- This is used to send messages to the RabbitMQ server.
157
+ Create a connection to the RabbitMQ server with retry logic.
137
158
  """
138
- connection = await connect_robust(self.url)
139
- return connection
159
+
160
+ async def _establish_connection() -> AbstractConnection:
161
+ logger.debug("Establishing connection to RabbitMQ")
162
+ connection = await connect(
163
+ self.url,
164
+ heartbeat=self.config.connection_heartbeat_interval,
165
+ )
166
+ logger.debug("Connected to RabbitMQ successfully")
167
+ return connection
168
+
169
+ return await retry_with_backoff(
170
+ _establish_connection,
171
+ retry_config=self.config.connection_retry_config,
172
+ retry_exceptions=(
173
+ AMQPConnectionError,
174
+ ConnectionError,
175
+ OSError,
176
+ TimeoutError,
177
+ ),
178
+ )
140
179
 
141
180
  async def _create_channel(self) -> AbstractChannel:
142
181
  """
143
- Create a channel to the RabbitMQ server.
144
- This is used to send messages to the RabbitMQ server.
182
+ Create a channel to the RabbitMQ server with retry logic.
145
183
  """
146
- async with self.conn_pool.acquire() as connection:
147
- channel = await connection.channel()
148
- return channel
184
+
185
+ async def _establish_channel() -> AbstractChannel:
186
+ async with self.conn_pool.acquire() as connection:
187
+ channel = await connection.channel()
188
+ return channel
189
+
190
+ return await retry_with_backoff(
191
+ _establish_channel,
192
+ retry_config=self.config.connection_retry_config,
193
+ retry_exceptions=(
194
+ AMQPConnectionError,
195
+ AMQPChannelError,
196
+ ChannelClosed,
197
+ ConnectionError,
198
+ ),
199
+ )
149
200
 
150
201
  async def dispatch_scheduled_action(self, action_id: str, timestamp: int) -> None:
151
202
  """
152
- Dispatch a message to the RabbitMQ server.
153
- This is used to send a message to the RabbitMQ server
154
- to trigger the scheduled action.
203
+ Dispatch a message to the RabbitMQ server with retry logic.
155
204
  """
205
+ if not self.connection_healthy:
206
+ await self._wait_for_connection()
156
207
 
157
- logger.info(f"Dispatching message to {action_id} at {timestamp}")
158
- async with self.channel_pool.acquire() as channel:
159
- exchange = await RabbitmqUtils.get_main_exchange(channel, self.exchange)
208
+ async def _dispatch() -> None:
209
+ logger.debug("Dispatching message to %s at %s", action_id, timestamp)
210
+ async with self.channel_pool.acquire() as channel:
211
+ exchange = await RabbitmqUtils.get_main_exchange(channel, self.exchange)
160
212
 
161
- await exchange.publish(
162
- aio_pika.Message(body=str(timestamp).encode()),
163
- routing_key=action_id,
213
+ await exchange.publish(
214
+ aio_pika.Message(body=str(timestamp).encode()),
215
+ routing_key=action_id,
216
+ )
217
+ logger.debug("Dispatched message to %s at %s", action_id, timestamp)
218
+
219
+ try:
220
+ await retry_with_backoff(
221
+ _dispatch,
222
+ retry_config=self.config.dispatch_retry_config,
223
+ retry_exceptions=(
224
+ AMQPConnectionError,
225
+ AMQPChannelError,
226
+ ChannelClosed,
227
+ ConnectionClosed,
228
+ AMQPError,
229
+ ),
230
+ )
231
+
232
+ except ChannelInvalidStateError as e:
233
+ logger.error(
234
+ "Channel invalid state error when dispatching to %s: %s", action_id, e
164
235
  )
165
- logger.info(f"Dispatched message to {action_id} at {timestamp}")
236
+ # Trigger shutdown if dispatch fails
237
+ self.shutdown_event.set()
238
+ raise
239
+
240
+ except Exception as e:
241
+ logger.error(
242
+ "Failed to dispatch message to %s after retries: %s", action_id, e
243
+ )
244
+ # Trigger shutdown if dispatch fails
245
+ self.shutdown_event.set()
246
+ raise
166
247
 
167
248
  async def dispatch_delayed_message(
168
249
  self, delayed_message: DelayedMessageData
169
250
  ) -> None:
170
251
  """
171
- Dispatch a delayed message to the RabbitMQ server.
172
- This is used to send a message to the RabbitMQ server
173
- to trigger the scheduled action.
252
+ Dispatch a delayed message to the RabbitMQ server with retry logic.
174
253
  """
175
- async with self.channel_pool.acquire() as channel:
254
+ if not self.connection_healthy:
255
+ await self._wait_for_connection()
256
+
257
+ async def _dispatch() -> None:
258
+ async with self.channel_pool.acquire() as channel:
259
+ exchange = await RabbitmqUtils.get_main_exchange(channel, self.exchange)
260
+ await exchange.publish(
261
+ aio_pika.Message(
262
+ body=delayed_message.payload,
263
+ ),
264
+ routing_key=f"{delayed_message.message_topic}.",
265
+ )
176
266
 
177
- exchange = await RabbitmqUtils.get_main_exchange(channel, self.exchange)
178
- await exchange.publish(
179
- aio_pika.Message(
180
- body=delayed_message.payload,
267
+ try:
268
+ await retry_with_backoff(
269
+ _dispatch,
270
+ retry_config=self.config.dispatch_retry_config,
271
+ retry_exceptions=(
272
+ AMQPConnectionError,
273
+ AMQPChannelError,
274
+ ChannelClosed,
275
+ ConnectionClosed,
276
+ AMQPError,
181
277
  ),
182
- routing_key=f"{delayed_message.message_topic}.",
183
278
  )
279
+ except Exception as e:
280
+ logger.error("Failed to dispatch delayed message after retries: %s", e)
281
+ # Trigger shutdown if dispatch fails
282
+ self.shutdown_event.set()
283
+ raise
184
284
 
185
285
  async def initialize(self, scheduled_actions: list[ScheduledActionData]) -> None:
186
286
  """
187
- Initialize the RabbitMQ server.
188
- This is used to create the exchange and queues for the scheduled actions.
287
+ Initialize the RabbitMQ server with retry logic.
189
288
  """
190
289
 
191
- async with self.channel_pool.acquire() as channel:
192
- await RabbitmqUtils.get_main_exchange(channel, self.exchange)
290
+ async def _initialize() -> None:
291
+ async with self.channel_pool.acquire() as channel:
292
+ await RabbitmqUtils.get_main_exchange(channel, self.exchange)
193
293
 
194
- for sched_act_data in scheduled_actions:
195
- queue_name = ScheduledAction.get_function_id(sched_act_data.callable)
294
+ for sched_act_data in scheduled_actions:
295
+ queue_name = ScheduledAction.get_function_id(
296
+ sched_act_data.callable
297
+ )
298
+
299
+ # Try to get existing queue
300
+ await RabbitmqUtils.get_scheduled_action_queue(
301
+ channel=channel,
302
+ queue_name=queue_name,
303
+ )
196
304
 
197
- # Try to get existing queue
198
- await RabbitmqUtils.get_scheduled_action_queue(
199
- channel=channel,
200
- queue_name=queue_name,
305
+ try:
306
+ logger.debug("Initializing RabbitMQ connection...")
307
+ await retry_with_backoff(
308
+ _initialize,
309
+ retry_config=self.config.connection_retry_config,
310
+ retry_exceptions=(
311
+ AMQPConnectionError,
312
+ AMQPChannelError,
313
+ ChannelClosed,
314
+ ConnectionClosed,
315
+ AMQPError,
316
+ ),
317
+ )
318
+
319
+ # Verify connection is actually healthy after initialization
320
+ if not await self._is_connection_healthy():
321
+ logger.warning(
322
+ "Connection health check failed after initialization, retrying..."
201
323
  )
324
+ # Wait a bit and try again
325
+ await asyncio.sleep(2.0)
326
+ if not await self._is_connection_healthy():
327
+ raise ConnectionError("Connection not healthy after initialization")
328
+
329
+ self.connection_healthy = True
330
+ logger.debug("RabbitMQ connection initialized successfully")
331
+
332
+ # Start health monitoring
333
+ self.health_check_task = asyncio.create_task(
334
+ self._monitor_connection_health()
335
+ )
336
+
337
+ except Exception as e:
338
+ logger.error("Failed to initialize RabbitMQ after retries: %s", e)
339
+ raise
202
340
 
203
341
  async def dispose(self) -> None:
204
- await self.channel_pool.close()
205
- await self.conn_pool.close()
342
+ """Clean up resources"""
343
+ logger.debug("Disposing RabbitMQ broker dispatcher")
344
+ self.shutdown_event.set()
345
+
346
+ # Cancel health monitoring
347
+ if self.health_check_task:
348
+ self.health_check_task.cancel()
349
+ try:
350
+ await self.health_check_task
351
+ except asyncio.CancelledError:
352
+ pass
353
+
354
+ # Clean up pools
355
+ await self._cleanup_pools()
356
+
357
+ async def _monitor_connection_health(self) -> None:
358
+ """Monitor connection health and trigger shutdown if needed"""
359
+ while not self.shutdown_event.is_set():
360
+ try:
361
+ await asyncio.sleep(self.config.health_check_interval)
362
+
363
+ if self.shutdown_event.is_set():
364
+ break
206
365
 
366
+ # Check connection health
367
+ if not await self._is_connection_healthy():
368
+ logger.error("Connection health check failed, triggering shutdown")
369
+ self.shutdown_event.set()
370
+ break
207
371
 
208
- def _get_message_broker_dispatcher_from_url(url: str) -> _MessageBrokerDispatcher:
372
+ except asyncio.CancelledError:
373
+ logger.debug("Connection health monitoring cancelled")
374
+ break
375
+ except Exception as e:
376
+ logger.error("Error in connection health monitoring: %s", e)
377
+ await asyncio.sleep(5) # Wait before retrying
378
+
379
+ async def _is_connection_healthy(self) -> bool:
380
+ """Check if the connection is healthy"""
381
+ try:
382
+ # Try to acquire a connection from the pool
383
+ async with self.conn_pool.acquire() as connection:
384
+ if connection.is_closed:
385
+ return False
386
+
387
+ # Try to create a channel to test connection
388
+ channel = await connection.channel()
389
+ await channel.close()
390
+ return True
391
+
392
+ except Exception as e:
393
+ logger.debug("Connection health check failed: %s", e)
394
+ return False
395
+
396
+ async def _cleanup_pools(self) -> None:
397
+ """Clean up existing connection pools"""
398
+ try:
399
+ if hasattr(self, "channel_pool"):
400
+ await self.channel_pool.close()
401
+ except Exception as e:
402
+ logger.warning("Error closing channel pool: %s", e)
403
+
404
+ try:
405
+ if hasattr(self, "conn_pool"):
406
+ await self.conn_pool.close()
407
+ except Exception as e:
408
+ logger.warning("Error closing connection pool: %s", e)
409
+
410
+ async def _wait_for_connection(self) -> None:
411
+ """Wait for connection to be healthy"""
412
+ max_wait = 30.0 # Maximum wait time
413
+ wait_time = 0.0
414
+
415
+ while not self.connection_healthy and wait_time < max_wait:
416
+ if self.shutdown_event.is_set():
417
+ raise ConnectionError("Shutdown requested while waiting for connection")
418
+
419
+ await asyncio.sleep(0.5)
420
+ wait_time += 0.5
421
+
422
+ if not self.connection_healthy:
423
+ raise ConnectionError("Connection not healthy after maximum wait time")
424
+
425
+
426
+ def _get_message_broker_dispatcher_from_url(
427
+ url: str,
428
+ config: "BeatWorkerConfig | None" = None,
429
+ shutdown_event: asyncio.Event | None = None,
430
+ ) -> _MessageBrokerDispatcher:
209
431
  """
210
432
  Factory function to create a message broker instance from a URL.
211
433
  Currently, only RabbitMQ is supported.
212
434
  """
213
435
  if url.startswith("amqp://") or url.startswith("amqps://"):
214
- return _RabbitMQBrokerDispatcher(url=url)
436
+ return _RabbitMQBrokerDispatcher(
437
+ url=url, config=config, shutdown_event=shutdown_event
438
+ )
215
439
  else:
216
440
  raise ValueError(f"Unsupported message broker URL: {url}")
217
441
 
@@ -219,6 +443,39 @@ def _get_message_broker_dispatcher_from_url(url: str) -> _MessageBrokerDispatche
219
443
  # endregion
220
444
 
221
445
 
446
+ @dataclass
447
+ class BeatWorkerConfig:
448
+ """Configuration for beat worker connection resilience"""
449
+
450
+ connection_retry_config: RetryConfig = field(
451
+ default_factory=lambda: RetryConfig(
452
+ max_retries=10,
453
+ initial_delay=2.0,
454
+ max_delay=60.0,
455
+ backoff_factor=2.0,
456
+ jitter=True,
457
+ )
458
+ )
459
+ dispatch_retry_config: RetryConfig = field(
460
+ default_factory=lambda: RetryConfig(
461
+ max_retries=3,
462
+ initial_delay=1.0,
463
+ max_delay=10.0,
464
+ backoff_factor=2.0,
465
+ jitter=True,
466
+ )
467
+ )
468
+ connection_heartbeat_interval: float = 30.0
469
+ health_check_interval: float = 15.0
470
+
471
+ # Connection establishment timeouts
472
+ connection_wait_timeout: float = 300.0 # 5 minutes to wait for initial connection
473
+
474
+ # Pool configuration
475
+ max_pool_size: int = 10
476
+ pool_recycle_time: float = 3600.0 # 1 hour
477
+
478
+
222
479
  class BeatWorker:
223
480
 
224
481
  def __init__(
@@ -228,11 +485,16 @@ class BeatWorker:
228
485
  broker_url: str,
229
486
  backend_url: str,
230
487
  scheduled_action_names: set[str] | None = None,
488
+ config: "BeatWorkerConfig | None" = None,
489
+ shutdown_event: asyncio.Event | None = None,
231
490
  ) -> None:
491
+ self.shutdown_event = shutdown_event or asyncio.Event()
492
+
232
493
  self.app = app
494
+ self.config = config or BeatWorkerConfig()
233
495
 
234
496
  self.broker: _MessageBrokerDispatcher = _get_message_broker_dispatcher_from_url(
235
- broker_url
497
+ broker_url, self.config, shutdown_event=self.shutdown_event
236
498
  )
237
499
  self.backend: MessageBrokerBackend = get_message_broker_backend_from_url(
238
500
  backend_url
@@ -243,41 +505,77 @@ class BeatWorker:
243
505
  self.container = Container(self.app)
244
506
  self.uow_provider = UnitOfWorkContextProvider(app, self.container)
245
507
 
246
- self.shutdown_event = asyncio.Event()
247
-
248
508
  self.lifecycle = AppLifecycle(app, self.container)
249
509
 
250
510
  def run(self) -> None:
251
511
 
252
- def on_signal_received(signal: int, frame_type: FrameType | None) -> None:
253
- logger.info("Received shutdown signal")
254
- self.shutdown_event.set()
255
-
256
- signal.signal(signal.SIGINT, on_signal_received)
512
+ def on_shutdown(loop: asyncio.AbstractEventLoop) -> None:
513
+ logger.debug("Shutting down - signal received")
514
+ # Schedule the shutdown to run in the event loop
515
+ asyncio.create_task(self._graceful_shutdown())
257
516
 
258
517
  with asyncio.Runner(loop_factory=uvloop.new_event_loop) as runner:
259
- runner.run(self.start_scheduler())
518
+ loop = runner.get_loop()
519
+ loop.add_signal_handler(signal.SIGINT, on_shutdown, loop)
520
+ # Add graceful shutdown handler for SIGTERM as well
521
+ loop.add_signal_handler(signal.SIGTERM, on_shutdown, loop)
522
+ try:
523
+ runner.run(self.start_scheduler())
524
+ except Exception as e:
525
+ logger.critical(
526
+ "Scheduler failed to start due to connection error: %s", e
527
+ )
528
+ # Exit with error code 1 to indicate startup failure
529
+ import sys
530
+
531
+ sys.exit(1)
260
532
 
261
533
  async def start_scheduler(self) -> None:
262
534
  """
263
535
  Declares the scheduled actions and starts the scheduler.
264
536
  This is the main entry point for the scheduler.
265
537
  """
266
- async with self.lifecycle():
538
+ with providing_app_type("beat"):
539
+ async with self.lifecycle():
267
540
 
268
- scheduled_actions = _extract_scheduled_actions(
269
- self.app, self.container, self.scheduler_names
270
- )
541
+ scheduled_actions = _extract_scheduled_actions(
542
+ self.app, self.container, self.scheduler_names
543
+ )
544
+
545
+ # Initialize and wait for connection to be established
546
+ logger.debug("Initializing broker connection...")
547
+ await self.broker.initialize(scheduled_actions)
271
548
 
272
- await self.broker.initialize(scheduled_actions)
549
+ # Wait for connection to be healthy before starting scheduler
550
+ logger.debug("Waiting for connection to be established...")
551
+ await self._wait_for_broker_connection()
273
552
 
274
- await self.run_scheduled_actions(scheduled_actions)
553
+ logger.debug("Connection established, starting scheduler...")
554
+ await self.run_scheduled_actions(scheduled_actions)
275
555
 
276
556
  async def run_scheduled_actions(
277
557
  self, scheduled_actions: list[ScheduledActionData]
278
558
  ) -> None:
279
559
 
560
+ logger.debug("Starting scheduled actions processing loop")
561
+
562
+ # Ensure we have a healthy connection before starting the main loop
563
+ if (
564
+ hasattr(self.broker, "connection_healthy")
565
+ and not self.broker.connection_healthy
566
+ ):
567
+ logger.error("Connection not healthy at start of processing loop. Exiting.")
568
+ return
569
+
280
570
  while not self.shutdown_event.is_set():
571
+ # Check connection health before processing scheduled actions
572
+ if (
573
+ hasattr(self.broker, "connection_healthy")
574
+ and not self.broker.connection_healthy
575
+ ):
576
+ logger.error("Broker connection is not healthy. Exiting.")
577
+ break
578
+
281
579
  now = int(time.time())
282
580
  for sched_act_data in scheduled_actions:
283
581
  func = sched_act_data.callable
@@ -285,58 +583,176 @@ class BeatWorker:
285
583
  if self.shutdown_event.is_set():
286
584
  break
287
585
 
288
- async with self.backend.lock():
586
+ try:
587
+ async with self.backend.lock():
289
588
 
290
- last_dispatch_time: int | None = (
291
- await self.backend.get_last_dispatch_time(
292
- ScheduledAction.get_function_id(func)
589
+ last_dispatch_time: int | None = (
590
+ await self.backend.get_last_dispatch_time(
591
+ ScheduledAction.get_function_id(func)
592
+ )
293
593
  )
294
- )
295
594
 
296
- if last_dispatch_time is not None:
297
- cron = croniter.croniter(
298
- scheduled_action.cron, last_dispatch_time
299
- )
300
- next_run: datetime = cron.get_next(datetime).replace(tzinfo=UTC)
301
- if next_run > datetime.now(UTC):
302
- logger.info(
303
- f"Skipping {func.__module__}.{func.__qualname__} until {next_run}"
595
+ if last_dispatch_time is not None:
596
+ cron = croniter.croniter(
597
+ scheduled_action.cron, last_dispatch_time
304
598
  )
305
- continue
599
+ next_run: datetime = cron.get_next(datetime).replace(
600
+ tzinfo=UTC
601
+ )
602
+ if next_run > datetime.now(UTC):
603
+ logger.debug(
604
+ "Skipping %s.%s until %s",
605
+ func.__module__,
606
+ func.__qualname__,
607
+ next_run,
608
+ )
609
+ continue
610
+
611
+ if not scheduled_action.allow_overlap:
612
+ if (
613
+ await self.backend.get_in_execution_count(
614
+ ScheduledAction.get_function_id(func)
615
+ )
616
+ > 0
617
+ ):
618
+ continue
619
+
620
+ try:
621
+ start_time = time.perf_counter()
622
+ await self.broker.dispatch_scheduled_action(
623
+ ScheduledAction.get_function_id(func),
624
+ now,
625
+ )
626
+ elapsed_time = time.perf_counter() - start_time
306
627
 
307
- if not scheduled_action.allow_overlap:
308
- if (
309
- await self.backend.get_in_execution_count(
310
- ScheduledAction.get_function_id(func)
628
+ await self.backend.set_last_dispatch_time(
629
+ ScheduledAction.get_function_id(func), now
311
630
  )
312
- > 0
313
- ):
631
+
632
+ logger.debug(
633
+ "Scheduled %s.%s at %s in %.4fs",
634
+ func.__module__,
635
+ func.__qualname__,
636
+ now,
637
+ elapsed_time,
638
+ )
639
+ except ChannelInvalidStateError as e:
640
+ logger.error(
641
+ "Channel invalid state error when dispatching %s.%s: %s",
642
+ func.__module__,
643
+ func.__qualname__,
644
+ e,
645
+ )
646
+ # Trigger shutdown if dispatch fails
647
+ self.shutdown_event.set()
648
+ raise
649
+ except Exception as e:
650
+ logger.error(
651
+ "Failed to dispatch scheduled action %s.%s: %s",
652
+ func.__module__,
653
+ func.__qualname__,
654
+ e,
655
+ )
656
+ # Continue with other scheduled actions even if one fails
314
657
  continue
315
658
 
316
- await self.broker.dispatch_scheduled_action(
317
- ScheduledAction.get_function_id(func),
318
- now,
659
+ except Exception as e:
660
+ logger.error(
661
+ "Error processing scheduled action %s.%s: %s",
662
+ func.__module__,
663
+ func.__qualname__,
664
+ e,
319
665
  )
666
+ # Continue with other scheduled actions even if one fails
667
+ continue
668
+
669
+ # Handle delayed messages
670
+ try:
671
+ delayed_messages = await self.backend.dequeue_next_delayed_messages(now)
672
+ for delayed_message_data in delayed_messages:
673
+ try:
674
+ start_time = time.perf_counter()
675
+ await self.broker.dispatch_delayed_message(delayed_message_data)
676
+ elapsed_time = time.perf_counter() - start_time
677
+ logger.debug(
678
+ "Dispatched delayed message for topic %s in %.4fs",
679
+ delayed_message_data.message_topic,
680
+ elapsed_time,
681
+ )
682
+ except Exception as e:
683
+ logger.error("Failed to dispatch delayed message: %s", e)
684
+ # Continue with other delayed messages even if one fails
685
+ continue
686
+ except Exception as e:
687
+ logger.error("Error processing delayed messages: %s", e)
320
688
 
321
- await self.backend.set_last_dispatch_time(
322
- ScheduledAction.get_function_id(func), now
323
- )
689
+ with contextlib.suppress(asyncio.TimeoutError):
690
+ await asyncio.wait_for(self.shutdown_event.wait(), self.interval)
324
691
 
325
- logger.info(
326
- f"Scheduled {func.__module__}.{func.__qualname__} at {now}"
327
- )
692
+ logger.debug("Scheduler stopped")
328
693
 
329
- for (
330
- delayed_message_data
331
- ) in await self.backend.dequeue_next_delayed_messages(now):
332
- await self.broker.dispatch_delayed_message(delayed_message_data)
694
+ try:
695
+ await self.backend.dispose()
696
+ except Exception as e:
697
+ logger.error("Error disposing backend: %s", e)
333
698
 
334
- with contextlib.suppress(asyncio.TimeoutError):
335
- await asyncio.wait_for(self.shutdown_event.wait(), self.interval)
699
+ try:
700
+ await self.broker.dispose()
701
+ except Exception as e:
702
+ logger.error("Error disposing broker: %s", e)
336
703
 
337
- # await self.shutdown_event.wait(self.interval)
704
+ async def _graceful_shutdown(self) -> None:
705
+ """Handles graceful shutdown process"""
706
+ logger.debug("Initiating graceful shutdown sequence")
707
+ self.shutdown_event.set()
708
+ logger.debug("Graceful shutdown completed")
338
709
 
339
- logger.info("Scheduler stopped")
710
+ async def _wait_for_broker_connection(self) -> None:
711
+ """
712
+ Wait for the broker connection to be established and healthy.
713
+ This ensures the scheduler doesn't start until RabbitMQ is ready.
714
+ """
715
+ max_wait_time = self.config.connection_wait_timeout
716
+ check_interval = 2.0 # Check every 2 seconds
717
+ elapsed_time = 0.0
718
+
719
+ logger.debug(
720
+ "Waiting for broker connection to be established (timeout: %ss)...",
721
+ max_wait_time,
722
+ )
723
+
724
+ while elapsed_time < max_wait_time:
725
+ if self.shutdown_event.is_set():
726
+ raise ConnectionError(
727
+ "Shutdown requested while waiting for broker connection"
728
+ )
729
+
730
+ # Check if broker connection is healthy
731
+ if (
732
+ hasattr(self.broker, "connection_healthy")
733
+ and self.broker.connection_healthy
734
+ ):
735
+ logger.debug("Broker connection is healthy")
736
+ return
737
+
738
+ # If broker doesn't have health status, try a simple health check
739
+ if not hasattr(self.broker, "connection_healthy"):
740
+ try:
741
+ # For non-RabbitMQ brokers, assume connection is ready after initialization
742
+ logger.debug("Broker connection assumed to be ready")
743
+ return
744
+ except Exception as e:
745
+ logger.debug("Broker connection check failed: %s", e)
746
+
747
+ if elapsed_time % 10.0 == 0.0: # Log every 10 seconds
748
+ logger.warning(
749
+ "Still waiting for broker connection... (%.1fs elapsed)",
750
+ elapsed_time,
751
+ )
340
752
 
341
- await self.backend.dispose()
342
- await self.broker.dispose()
753
+ await asyncio.sleep(check_interval)
754
+ elapsed_time += check_interval
755
+
756
+ raise ConnectionError(
757
+ f"Broker connection not established after {max_wait_time} seconds"
758
+ )