jararaca 0.3.9__py3-none-any.whl → 0.3.11__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.

Files changed (35) hide show
  1. jararaca/__init__.py +76 -5
  2. jararaca/cli.py +460 -116
  3. jararaca/core/uow.py +17 -12
  4. jararaca/messagebus/decorators.py +33 -30
  5. jararaca/messagebus/interceptors/aiopika_publisher_interceptor.py +30 -2
  6. jararaca/messagebus/interceptors/publisher_interceptor.py +7 -3
  7. jararaca/messagebus/publisher.py +14 -6
  8. jararaca/messagebus/worker.py +1102 -88
  9. jararaca/microservice.py +137 -34
  10. jararaca/observability/decorators.py +7 -3
  11. jararaca/observability/interceptor.py +4 -2
  12. jararaca/observability/providers/otel.py +14 -10
  13. jararaca/persistence/base.py +2 -1
  14. jararaca/persistence/interceptors/aiosqa_interceptor.py +167 -16
  15. jararaca/persistence/utilities.py +32 -20
  16. jararaca/presentation/decorators.py +96 -10
  17. jararaca/presentation/server.py +31 -4
  18. jararaca/presentation/websocket/context.py +30 -4
  19. jararaca/presentation/websocket/types.py +2 -2
  20. jararaca/presentation/websocket/websocket_interceptor.py +28 -4
  21. jararaca/reflect/__init__.py +0 -0
  22. jararaca/reflect/controller_inspect.py +75 -0
  23. jararaca/{tools → reflect}/metadata.py +25 -5
  24. jararaca/scheduler/{scheduler_v2.py → beat_worker.py} +49 -53
  25. jararaca/scheduler/decorators.py +55 -20
  26. jararaca/tools/app_config/interceptor.py +4 -2
  27. jararaca/utils/rabbitmq_utils.py +259 -5
  28. jararaca/utils/retry.py +141 -0
  29. {jararaca-0.3.9.dist-info → jararaca-0.3.11.dist-info}/METADATA +2 -1
  30. {jararaca-0.3.9.dist-info → jararaca-0.3.11.dist-info}/RECORD +33 -32
  31. {jararaca-0.3.9.dist-info → jararaca-0.3.11.dist-info}/WHEEL +1 -1
  32. jararaca/messagebus/worker_v2.py +0 -617
  33. jararaca/scheduler/scheduler.py +0 -161
  34. {jararaca-0.3.9.dist-info → jararaca-0.3.11.dist-info}/LICENSE +0 -0
  35. {jararaca-0.3.9.dist-info → jararaca-0.3.11.dist-info}/entry_points.txt +0 -0
