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.
- README.md +121 -0
- jararaca/__init__.py +184 -12
- jararaca/__main__.py +4 -0
- jararaca/broker_backend/__init__.py +4 -0
- jararaca/broker_backend/mapper.py +4 -0
- jararaca/broker_backend/redis_broker_backend.py +9 -3
- jararaca/cli.py +272 -47
- jararaca/common/__init__.py +3 -0
- jararaca/core/__init__.py +3 -0
- jararaca/core/providers.py +4 -0
- jararaca/core/uow.py +41 -7
- jararaca/di.py +4 -0
- jararaca/files/entity.py.mako +4 -0
- jararaca/lifecycle.py +6 -2
- jararaca/messagebus/__init__.py +4 -0
- jararaca/messagebus/bus_message_controller.py +4 -0
- jararaca/messagebus/consumers/__init__.py +3 -0
- jararaca/messagebus/decorators.py +33 -67
- jararaca/messagebus/implicit_headers.py +49 -0
- jararaca/messagebus/interceptors/__init__.py +3 -0
- jararaca/messagebus/interceptors/aiopika_publisher_interceptor.py +13 -4
- jararaca/messagebus/interceptors/publisher_interceptor.py +4 -0
- jararaca/messagebus/message.py +4 -0
- jararaca/messagebus/publisher.py +6 -0
- jararaca/messagebus/worker.py +850 -383
- jararaca/microservice.py +110 -1
- jararaca/observability/constants.py +7 -0
- jararaca/observability/decorators.py +170 -13
- jararaca/observability/fastapi_exception_handler.py +37 -0
- jararaca/observability/hooks.py +109 -0
- jararaca/observability/interceptor.py +4 -0
- jararaca/observability/providers/__init__.py +3 -0
- jararaca/observability/providers/otel.py +202 -11
- jararaca/persistence/base.py +38 -2
- jararaca/persistence/exports.py +4 -0
- jararaca/persistence/interceptors/__init__.py +3 -0
- jararaca/persistence/interceptors/aiosqa_interceptor.py +86 -73
- jararaca/persistence/interceptors/constants.py +5 -0
- jararaca/persistence/interceptors/decorators.py +50 -0
- jararaca/persistence/session.py +3 -0
- jararaca/persistence/sort_filter.py +4 -0
- jararaca/persistence/utilities.py +50 -20
- jararaca/presentation/__init__.py +3 -0
- jararaca/presentation/decorators.py +88 -86
- jararaca/presentation/exceptions.py +23 -0
- jararaca/presentation/hooks.py +4 -0
- jararaca/presentation/http_microservice.py +4 -0
- jararaca/presentation/server.py +97 -45
- jararaca/presentation/websocket/__init__.py +3 -0
- jararaca/presentation/websocket/base_types.py +4 -0
- jararaca/presentation/websocket/context.py +4 -0
- jararaca/presentation/websocket/decorators.py +8 -41
- jararaca/presentation/websocket/redis.py +280 -53
- jararaca/presentation/websocket/types.py +4 -0
- jararaca/presentation/websocket/websocket_interceptor.py +46 -19
- jararaca/reflect/__init__.py +3 -0
- jararaca/reflect/controller_inspect.py +16 -10
- jararaca/reflect/decorators.py +238 -0
- jararaca/reflect/metadata.py +34 -25
- jararaca/rpc/__init__.py +3 -0
- jararaca/rpc/http/__init__.py +101 -0
- jararaca/rpc/http/backends/__init__.py +14 -0
- jararaca/rpc/http/backends/httpx.py +43 -9
- jararaca/rpc/http/backends/otel.py +4 -0
- jararaca/rpc/http/decorators.py +378 -113
- jararaca/rpc/http/httpx.py +3 -0
- jararaca/scheduler/__init__.py +3 -0
- jararaca/scheduler/beat_worker.py +521 -105
- jararaca/scheduler/decorators.py +15 -22
- jararaca/scheduler/types.py +4 -0
- jararaca/tools/app_config/__init__.py +3 -0
- jararaca/tools/app_config/decorators.py +7 -19
- jararaca/tools/app_config/interceptor.py +6 -2
- jararaca/tools/typescript/__init__.py +3 -0
- jararaca/tools/typescript/decorators.py +120 -0
- jararaca/tools/typescript/interface_parser.py +1074 -173
- jararaca/utils/__init__.py +3 -0
- jararaca/utils/rabbitmq_utils.py +65 -39
- jararaca/utils/retry.py +10 -3
- jararaca-0.4.0a5.dist-info/LICENSE +674 -0
- jararaca-0.4.0a5.dist-info/LICENSES/GPL-3.0-or-later.txt +232 -0
- {jararaca-0.3.11a16.dist-info → jararaca-0.4.0a5.dist-info}/METADATA +11 -7
- jararaca-0.4.0a5.dist-info/RECORD +88 -0
- {jararaca-0.3.11a16.dist-info → jararaca-0.4.0a5.dist-info}/WHEEL +1 -1
- pyproject.toml +131 -0
- jararaca-0.3.11a16.dist-info/RECORD +0 -74
- /jararaca-0.3.11a16.dist-info/LICENSE → /LICENSE +0 -0
- {jararaca-0.3.11a16.dist-info → jararaca-0.4.0a5.dist-info}/entry_points.txt +0 -0
jararaca/messagebus/worker.py
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
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
|
|
@@ -23,7 +27,14 @@ from urllib.parse import parse_qs, urlparse
|
|
|
23
27
|
import aio_pika
|
|
24
28
|
import aio_pika.abc
|
|
25
29
|
import uvloop
|
|
26
|
-
from aio_pika.exceptions import
|
|
30
|
+
from aio_pika.exceptions import (
|
|
31
|
+
AMQPChannelError,
|
|
32
|
+
AMQPConnectionError,
|
|
33
|
+
AMQPError,
|
|
34
|
+
ChannelClosed,
|
|
35
|
+
ChannelNotFoundEntity,
|
|
36
|
+
ConnectionClosed,
|
|
37
|
+
)
|
|
27
38
|
from pydantic import BaseModel
|
|
28
39
|
|
|
29
40
|
from jararaca.broker_backend import MessageBrokerBackend
|
|
@@ -43,12 +54,16 @@ from jararaca.messagebus.decorators import (
|
|
|
43
54
|
MessageHandlerData,
|
|
44
55
|
ScheduleDispatchData,
|
|
45
56
|
)
|
|
57
|
+
from jararaca.messagebus.implicit_headers import provide_implicit_headers
|
|
46
58
|
from jararaca.messagebus.message import Message, MessageOf
|
|
47
59
|
from jararaca.microservice import (
|
|
48
60
|
AppTransactionContext,
|
|
49
61
|
MessageBusTransactionData,
|
|
50
62
|
Microservice,
|
|
51
63
|
SchedulerTransactionData,
|
|
64
|
+
ShutdownState,
|
|
65
|
+
provide_shutdown_state,
|
|
66
|
+
providing_app_type,
|
|
52
67
|
)
|
|
53
68
|
from jararaca.scheduler.decorators import ScheduledActionData
|
|
54
69
|
from jararaca.utils.rabbitmq_utils import RabbitmqUtils
|
|
@@ -78,6 +93,9 @@ class AioPikaWorkerConfig:
|
|
|
78
93
|
backoff_factor=2.0,
|
|
79
94
|
)
|
|
80
95
|
)
|
|
96
|
+
# Connection health monitoring settings
|
|
97
|
+
connection_heartbeat_interval: float = 30.0 # seconds
|
|
98
|
+
connection_health_check_interval: float = 10.0 # seconds
|
|
81
99
|
|
|
82
100
|
|
|
83
101
|
class AioPikaMessage(MessageOf[Message]):
|
|
@@ -129,6 +147,20 @@ class MessageBusConsumer(ABC):
|
|
|
129
147
|
"""Close all resources related to the consumer"""
|
|
130
148
|
|
|
131
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
|
+
|
|
132
164
|
class AioPikaMicroserviceConsumer(MessageBusConsumer):
|
|
133
165
|
def __init__(
|
|
134
166
|
self,
|
|
@@ -146,11 +178,18 @@ class AioPikaMicroserviceConsumer(MessageBusConsumer):
|
|
|
146
178
|
self.incoming_map: dict[str, MessageHandlerData] = {}
|
|
147
179
|
self.uow_context_provider = uow_context_provider
|
|
148
180
|
self.shutdown_event = asyncio.Event()
|
|
181
|
+
self.shutdown_state = _WorkerShutdownState(self.shutdown_event)
|
|
149
182
|
self.lock = asyncio.Lock()
|
|
150
183
|
self.tasks: set[asyncio.Task[Any]] = set()
|
|
151
184
|
self.connection: aio_pika.abc.AbstractConnection | None = None
|
|
152
185
|
self.channels: dict[str, aio_pika.abc.AbstractChannel] = {}
|
|
153
186
|
|
|
187
|
+
# Connection resilience attributes
|
|
188
|
+
self.connection_healthy = False
|
|
189
|
+
self.connection_lock = asyncio.Lock()
|
|
190
|
+
self.consumer_tags: dict[str, str] = {} # Track consumer tags for cleanup
|
|
191
|
+
self.health_check_task: asyncio.Task[Any] | None = None
|
|
192
|
+
|
|
154
193
|
async def _verify_infrastructure(self) -> bool:
|
|
155
194
|
"""
|
|
156
195
|
Verify that the required RabbitMQ infrastructure (exchanges, queues) exists.
|
|
@@ -188,31 +227,48 @@ class AioPikaMicroserviceConsumer(MessageBusConsumer):
|
|
|
188
227
|
async def setup_consumer() -> None:
|
|
189
228
|
# Create a channel using the context manager
|
|
190
229
|
async with self.create_channel(queue_name) as channel:
|
|
191
|
-
queue = await RabbitmqUtils.get_queue(
|
|
230
|
+
queue: aio_pika.abc.AbstractQueue = await RabbitmqUtils.get_queue(
|
|
192
231
|
channel=channel, queue_name=queue_name
|
|
193
232
|
)
|
|
194
233
|
|
|
195
|
-
# Configure consumer
|
|
196
|
-
await queue.consume(
|
|
234
|
+
# Configure consumer and get the consumer tag
|
|
235
|
+
consumer_tag = await queue.consume(
|
|
197
236
|
callback=MessageHandlerCallback(
|
|
198
237
|
consumer=self,
|
|
199
238
|
queue_name=queue_name,
|
|
200
239
|
routing_key=routing_key,
|
|
201
240
|
message_handler=handler,
|
|
202
241
|
),
|
|
203
|
-
no_ack=handler.spec.auto_ack,
|
|
242
|
+
# no_ack=handler.spec.auto_ack,
|
|
204
243
|
)
|
|
205
244
|
|
|
245
|
+
# Store consumer tag for cleanup
|
|
246
|
+
self.consumer_tags[queue_name] = consumer_tag
|
|
247
|
+
|
|
206
248
|
logger.info(
|
|
207
|
-
|
|
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
|
|
208
256
|
)
|
|
257
|
+
await queue.cancel(consumer_tag)
|
|
209
258
|
|
|
210
259
|
try:
|
|
211
260
|
# Setup with retry
|
|
212
261
|
await retry_with_backoff(
|
|
213
262
|
setup_consumer,
|
|
214
263
|
retry_config=self.config.consumer_retry_config,
|
|
215
|
-
retry_exceptions=(
|
|
264
|
+
retry_exceptions=(
|
|
265
|
+
ChannelNotFoundEntity,
|
|
266
|
+
ChannelClosed,
|
|
267
|
+
AMQPError,
|
|
268
|
+
AMQPConnectionError,
|
|
269
|
+
AMQPChannelError,
|
|
270
|
+
ConnectionClosed,
|
|
271
|
+
),
|
|
216
272
|
)
|
|
217
273
|
return True
|
|
218
274
|
except Exception as e:
|
|
@@ -238,8 +294,8 @@ class AioPikaMicroserviceConsumer(MessageBusConsumer):
|
|
|
238
294
|
channel=channel, queue_name=queue_name
|
|
239
295
|
)
|
|
240
296
|
|
|
241
|
-
# Configure consumer
|
|
242
|
-
await queue.consume(
|
|
297
|
+
# Configure consumer and get the consumer tag
|
|
298
|
+
consumer_tag = await queue.consume(
|
|
243
299
|
callback=ScheduledMessageHandlerCallback(
|
|
244
300
|
consumer=self,
|
|
245
301
|
queue_name=queue_name,
|
|
@@ -249,14 +305,31 @@ class AioPikaMicroserviceConsumer(MessageBusConsumer):
|
|
|
249
305
|
no_ack=True,
|
|
250
306
|
)
|
|
251
307
|
|
|
252
|
-
|
|
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)
|
|
253
319
|
|
|
254
320
|
try:
|
|
255
321
|
# Setup with retry
|
|
256
322
|
await retry_with_backoff(
|
|
257
323
|
setup_consumer,
|
|
258
324
|
retry_config=self.config.consumer_retry_config,
|
|
259
|
-
retry_exceptions=(
|
|
325
|
+
retry_exceptions=(
|
|
326
|
+
ChannelNotFoundEntity,
|
|
327
|
+
ChannelClosed,
|
|
328
|
+
AMQPError,
|
|
329
|
+
AMQPConnectionError,
|
|
330
|
+
AMQPChannelError,
|
|
331
|
+
ConnectionClosed,
|
|
332
|
+
),
|
|
260
333
|
)
|
|
261
334
|
return True
|
|
262
335
|
except Exception as e:
|
|
@@ -269,160 +342,195 @@ class AioPikaMicroserviceConsumer(MessageBusConsumer):
|
|
|
269
342
|
"""
|
|
270
343
|
Main consume method that sets up all message handlers and scheduled actions with retry mechanisms.
|
|
271
344
|
"""
|
|
272
|
-
#
|
|
273
|
-
|
|
274
|
-
self.
|
|
275
|
-
|
|
276
|
-
retry_exceptions=(Exception,),
|
|
277
|
-
)
|
|
278
|
-
|
|
279
|
-
if not infra_check_success:
|
|
280
|
-
logger.critical("Failed to verify RabbitMQ infrastructure. Shutting down.")
|
|
281
|
-
self.shutdown_event.set()
|
|
282
|
-
return
|
|
283
|
-
|
|
284
|
-
async def wait_for(
|
|
285
|
-
type: str, name: str, coroutine: Awaitable[bool]
|
|
286
|
-
) -> tuple[str, str, bool]:
|
|
287
|
-
return type, name, await coroutine
|
|
345
|
+
# Establish initial connection
|
|
346
|
+
try:
|
|
347
|
+
async with self.connect() as connection:
|
|
348
|
+
self.connection_healthy = True
|
|
288
349
|
|
|
289
|
-
|
|
350
|
+
# Start connection health monitoring
|
|
351
|
+
self.health_check_task = asyncio.create_task(
|
|
352
|
+
self._monitor_connection_health(), name="ConnectionHealthMonitor"
|
|
353
|
+
)
|
|
290
354
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
+
)
|
|
295
361
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
"message_handler",
|
|
300
|
-
queue_name,
|
|
301
|
-
self._setup_message_handler_consumer(handler),
|
|
362
|
+
if not infra_check_success:
|
|
363
|
+
logger.critical(
|
|
364
|
+
"Failed to verify RabbitMQ infrastructure. Shutting down."
|
|
302
365
|
)
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
+
)
|
|
322
390
|
)
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
type, name, success = await task
|
|
337
|
-
if success:
|
|
338
|
-
logger.info(f"Successfully set up {type} consumer for {name}")
|
|
339
|
-
else:
|
|
340
|
-
logger.warning(
|
|
341
|
-
f"Failed to set up {type} consumer for {name}, will not process messages from this queue"
|
|
391
|
+
|
|
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
|
+
)
|
|
342
404
|
)
|
|
343
405
|
|
|
344
|
-
|
|
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
|
+
)
|
|
345
419
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
handle_task_results_task.cancel()
|
|
350
|
-
with suppress(asyncio.CancelledError):
|
|
351
|
-
await handle_task_results_task
|
|
352
|
-
for task in tasks:
|
|
353
|
-
if not task.done():
|
|
354
|
-
task.cancel()
|
|
355
|
-
with suppress(asyncio.CancelledError):
|
|
356
|
-
await task
|
|
357
|
-
logger.info("Worker shutting down")
|
|
420
|
+
handle_task_results_task = asyncio.create_task(
|
|
421
|
+
handle_task_results(), name="HandleSetupTaskResults"
|
|
422
|
+
)
|
|
358
423
|
|
|
359
|
-
|
|
360
|
-
|
|
424
|
+
# Wait for shutdown signal
|
|
425
|
+
await self.shutdown_event.wait()
|
|
426
|
+
logger.debug("Shutdown event received, stopping consumers")
|
|
361
427
|
|
|
362
|
-
|
|
363
|
-
await self.close_channels_and_connection()
|
|
428
|
+
await self.cancel_queue_consumers()
|
|
364
429
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
|
368
435
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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()
|
|
374
450
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
|
379
455
|
|
|
380
|
-
async def
|
|
381
|
-
"""
|
|
382
|
-
|
|
383
|
-
|
|
456
|
+
async def cancel_queue_consumers(self) -> None:
|
|
457
|
+
"""
|
|
458
|
+
Cancel all active queue consumers.
|
|
459
|
+
"""
|
|
460
|
+
logger.debug("Cancelling all active queue consumers...")
|
|
384
461
|
for queue_name, channel in self.channels.items():
|
|
385
462
|
try:
|
|
386
463
|
if not channel.is_closed:
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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]
|
|
391
477
|
except Exception as e:
|
|
392
|
-
logger.
|
|
393
|
-
f"Error preparing to close channel for queue {queue_name}: {e}"
|
|
394
|
-
)
|
|
478
|
+
logger.warning("Error cancelling consumer for %s: %s", queue_name, e)
|
|
395
479
|
|
|
396
|
-
|
|
397
|
-
if
|
|
398
|
-
|
|
399
|
-
await asyncio.gather(*channel_close_tasks, return_exceptions=True)
|
|
400
|
-
except Exception as e:
|
|
401
|
-
logger.error(f"Error during channel closures: {e}")
|
|
480
|
+
async def wait_all_tasks_done(self) -> None:
|
|
481
|
+
if not self.tasks:
|
|
482
|
+
return
|
|
402
483
|
|
|
403
|
-
|
|
404
|
-
|
|
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)
|
|
405
512
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
logger.info("Closing RabbitMQ connection")
|
|
411
|
-
await self.connection.close()
|
|
412
|
-
else:
|
|
413
|
-
logger.info("RabbitMQ connection already closed")
|
|
414
|
-
except Exception as e:
|
|
415
|
-
logger.error(f"Error closing RabbitMQ connection: {e}")
|
|
416
|
-
self.connection = None
|
|
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()
|
|
417
517
|
|
|
418
518
|
def shutdown(self) -> None:
|
|
419
519
|
"""Signal for shutdown"""
|
|
420
|
-
logger.
|
|
520
|
+
logger.warning("Initiating graceful shutdown")
|
|
421
521
|
self.shutdown_event.set()
|
|
422
522
|
|
|
423
523
|
async def close(self) -> None:
|
|
424
524
|
"""Implement MessageBusConsumer.close for cleanup"""
|
|
525
|
+
logger.warning("Closing consumer...")
|
|
425
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
|
+
|
|
426
534
|
await self.wait_all_tasks_done()
|
|
427
535
|
await self.close_channels_and_connection()
|
|
428
536
|
|
|
@@ -432,25 +540,45 @@ class AioPikaMicroserviceConsumer(MessageBusConsumer):
|
|
|
432
540
|
This helps with error handling when a channel might have been closed.
|
|
433
541
|
"""
|
|
434
542
|
if queue_name not in self.channels:
|
|
435
|
-
logger.warning(
|
|
543
|
+
logger.warning("No channel found for queue %s", queue_name)
|
|
436
544
|
return None
|
|
437
545
|
|
|
438
546
|
try:
|
|
439
547
|
channel = self.channels[queue_name]
|
|
440
548
|
if channel.is_closed:
|
|
441
|
-
logger.warning(
|
|
442
|
-
#
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
|
451
577
|
return channel
|
|
452
578
|
except Exception as e:
|
|
453
|
-
logger.error(
|
|
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()
|
|
454
582
|
return None
|
|
455
583
|
|
|
456
584
|
async def _establish_channel(self, queue_name: str) -> aio_pika.abc.AbstractChannel:
|
|
@@ -459,14 +587,14 @@ class AioPikaMicroserviceConsumer(MessageBusConsumer):
|
|
|
459
587
|
"""
|
|
460
588
|
if self.connection is None or self.connection.is_closed:
|
|
461
589
|
logger.warning(
|
|
462
|
-
|
|
590
|
+
"Cannot create channel for %s: connection is not available", queue_name
|
|
463
591
|
)
|
|
464
592
|
raise RuntimeError("Connection is not available")
|
|
465
593
|
|
|
466
|
-
logger.debug(
|
|
594
|
+
logger.debug("Creating channel for queue %s", queue_name)
|
|
467
595
|
channel = await self.connection.channel()
|
|
468
596
|
await channel.set_qos(prefetch_count=self.config.prefetch_count)
|
|
469
|
-
logger.debug(
|
|
597
|
+
logger.debug("Created channel for queue %s", queue_name)
|
|
470
598
|
return channel
|
|
471
599
|
|
|
472
600
|
@asynccontextmanager
|
|
@@ -483,15 +611,15 @@ class AioPikaMicroserviceConsumer(MessageBusConsumer):
|
|
|
483
611
|
fn=lambda: self._establish_channel(queue_name),
|
|
484
612
|
retry_config=self.config.consumer_retry_config,
|
|
485
613
|
retry_exceptions=(
|
|
486
|
-
|
|
487
|
-
|
|
614
|
+
AMQPConnectionError,
|
|
615
|
+
AMQPChannelError,
|
|
488
616
|
ConnectionError,
|
|
489
617
|
),
|
|
490
618
|
)
|
|
491
619
|
|
|
492
620
|
# Save in the channels dict for tracking
|
|
493
621
|
self.channels[queue_name] = channel
|
|
494
|
-
logger.debug(
|
|
622
|
+
logger.debug("Created new channel for queue %s", queue_name)
|
|
495
623
|
|
|
496
624
|
try:
|
|
497
625
|
yield channel
|
|
@@ -501,7 +629,7 @@ class AioPikaMicroserviceConsumer(MessageBusConsumer):
|
|
|
501
629
|
pass
|
|
502
630
|
except aio_pika.exceptions.AMQPError as e:
|
|
503
631
|
logger.error(
|
|
504
|
-
|
|
632
|
+
"Error creating channel for queue %s after retries: %s", queue_name, e
|
|
505
633
|
)
|
|
506
634
|
raise
|
|
507
635
|
|
|
@@ -510,12 +638,15 @@ class AioPikaMicroserviceConsumer(MessageBusConsumer):
|
|
|
510
638
|
Creates a new RabbitMQ connection with retry logic.
|
|
511
639
|
"""
|
|
512
640
|
try:
|
|
513
|
-
logger.
|
|
514
|
-
connection = await aio_pika.connect(
|
|
515
|
-
|
|
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")
|
|
516
647
|
return connection
|
|
517
648
|
except Exception as e:
|
|
518
|
-
logger.error(
|
|
649
|
+
logger.error("Failed to connect to RabbitMQ: %s", e)
|
|
519
650
|
raise
|
|
520
651
|
|
|
521
652
|
@asynccontextmanager
|
|
@@ -538,7 +669,7 @@ class AioPikaMicroserviceConsumer(MessageBusConsumer):
|
|
|
538
669
|
self._establish_connection,
|
|
539
670
|
retry_config=self.config.connection_retry_config,
|
|
540
671
|
retry_exceptions=(
|
|
541
|
-
|
|
672
|
+
AMQPConnectionError,
|
|
542
673
|
ConnectionError,
|
|
543
674
|
OSError,
|
|
544
675
|
TimeoutError,
|
|
@@ -552,14 +683,15 @@ class AioPikaMicroserviceConsumer(MessageBusConsumer):
|
|
|
552
683
|
pass
|
|
553
684
|
except Exception as e:
|
|
554
685
|
logger.error(
|
|
555
|
-
|
|
686
|
+
"Failed to establish connection to RabbitMQ after retries: %s", e
|
|
556
687
|
)
|
|
557
688
|
if self.connection:
|
|
558
689
|
try:
|
|
559
690
|
await self.connection.close()
|
|
560
691
|
except Exception as close_error:
|
|
561
692
|
logger.error(
|
|
562
|
-
|
|
693
|
+
"Error closing connection after connect failure: %s",
|
|
694
|
+
close_error,
|
|
563
695
|
)
|
|
564
696
|
self.connection = None
|
|
565
697
|
raise
|
|
@@ -572,22 +704,153 @@ class AioPikaMicroserviceConsumer(MessageBusConsumer):
|
|
|
572
704
|
Get a channel for a specific queue as a context manager.
|
|
573
705
|
This is safer than using get_channel directly as it ensures proper error handling.
|
|
574
706
|
"""
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
async with self.create_channel(queue_name) as new_channel:
|
|
580
|
-
yield new_channel
|
|
581
|
-
else:
|
|
582
|
-
raise RuntimeError(
|
|
583
|
-
f"Cannot get channel for queue {queue_name}: no connection available"
|
|
584
|
-
)
|
|
585
|
-
else:
|
|
707
|
+
max_retries = 3
|
|
708
|
+
retry_delay = 1.0
|
|
709
|
+
|
|
710
|
+
for attempt in range(max_retries):
|
|
586
711
|
try:
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
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
|
|
591
854
|
|
|
592
855
|
|
|
593
856
|
def create_message_bus(
|
|
@@ -632,6 +895,10 @@ def create_message_bus(
|
|
|
632
895
|
max_retries=30, initial_delay=5, max_delay=60.0, backoff_factor=3.0
|
|
633
896
|
)
|
|
634
897
|
|
|
898
|
+
# Parse heartbeat and health check intervals
|
|
899
|
+
connection_heartbeat_interval = 30.0
|
|
900
|
+
connection_health_check_interval = 10.0
|
|
901
|
+
|
|
635
902
|
# Connection retry config parameters
|
|
636
903
|
if (
|
|
637
904
|
"connection_retry_max" in query_params
|
|
@@ -698,12 +965,31 @@ def create_message_bus(
|
|
|
698
965
|
except ValueError:
|
|
699
966
|
pass
|
|
700
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
|
+
|
|
701
985
|
config = AioPikaWorkerConfig(
|
|
702
986
|
url=broker_url,
|
|
703
987
|
exchange=exchange,
|
|
704
988
|
prefetch_count=prefetch_count,
|
|
705
989
|
connection_retry_config=connection_retry_config,
|
|
706
990
|
consumer_retry_config=consumer_retry_config,
|
|
991
|
+
connection_heartbeat_interval=connection_heartbeat_interval,
|
|
992
|
+
connection_health_check_interval=connection_health_check_interval,
|
|
707
993
|
)
|
|
708
994
|
|
|
709
995
|
return AioPikaMicroserviceConsumer(
|
|
@@ -737,37 +1023,59 @@ class ScheduledMessageHandlerCallback:
|
|
|
737
1023
|
) -> None:
|
|
738
1024
|
|
|
739
1025
|
if self.consumer.shutdown_event.is_set():
|
|
740
|
-
logger.
|
|
741
|
-
|
|
1026
|
+
logger.debug(
|
|
1027
|
+
"Shutdown in progress. Requeuing scheduled message for %s",
|
|
1028
|
+
self.queue_name,
|
|
742
1029
|
)
|
|
743
1030
|
try:
|
|
744
1031
|
# Use channel context for requeuing
|
|
745
|
-
|
|
746
|
-
await aio_pika_message.reject(requeue=True)
|
|
1032
|
+
await aio_pika_message.reject(requeue=True)
|
|
747
1033
|
except RuntimeError:
|
|
748
1034
|
logger.warning(
|
|
749
|
-
|
|
1035
|
+
"Could not requeue scheduled message during shutdown - channel not available"
|
|
750
1036
|
)
|
|
751
1037
|
except Exception as e:
|
|
752
1038
|
logger.error(
|
|
753
|
-
|
|
1039
|
+
"Failed to requeue scheduled message during shutdown: %s", e
|
|
754
1040
|
)
|
|
755
1041
|
return
|
|
756
1042
|
|
|
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
|
|
1061
|
+
|
|
757
1062
|
async with self.consumer.lock:
|
|
758
|
-
task = asyncio.create_task(
|
|
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}",
|
|
1066
|
+
)
|
|
759
1067
|
self.consumer.tasks.add(task)
|
|
760
1068
|
task.add_done_callback(self.handle_message_consume_done)
|
|
761
1069
|
|
|
762
1070
|
def handle_message_consume_done(self, task: asyncio.Task[Any]) -> None:
|
|
763
1071
|
self.consumer.tasks.discard(task)
|
|
764
1072
|
if task.cancelled():
|
|
765
|
-
logger.warning(
|
|
1073
|
+
logger.warning("Scheduled task for %s was cancelled", self.queue_name)
|
|
766
1074
|
return
|
|
767
1075
|
|
|
768
1076
|
if (error := task.exception()) is not None:
|
|
769
1077
|
logger.exception(
|
|
770
|
-
|
|
1078
|
+
"Error processing scheduled action %s", self.queue_name, exc_info=error
|
|
771
1079
|
)
|
|
772
1080
|
|
|
773
1081
|
async def handle_message(
|
|
@@ -775,7 +1083,9 @@ class ScheduledMessageHandlerCallback:
|
|
|
775
1083
|
) -> None:
|
|
776
1084
|
|
|
777
1085
|
if self.consumer.shutdown_event.is_set():
|
|
778
|
-
logger.
|
|
1086
|
+
logger.debug(
|
|
1087
|
+
"Shutdown event set. Requeuing message for %s", self.queue_name
|
|
1088
|
+
)
|
|
779
1089
|
try:
|
|
780
1090
|
# Use channel context for requeuing
|
|
781
1091
|
async with self.consumer.get_channel_ctx(self.queue_name):
|
|
@@ -783,10 +1093,27 @@ class ScheduledMessageHandlerCallback:
|
|
|
783
1093
|
return
|
|
784
1094
|
except RuntimeError:
|
|
785
1095
|
logger.warning(
|
|
786
|
-
|
|
1096
|
+
"Could not requeue message during shutdown - channel not available"
|
|
787
1097
|
)
|
|
788
1098
|
except Exception as e:
|
|
789
|
-
logger.error(
|
|
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
|
+
)
|
|
790
1117
|
return
|
|
791
1118
|
|
|
792
1119
|
sig = inspect.signature(self.scheduled_action.callable)
|
|
@@ -797,7 +1124,8 @@ class ScheduledMessageHandlerCallback:
|
|
|
797
1124
|
self.scheduled_action,
|
|
798
1125
|
(ScheduleDispatchData(int(aio_pika_message.body.decode("utf-8"))),),
|
|
799
1126
|
{},
|
|
800
|
-
)
|
|
1127
|
+
),
|
|
1128
|
+
name=f"ScheduledAction-{self.queue_name}-handle-message-{aio_pika_message.message_id}",
|
|
801
1129
|
)
|
|
802
1130
|
|
|
803
1131
|
elif len(sig.parameters) == 0:
|
|
@@ -806,7 +1134,8 @@ class ScheduledMessageHandlerCallback:
|
|
|
806
1134
|
self.scheduled_action,
|
|
807
1135
|
(),
|
|
808
1136
|
{},
|
|
809
|
-
)
|
|
1137
|
+
),
|
|
1138
|
+
name=f"ScheduledAction-{self.queue_name}-handle-message-{aio_pika_message.message_id}",
|
|
810
1139
|
)
|
|
811
1140
|
else:
|
|
812
1141
|
logger.warning(
|
|
@@ -823,7 +1152,7 @@ class ScheduledMessageHandlerCallback:
|
|
|
823
1152
|
except Exception as e:
|
|
824
1153
|
|
|
825
1154
|
logger.exception(
|
|
826
|
-
|
|
1155
|
+
"Error processing scheduled action %s: %s", self.queue_name, e
|
|
827
1156
|
)
|
|
828
1157
|
|
|
829
1158
|
async def run_with_context(
|
|
@@ -832,18 +1161,22 @@ class ScheduledMessageHandlerCallback:
|
|
|
832
1161
|
args: tuple[Any, ...],
|
|
833
1162
|
kwargs: dict[str, Any],
|
|
834
1163
|
) -> None:
|
|
835
|
-
async with self.consumer.uow_context_provider(
|
|
836
|
-
AppTransactionContext(
|
|
837
|
-
controller_member_reflect=scheduled_action.controller_member,
|
|
838
|
-
transaction_data=SchedulerTransactionData(
|
|
839
|
-
scheduled_to=datetime.now(UTC),
|
|
840
|
-
cron_expression=scheduled_action.spec.cron,
|
|
841
|
-
triggered_at=datetime.now(UTC),
|
|
842
|
-
),
|
|
843
|
-
)
|
|
844
|
-
):
|
|
845
1164
|
|
|
846
|
-
|
|
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)
|
|
847
1180
|
|
|
848
1181
|
|
|
849
1182
|
class MessageHandlerCallback:
|
|
@@ -865,8 +1198,8 @@ class MessageHandlerCallback:
|
|
|
865
1198
|
self, aio_pika_message: aio_pika.abc.AbstractIncomingMessage
|
|
866
1199
|
) -> None:
|
|
867
1200
|
if self.consumer.shutdown_event.is_set():
|
|
868
|
-
logger.
|
|
869
|
-
|
|
1201
|
+
logger.debug(
|
|
1202
|
+
"Shutdown in progress. Requeuing message for %s", self.queue_name
|
|
870
1203
|
)
|
|
871
1204
|
try:
|
|
872
1205
|
# Use channel context for requeuing
|
|
@@ -874,26 +1207,46 @@ class MessageHandlerCallback:
|
|
|
874
1207
|
await aio_pika_message.reject(requeue=True)
|
|
875
1208
|
except RuntimeError:
|
|
876
1209
|
logger.warning(
|
|
877
|
-
|
|
1210
|
+
"Could not requeue message during shutdown - channel not available"
|
|
878
1211
|
)
|
|
879
1212
|
except Exception as e:
|
|
880
|
-
logger.error(
|
|
1213
|
+
logger.error("Failed to requeue message during shutdown: %s", e)
|
|
881
1214
|
return
|
|
882
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
|
+
|
|
883
1233
|
async with self.consumer.lock:
|
|
884
|
-
task = asyncio.create_task(
|
|
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
|
+
)
|
|
885
1238
|
self.consumer.tasks.add(task)
|
|
886
1239
|
task.add_done_callback(self.handle_message_consume_done)
|
|
887
1240
|
|
|
888
1241
|
def handle_message_consume_done(self, task: asyncio.Task[Any]) -> None:
|
|
889
1242
|
self.consumer.tasks.discard(task)
|
|
890
1243
|
if task.cancelled():
|
|
891
|
-
logger.warning(
|
|
1244
|
+
logger.warning("Task for queue %s was cancelled", self.queue_name)
|
|
892
1245
|
return
|
|
893
1246
|
|
|
894
1247
|
if (error := task.exception()) is not None:
|
|
895
1248
|
logger.exception(
|
|
896
|
-
|
|
1249
|
+
"Error processing message for queue %s", self.queue_name, exc_info=error
|
|
897
1250
|
)
|
|
898
1251
|
|
|
899
1252
|
async def __call__(
|
|
@@ -919,19 +1272,11 @@ class MessageHandlerCallback:
|
|
|
919
1272
|
"""
|
|
920
1273
|
message_id = aio_pika_message.message_id or str(uuid.uuid4())
|
|
921
1274
|
|
|
922
|
-
# If auto_ack is enabled, we cannot retry the message through RabbitMQ reject mechanism
|
|
923
|
-
if self.message_handler.spec.auto_ack:
|
|
924
|
-
if requeue:
|
|
925
|
-
logger.warning(
|
|
926
|
-
f"Message {message_id} ({self.queue_name}) cannot be requeued because auto_ack is enabled"
|
|
927
|
-
)
|
|
928
|
-
return
|
|
929
|
-
|
|
930
1275
|
try:
|
|
931
1276
|
# Check if we should retry with backoff
|
|
932
1277
|
if (
|
|
933
1278
|
not requeue
|
|
934
|
-
and self.message_handler.spec.
|
|
1279
|
+
and self.message_handler.spec.nack_on_exception
|
|
935
1280
|
and exception is not None
|
|
936
1281
|
):
|
|
937
1282
|
# Get retry config from consumer
|
|
@@ -940,12 +1285,20 @@ class MessageHandlerCallback:
|
|
|
940
1285
|
# Check if we reached max retries
|
|
941
1286
|
if retry_count >= retry_config.max_retries:
|
|
942
1287
|
logger.warning(
|
|
943
|
-
|
|
944
|
-
|
|
1288
|
+
"Message %s (%s) failed after %s retries, dead-lettering: %s",
|
|
1289
|
+
message_id,
|
|
1290
|
+
self.queue_name,
|
|
1291
|
+
retry_count,
|
|
1292
|
+
str(exception),
|
|
945
1293
|
)
|
|
946
1294
|
# Dead-letter the message after max retries
|
|
947
|
-
|
|
1295
|
+
try:
|
|
1296
|
+
|
|
948
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
|
+
)
|
|
949
1302
|
return
|
|
950
1303
|
|
|
951
1304
|
# Calculate delay for this retry attempt
|
|
@@ -961,9 +1314,14 @@ class MessageHandlerCallback:
|
|
|
961
1314
|
|
|
962
1315
|
delay = min(delay, retry_config.max_delay)
|
|
963
1316
|
|
|
964
|
-
logger.
|
|
965
|
-
|
|
966
|
-
|
|
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,
|
|
967
1325
|
)
|
|
968
1326
|
|
|
969
1327
|
# Store retry state for this message
|
|
@@ -977,33 +1335,44 @@ class MessageHandlerCallback:
|
|
|
977
1335
|
asyncio.create_task(
|
|
978
1336
|
self._delayed_retry(
|
|
979
1337
|
aio_pika_message, delay, retry_count + 1, exception
|
|
980
|
-
)
|
|
1338
|
+
),
|
|
1339
|
+
name=f"MessageHandler-{self.queue_name}-delayed-retry-{message_id}",
|
|
981
1340
|
)
|
|
982
1341
|
|
|
983
1342
|
# Acknowledge the current message since we'll handle retry ourselves
|
|
984
|
-
|
|
1343
|
+
try:
|
|
985
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
|
+
)
|
|
986
1349
|
return
|
|
987
1350
|
|
|
988
1351
|
# Standard reject without retry or with immediate requeue
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
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)
|
|
999
1369
|
|
|
1000
|
-
except RuntimeError as e:
|
|
1001
|
-
logger.error(
|
|
1002
|
-
f"Error rejecting message {message_id} ({self.queue_name}): {e}"
|
|
1003
|
-
)
|
|
1004
1370
|
except Exception as e:
|
|
1005
1371
|
logger.exception(
|
|
1006
|
-
|
|
1372
|
+
"Unexpected error in handle_reject_message for %s (%s): %s",
|
|
1373
|
+
message_id,
|
|
1374
|
+
self.queue_name,
|
|
1375
|
+
e,
|
|
1007
1376
|
)
|
|
1008
1377
|
|
|
1009
1378
|
async def _delayed_retry(
|
|
@@ -1018,7 +1387,7 @@ class MessageHandlerCallback:
|
|
|
1018
1387
|
|
|
1019
1388
|
Args:
|
|
1020
1389
|
aio_pika_message: The original message
|
|
1021
|
-
delay: Delay in seconds before
|
|
1390
|
+
delay: Delay in seconds before retrying
|
|
1022
1391
|
retry_count: The current retry count (after increment)
|
|
1023
1392
|
exception: The exception that caused the failure
|
|
1024
1393
|
"""
|
|
@@ -1026,7 +1395,7 @@ class MessageHandlerCallback:
|
|
|
1026
1395
|
|
|
1027
1396
|
try:
|
|
1028
1397
|
# Wait for the backoff delay
|
|
1029
|
-
await
|
|
1398
|
+
await self._wait_delay_or_shutdown(delay)
|
|
1030
1399
|
|
|
1031
1400
|
# Get message body and properties for republishing
|
|
1032
1401
|
message_body = aio_pika_message.body
|
|
@@ -1043,32 +1412,62 @@ class MessageHandlerCallback:
|
|
|
1043
1412
|
if message_id in self.retry_state:
|
|
1044
1413
|
del self.retry_state[message_id]
|
|
1045
1414
|
|
|
1046
|
-
# Republish the message to the same queue
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
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
|
+
)
|
|
1052
1426
|
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
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
|
+
)
|
|
1064
1438
|
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
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
|
|
1068
1464
|
|
|
1069
1465
|
except Exception as e:
|
|
1070
1466
|
logger.exception(
|
|
1071
|
-
|
|
1467
|
+
"Failed to execute delayed retry for message %s (%s): %s",
|
|
1468
|
+
message_id,
|
|
1469
|
+
self.queue_name,
|
|
1470
|
+
e,
|
|
1072
1471
|
)
|
|
1073
1472
|
# If we fail to republish, try to dead-letter the original message
|
|
1074
1473
|
try:
|
|
@@ -1077,6 +1476,24 @@ class MessageHandlerCallback:
|
|
|
1077
1476
|
except Exception:
|
|
1078
1477
|
pass
|
|
1079
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
|
+
|
|
1080
1497
|
async def handle_message(
|
|
1081
1498
|
self, aio_pika_message: aio_pika.abc.AbstractIncomingMessage
|
|
1082
1499
|
) -> None:
|
|
@@ -1130,70 +1547,89 @@ class MessageHandlerCallback:
|
|
|
1130
1547
|
|
|
1131
1548
|
builded_message = AioPikaMessage(aio_pika_message, message_type)
|
|
1132
1549
|
|
|
1133
|
-
incoming_message_spec = MessageHandler.
|
|
1550
|
+
incoming_message_spec = MessageHandler.get_last(handler)
|
|
1134
1551
|
assert incoming_message_spec is not None
|
|
1135
1552
|
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
controller_member_reflect=handler_data.controller_member,
|
|
1139
|
-
transaction_data=MessageBusTransactionData(
|
|
1140
|
-
message=builded_message,
|
|
1141
|
-
topic=routing_key,
|
|
1142
|
-
),
|
|
1143
|
-
)
|
|
1553
|
+
with provide_implicit_headers(aio_pika_message.headers), provide_shutdown_state(
|
|
1554
|
+
self.consumer.shutdown_state
|
|
1144
1555
|
):
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
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)))
|
|
1168
1598
|
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
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:
|
|
1174
1611
|
logger.exception(
|
|
1175
|
-
|
|
1612
|
+
"Error processing message %s on topic %s: %s",
|
|
1613
|
+
message_id,
|
|
1614
|
+
routing_key,
|
|
1615
|
+
str(base_exc),
|
|
1176
1616
|
)
|
|
1177
|
-
else:
|
|
1178
|
-
logger.exception(
|
|
1179
|
-
f"Error processing message {message_id} on topic {routing_key}: {str(base_exc)}"
|
|
1180
|
-
)
|
|
1181
1617
|
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
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
|
|
1197
1633
|
# Message processed successfully, log and clean up any retry state
|
|
1198
1634
|
message_id = aio_pika_message.message_id or str(uuid.uuid4())
|
|
1199
1635
|
if message_id in self.retry_state:
|
|
@@ -1201,14 +1637,32 @@ class MessageHandlerCallback:
|
|
|
1201
1637
|
|
|
1202
1638
|
# Log success with retry information if applicable
|
|
1203
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
|
+
|
|
1204
1645
|
if "x-retry-count" in headers:
|
|
1205
1646
|
retry_count = int(str(headers.get("x-retry-count", 0)))
|
|
1206
|
-
logger.
|
|
1207
|
-
|
|
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,
|
|
1208
1656
|
)
|
|
1209
1657
|
else:
|
|
1210
|
-
logger.
|
|
1211
|
-
|
|
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,
|
|
1212
1666
|
)
|
|
1213
1667
|
|
|
1214
1668
|
|
|
@@ -1248,68 +1702,73 @@ class MessageBusWorker:
|
|
|
1248
1702
|
async def start_async(self) -> None:
|
|
1249
1703
|
all_message_handlers_set: MESSAGE_HANDLER_DATA_SET = set()
|
|
1250
1704
|
all_scheduled_actions_set: SCHEDULED_ACTION_DATA_SET = set()
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
if controller is None:
|
|
1256
|
-
continue
|
|
1257
|
-
|
|
1258
|
-
instance: Any = self.container.get_by_type(instance_class)
|
|
1259
|
-
|
|
1260
|
-
factory = controller.get_messagebus_factory()
|
|
1261
|
-
handlers, schedulers = factory(instance)
|
|
1262
|
-
|
|
1263
|
-
message_handler_data_map: dict[str, MessageHandlerData] = {}
|
|
1264
|
-
all_scheduled_actions_set.update(schedulers)
|
|
1265
|
-
for handler_data in handlers:
|
|
1266
|
-
message_type = handler_data.spec.message_type
|
|
1267
|
-
topic = message_type.MESSAGE_TOPIC
|
|
1268
|
-
|
|
1269
|
-
# Filter handlers by name if specified
|
|
1270
|
-
if (
|
|
1271
|
-
self.handler_names is not None
|
|
1272
|
-
and handler_data.spec.name is not None
|
|
1273
|
-
):
|
|
1274
|
-
if handler_data.spec.name not in self.handler_names:
|
|
1275
|
-
continue
|
|
1276
|
-
elif (
|
|
1277
|
-
self.handler_names is not None
|
|
1278
|
-
and handler_data.spec.name is None
|
|
1279
|
-
):
|
|
1280
|
-
# Skip handlers without names when filtering is requested
|
|
1281
|
-
continue
|
|
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)
|
|
1282
1709
|
|
|
1283
|
-
if
|
|
1284
|
-
topic in message_handler_data_map
|
|
1285
|
-
and message_type.MESSAGE_TYPE == "task"
|
|
1286
|
-
):
|
|
1287
|
-
logger.warning(
|
|
1288
|
-
"Task handler for topic '%s' already registered. Skipping"
|
|
1289
|
-
% topic
|
|
1290
|
-
)
|
|
1710
|
+
if controller is None:
|
|
1291
1711
|
continue
|
|
1292
|
-
message_handler_data_map[topic] = handler_data
|
|
1293
|
-
all_message_handlers_set.add(handler_data)
|
|
1294
1712
|
|
|
1295
|
-
|
|
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
|
|
1296
1737
|
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
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)
|
|
1304
1749
|
|
|
1305
|
-
|
|
1750
|
+
broker_backend = get_message_broker_backend_from_url(
|
|
1751
|
+
url=self.backend_url
|
|
1752
|
+
)
|
|
1753
|
+
|
|
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
|
+
)
|
|
1761
|
+
|
|
1762
|
+
await consumer.consume()
|
|
1306
1763
|
|
|
1307
1764
|
def start_sync(self) -> None:
|
|
1308
1765
|
|
|
1309
1766
|
def on_shutdown(loop: asyncio.AbstractEventLoop) -> None:
|
|
1310
|
-
logger.
|
|
1767
|
+
logger.warning("Shutting down - signal received")
|
|
1311
1768
|
# Schedule the shutdown to run in the event loop
|
|
1312
|
-
asyncio.create_task(
|
|
1769
|
+
asyncio.create_task(
|
|
1770
|
+
self._graceful_shutdown(), name="Worker-Graceful-Shutdown"
|
|
1771
|
+
)
|
|
1313
1772
|
# wait until the shutdown is complete
|
|
1314
1773
|
|
|
1315
1774
|
with asyncio.Runner(loop_factory=uvloop.new_event_loop) as runner:
|
|
@@ -1317,15 +1776,22 @@ class MessageBusWorker:
|
|
|
1317
1776
|
loop.add_signal_handler(signal.SIGINT, on_shutdown, loop)
|
|
1318
1777
|
# Add graceful shutdown handler for SIGTERM as well
|
|
1319
1778
|
loop.add_signal_handler(signal.SIGTERM, on_shutdown, loop)
|
|
1320
|
-
|
|
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)
|
|
1321
1787
|
|
|
1322
1788
|
async def _graceful_shutdown(self) -> None:
|
|
1323
1789
|
"""Handles graceful shutdown process"""
|
|
1324
|
-
logger.
|
|
1790
|
+
logger.warning("Initiating graceful shutdown sequence")
|
|
1325
1791
|
# Use the comprehensive close method that handles shutdown, task waiting and connection cleanup
|
|
1326
1792
|
|
|
1327
1793
|
self.consumer.shutdown()
|
|
1328
|
-
logger.
|
|
1794
|
+
logger.warning("Graceful shutdown completed")
|
|
1329
1795
|
|
|
1330
1796
|
|
|
1331
1797
|
class AioPikaMessageBusController(BusMessageController):
|
|
@@ -1404,13 +1870,14 @@ class AioPikaMessageBusController(BusMessageController):
|
|
|
1404
1870
|
float(delay),
|
|
1405
1871
|
retry_count + 1,
|
|
1406
1872
|
None, # No specific exception
|
|
1407
|
-
)
|
|
1873
|
+
),
|
|
1874
|
+
name=f"MessageHandler-{callback.queue_name}-delayed-retry-{self.aio_pika_message.message_id or 'unknown'}-{int(time.time())}",
|
|
1408
1875
|
)
|
|
1409
1876
|
|
|
1410
1877
|
# Acknowledge the current message since we'll republish
|
|
1411
1878
|
await self.aio_pika_message.ack()
|
|
1412
1879
|
|
|
1413
1880
|
except Exception as e:
|
|
1414
|
-
logger.exception(
|
|
1881
|
+
logger.exception("Failed to schedule retry_later: %s", e)
|
|
1415
1882
|
# Fall back to immediate retry
|
|
1416
1883
|
await self.aio_pika_message.reject(requeue=True)
|