jararaca 0.4.0a5__py3-none-any.whl → 0.4.0a19__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. jararaca/__init__.py +9 -9
  2. jararaca/cli.py +643 -4
  3. jararaca/core/providers.py +4 -0
  4. jararaca/helpers/__init__.py +3 -0
  5. jararaca/helpers/global_scheduler/__init__.py +3 -0
  6. jararaca/helpers/global_scheduler/config.py +21 -0
  7. jararaca/helpers/global_scheduler/controller.py +42 -0
  8. jararaca/helpers/global_scheduler/registry.py +32 -0
  9. jararaca/messagebus/decorators.py +104 -10
  10. jararaca/messagebus/interceptors/aiopika_publisher_interceptor.py +50 -8
  11. jararaca/messagebus/interceptors/message_publisher_collector.py +62 -0
  12. jararaca/messagebus/interceptors/publisher_interceptor.py +25 -3
  13. jararaca/messagebus/worker.py +276 -200
  14. jararaca/microservice.py +3 -1
  15. jararaca/observability/providers/otel.py +31 -13
  16. jararaca/persistence/base.py +1 -1
  17. jararaca/persistence/utilities.py +47 -24
  18. jararaca/presentation/decorators.py +3 -3
  19. jararaca/reflect/decorators.py +24 -10
  20. jararaca/reflect/helpers.py +18 -0
  21. jararaca/rpc/http/__init__.py +2 -2
  22. jararaca/rpc/http/decorators.py +9 -9
  23. jararaca/scheduler/beat_worker.py +14 -14
  24. jararaca/tools/typescript/decorators.py +4 -4
  25. jararaca/tools/typescript/interface_parser.py +3 -1
  26. jararaca/utils/env_parse_utils.py +133 -0
  27. jararaca/utils/rabbitmq_utils.py +47 -0
  28. jararaca/utils/retry.py +11 -13
  29. {jararaca-0.4.0a5.dist-info → jararaca-0.4.0a19.dist-info}/METADATA +2 -1
  30. {jararaca-0.4.0a5.dist-info → jararaca-0.4.0a19.dist-info}/RECORD +35 -27
  31. pyproject.toml +2 -1
  32. {jararaca-0.4.0a5.dist-info → jararaca-0.4.0a19.dist-info}/LICENSE +0 -0
  33. {jararaca-0.4.0a5.dist-info → jararaca-0.4.0a19.dist-info}/LICENSES/GPL-3.0-or-later.txt +0 -0
  34. {jararaca-0.4.0a5.dist-info → jararaca-0.4.0a19.dist-info}/WHEEL +0 -0
  35. {jararaca-0.4.0a5.dist-info → jararaca-0.4.0a19.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,42 @@
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import asyncio
6
+
7
+ from jararaca.helpers.global_scheduler.config import GlobalSchedulerConfigAnnotated
8
+ from jararaca.helpers.global_scheduler.registry import (
9
+ GlobalScheduleFnType,
10
+ GlobalSchedulerRegistry,
11
+ )
12
+ from jararaca.messagebus.decorators import MessageBusController
13
+ from jararaca.scheduler.decorators import ScheduledAction
14
+ from jararaca.utils.env_parse_utils import get_env_str
15
+
16
+ SCHEDULER_CRON = get_env_str("SCHEDULER_CRON", "*/5 * * * *")
17
+
18
+
19
+ @MessageBusController()
20
+ class GlobalSchedulerController:
21
+
22
+ def __init__(self, config: GlobalSchedulerConfigAnnotated):
23
+ self._config = config
24
+
25
+ @ScheduledAction(cron=SCHEDULER_CRON)
26
+ async def trigger_scheduled_actions(self) -> None:
27
+ """Trigger all registered scheduled actions."""
28
+
29
+ taks = []
30
+
31
+ semaphore = asyncio.Semaphore(self._config.MAX_CONCURRENT_JOBS)
32
+
33
+ for action in GlobalSchedulerRegistry.get_registered_actions():
34
+ task = asyncio.create_task(self._run_with_semaphore(semaphore, action))
35
+
36
+ taks.append(task)
37
+
38
+ async def _run_with_semaphore(
39
+ self, semaphore: asyncio.Semaphore, action: GlobalScheduleFnType
40
+ ) -> None:
41
+ async with semaphore:
42
+ await action()
@@ -0,0 +1,32 @@
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+
6
+ from typing import Any, Awaitable, Callable
7
+
8
+ GlobalScheduleFnType = Callable[[], Awaitable[Any]]
9
+
10
+
11
+ class GlobalSchedulerRegistry:
12
+ """Registry for the Global Scheduler helper."""
13
+
14
+ _REGISTRY: list[GlobalScheduleFnType] = []
15
+
16
+ @classmethod
17
+ def register(cls, fn: GlobalScheduleFnType) -> None:
18
+ """Register a scheduled action function.
19
+
20
+ Args:
21
+ fn: The scheduled action function to register.
22
+ """
23
+ cls._REGISTRY.append(fn)
24
+
25
+ @classmethod
26
+ def get_registered_actions(cls) -> list[GlobalScheduleFnType]:
27
+ """Get the list of registered scheduled action functions.
28
+
29
+ Returns:
30
+ A list of registered scheduled action functions.
31
+ """
32
+ return cls._REGISTRY
@@ -5,35 +5,129 @@
5
5
 
6
6
  import inspect
7
7
  from dataclasses import dataclass
8
- from typing import Any, Awaitable, Callable
8
+ from typing import Any, Awaitable, Callable, Literal, TypeVar, cast, get_args
9
9
 
10
- from jararaca.messagebus.message import INHERITS_MESSAGE_CO
10
+ from jararaca.messagebus.message import INHERITS_MESSAGE_CO, Message, MessageOf
11
11
  from jararaca.reflect.controller_inspect import (
12
12
  ControllerMemberReflect,
13
13
  inspect_controller,
14
14
  )
15
- from jararaca.reflect.decorators import DECORATED_T, StackableDecorator
15
+ from jararaca.reflect.decorators import (
16
+ FUNC_OR_TYPE_T,
17
+ GenericStackableDecorator,
18
+ StackableDecorator,
19
+ )
20
+ from jararaca.reflect.helpers import is_generic_alias
16
21
  from jararaca.scheduler.decorators import ScheduledAction, ScheduledActionData
22
+ from jararaca.utils.env_parse_utils import get_env_float, get_env_int, is_env_truffy
23
+ from jararaca.utils.retry import RetryPolicy
24
+
25
+ AcceptableHandler = (
26
+ Callable[[Any, MessageOf[Any]], Awaitable[None]]
27
+ | Callable[[Any, Any], Awaitable[None]]
28
+ )
29
+ MessageHandlerT = TypeVar("MessageHandlerT", bound=AcceptableHandler)
17
30
 
31
+ DEFAULT_TIMEOUT = get_env_int("JARARACA_MESSAGEBUS_HANDLER_TIMEOUT")
32
+ DEFAULT_NACK_ON_EXCEPTION = is_env_truffy("JARARACA_MESSAGEBUS_NACK_ON_EXCEPTION")
33
+ DEFAULT_AUTO_ACK = is_env_truffy("JARARACA_MESSAGEBUS_AUTO_ACK")
34
+ DEFAULT_NACK_DELAY_ON_EXCEPTION = get_env_float(
35
+ "JARARACA_MESSAGEBUS_NACK_DELAY_ON_EXCEPTION"
36
+ )
18
37
 
19
- class MessageHandler(StackableDecorator):
38
+
39
+ class MessageHandler(GenericStackableDecorator[AcceptableHandler]):
20
40
 
21
41
  def __init__(
22
42
  self,
23
43
  message: type[INHERITS_MESSAGE_CO],
24
- timeout: int | None = None,
44
+ *,
45
+ timeout: int | None = DEFAULT_TIMEOUT if DEFAULT_TIMEOUT is not False else None,
25
46
  exception_handler: Callable[[BaseException], None] | None = None,
26
- nack_on_exception: bool = False,
27
- auto_ack: bool = False,
47
+ nack_on_exception: bool = DEFAULT_NACK_ON_EXCEPTION,
48
+ nack_delay_on_exception: float = DEFAULT_NACK_DELAY_ON_EXCEPTION or 5.0,
49
+ auto_ack: bool = DEFAULT_AUTO_ACK,
28
50
  name: str | None = None,
51
+ retry_config: RetryPolicy | None = None,
29
52
  ) -> None:
30
53
  self.message_type = message
31
54
 
32
55
  self.timeout = timeout
33
56
  self.exception_handler = exception_handler
34
57
  self.nack_on_exception = nack_on_exception
58
+ self.nack_delay_on_exception = nack_delay_on_exception
59
+
35
60
  self.auto_ack = auto_ack
36
61
  self.name = name
62
+ self.retry_config = retry_config
63
+
64
+ def __call__(self, subject: MessageHandlerT) -> MessageHandlerT:
65
+ return cast(MessageHandlerT, super().__call__(subject))
66
+
67
+ def pre_decorated(self, subject: FUNC_OR_TYPE_T) -> None:
68
+ MessageHandler.validate_decorated_fn(subject)
69
+
70
+ @staticmethod
71
+ def validate_decorated_fn(
72
+ subject: FUNC_OR_TYPE_T,
73
+ ) -> tuple[Literal["WRAPPED", "DIRECT"], type[Message]]:
74
+ """Validates that the decorated function has the correct signature
75
+ the decorated must follow one of the patterns:
76
+
77
+ async def handler(self, message: MessageOf[YourMessageType]) -> None:
78
+ ...
79
+
80
+ async def handler(self, message: YourMessageType) -> None:
81
+ ...
82
+
83
+ """
84
+
85
+ if not inspect.iscoroutinefunction(subject):
86
+ raise RuntimeError(
87
+ "Message handler '%s' must be a coroutine function"
88
+ % (subject.__qualname__)
89
+ )
90
+
91
+ signature = inspect.signature(subject)
92
+
93
+ parameters = list(signature.parameters.values())
94
+
95
+ if len(parameters) != 2:
96
+ raise RuntimeError(
97
+ "Message handler '%s' must have exactly two parameters (self, message)"
98
+ % (subject.__qualname__)
99
+ )
100
+
101
+ message_param = parameters[1]
102
+
103
+ if message_param.annotation is inspect.Parameter.empty:
104
+ raise RuntimeError(
105
+ "Message handler '%s' must have type annotation for the message parameter"
106
+ % (subject.__qualname__)
107
+ )
108
+
109
+ annotation_type = message_param.annotation
110
+ mode: Literal["WRAPPED", "DIRECT"]
111
+ if is_generic_alias(annotation_type):
112
+
113
+ message_model_type = get_args(annotation_type)[0]
114
+
115
+ mode = "WRAPPED"
116
+
117
+ else:
118
+ message_model_type = annotation_type
119
+
120
+ mode = "DIRECT"
121
+
122
+ if not inspect.isclass(message_model_type) or not issubclass(
123
+ message_model_type, Message
124
+ ):
125
+ raise RuntimeError(
126
+ "Message handler '%s' message parameter must be of type 'MessageOf[YourMessageType]' or 'YourMessageType' where 'YourMessageType' is a subclass of 'Message'"
127
+ % (subject.__qualname__)
128
+ )
129
+
130
+ return mode, message_model_type
37
131
 
38
132
 
39
133
  @dataclass(frozen=True)
@@ -73,16 +167,16 @@ class MessageBusController(StackableDecorator):
73
167
  def get_messagebus_factory(
74
168
  self,
75
169
  ) -> Callable[
76
- [DECORATED_T], tuple[MESSAGE_HANDLER_DATA_SET, SCHEDULED_ACTION_DATA_SET]
170
+ [FUNC_OR_TYPE_T], tuple[MESSAGE_HANDLER_DATA_SET, SCHEDULED_ACTION_DATA_SET]
77
171
  ]:
78
172
  if self.messagebus_factory is None:
79
173
  raise Exception("MessageBus factory is not set")
80
174
  return self.messagebus_factory
81
175
 
82
- def post_decorated(self, subject: DECORATED_T) -> None:
176
+ def post_decorated(self, subject: FUNC_OR_TYPE_T) -> None:
83
177
 
84
178
  def messagebus_factory(
85
- instance: DECORATED_T,
179
+ instance: FUNC_OR_TYPE_T,
86
180
  ) -> tuple[MESSAGE_HANDLER_DATA_SET, SCHEDULED_ACTION_DATA_SET]:
87
181
  handlers: MESSAGE_HANDLER_DATA_SET = set()
88
182
 
@@ -9,6 +9,7 @@ from datetime import tzinfo as _TzInfo
9
9
  from typing import Any, AsyncGenerator
10
10
 
11
11
  import aio_pika
12
+ import tenacity
12
13
  from aio_pika.abc import AbstractConnection
13
14
  from pydantic import BaseModel
14
15
 
@@ -71,7 +72,9 @@ class AIOPikaMessagePublisher(MessagePublisher):
71
72
  )