@@ -1,617 +0,0 @@
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
- passive_declare: bool = False,
116
- ):
117
-
118
- self.broker_backend = broker_backend
119
- self.config = config
120
- self.message_handler_set = message_handler_set
121
- self.scheduled_actions = scheduled_actions
122
- self.incoming_map: dict[str, MessageHandlerData] = {}
123
- self.uow_context_provider = uow_context_provider
124
- self.shutdown_event = asyncio.Event()
125
- self.lock = asyncio.Lock()
126
- self.tasks: set[asyncio.Task[Any]] = set()
127
- self.passive_declare = passive_declare
128
-
129
- async def consume(self) -> None:
130
-
131
- connection = await aio_pika.connect(self.config.url)
132
-
133
- channel = await connection.channel()
134
-
135
- await channel.set_qos(prefetch_count=self.config.prefetch_count)
136
-
137
- await RabbitmqUtils.declare_main_exchange(
138
- channel=channel,
139
- exchange_name=self.config.exchange,
140
- passive=self.passive_declare,
141
- )
142
-
143
- dlx = await RabbitmqUtils.declare_dl_exchange(
144
- channel=channel, passive=self.passive_declare
145
- )
146
-
147
- dlq = await RabbitmqUtils.declare_dl_queue(
148
- channel=channel, passive=self.passive_declare
149
- )
150
-
151
- await dlq.bind(dlx, routing_key=RabbitmqUtils.DEAD_LETTER_EXCHANGE)
152
-
153
- for handler in self.message_handler_set:
154
-
155
- queue_name = f"{handler.message_type.MESSAGE_TOPIC}.{handler.callable.__module__}.{handler.callable.__qualname__}"
156
- routing_key = f"{handler.message_type.MESSAGE_TOPIC}.#"
157
-
158
- self.incoming_map[queue_name] = handler
159
-
160
- queue = await RabbitmqUtils.declare_queue(
161
- channel=channel, queue_name=queue_name, passive=self.passive_declare
162
- )
163
-
164
- await queue.bind(exchange=self.config.exchange, routing_key=routing_key)
165
-
166
- await queue.consume(
167
- callback=MessageHandlerCallback(
168
- consumer=self,
169
- queue_name=queue_name,
170
- routing_key=routing_key,
171
- message_handler=handler,
172
- ),
173
- no_ack=handler.spec.auto_ack,
174
- )
175
-
176
- logger.info(f"Consuming message handler {queue_name}")
177
-
178
- for scheduled_action in self.scheduled_actions:
179
-
180
- queue_name = f"{scheduled_action.callable.__module__}.{scheduled_action.callable.__qualname__}"
181
-
182
- routing_key = queue_name
183
-
184
- queue = await RabbitmqUtils.declare_queue(
185
- channel=channel, queue_name=queue_name, passive=self.passive_declare
186
- )
187
-
188
- await queue.bind(exchange=self.config.exchange, routing_key=routing_key)
189
-
190
- await queue.consume(
191
- callback=ScheduledMessageHandlerCallback(
192
- consumer=self,
193
- queue_name=queue_name,
194
- routing_key=routing_key,
195
- scheduled_action=scheduled_action,
196
- ),
197
- no_ack=True,
198
- )
199
-
200
- logger.info(f"Consuming scheduler {queue_name}")
201
-
202
- await self.shutdown_event.wait()
203
- logger.info("Worker shutting down")
204
-
205
- await self.wait_all_tasks_done()
206
-
207
- await channel.close()
208
- await connection.close()
209
-
210
- async def wait_all_tasks_done(self) -> None:
211
- async with self.lock:
212
- await asyncio.gather(*self.tasks)
213
-
214
- def shutdown(self) -> None:
215
- self.shutdown_event.set()
216
-
217
-
218
- def create_message_bus(
219
- broker_url: str,
220
- broker_backend: MessageBrokerBackend,
221
- scheduled_actions: SCHEDULED_ACTION_DATA_SET,
222
- message_handler_set: MESSAGE_HANDLER_DATA_SET,
223
- uow_context_provider: UnitOfWorkContextProvider,
224
- ) -> MessageBusConsumer:
225
-
226
- parsed_url = urlparse(broker_url)
227
-
228
- if parsed_url.scheme == "amqp" or parsed_url.scheme == "amqps":
229
- assert parsed_url.query, "Query string must be set for AMQP URLs"
230
-
231
- query_params: dict[str, list[str]] = parse_qs(parsed_url.query)
232
-
233
- assert "exchange" in query_params, "Exchange must be set in the query string"
234
- assert (
235
- len(query_params["exchange"]) == 1
236
- ), "Exchange must be set in the query string"
237
- assert (
238
- "prefetch_count" in query_params
239
- ), "Prefetch count must be set in the query string"
240
- assert (
241
- len(query_params["prefetch_count"]) == 1
242
- ), "Prefetch count must be set in the query string"
243
- assert query_params["prefetch_count"][
244
- 0
245
- ].isdigit(), "Prefetch count must be an integer in the query string"
246
- assert query_params["exchange"][0], "Exchange must be set in the query string"
247
- assert query_params["prefetch_count"][
248
- 0
249
- ], "Prefetch count must be set in the query string"
250
-
251
- exchange = query_params["exchange"][0]
252
- prefetch_count = int(query_params["prefetch_count"][0])
253
-
254
- config = AioPikaWorkerConfig(
255
- url=broker_url,
256
- exchange=exchange,
257
- prefetch_count=prefetch_count,
258
- )
259
-
260
- return AioPikaMicroserviceConsumer(
261
- config=config,
262
- broker_backend=broker_backend,
263
- message_handler_set=message_handler_set,
264
- scheduled_actions=scheduled_actions,
265
- uow_context_provider=uow_context_provider,
266
- )
267
-
268
- raise ValueError(
269
- f"Unsupported broker URL scheme: {parsed_url.scheme}. Supported schemes are amqp and amqps"
270
- )
271
-
272
-
273
- class ScheduledMessageHandlerCallback:
274
- def __init__(
275
- self,
276
- consumer: AioPikaMicroserviceConsumer,
277
- queue_name: str,
278
- routing_key: str,
279
- scheduled_action: ScheduledActionData,
280
- ):
281
- self.consumer = consumer
282
- self.queue_name = queue_name
283
- self.routing_key = routing_key
284
- self.scheduled_action = scheduled_action
285
-
286
- async def __call__(
287
- self, aio_pika_message: aio_pika.abc.AbstractIncomingMessage
288
- ) -> None:
289
-
290
- if self.consumer.shutdown_event.is_set():
291
- return
292
-
293
- async with self.consumer.lock:
294
- task = asyncio.create_task(self.handle_message(aio_pika_message))
295
- self.consumer.tasks.add(task)
296
- task.add_done_callback(self.handle_message_consume_done)
297
-
298
- def handle_message_consume_done(self, task: asyncio.Task[Any]) -> None:
299
- self.consumer.tasks.discard(task)
300
-
301
- async def handle_message(
302
- self, aio_pika_message: aio_pika.abc.AbstractIncomingMessage
303
- ) -> None:
304
-
305
- if self.consumer.shutdown_event.is_set():
306
- logger.info("Shutdown event set. Rqueuing message")
307
- await aio_pika_message.reject(requeue=True)
308
-
309
- sig = inspect.signature(self.scheduled_action.callable)
310
- if len(sig.parameters) == 1:
311
-
312
- task = asyncio.create_task(
313
- self.run_with_context(
314
- self.scheduled_action.callable,
315
- self.scheduled_action,
316
- (ScheduleDispatchData(int(aio_pika_message.body.decode("utf-8"))),),
317
- {},
318
- )
319
- )
320
-
321
- elif len(sig.parameters) == 0:
322
- task = asyncio.create_task(
323
- self.run_with_context(
324
- self.scheduled_action.callable,
325
- self.scheduled_action,
326
- (),
327
- {},
328
- )
329
- )
330
- else:
331
- logger.warning(
332
- "Scheduled action '%s' must have exactly one parameter of type ScheduleDispatchData or no parameters"
333
- % self.queue_name
334
- )
335
- return
336
-
337
- self.consumer.tasks.add(task)
338
- task.add_done_callback(self.handle_message_consume_done)
339
-
340
- try:
341
- await task
342
- except Exception as e:
343
-
344
- logger.exception(
345
- f"Error processing scheduled action {self.queue_name}: {e}"
346
- )
347
-
348
- async def run_with_context(
349
- self,
350
- func: Callable[..., Awaitable[None]],
351
- scheduled_action: ScheduledActionData,
352
- args: tuple[Any, ...],
353
- kwargs: dict[str, Any],
354
- ) -> None:
355
- async with self.consumer.uow_context_provider(
356
- SchedulerAppContext(
357
- action=func,
358
- scheduled_to=datetime.now(UTC),
359
- cron_expression=scheduled_action.spec.cron,
360
- triggered_at=datetime.now(UTC),
361
- )
362
- ):
363
-
364
- await func(*args, **kwargs)
365
-
366
-
367
- class MessageHandlerCallback:
368
-
369
- def __init__(
370
- self,
371
- consumer: AioPikaMicroserviceConsumer,
372
- queue_name: str,
373
- routing_key: str,
374
- message_handler: MessageHandlerData,
375
- ):
376
- self.consumer = consumer
377
- self.queue_name = queue_name
378
- self.routing_key = routing_key
379
- self.message_handler = message_handler
380
-
381
- async def message_consumer(
382
- self, aio_pika_message: aio_pika.abc.AbstractIncomingMessage
383
- ) -> None:
384
- if self.consumer.shutdown_event.is_set():
385
- return
386
-
387
- async with self.consumer.lock:
388
- task = asyncio.create_task(self.handle_message(aio_pika_message))
389
- self.consumer.tasks.add(task)
390
- task.add_done_callback(self.handle_message_consume_done)
391
-
392
- def handle_message_consume_done(self, task: asyncio.Task[Any]) -> None:
393
- self.consumer.tasks.discard(task)
394
- if task.cancelled():
395
- return
396
-
397
- if (error := task.exception()) is not None:
398
- logger.exception("Error processing message", exc_info=error)
399
-
400
- async def __call__(
401
- self, aio_pika_message: aio_pika.abc.AbstractIncomingMessage
402
- ) -> None:
403
- await self.message_consumer(aio_pika_message)
404
-
405
- async def handle_reject_message(
406
- self,
407
- aio_pika_message: aio_pika.abc.AbstractIncomingMessage,
408
- requeue: bool = False,
409
- ) -> None:
410
- if self.message_handler.spec.auto_ack is False:
411
- await aio_pika_message.reject(requeue=requeue)
412
- elif requeue:
413
- logger.warning(
414
- f"Message {aio_pika_message.message_id} ({self.queue_name}) cannot be requeued because auto_ack is enabled"
415
- )
416
-
417
- async def handle_message(
418
- self, aio_pika_message: aio_pika.abc.AbstractIncomingMessage
419
- ) -> None:
420
-
421
- routing_key = self.queue_name
422
-
423
- if routing_key is None:
424
- logger.warning("No topic found for message")
425
- await self.handle_reject_message(aio_pika_message)
426
- return
427
-
428
- handler_data = self.message_handler
429
-
430
- handler = handler_data.callable
431
-
432
- sig = inspect.signature(handler)
433
-
434
- if len(sig.parameters) != 1:
435
- logger.warning(
436
- "Handler for topic '%s' must have exactly one parameter which is MessageOf[T extends Message]"
437
- % routing_key
438
- )
439
- return
440
-
441
- parameter = list(sig.parameters.values())[0]
442
-
443
- param_origin = get_origin(parameter.annotation)
444
-
445
- if param_origin is not MessageOf:
446
- logger.warning(
447
- "Handler for topic '%s' must have exactly one parameter of type Message"
448
- % routing_key
449
- )
450
- return
451
-
452
- if len(parameter.annotation.__args__) != 1:
453
- logger.warning(
454
- "Handler for topic '%s' must have exactly one parameter of type Message"
455
- % routing_key
456
- )
457
- return
458
-
459
- message_type = parameter.annotation.__args__[0]
460
-
461
- if not issubclass(message_type, BaseModel):
462
- logger.warning(
463
- "Handler for topic '%s' must have exactly one parameter of type MessageOf[BaseModel]"
464
- % routing_key
465
- )
466
- return
467
-
468
- builded_message = AioPikaMessage(aio_pika_message, message_type)
469
-
470
- incoming_message_spec = MessageHandler.get_message_incoming(handler)
471
- assert incoming_message_spec is not None
472
-
473
- async with self.consumer.uow_context_provider(
474
- MessageBusAppContext(
475
- message=builded_message,
476
- topic=routing_key,
477
- )
478
- ):
479
- ctx: AsyncContextManager[Any]
480
- if incoming_message_spec.timeout is not None:
481
- ctx = asyncio.timeout(incoming_message_spec.timeout)
482
- else:
483
- ctx = none_context()
484
- async with ctx:
485
- try:
486
- with provide_bus_message_controller(
487
- AioPikaMessageBusController(aio_pika_message)
488
- ):
489
- await handler(builded_message)
490
- if not incoming_message_spec.auto_ack:
491
- with suppress(aio_pika.MessageProcessError):
492
- await aio_pika_message.ack()
493
- except BaseException as base_exc:
494
- if incoming_message_spec.exception_handler is not None:
495
- try:
496
- incoming_message_spec.exception_handler(base_exc)
497
- except Exception as nested_exc:
498
- logger.exception(
499
- f"Error processing exception handler: {base_exc} | {nested_exc}"
500
- )
501
- else:
502
- logger.exception(
503
- f"Error processing message on topic {routing_key}"
504
- )
505
- if incoming_message_spec.requeue_on_exception:
506
- await self.handle_reject_message(aio_pika_message, requeue=True)
507
- else:
508
- await self.handle_reject_message(
509
- aio_pika_message, requeue=False
510
- )
511
- else:
512
- logger.info(
513
- f"Message {aio_pika_message.message_id}#{self.queue_name} processed successfully"
514
- )
515
-
516
-
517
- @asynccontextmanager
518
- async def none_context() -> AsyncGenerator[None, None]:
519
- yield
520
-
521
-
522
- class MessageBusWorker:
523
- def __init__(self, app: Microservice, broker_url: str, backend_url: str) -> None:
524
- self.app = app
525
- self.backend_url = backend_url
526
- self.broker_url = broker_url
527
-
528
- self.container = Container(app)
529
- self.lifecycle = AppLifecycle(app, self.container)
530
-
531
- self.uow_context_provider = UnitOfWorkContextProvider(
532
- app=app, container=self.container
533
- )
534
-
535
- self._consumer: MessageBusConsumer | None = None
536
-
537
- @property
538
- def consumer(self) -> MessageBusConsumer:
539
- if self._consumer is None:
540
- raise RuntimeError("Consumer not started")
541
- return self._consumer
542
-
543
- async def start_async(self) -> None:
544
- all_message_handlers_set: MESSAGE_HANDLER_DATA_SET = set()
545
- all_scheduled_actions_set: SCHEDULED_ACTION_DATA_SET = set()
546
- async with self.lifecycle():
547
- for instance_class in self.app.controllers:
548
- controller = MessageBusController.get_messagebus(instance_class)
549
-
550
- if controller is None:
551
- continue
552
-
553
- instance: Any = self.container.get_by_type(instance_class)
554
-
555
- factory = controller.get_messagebus_factory()
556
- handlers, schedulers = factory(instance)
557
-
558
- message_handler_data_map: dict[str, MessageHandlerData] = {}
559
- all_scheduled_actions_set.update(schedulers)
560
- for handler_data in handlers:
561
- message_type = handler_data.spec.message_type
562
- topic = message_type.MESSAGE_TOPIC
563
- if (
564
- topic in message_handler_data_map
565
- and message_type.MESSAGE_TYPE == "task"
566
- ):
567
- logger.warning(
568
- "Task handler for topic '%s' already registered. Skipping"
569
- % topic
570
- )
571
- continue
572
- message_handler_data_map[topic] = handler_data
573
- all_message_handlers_set.add(handler_data)
574
-
575
- broker_backend = get_message_broker_backend_from_url(url=self.backend_url)
576
-
577
- consumer = self._consumer = create_message_bus(
578
- broker_url=self.broker_url,
579
- broker_backend=broker_backend,
580
- scheduled_actions=all_scheduled_actions_set,
581
- message_handler_set=all_message_handlers_set,
582
- uow_context_provider=self.uow_context_provider,
583
- )
584
-
585
- await consumer.consume()
586
-
587
- def start_sync(self) -> None:
588
-
589
- def on_shutdown(loop: asyncio.AbstractEventLoop) -> None:
590
- logger.info("Shutting down")
591
- self.consumer.shutdown()
592
-
593
- with asyncio.Runner(loop_factory=uvloop.new_event_loop) as runner:
594
- runner.get_loop().add_signal_handler(
595
- signal.SIGINT, on_shutdown, runner.get_loop()
596
- )
597
- runner.run(self.start_async())
598
-
599
-
600
- class AioPikaMessageBusController(BusMessageController):
601
- def __init__(self, aio_pika_message: aio_pika.abc.AbstractIncomingMessage):
602
- self.aio_pika_message = aio_pika_message
603
-
604
- async def ack(self) -> None:
605
- await self.aio_pika_message.ack()
606
-
607
- async def nack(self) -> None:
608
- await self.aio_pika_message.nack()
609
-
610
- async def reject(self) -> None:
611
- await self.aio_pika_message.reject()
612
-
613
- async def retry(self) -> None:
614
- await self.aio_pika_message.reject(requeue=True)
615
-
616
- async def retry_later(self, delay: int) -> None:
617
- raise NotImplementedError("Not implemented")