jararaca 0.2.37a12__py3-none-any.whl → 0.3.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of jararaca might be problematic. Click here for more details.
- jararaca/__init__.py +13 -4
- jararaca/broker_backend/__init__.py +102 -0
- jararaca/broker_backend/mapper.py +21 -0
- jararaca/broker_backend/redis_broker_backend.py +162 -0
- jararaca/cli.py +136 -44
- jararaca/messagebus/__init__.py +1 -1
- jararaca/messagebus/consumers/__init__.py +0 -0
- jararaca/messagebus/decorators.py +57 -21
- jararaca/messagebus/interceptors/aiopika_publisher_interceptor.py +56 -35
- jararaca/messagebus/interceptors/publisher_interceptor.py +34 -0
- jararaca/messagebus/message.py +27 -0
- jararaca/messagebus/publisher.py +31 -2
- jararaca/messagebus/worker.py +12 -16
- jararaca/messagebus/worker_v2.py +608 -0
- jararaca/microservice.py +1 -1
- jararaca/scheduler/decorators.py +34 -1
- jararaca/scheduler/scheduler.py +16 -9
- jararaca/scheduler/scheduler_v2.py +346 -0
- jararaca/scheduler/types.py +7 -0
- jararaca/utils/__init__.py +0 -0
- jararaca/utils/rabbitmq_utils.py +84 -0
- {jararaca-0.2.37a12.dist-info → jararaca-0.3.1.dist-info}/METADATA +3 -1
- {jararaca-0.2.37a12.dist-info → jararaca-0.3.1.dist-info}/RECORD +26 -16
- jararaca/messagebus/types.py +0 -30
- {jararaca-0.2.37a12.dist-info → jararaca-0.3.1.dist-info}/LICENSE +0 -0
- {jararaca-0.2.37a12.dist-info → jararaca-0.3.1.dist-info}/WHEEL +0 -0
- {jararaca-0.2.37a12.dist-info → jararaca-0.3.1.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import inspect
|
|
3
|
+
import logging
|
|
4
|
+
import signal
|
|
5
|
+
from abc import ABC
|
|
6
|
+
from contextlib import asynccontextmanager, suppress
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
from typing import (
|
|
10
|
+
Any,
|
|
11
|
+
AsyncContextManager,
|
|
12
|
+
AsyncGenerator,
|
|
13
|
+
Awaitable,
|
|
14
|
+
Callable,
|
|
15
|
+
Type,
|
|
16
|
+
get_origin,
|
|
17
|
+
)
|
|
18
|
+
from urllib.parse import parse_qs, urlparse
|
|
19
|
+
|
|
20
|
+
import aio_pika
|
|
21
|
+
import aio_pika.abc
|
|
22
|
+
import uvloop
|
|
23
|
+
from pydantic import BaseModel
|
|
24
|
+
|
|
25
|
+
from jararaca.broker_backend import MessageBrokerBackend
|
|
26
|
+
from jararaca.broker_backend.mapper import get_message_broker_backend_from_url
|
|
27
|
+
from jararaca.core.uow import UnitOfWorkContextProvider
|
|
28
|
+
from jararaca.di import Container
|
|
29
|
+
from jararaca.lifecycle import AppLifecycle
|
|
30
|
+
from jararaca.messagebus.bus_message_controller import (
|
|
31
|
+
BusMessageController,
|
|
32
|
+
provide_bus_message_controller,
|
|
33
|
+
)
|
|
34
|
+
from jararaca.messagebus.decorators import (
|
|
35
|
+
MESSAGE_HANDLER_DATA_SET,
|
|
36
|
+
SCHEDULED_ACTION_DATA_SET,
|
|
37
|
+
MessageBusController,
|
|
38
|
+
MessageHandler,
|
|
39
|
+
MessageHandlerData,
|
|
40
|
+
ScheduledActionData,
|
|
41
|
+
ScheduleDispatchData,
|
|
42
|
+
)
|
|
43
|
+
from jararaca.messagebus.message import Message, MessageOf
|
|
44
|
+
from jararaca.microservice import (
|
|
45
|
+
MessageBusAppContext,
|
|
46
|
+
Microservice,
|
|
47
|
+
SchedulerAppContext,
|
|
48
|
+
)
|
|
49
|
+
from jararaca.utils.rabbitmq_utils import RabbitmqUtils
|
|
50
|
+
|
|
51
|
+
logger = logging.getLogger(__name__)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class AioPikaWorkerConfig:
|
|
56
|
+
url: str
|
|
57
|
+
exchange: str
|
|
58
|
+
prefetch_count: int
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class AioPikaMessage(MessageOf[Message]):
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
aio_pika_message: aio_pika.abc.AbstractIncomingMessage,
|
|
66
|
+
model_type: Type[Message],
|
|
67
|
+
):
|
|
68
|
+
self.aio_pika_message = aio_pika_message
|
|
69
|
+
self.model_type = model_type
|
|
70
|
+
|
|
71
|
+
def payload(self) -> Message:
|
|
72
|
+
return self.model_type.model_validate_json(self.aio_pika_message.body)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class MessageProcessingLocker:
|
|
76
|
+
|
|
77
|
+
def __init__(self) -> None:
|
|
78
|
+
self.messages_lock = asyncio.Lock()
|
|
79
|
+
self.current_processing_messages_set: set[asyncio.Task[Any]] = set()
|
|
80
|
+
|
|
81
|
+
@asynccontextmanager
|
|
82
|
+
async def lock_message_task(
|
|
83
|
+
self, task: asyncio.Task[Any]
|
|
84
|
+
) -> AsyncGenerator[None, Any]:
|
|
85
|
+
async with self.messages_lock:
|
|
86
|
+
self.current_processing_messages_set.add(task)
|
|
87
|
+
try:
|
|
88
|
+
yield
|
|
89
|
+
finally:
|
|
90
|
+
self.current_processing_messages_set.discard(task)
|
|
91
|
+
|
|
92
|
+
async def wait_all_messages_processed(self) -> None:
|
|
93
|
+
if len(self.current_processing_messages_set) == 0:
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
await asyncio.gather(*self.current_processing_messages_set)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class MessageBusConsumer(ABC):
|
|
100
|
+
|
|
101
|
+
async def consume(self) -> None:
|
|
102
|
+
raise NotImplementedError("consume method not implemented")
|
|
103
|
+
|
|
104
|
+
def shutdown(self) -> None: ...
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class AioPikaMicroserviceConsumer(MessageBusConsumer):
|
|
108
|
+
def __init__(
|
|
109
|
+
self,
|
|
110
|
+
broker_backend: MessageBrokerBackend,
|
|
111
|
+
config: AioPikaWorkerConfig,
|
|
112
|
+
message_handler_set: MESSAGE_HANDLER_DATA_SET,
|
|
113
|
+
scheduled_actions: SCHEDULED_ACTION_DATA_SET,
|
|
114
|
+
uow_context_provider: UnitOfWorkContextProvider,
|
|
115
|
+
):
|
|
116
|
+
|
|
117
|
+
self.broker_backend = broker_backend
|
|
118
|
+
self.config = config
|
|
119
|
+
self.message_handler_set = message_handler_set
|
|
120
|
+
self.scheduled_actions = scheduled_actions
|
|
121
|
+
self.incoming_map: dict[str, MessageHandlerData] = {}
|
|
122
|
+
self.uow_context_provider = uow_context_provider
|
|
123
|
+
self.shutdown_event = asyncio.Event()
|
|
124
|
+
self.lock = asyncio.Lock()
|
|
125
|
+
self.tasks: set[asyncio.Task[Any]] = set()
|
|
126
|
+
|
|
127
|
+
async def consume(self) -> None:
|
|
128
|
+
|
|
129
|
+
connection = await aio_pika.connect(self.config.url)
|
|
130
|
+
|
|
131
|
+
channel = await connection.channel()
|
|
132
|
+
|
|
133
|
+
await channel.set_qos(prefetch_count=self.config.prefetch_count)
|
|
134
|
+
|
|
135
|
+
await RabbitmqUtils.declare_main_exchange(
|
|
136
|
+
channel=channel,
|
|
137
|
+
exchange_name=self.config.exchange,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
dlx = await RabbitmqUtils.declare_dl_exchange(channel=channel)
|
|
141
|
+
|
|
142
|
+
dlq = await RabbitmqUtils.declare_dl_queue(channel=channel)
|
|
143
|
+
|
|
144
|
+
await dlq.bind(dlx, routing_key=RabbitmqUtils.DEAD_LETTER_EXCHANGE)
|
|
145
|
+
|
|
146
|
+
for handler in self.message_handler_set:
|
|
147
|
+
|
|
148
|
+
queue_name = f"{handler.message_type.MESSAGE_TOPIC}.{handler.callable.__module__}.{handler.callable.__qualname__}"
|
|
149
|
+
routing_key = f"{handler.message_type.MESSAGE_TOPIC}.#"
|
|
150
|
+
|
|
151
|
+
self.incoming_map[queue_name] = handler
|
|
152
|
+
|
|
153
|
+
queue = await RabbitmqUtils.declare_queue(
|
|
154
|
+
channel=channel, queue_name=queue_name
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
await queue.bind(exchange=self.config.exchange, routing_key=routing_key)
|
|
158
|
+
|
|
159
|
+
await queue.consume(
|
|
160
|
+
callback=MessageHandlerCallback(
|
|
161
|
+
consumer=self,
|
|
162
|
+
queue_name=queue_name,
|
|
163
|
+
routing_key=routing_key,
|
|
164
|
+
message_handler=handler,
|
|
165
|
+
),
|
|
166
|
+
no_ack=handler.spec.auto_ack,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
logger.info(f"Consuming message handler {queue_name}")
|
|
170
|
+
|
|
171
|
+
for scheduled_action in self.scheduled_actions:
|
|
172
|
+
|
|
173
|
+
queue_name = f"{scheduled_action.callable.__module__}.{scheduled_action.callable.__qualname__}"
|
|
174
|
+
|
|
175
|
+
routing_key = queue_name
|
|
176
|
+
|
|
177
|
+
queue = await channel.declare_queue(queue_name, durable=True)
|
|
178
|
+
|
|
179
|
+
await queue.bind(exchange=self.config.exchange, routing_key=routing_key)
|
|
180
|
+
|
|
181
|
+
await queue.consume(
|
|
182
|
+
callback=ScheduledMessageHandlerCallback(
|
|
183
|
+
consumer=self,
|
|
184
|
+
queue_name=queue_name,
|
|
185
|
+
routing_key=routing_key,
|
|
186
|
+
scheduled_action=scheduled_action,
|
|
187
|
+
),
|
|
188
|
+
no_ack=True,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
logger.info(f"Consuming scheduler {queue_name}")
|
|
192
|
+
|
|
193
|
+
await self.shutdown_event.wait()
|
|
194
|
+
logger.info("Worker shutting down")
|
|
195
|
+
|
|
196
|
+
await self.wait_all_tasks_done()
|
|
197
|
+
|
|
198
|
+
await channel.close()
|
|
199
|
+
await connection.close()
|
|
200
|
+
|
|
201
|
+
async def wait_all_tasks_done(self) -> None:
|
|
202
|
+
async with self.lock:
|
|
203
|
+
await asyncio.gather(*self.tasks)
|
|
204
|
+
|
|
205
|
+
def shutdown(self) -> None:
|
|
206
|
+
self.shutdown_event.set()
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def create_message_bus(
|
|
210
|
+
broker_url: str,
|
|
211
|
+
broker_backend: MessageBrokerBackend,
|
|
212
|
+
scheduled_actions: SCHEDULED_ACTION_DATA_SET,
|
|
213
|
+
message_handler_set: MESSAGE_HANDLER_DATA_SET,
|
|
214
|
+
uow_context_provider: UnitOfWorkContextProvider,
|
|
215
|
+
) -> MessageBusConsumer:
|
|
216
|
+
|
|
217
|
+
parsed_url = urlparse(broker_url)
|
|
218
|
+
|
|
219
|
+
if parsed_url.scheme == "amqp" or parsed_url.scheme == "amqps":
|
|
220
|
+
assert parsed_url.query, "Query string must be set for AMQP URLs"
|
|
221
|
+
|
|
222
|
+
query_params: dict[str, list[str]] = parse_qs(parsed_url.query)
|
|
223
|
+
|
|
224
|
+
assert "exchange" in query_params, "Exchange must be set in the query string"
|
|
225
|
+
assert (
|
|
226
|
+
len(query_params["exchange"]) == 1
|
|
227
|
+
), "Exchange must be set in the query string"
|
|
228
|
+
assert (
|
|
229
|
+
"prefetch_count" in query_params
|
|
230
|
+
), "Prefetch count must be set in the query string"
|
|
231
|
+
assert (
|
|
232
|
+
len(query_params["prefetch_count"]) == 1
|
|
233
|
+
), "Prefetch count must be set in the query string"
|
|
234
|
+
assert query_params["prefetch_count"][
|
|
235
|
+
0
|
|
236
|
+
].isdigit(), "Prefetch count must be an integer in the query string"
|
|
237
|
+
assert query_params["exchange"][0], "Exchange must be set in the query string"
|
|
238
|
+
assert query_params["prefetch_count"][
|
|
239
|
+
0
|
|
240
|
+
], "Prefetch count must be set in the query string"
|
|
241
|
+
|
|
242
|
+
exchange = query_params["exchange"][0]
|
|
243
|
+
prefetch_count = int(query_params["prefetch_count"][0])
|
|
244
|
+
|
|
245
|
+
config = AioPikaWorkerConfig(
|
|
246
|
+
url=broker_url,
|
|
247
|
+
exchange=exchange,
|
|
248
|
+
prefetch_count=prefetch_count,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
return AioPikaMicroserviceConsumer(
|
|
252
|
+
config=config,
|
|
253
|
+
broker_backend=broker_backend,
|
|
254
|
+
message_handler_set=message_handler_set,
|
|
255
|
+
scheduled_actions=scheduled_actions,
|
|
256
|
+
uow_context_provider=uow_context_provider,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
raise ValueError(
|
|
260
|
+
f"Unsupported broker URL scheme: {parsed_url.scheme}. Supported schemes are amqp and amqps"
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class ScheduledMessageHandlerCallback:
|
|
265
|
+
def __init__(
|
|
266
|
+
self,
|
|
267
|
+
consumer: AioPikaMicroserviceConsumer,
|
|
268
|
+
queue_name: str,
|
|
269
|
+
routing_key: str,
|
|
270
|
+
scheduled_action: ScheduledActionData,
|
|
271
|
+
):
|
|
272
|
+
self.consumer = consumer
|
|
273
|
+
self.queue_name = queue_name
|
|
274
|
+
self.routing_key = routing_key
|
|
275
|
+
self.scheduled_action = scheduled_action
|
|
276
|
+
|
|
277
|
+
async def __call__(
|
|
278
|
+
self, aio_pika_message: aio_pika.abc.AbstractIncomingMessage
|
|
279
|
+
) -> None:
|
|
280
|
+
|
|
281
|
+
if self.consumer.shutdown_event.is_set():
|
|
282
|
+
return
|
|
283
|
+
|
|
284
|
+
async with self.consumer.lock:
|
|
285
|
+
task = asyncio.create_task(self.handle_message(aio_pika_message))
|
|
286
|
+
self.consumer.tasks.add(task)
|
|
287
|
+
task.add_done_callback(self.handle_message_consume_done)
|
|
288
|
+
|
|
289
|
+
def handle_message_consume_done(self, task: asyncio.Task[Any]) -> None:
|
|
290
|
+
self.consumer.tasks.discard(task)
|
|
291
|
+
|
|
292
|
+
async def handle_message(
|
|
293
|
+
self, aio_pika_message: aio_pika.abc.AbstractIncomingMessage
|
|
294
|
+
) -> None:
|
|
295
|
+
|
|
296
|
+
if self.consumer.shutdown_event.is_set():
|
|
297
|
+
logger.info("Shutdown event set. Rqueuing message")
|
|
298
|
+
await aio_pika_message.reject(requeue=True)
|
|
299
|
+
|
|
300
|
+
sig = inspect.signature(self.scheduled_action.callable)
|
|
301
|
+
if len(sig.parameters) == 1:
|
|
302
|
+
|
|
303
|
+
task = asyncio.create_task(
|
|
304
|
+
self.run_with_context(
|
|
305
|
+
self.scheduled_action.callable,
|
|
306
|
+
self.scheduled_action,
|
|
307
|
+
(ScheduleDispatchData(int(aio_pika_message.body.decode("utf-8"))),),
|
|
308
|
+
{},
|
|
309
|
+
)
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
elif len(sig.parameters) == 0:
|
|
313
|
+
task = asyncio.create_task(
|
|
314
|
+
self.run_with_context(
|
|
315
|
+
self.scheduled_action.callable,
|
|
316
|
+
self.scheduled_action,
|
|
317
|
+
(),
|
|
318
|
+
{},
|
|
319
|
+
)
|
|
320
|
+
)
|
|
321
|
+
else:
|
|
322
|
+
logger.warning(
|
|
323
|
+
"Scheduled action '%s' must have exactly one parameter of type ScheduleDispatchData or no parameters"
|
|
324
|
+
% self.queue_name
|
|
325
|
+
)
|
|
326
|
+
return
|
|
327
|
+
|
|
328
|
+
self.consumer.tasks.add(task)
|
|
329
|
+
task.add_done_callback(self.handle_message_consume_done)
|
|
330
|
+
|
|
331
|
+
try:
|
|
332
|
+
await task
|
|
333
|
+
except Exception as e:
|
|
334
|
+
|
|
335
|
+
logger.exception(
|
|
336
|
+
f"Error processing scheduled action {self.queue_name}: {e}"
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
async def run_with_context(
|
|
340
|
+
self,
|
|
341
|
+
func: Callable[..., Awaitable[None]],
|
|
342
|
+
scheduled_action: ScheduledActionData,
|
|
343
|
+
args: tuple[Any, ...],
|
|
344
|
+
kwargs: dict[str, Any],
|
|
345
|
+
) -> None:
|
|
346
|
+
async with self.consumer.uow_context_provider(
|
|
347
|
+
SchedulerAppContext(
|
|
348
|
+
action=func,
|
|
349
|
+
scheduled_to=datetime.now(UTC),
|
|
350
|
+
cron_expression=scheduled_action.spec.cron,
|
|
351
|
+
triggered_at=datetime.now(UTC),
|
|
352
|
+
)
|
|
353
|
+
):
|
|
354
|
+
|
|
355
|
+
await func(*args, **kwargs)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
class MessageHandlerCallback:
|
|
359
|
+
|
|
360
|
+
def __init__(
|
|
361
|
+
self,
|
|
362
|
+
consumer: AioPikaMicroserviceConsumer,
|
|
363
|
+
queue_name: str,
|
|
364
|
+
routing_key: str,
|
|
365
|
+
message_handler: MessageHandlerData,
|
|
366
|
+
):
|
|
367
|
+
self.consumer = consumer
|
|
368
|
+
self.queue_name = queue_name
|
|
369
|
+
self.routing_key = routing_key
|
|
370
|
+
self.message_handler = message_handler
|
|
371
|
+
|
|
372
|
+
async def message_consumer(
|
|
373
|
+
self, aio_pika_message: aio_pika.abc.AbstractIncomingMessage
|
|
374
|
+
) -> None:
|
|
375
|
+
if self.consumer.shutdown_event.is_set():
|
|
376
|
+
return
|
|
377
|
+
|
|
378
|
+
async with self.consumer.lock:
|
|
379
|
+
task = asyncio.create_task(self.handle_message(aio_pika_message))
|
|
380
|
+
self.consumer.tasks.add(task)
|
|
381
|
+
task.add_done_callback(self.handle_message_consume_done)
|
|
382
|
+
|
|
383
|
+
def handle_message_consume_done(self, task: asyncio.Task[Any]) -> None:
|
|
384
|
+
self.consumer.tasks.discard(task)
|
|
385
|
+
if task.cancelled():
|
|
386
|
+
return
|
|
387
|
+
|
|
388
|
+
if (error := task.exception()) is not None:
|
|
389
|
+
logger.exception("Error processing message", exc_info=error)
|
|
390
|
+
|
|
391
|
+
async def __call__(
|
|
392
|
+
self, aio_pika_message: aio_pika.abc.AbstractIncomingMessage
|
|
393
|
+
) -> None:
|
|
394
|
+
await self.message_consumer(aio_pika_message)
|
|
395
|
+
|
|
396
|
+
async def handle_reject_message(
|
|
397
|
+
self,
|
|
398
|
+
aio_pika_message: aio_pika.abc.AbstractIncomingMessage,
|
|
399
|
+
requeue: bool = False,
|
|
400
|
+
) -> None:
|
|
401
|
+
if self.message_handler.spec.auto_ack is False:
|
|
402
|
+
await aio_pika_message.reject(requeue=requeue)
|
|
403
|
+
elif requeue:
|
|
404
|
+
logger.warning(
|
|
405
|
+
f"Message {aio_pika_message.message_id} ({self.queue_name}) cannot be requeued because auto_ack is enabled"
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
async def handle_message(
|
|
409
|
+
self, aio_pika_message: aio_pika.abc.AbstractIncomingMessage
|
|
410
|
+
) -> None:
|
|
411
|
+
|
|
412
|
+
routing_key = self.queue_name
|
|
413
|
+
|
|
414
|
+
if routing_key is None:
|
|
415
|
+
logger.warning("No topic found for message")
|
|
416
|
+
await self.handle_reject_message(aio_pika_message)
|
|
417
|
+
return
|
|
418
|
+
|
|
419
|
+
handler_data = self.message_handler
|
|
420
|
+
|
|
421
|
+
handler = handler_data.callable
|
|
422
|
+
|
|
423
|
+
sig = inspect.signature(handler)
|
|
424
|
+
|
|
425
|
+
if len(sig.parameters) != 1:
|
|
426
|
+
logger.warning(
|
|
427
|
+
"Handler for topic '%s' must have exactly one parameter which is MessageOf[T extends Message]"
|
|
428
|
+
% routing_key
|
|
429
|
+
)
|
|
430
|
+
return
|
|
431
|
+
|
|
432
|
+
parameter = list(sig.parameters.values())[0]
|
|
433
|
+
|
|
434
|
+
param_origin = get_origin(parameter.annotation)
|
|
435
|
+
|
|
436
|
+
if param_origin is not MessageOf:
|
|
437
|
+
logger.warning(
|
|
438
|
+
"Handler for topic '%s' must have exactly one parameter of type Message"
|
|
439
|
+
% routing_key
|
|
440
|
+
)
|
|
441
|
+
return
|
|
442
|
+
|
|
443
|
+
if len(parameter.annotation.__args__) != 1:
|
|
444
|
+
logger.warning(
|
|
445
|
+
"Handler for topic '%s' must have exactly one parameter of type Message"
|
|
446
|
+
% routing_key
|
|
447
|
+
)
|
|
448
|
+
return
|
|
449
|
+
|
|
450
|
+
message_type = parameter.annotation.__args__[0]
|
|
451
|
+
|
|
452
|
+
if not issubclass(message_type, BaseModel):
|
|
453
|
+
logger.warning(
|
|
454
|
+
"Handler for topic '%s' must have exactly one parameter of type MessageOf[BaseModel]"
|
|
455
|
+
% routing_key
|
|
456
|
+
)
|
|
457
|
+
return
|
|
458
|
+
|
|
459
|
+
builded_message = AioPikaMessage(aio_pika_message, message_type)
|
|
460
|
+
|
|
461
|
+
incoming_message_spec = MessageHandler.get_message_incoming(handler)
|
|
462
|
+
assert incoming_message_spec is not None
|
|
463
|
+
|
|
464
|
+
async with self.consumer.uow_context_provider(
|
|
465
|
+
MessageBusAppContext(
|
|
466
|
+
message=builded_message,
|
|
467
|
+
topic=routing_key,
|
|
468
|
+
)
|
|
469
|
+
):
|
|
470
|
+
ctx: AsyncContextManager[Any]
|
|
471
|
+
if incoming_message_spec.timeout is not None:
|
|
472
|
+
ctx = asyncio.timeout(incoming_message_spec.timeout)
|
|
473
|
+
else:
|
|
474
|
+
ctx = none_context()
|
|
475
|
+
async with ctx:
|
|
476
|
+
try:
|
|
477
|
+
with provide_bus_message_controller(
|
|
478
|
+
AioPikaMessageBusController(aio_pika_message)
|
|
479
|
+
):
|
|
480
|
+
await handler(builded_message)
|
|
481
|
+
if not incoming_message_spec.auto_ack:
|
|
482
|
+
with suppress(aio_pika.MessageProcessError):
|
|
483
|
+
await aio_pika_message.ack()
|
|
484
|
+
except BaseException as base_exc:
|
|
485
|
+
if incoming_message_spec.exception_handler is not None:
|
|
486
|
+
try:
|
|
487
|
+
incoming_message_spec.exception_handler(base_exc)
|
|
488
|
+
except Exception as nested_exc:
|
|
489
|
+
logger.exception(
|
|
490
|
+
f"Error processing exception handler: {base_exc} | {nested_exc}"
|
|
491
|
+
)
|
|
492
|
+
else:
|
|
493
|
+
logger.exception(
|
|
494
|
+
f"Error processing message on topic {routing_key}"
|
|
495
|
+
)
|
|
496
|
+
if incoming_message_spec.requeue_on_exception:
|
|
497
|
+
await self.handle_reject_message(aio_pika_message, requeue=True)
|
|
498
|
+
else:
|
|
499
|
+
await self.handle_reject_message(
|
|
500
|
+
aio_pika_message, requeue=False
|
|
501
|
+
)
|
|
502
|
+
else:
|
|
503
|
+
logger.info(
|
|
504
|
+
f"Message {aio_pika_message.message_id}#{self.queue_name} processed successfully"
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
@asynccontextmanager
|
|
509
|
+
async def none_context() -> AsyncGenerator[None, None]:
|
|
510
|
+
yield
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
class MessageBusWorker:
|
|
514
|
+
def __init__(self, app: Microservice, broker_url: str, backend_url: str) -> None:
|
|
515
|
+
self.app = app
|
|
516
|
+
self.backend_url = backend_url
|
|
517
|
+
self.broker_url = broker_url
|
|
518
|
+
|
|
519
|
+
self.container = Container(app)
|
|
520
|
+
self.lifecycle = AppLifecycle(app, self.container)
|
|
521
|
+
|
|
522
|
+
self.uow_context_provider = UnitOfWorkContextProvider(
|
|
523
|
+
app=app, container=self.container
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
self._consumer: MessageBusConsumer | None = None
|
|
527
|
+
|
|
528
|
+
@property
|
|
529
|
+
def consumer(self) -> MessageBusConsumer:
|
|
530
|
+
if self._consumer is None:
|
|
531
|
+
raise RuntimeError("Consumer not started")
|
|
532
|
+
return self._consumer
|
|
533
|
+
|
|
534
|
+
async def start_async(self) -> None:
|
|
535
|
+
all_message_handlers_set: MESSAGE_HANDLER_DATA_SET = set()
|
|
536
|
+
all_scheduled_actions_set: SCHEDULED_ACTION_DATA_SET = set()
|
|
537
|
+
async with self.lifecycle():
|
|
538
|
+
for instance_class in self.app.controllers:
|
|
539
|
+
controller = MessageBusController.get_messagebus(instance_class)
|
|
540
|
+
|
|
541
|
+
if controller is None:
|
|
542
|
+
continue
|
|
543
|
+
|
|
544
|
+
instance: Any = self.container.get_by_type(instance_class)
|
|
545
|
+
|
|
546
|
+
factory = controller.get_messagebus_factory()
|
|
547
|
+
handlers, schedulers = factory(instance)
|
|
548
|
+
|
|
549
|
+
message_handler_data_map: dict[str, MessageHandlerData] = {}
|
|
550
|
+
all_scheduled_actions_set.update(schedulers)
|
|
551
|
+
for handler_data in handlers:
|
|
552
|
+
message_type = handler_data.spec.message_type
|
|
553
|
+
topic = message_type.MESSAGE_TOPIC
|
|
554
|
+
if (
|
|
555
|
+
topic in message_handler_data_map
|
|
556
|
+
and message_type.MESSAGE_TYPE == "task"
|
|
557
|
+
):
|
|
558
|
+
logger.warning(
|
|
559
|
+
"Task handler for topic '%s' already registered. Skipping"
|
|
560
|
+
% topic
|
|
561
|
+
)
|
|
562
|
+
continue
|
|
563
|
+
message_handler_data_map[topic] = handler_data
|
|
564
|
+
all_message_handlers_set.add(handler_data)
|
|
565
|
+
|
|
566
|
+
broker_backend = get_message_broker_backend_from_url(url=self.backend_url)
|
|
567
|
+
|
|
568
|
+
consumer = self._consumer = create_message_bus(
|
|
569
|
+
broker_url=self.broker_url,
|
|
570
|
+
broker_backend=broker_backend,
|
|
571
|
+
scheduled_actions=all_scheduled_actions_set,
|
|
572
|
+
message_handler_set=all_message_handlers_set,
|
|
573
|
+
uow_context_provider=self.uow_context_provider,
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
await consumer.consume()
|
|
577
|
+
|
|
578
|
+
def start_sync(self) -> None:
|
|
579
|
+
|
|
580
|
+
def on_shutdown(loop: asyncio.AbstractEventLoop) -> None:
|
|
581
|
+
logger.info("Shutting down")
|
|
582
|
+
self.consumer.shutdown()
|
|
583
|
+
|
|
584
|
+
with asyncio.Runner(loop_factory=uvloop.new_event_loop) as runner:
|
|
585
|
+
runner.get_loop().add_signal_handler(
|
|
586
|
+
signal.SIGINT, on_shutdown, runner.get_loop()
|
|
587
|
+
)
|
|
588
|
+
runner.run(self.start_async())
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
class AioPikaMessageBusController(BusMessageController):
|
|
592
|
+
def __init__(self, aio_pika_message: aio_pika.abc.AbstractIncomingMessage):
|
|
593
|
+
self.aio_pika_message = aio_pika_message
|
|
594
|
+
|
|
595
|
+
async def ack(self) -> None:
|
|
596
|
+
await self.aio_pika_message.ack()
|
|
597
|
+
|
|
598
|
+
async def nack(self) -> None:
|
|
599
|
+
await self.aio_pika_message.nack()
|
|
600
|
+
|
|
601
|
+
async def reject(self) -> None:
|
|
602
|
+
await self.aio_pika_message.reject()
|
|
603
|
+
|
|
604
|
+
async def retry(self) -> None:
|
|
605
|
+
await self.aio_pika_message.reject(requeue=True)
|
|
606
|
+
|
|
607
|
+
async def retry_later(self, delay: int) -> None:
|
|
608
|
+
raise NotImplementedError("Not implemented")
|
jararaca/microservice.py
CHANGED
|
@@ -22,7 +22,7 @@ from fastapi import Request, WebSocket
|
|
|
22
22
|
|
|
23
23
|
from jararaca.core.providers import ProviderSpec, T, Token
|
|
24
24
|
from jararaca.messagebus import MessageOf
|
|
25
|
-
from jararaca.messagebus.
|
|
25
|
+
from jararaca.messagebus.message import Message
|
|
26
26
|
|
|
27
27
|
if TYPE_CHECKING:
|
|
28
28
|
from typing_extensions import TypeIs
|
jararaca/scheduler/decorators.py
CHANGED
|
@@ -11,7 +11,7 @@ class ScheduledAction:
|
|
|
11
11
|
self,
|
|
12
12
|
cron: str,
|
|
13
13
|
allow_overlap: bool = False,
|
|
14
|
-
exclusive: bool =
|
|
14
|
+
exclusive: bool = True,
|
|
15
15
|
timeout: int | None = None,
|
|
16
16
|
exception_handler: Callable[[BaseException], None] | None = None,
|
|
17
17
|
) -> None:
|
|
@@ -19,12 +19,35 @@ class ScheduledAction:
|
|
|
19
19
|
:param cron: A string representing the cron expression for the scheduled action.
|
|
20
20
|
:param allow_overlap: A boolean indicating if the scheduled action should new executions even if the previous one is still running.
|
|
21
21
|
:param exclusive: A boolean indicating if the scheduled action should be executed in one instance of the application. (Requires a distributed lock provided by a backend)
|
|
22
|
+
:param exception_handler: A callable that will be called when an exception is raised during the execution of the scheduled action.
|
|
23
|
+
:param timeout: An integer representing the timeout for the scheduled action in seconds. If the scheduled action takes longer than this time, it will be terminated.
|
|
22
24
|
"""
|
|
23
25
|
self.cron = cron
|
|
26
|
+
"""
|
|
27
|
+
A string representing the cron expression for the scheduled action.
|
|
28
|
+
"""
|
|
29
|
+
|
|
24
30
|
self.allow_overlap = allow_overlap
|
|
31
|
+
"""
|
|
32
|
+
A boolean indicating if the scheduled action should new executions even if the previous one is still running.
|
|
33
|
+
"""
|
|
34
|
+
|
|
25
35
|
self.exclusive = exclusive
|
|
36
|
+
"""
|
|
37
|
+
A boolean indicating if the scheduled action should be executed
|
|
38
|
+
in one instance of the application. (Requires a distributed lock provided by a backend)
|
|
39
|
+
"""
|
|
40
|
+
|
|
26
41
|
self.exception_handler = exception_handler
|
|
42
|
+
"""
|
|
43
|
+
A callable that will be called when an exception is raised during the execution of the scheduled action.
|
|
44
|
+
"""
|
|
45
|
+
|
|
27
46
|
self.timeout = timeout
|
|
47
|
+
"""
|
|
48
|
+
An integer representing the timeout for the scheduled action in seconds.
|
|
49
|
+
If the scheduled action takes longer than this time, it will be terminated.
|
|
50
|
+
"""
|
|
28
51
|
|
|
29
52
|
def __call__(self, func: DECORATED_FUNC) -> DECORATED_FUNC:
|
|
30
53
|
ScheduledAction.register(func, self)
|
|
@@ -61,3 +84,13 @@ class ScheduledAction:
|
|
|
61
84
|
scheduled_actions.append((member, scheduled_action))
|
|
62
85
|
|
|
63
86
|
return scheduled_actions
|
|
87
|
+
|
|
88
|
+
@staticmethod
|
|
89
|
+
def get_function_id(
|
|
90
|
+
func: Callable[..., Any],
|
|
91
|
+
) -> str:
|
|
92
|
+
"""
|
|
93
|
+
Get the function ID of the scheduled action.
|
|
94
|
+
This is used to identify the scheduled action in the message broker.
|
|
95
|
+
"""
|
|
96
|
+
return f"{func.__module__}.{func.__qualname__}"
|