72
73
  )
73
74
 
74
- async def schedule(self, message: IMessage, when: datetime, tz: _TzInfo) -> None:
75
+ async def schedule(
76
+ self, message: IMessage, when: datetime, timezone: _TzInfo
77
+ ) -> None:
75
78
  if not self.message_broker_backend:
76
79
  raise NotImplementedError(
77
80
  "Schedule is not implemented for AIOPikaMessagePublisher"
@@ -110,6 +113,18 @@ class GenericPoolConfig(BaseModel):
110
113
  max_size: int
111
114
 
112
115
 
116
+ default_retry = tenacity.retry(
117
+ wait=tenacity.wait_exponential_jitter(initial=1, max=60),
118
+ before_sleep=tenacity.before_sleep_log(logger, logging.WARNING),
119
+ )
120
+
121
+ default_retry_channel = tenacity.retry(
122
+ wait=tenacity.wait_exponential_jitter(initial=1, max=60),
123
+ before_sleep=tenacity.before_sleep_log(logger, logging.WARNING),
124
+ stop=tenacity.stop_after_attempt(5),
125
+ )
126
+
127
+
113
128
  class AIOPikaConnectionFactory(MessageBusConnectionFactory):
114
129
 
115
130
  def __init__(
@@ -130,6 +145,7 @@ class AIOPikaConnectionFactory(MessageBusConnectionFactory):
130
145
 
131
146
  if connection_pool_config:
132
147
 
148
+ @default_retry
133
149
  async def get_connection() -> AbstractConnection:
134
150
  return await aio_pika.connect(self.url)
135
151
 
@@ -140,6 +156,7 @@ class AIOPikaConnectionFactory(MessageBusConnectionFactory):
140
156
 
141
157
  if channel_pool_config:
142
158
 
159
+ @default_retry_channel
143
160
  async def get_channel() -> aio_pika.abc.AbstractChannel:
144
161
  async with self.acquire_connection() as connection:
145
162
  return await connection.channel(publisher_confirms=False)
@@ -148,10 +165,14 @@ class AIOPikaConnectionFactory(MessageBusConnectionFactory):
148
165
  get_channel, max_size=channel_pool_config.max_size
149
166
  )
150
167
 
168
+ @default_retry
169
+ async def _connect(self) -> AbstractConnection:
170
+ return await aio_pika.connect(self.url)
171
+
151
172
  @asynccontextmanager
152
173
  async def acquire_connection(self) -> AsyncGenerator[AbstractConnection, Any]:
153
174
  if not self.connection_pool:
154
- async with await aio_pika.connect(self.url) as connection:
175
+ async with await self._connect() as connection:
155
176
  yield connection
156
177
  else:
157
178
 
@@ -162,12 +183,33 @@ class AIOPikaConnectionFactory(MessageBusConnectionFactory):
162
183
  async def acquire_channel(
163
184
  self,
164
185
  ) -> AsyncGenerator[aio_pika.abc.AbstractChannel, Any]:
165
- if not self.channel_pool:
166
- async with self.acquire_connection() as connection:
167
- yield await connection.channel(publisher_confirms=False)
168
- else:
169
- async with self.channel_pool.acquire() as channel:
170
- yield channel
186
+
187
+ async for attempt in tenacity.AsyncRetrying(
188
+ wait=tenacity.wait_exponential_jitter(initial=1, max=10),
189
+ before_sleep=tenacity.before_sleep_log(logger, logging.WARNING),
190
+ # stop=tenacity.stop_after_attempt(9000),
191
+ ):
192
+ with attempt:
193
+
194
+ if not self.connection_pool or not self.channel_pool:
195
+ async with self.acquire_connection() as connection:
196
+ yield await connection.channel(publisher_confirms=False)
197
+ else:
198
+
199
+ async with self.connection_pool.acquire() as connection:
200
+ if not connection.connected.is_set():
201
+ await connection.connect()
202
+
203
+ async with connection.channel(
204
+ publisher_confirms=False
205
+ ) as channel:
206
+ yield channel
207
+ # await connection.close()
208
+ if attempt.retry_state.attempt_number > 1:
209
+ logger.warning(
210
+ "Later successful connection attempt #%d",
211
+ attempt.retry_state.attempt_number,
212
+ )
171
213
 
172
214
  @asynccontextmanager
173
215
  async def provide_connection(self) -> AsyncGenerator[AIOPikaMessagePublisher, Any]:
@@ -0,0 +1,62 @@
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import UTC, datetime, timedelta, tzinfo
7
+
8
+ from jararaca.messagebus.publisher import IMessage, MessagePublisher
9
+
10
+
11
+ @dataclass
12
+ class CollectorDelayedMessageData:
13
+ message: IMessage
14
+ when: datetime
15
+ timezone: tzinfo
16
+
17
+
18
+ class MessagePublisherCollector(MessagePublisher):
19
+
20
+ def __init__(self) -> None:
21
+ self.staged_delayed_messages: list[CollectorDelayedMessageData] = []
22
+ self.staged_messages: list[IMessage] = []
23
+
24
+ async def publish(self, message: IMessage, topic: str) -> None:
25
+ self.staged_messages.append(message)
26
+
27
+ async def delay(self, message: IMessage, seconds: int) -> None:
28
+ self.staged_delayed_messages.append(
29
+ CollectorDelayedMessageData(
30
+ message=message,
31
+ when=datetime.now(UTC) + timedelta(seconds=seconds),
32
+ timezone=UTC,
33
+ )
34
+ )
35
+
36
+ async def schedule(
37
+ self, message: IMessage, when: datetime, timezone: tzinfo
38
+ ) -> None:
39
+ self.staged_delayed_messages.append(
40
+ CollectorDelayedMessageData(
41
+ message=message,
42
+ when=when,
43
+ timezone=timezone,
44
+ )
45
+ )
46
+
47
+ async def fill(self, publisher: MessagePublisher) -> None:
48
+ for message in self.staged_messages:
49
+ await publisher.publish(message, message.MESSAGE_TOPIC)
50
+
51
+ for delayed_message in self.staged_delayed_messages:
52
+ await publisher.schedule(
53
+ delayed_message.message,
54
+ delayed_message.when,
55
+ delayed_message.timezone,
56
+ )
57
+
58
+ def has_messages(self) -> bool:
59
+ return bool(self.staged_messages or self.staged_delayed_messages)
60
+
61
+ async def flush(self) -> None:
62
+ raise NotImplementedError("I'm just a poor little collector! :(")
@@ -6,6 +6,9 @@ from contextlib import asynccontextmanager
6
6
  from typing import AsyncContextManager, AsyncGenerator, Protocol
7
7
 
8
8
  from jararaca.broker_backend import MessageBrokerBackend
9
+ from jararaca.messagebus.interceptors.message_publisher_collector import (
10
+ MessagePublisherCollector,
11
+ )
9
12
  from jararaca.messagebus.publisher import MessagePublisher, provide_message_publisher
10
13
  from jararaca.microservice import AppInterceptor, AppTransactionContext
11
14
 
@@ -22,10 +25,15 @@ class MessageBusPublisherInterceptor(AppInterceptor):
22
25
  connection_factory: MessageBusConnectionFactory,
23
26
  connection_name: str = "default",
24
27
  message_scheduler: MessageBrokerBackend | None = None,
28
+ *,
29
+ open_connection_at_end_of_transaction: bool = False,
25
30
  ):
26
31
  self.connection_factory = connection_factory
27
32
  self.connection_name = connection_name
28
33
  self.message_scheduler = message_scheduler
34
+ self.open_connection_at_end_of_transaction = (
35
+ open_connection_at_end_of_transaction
36
+ )
29
37
 
30
38
  @asynccontextmanager
31
39
  async def intercept(
@@ -35,8 +43,22 @@ class MessageBusPublisherInterceptor(AppInterceptor):
35
43
  yield
36
44
  return
37
45
 
38
- async with self.connection_factory.provide_connection() as connection:
39
- with provide_message_publisher(self.connection_name, connection):
46
+ if self.open_connection_at_end_of_transaction:
47
+
48
+ collector = MessagePublisherCollector()
49
+ with provide_message_publisher(self.connection_name, collector):
40
50
  yield
41
51
 
42
- await connection.flush()
52
+ if collector.has_messages():
53
+ async with self.connection_factory.provide_connection() as connection:
54
+ await collector.fill(connection)
55
+ await connection.flush()
56
+ return
57
+ else:
58
+
59
+ yield
60
+
61
+ async with self.connection_factory.provide_connection() as connection:
62
+ with provide_message_publisher(self.connection_name, connection):
63
+
64
+ await connection.flush()