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