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.
Files changed (91) hide show
  1. README.md +121 -0
  2. jararaca/__init__.py +267 -15
  3. jararaca/__main__.py +4 -0
  4. jararaca/broker_backend/__init__.py +106 -0
  5. jararaca/broker_backend/mapper.py +25 -0
  6. jararaca/broker_backend/redis_broker_backend.py +168 -0
  7. jararaca/cli.py +840 -103
  8. jararaca/common/__init__.py +3 -0
  9. jararaca/core/__init__.py +3 -0
  10. jararaca/core/providers.py +4 -0
  11. jararaca/core/uow.py +55 -16
  12. jararaca/di.py +4 -0
  13. jararaca/files/entity.py.mako +4 -0
  14. jararaca/lifecycle.py +6 -2
  15. jararaca/messagebus/__init__.py +5 -1
  16. jararaca/messagebus/bus_message_controller.py +4 -0
  17. jararaca/messagebus/consumers/__init__.py +3 -0
  18. jararaca/messagebus/decorators.py +90 -85
  19. jararaca/messagebus/implicit_headers.py +49 -0
  20. jararaca/messagebus/interceptors/__init__.py +3 -0
  21. jararaca/messagebus/interceptors/aiopika_publisher_interceptor.py +95 -37
  22. jararaca/messagebus/interceptors/publisher_interceptor.py +42 -0
  23. jararaca/messagebus/message.py +31 -0
  24. jararaca/messagebus/publisher.py +47 -4
  25. jararaca/messagebus/worker.py +1615 -135
  26. jararaca/microservice.py +248 -36
  27. jararaca/observability/constants.py +7 -0
  28. jararaca/observability/decorators.py +177 -16
  29. jararaca/observability/fastapi_exception_handler.py +37 -0
  30. jararaca/observability/hooks.py +109 -0
  31. jararaca/observability/interceptor.py +8 -2
  32. jararaca/observability/providers/__init__.py +3 -0
  33. jararaca/observability/providers/otel.py +213 -18
  34. jararaca/persistence/base.py +40 -3
  35. jararaca/persistence/exports.py +4 -0
  36. jararaca/persistence/interceptors/__init__.py +3 -0
  37. jararaca/persistence/interceptors/aiosqa_interceptor.py +187 -23
  38. jararaca/persistence/interceptors/constants.py +5 -0
  39. jararaca/persistence/interceptors/decorators.py +50 -0
  40. jararaca/persistence/session.py +3 -0
  41. jararaca/persistence/sort_filter.py +4 -0
  42. jararaca/persistence/utilities.py +74 -32
  43. jararaca/presentation/__init__.py +3 -0
  44. jararaca/presentation/decorators.py +170 -82
  45. jararaca/presentation/exceptions.py +23 -0
  46. jararaca/presentation/hooks.py +4 -0
  47. jararaca/presentation/http_microservice.py +4 -0
  48. jararaca/presentation/server.py +120 -41
  49. jararaca/presentation/websocket/__init__.py +3 -0
  50. jararaca/presentation/websocket/base_types.py +4 -0
  51. jararaca/presentation/websocket/context.py +34 -4
  52. jararaca/presentation/websocket/decorators.py +8 -41
  53. jararaca/presentation/websocket/redis.py +280 -53
  54. jararaca/presentation/websocket/types.py +6 -2
  55. jararaca/presentation/websocket/websocket_interceptor.py +74 -23
  56. jararaca/reflect/__init__.py +3 -0
  57. jararaca/reflect/controller_inspect.py +81 -0
  58. jararaca/reflect/decorators.py +238 -0
  59. jararaca/reflect/metadata.py +76 -0
  60. jararaca/rpc/__init__.py +3 -0
  61. jararaca/rpc/http/__init__.py +101 -0
  62. jararaca/rpc/http/backends/__init__.py +14 -0
  63. jararaca/rpc/http/backends/httpx.py +43 -9
  64. jararaca/rpc/http/backends/otel.py +4 -0
  65. jararaca/rpc/http/decorators.py +378 -113
  66. jararaca/rpc/http/httpx.py +3 -0
  67. jararaca/scheduler/__init__.py +3 -0
  68. jararaca/scheduler/beat_worker.py +758 -0
  69. jararaca/scheduler/decorators.py +89 -28
  70. jararaca/scheduler/types.py +11 -0
  71. jararaca/tools/app_config/__init__.py +3 -0
  72. jararaca/tools/app_config/decorators.py +7 -19
  73. jararaca/tools/app_config/interceptor.py +10 -4
  74. jararaca/tools/typescript/__init__.py +3 -0
  75. jararaca/tools/typescript/decorators.py +120 -0
  76. jararaca/tools/typescript/interface_parser.py +1126 -189
  77. jararaca/utils/__init__.py +3 -0
  78. jararaca/utils/rabbitmq_utils.py +372 -0
  79. jararaca/utils/retry.py +148 -0
  80. jararaca-0.4.0a5.dist-info/LICENSE +674 -0
  81. jararaca-0.4.0a5.dist-info/LICENSES/GPL-3.0-or-later.txt +232 -0
  82. {jararaca-0.2.37a12.dist-info → jararaca-0.4.0a5.dist-info}/METADATA +14 -7
  83. jararaca-0.4.0a5.dist-info/RECORD +88 -0
  84. {jararaca-0.2.37a12.dist-info → jararaca-0.4.0a5.dist-info}/WHEEL +1 -1
  85. pyproject.toml +131 -0
  86. jararaca/messagebus/types.py +0 -30
  87. jararaca/scheduler/scheduler.py +0 -154
  88. jararaca/tools/metadata.py +0 -47
  89. jararaca-0.2.37a12.dist-info/RECORD +0 -63
  90. /jararaca-0.2.37a12.dist-info/LICENSE → /LICENSE +0 -0
  91. {jararaca-0.2.37a12.dist-info → jararaca-0.4.0a5.dist-info}/entry_points.txt +0 -0
@@ -1,3 +1,7 @@
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
1
5
  from contextlib import contextmanager, suppress
2
6
  from contextvars import ContextVar
3
7
  from typing import Generator, Protocol
@@ -16,12 +20,12 @@ class WebSocketConnectionManager(Protocol):
16
20
  async def remove_websocket(self, websocket: WebSocket) -> None: ...
17
21
 
18
22
 
19
- _ws_manage_ctx = ContextVar[WebSocketConnectionManager]("ws_manage_ctx")
23
+ _ws_conn_manager_ctx = ContextVar[WebSocketConnectionManager]("ws_manage_ctx")
20
24
 
21
25
 
22
26
  def use_ws_manager() -> WebSocketConnectionManager:
23
27
  try:
24
- return _ws_manage_ctx.get()
28
+ return _ws_conn_manager_ctx.get()
25
29
  except LookupError:
26
30
  raise RuntimeError("No WebSocketConnectionManager found")
27
31
 
@@ -30,9 +34,35 @@ def use_ws_manager() -> WebSocketConnectionManager:
30
34
  def provide_ws_manager(
31
35
  ws_manager: WebSocketConnectionManager,
32
36
  ) -> Generator[None, None, None]:
33
- token = _ws_manage_ctx.set(ws_manager)
37
+ token = _ws_conn_manager_ctx.set(ws_manager)
38
+ try:
39
+ yield
40
+ finally:
41
+ with suppress(ValueError):
42
+ _ws_conn_manager_ctx.reset(token)
43
+
44
+
45
+ class WebSocketMessageSender(Protocol):
46
+ async def send(self, rooms: list[str], message: WebSocketMessageBase) -> None: ...
47
+
48
+
49
+ _ws_msg_sender_ctx = ContextVar[WebSocketMessageSender]("ws_msg_sender_ctx")
50
+
51
+
52
+ def use_ws_message_sender() -> WebSocketMessageSender:
53
+ try:
54
+ return _ws_msg_sender_ctx.get()
55
+ except LookupError:
56
+ raise RuntimeError("No WebSocketMessageSender found")
57
+
58
+
59
+ @contextmanager
60
+ def provide_ws_message_sender(
61
+ ws_message_sender: WebSocketMessageSender,
62
+ ) -> Generator[None, None, None]:
63
+ token = _ws_msg_sender_ctx.set(ws_message_sender)
34
64
  try:
35
65
  yield
36
66
  finally:
37
67
  with suppress(ValueError):
38
- _ws_manage_ctx.reset(token)
68
+ _ws_msg_sender_ctx.reset(token)
@@ -1,6 +1,11 @@
1
- from typing import TypedDict, TypeVar, cast
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ from typing import TypedDict, TypeVar
2
6
 
3
7
  from jararaca.presentation.websocket.base_types import WebSocketMessageBase
8
+ from jararaca.reflect.decorators import StackableDecorator
4
9
 
5
10
  DECORATED_CLASS = TypeVar("DECORATED_CLASS")
6
11
 
@@ -8,9 +13,8 @@ DECORATED_CLASS = TypeVar("DECORATED_CLASS")
8
13
  class WebSocketEndpointOptions(TypedDict): ...
9
14
 
10
15
 
11
- class WebSocketEndpoint:
16
+ class WebSocketEndpoint(StackableDecorator):
12
17
 
13
- WEBSOCKET_ENDPOINT_ATTR = "__websocket_endpoint__"
14
18
  ORDER_COUNTER = 0
15
19
 
16
20
  def __init__(self, path: str, options: WebSocketEndpointOptions = {}) -> None:
@@ -19,48 +23,11 @@ class WebSocketEndpoint:
19
23
  WebSocketEndpoint.ORDER_COUNTER += 1
20
24
  self.order = WebSocketEndpoint.ORDER_COUNTER
21
25
 
22
- @staticmethod
23
- def register(cls: DECORATED_CLASS, instance: "WebSocketEndpoint") -> None:
24
- setattr(cls, WebSocketEndpoint.WEBSOCKET_ENDPOINT_ATTR, instance)
25
-
26
- @staticmethod
27
- def get(cls: DECORATED_CLASS) -> "WebSocketEndpoint | None":
28
- if not hasattr(cls, WebSocketEndpoint.WEBSOCKET_ENDPOINT_ATTR):
29
- return None
30
-
31
- return cast(
32
- WebSocketEndpoint, getattr(cls, WebSocketEndpoint.WEBSOCKET_ENDPOINT_ATTR)
33
- )
34
-
35
- def __call__(self, cls: DECORATED_CLASS) -> DECORATED_CLASS:
36
- WebSocketEndpoint.register(cls, self)
37
- return cls
38
-
39
26
 
40
27
  INHERITS_WS_MESSAGE = TypeVar("INHERITS_WS_MESSAGE", bound=WebSocketMessageBase)
41
28
 
42
29
 
43
- class RegisterWebSocketMessage:
44
-
45
- REGISTER_WEBSOCKET_MESSAGE_ATTR = "__register_websocket_message__"
30
+ class RegisterWebSocketMessage(StackableDecorator):
46
31
 
47
32
  def __init__(self, *message_types: type[INHERITS_WS_MESSAGE]) -> None:
48
33
  self.message_types = message_types
49
-
50
- @staticmethod
51
- def register(cls: DECORATED_CLASS, instance: "RegisterWebSocketMessage") -> None:
52
- setattr(cls, RegisterWebSocketMessage.REGISTER_WEBSOCKET_MESSAGE_ATTR, instance)
53
-
54
- @staticmethod
55
- def get(cls: DECORATED_CLASS) -> "RegisterWebSocketMessage | None":
56
- if not hasattr(cls, RegisterWebSocketMessage.REGISTER_WEBSOCKET_MESSAGE_ATTR):
57
- return None
58
-
59
- return cast(
60
- RegisterWebSocketMessage,
61
- getattr(cls, RegisterWebSocketMessage.REGISTER_WEBSOCKET_MESSAGE_ATTR),
62
- )
63
-
64
- def __call__(self, cls: DECORATED_CLASS) -> DECORATED_CLASS:
65
- RegisterWebSocketMessage.register(cls, self)
66
- return cls
@@ -1,4 +1,9 @@
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
1
5
  import asyncio
6
+ import logging
2
7
  from dataclasses import dataclass
3
8
  from typing import Any
4
9
 
@@ -10,6 +15,8 @@ from jararaca.presentation.websocket.websocket_interceptor import (
10
15
  WebSocketConnectionBackend,
11
16
  )
12
17
 
18
+ logger = logging.getLogger(__name__)
19
+
13
20
 
14
21
  @dataclass
15
22
  class BroadcastMessage:
@@ -55,6 +62,8 @@ class RedisWebSocketConnectionBackend(WebSocketConnectionBackend):
55
62
  send_pubsub_channel: str,
56
63
  consume_broadcast_timeout: int = 1,
57
64
  consume_send_timeout: int = 1,
65
+ retry_delay: float = 5.0,
66
+ max_concurrent_tasks: int = 1000,
58
67
  ) -> None:
59
68
 
60
69
  self.redis = conn
@@ -63,89 +72,307 @@ class RedisWebSocketConnectionBackend(WebSocketConnectionBackend):
63
72
 
64
73
  self.lock = asyncio.Lock()
65
74
  self.tasks: set[asyncio.Task[Any]] = set()
75
+ self.max_concurrent_tasks = max_concurrent_tasks
76
+ self.task_semaphore = asyncio.Semaphore(max_concurrent_tasks)
66
77
 
67
78
  self.consume_broadcast_timeout = consume_broadcast_timeout
68
79
  self.consume_send_timeout = consume_send_timeout
80
+ self.retry_delay = retry_delay
81
+ self.__shutdown_event: asyncio.Event | None = None
82
+
83
+ self.__send_func: SendFunc | None = None
84
+ self.__broadcast_func: BroadcastFunc | None = None
85
+
86
+ @property
87
+ def shutdown_event(self) -> asyncio.Event:
88
+ if self.__shutdown_event is None:
89
+ raise RuntimeError(
90
+ "Shutdown event is not set. Please configure the backend before using it."
91
+ )
92
+ return self.__shutdown_event
93
+
94
+ @property
95
+ def send_func(self) -> SendFunc:
96
+ if self.__send_func is None:
97
+ raise RuntimeError(
98
+ "Send function is not set. Please configure the backend before using it."
99
+ )
100
+ return self.__send_func
101
+
102
+ @property
103
+ def broadcast_func(self) -> BroadcastFunc:
104
+ if self.__broadcast_func is None:
105
+ raise RuntimeError(
106
+ "Broadcast function is not set. Please configure the backend before using it."
107
+ )
108
+ return self.__broadcast_func
69
109
 
70
110
  async def broadcast(self, message: bytes) -> None:
71
- await self.redis.publish(
72
- self.broadcast_pubsub_channel,
73
- BroadcastMessage.from_message(message).encode(),
74
- )
111
+ try:
112
+ await self.redis.publish(
113
+ self.broadcast_pubsub_channel,
114
+ BroadcastMessage.from_message(message).encode(),
115
+ )
116
+ except Exception as e:
117
+ logger.error(
118
+ "Failed to publish broadcast message to Redis: %s", e, exc_info=True
119
+ )
120
+ raise
75
121
 
76
122
  async def send(self, rooms: list[str], message: bytes) -> None:
77
- await self.redis.publish(
78
- self.send_pubsub_channel,
79
- SendToRoomsMessage.from_message(rooms, message).encode(),
80
- )
123
+ try:
124
+ await self.redis.publish(
125
+ self.send_pubsub_channel,
126
+ SendToRoomsMessage.from_message(rooms, message).encode(),
127
+ )
128
+ except Exception as e:
129
+ logger.error(
130
+ "Failed to publish send message to Redis: %s", e, exc_info=True
131
+ )
132
+ raise
81
133
 
82
134
  def configure(
83
135
  self, broadcast: BroadcastFunc, send: SendFunc, shutdown_event: asyncio.Event
84
136
  ) -> None:
137
+ if self.__shutdown_event is not None:
138
+ raise RuntimeError("Backend is already configured.")
139
+ self.__shutdown_event = shutdown_event
140
+ self.__send_func = send
141
+ self.__broadcast_func = broadcast
142
+ self.setup_send_consumer()
143
+ self.setup_broadcast_consumer()
85
144
 
86
- broadcast_task = asyncio.get_event_loop().create_task(
87
- self.consume_broadcast(broadcast, shutdown_event)
88
- )
145
+ def setup_send_consumer(self) -> None:
89
146
 
90
147
  send_task = asyncio.get_event_loop().create_task(
91
- self.consume_send(send, shutdown_event)
148
+ self.consume_send(self.send_func, self.shutdown_event)
92
149
  )
93
150
 
94
- self.tasks.add(broadcast_task)
95
- self.tasks.add(send_task)
96
-
97
- async def consume_broadcast(
98
- self, broadcast: BroadcastFunc, shutdown_event: asyncio.Event
99
- ) -> None:
151
+ # Use lock when modifying tasks set to prevent race conditions
152
+ async def add_task() -> None:
153
+ async with self.lock:
154
+ self.tasks.add(send_task)
100
155
 
101
- async with self.redis.pubsub() as pubsub:
102
- await pubsub.subscribe(self.broadcast_pubsub_channel)
156
+ asyncio.get_event_loop().create_task(add_task())
157
+ send_task.add_done_callback(self.handle_send_task_done)
103
158
 
104
- while not shutdown_event.is_set():
105
- message: dict[str, Any] | None = await pubsub.get_message(
106
- ignore_subscribe_messages=True,
107
- timeout=self.consume_broadcast_timeout,
108
- )
159
+ def setup_broadcast_consumer(self) -> None:
109
160
 
110
- if message is None:
111
- continue
112
-
113
- broadcast_message = BroadcastMessage.decode(message["data"])
114
-
115
- async with self.lock:
116
- task = asyncio.get_event_loop().create_task(
117
- broadcast(message=broadcast_message.message)
118
- )
161
+ broadcast_task = asyncio.get_event_loop().create_task(
162
+ self.consume_broadcast(self.broadcast_func, self.shutdown_event)
163
+ )
119
164
 
120
- self.tasks.add(task)
165
+ # Use lock when modifying tasks set to prevent race conditions
166
+ async def add_task() -> None:
167
+ async with self.lock:
168
+ self.tasks.add(broadcast_task)
169
+
170
+ asyncio.get_event_loop().create_task(add_task())
171
+
172
+ broadcast_task.add_done_callback(self.handle_broadcast_task_done)
173
+
174
+ def handle_broadcast_task_done(self, task: asyncio.Task[Any]) -> None:
175
+ # Remove task from set safely with lock
176
+ async def remove_task() -> None:
177
+ async with self.lock:
178
+ self.tasks.discard(task)
179
+
180
+ asyncio.get_event_loop().create_task(remove_task())
181
+
182
+ if task.cancelled():
183
+ logger.warning("Broadcast task was cancelled.")
184
+ elif task.exception() is not None:
185
+ logger.exception(
186
+ "Broadcast task raised an exception:", exc_info=task.exception()
187
+ )
188
+ else:
189
+ logger.warning("Broadcast task somehow completed successfully.")
190
+
191
+ if not self.shutdown_event.is_set():
192
+ logger.warning(
193
+ "Broadcast task completed, but shutdown event is not set. This is unexpected."
194
+ )
195
+ # Add delay before retrying to avoid excessive CPU usage
196
+ asyncio.get_event_loop().create_task(
197
+ self._retry_broadcast_consumer_with_delay()
198
+ )
199
+
200
+ def handle_send_task_done(self, task: asyncio.Task[Any]) -> None:
201
+ # Remove task from set safely with lock
202
+ async def remove_task() -> None:
203
+ async with self.lock:
204
+ self.tasks.discard(task)
205
+
206
+ asyncio.get_event_loop().create_task(remove_task())
207
+
208
+ if task.cancelled():
209
+ logger.warning("Send task was cancelled.")
210
+ elif task.exception() is not None:
211
+ logger.exception(
212
+ "Send task raised an exception:", exc_info=task.exception()
213
+ )
214
+ else:
215
+ logger.warning("Send task somehow completed successfully.")
216
+
217
+ if not self.shutdown_event.is_set():
218
+ logger.warning(
219
+ "Send task completed, but shutdown event is not set. This is unexpected."
220
+ )
221
+ # Add delay before retrying to avoid excessive CPU usage
222
+ asyncio.get_event_loop().create_task(self._retry_send_consumer_with_delay())
223
+
224
+ async def _retry_broadcast_consumer_with_delay(self) -> None:
225
+ """Retry setting up broadcast consumer after a delay to avoid excessive CPU usage."""
226
+ logger.warning(
227
+ "Waiting %s seconds before retrying broadcast consumer...", self.retry_delay
228
+ )
229
+ await asyncio.sleep(self.retry_delay)
121
230
 
122
- task.add_done_callback(self.tasks.discard)
231
+ if not self.shutdown_event.is_set():
232
+ logger.warning("Retrying broadcast consumer setup...")
233
+ self.setup_broadcast_consumer()
123
234
 
124
- async def consume_send(self, send: SendFunc, shutdown_event: asyncio.Event) -> None:
125
-
126
- async with self.redis.pubsub() as pubsub:
127
- await pubsub.subscribe(self.send_pubsub_channel)
235
+ async def _retry_send_consumer_with_delay(self) -> None:
236
+ """Retry setting up send consumer after a delay to avoid excessive CPU usage."""
237
+ logger.warning(
238
+ "Waiting %s seconds before retrying send consumer...", self.retry_delay
239
+ )
240
+ await asyncio.sleep(self.retry_delay)
128
241
 
129
- while not shutdown_event.is_set():
242
+ if not self.shutdown_event.is_set():
243
+ logger.warning("Retrying send consumer setup...")
244
+ self.setup_send_consumer()
130
245
 
131
- message: dict[str, Any] | None = await pubsub.get_message(
132
- ignore_subscribe_messages=True, timeout=self.consume_send_timeout
246
+ async def consume_broadcast(
247
+ self, broadcast: BroadcastFunc, shutdown_event: asyncio.Event
248
+ ) -> None:
249
+ logger.debug("Starting broadcast consumer...")
250
+ try:
251
+ # Validate Redis connection before starting
252
+ try:
253
+ await self.redis.ping()
254
+ logger.debug("Redis connection validated for broadcast consumer")
255
+ except Exception as e:
256
+ logger.error("Redis connection validation failed: %s", e, exc_info=True)
257
+ raise
258
+
259
+ async with self.redis.pubsub() as pubsub:
260
+ await pubsub.subscribe(self.broadcast_pubsub_channel)
261
+ logger.debug(
262
+ "Subscribed to broadcast channel: %s", self.broadcast_pubsub_channel
133
263
  )
134
264
 
135
- if message is None:
136
- continue
137
-
138
- send_message = SendToRoomsMessage.decode(message["data"])
265
+ while not shutdown_event.is_set():
266
+ message: dict[str, Any] | None = await pubsub.get_message(
267
+ ignore_subscribe_messages=True,
268
+ timeout=self.consume_broadcast_timeout,
269
+ )
139
270
 
140
- async with self.lock:
271
+ if message is None:
272
+ continue
273
+
274
+ broadcast_message = BroadcastMessage.decode(message["data"])
275
+
276
+ # Use semaphore for backpressure control
277
+ acquired = False
278
+ try:
279
+ await self.task_semaphore.acquire()
280
+ acquired = True
281
+
282
+ async def broadcast_with_cleanup(msg: bytes) -> None:
283
+ try:
284
+ await broadcast(message=msg)
285
+ finally:
286
+ self.task_semaphore.release()
287
+
288
+ async with self.lock:
289
+ task = asyncio.get_event_loop().create_task(
290
+ broadcast_with_cleanup(broadcast_message.message)
291
+ )
292
+
293
+ self.tasks.add(task)
294
+
295
+ task.add_done_callback(self.tasks.discard)
296
+ except Exception as e:
297
+ # Release semaphore if we acquired it but failed to create task
298
+ if acquired:
299
+ self.task_semaphore.release()
300
+ logger.error(
301
+ "Error processing broadcast message: %s", e, exc_info=True
302
+ )
303
+ # Continue processing other messages
304
+ continue
305
+ except Exception as e:
306
+ logger.error(
307
+ "Fatal error in broadcast consumer, will retry: %s", e, exc_info=True
308
+ )
309
+ raise
141
310
 
142
- task = asyncio.get_event_loop().create_task(
143
- send(send_message.rooms, send_message.message)
311
+ async def consume_send(self, send: SendFunc, shutdown_event: asyncio.Event) -> None:
312
+ logger.debug("Starting send consumer...")
313
+ try:
314
+ # Validate Redis connection before starting
315
+ try:
316
+ await self.redis.ping()
317
+ logger.debug("Redis connection validated for send consumer")
318
+ except Exception as e:
319
+ logger.error("Redis connection validation failed: %s", e, exc_info=True)
320
+ raise
321
+
322
+ async with self.redis.pubsub() as pubsub:
323
+ await pubsub.subscribe(self.send_pubsub_channel)
324
+ logger.debug("Subscribed to send channel: %s", self.send_pubsub_channel)
325
+
326
+ while not shutdown_event.is_set():
327
+ message: dict[str, Any] | None = await pubsub.get_message(
328
+ ignore_subscribe_messages=True,
329
+ timeout=self.consume_send_timeout,
144
330
  )
145
331
 
146
- self.tasks.add(task)
147
-
148
- task.add_done_callback(self.tasks.discard)
332
+ if message is None:
333
+ continue
334
+
335
+ send_message = SendToRoomsMessage.decode(message["data"])
336
+
337
+ # Use semaphore for backpressure control
338
+ acquired = False
339
+ try:
340
+ await self.task_semaphore.acquire()
341
+ acquired = True
342
+
343
+ async def send_with_cleanup(
344
+ rooms: list[str], msg: bytes
345
+ ) -> None:
346
+ try:
347
+ await send(rooms, msg)
348
+ finally:
349
+ self.task_semaphore.release()
350
+
351
+ async with self.lock:
352
+
353
+ task = asyncio.get_event_loop().create_task(
354
+ send_with_cleanup(
355
+ send_message.rooms, send_message.message
356
+ )
357
+ )
358
+
359
+ self.tasks.add(task)
360
+
361
+ task.add_done_callback(self.tasks.discard)
362
+ except Exception as e:
363
+ # Release semaphore if we acquired it but failed to create task
364
+ if acquired:
365
+ self.task_semaphore.release()
366
+ logger.error(
367
+ "Error processing send message: %s", e, exc_info=True
368
+ )
369
+ # Continue processing other messages
370
+ continue
371
+ except Exception as e:
372
+ logger.error(
373
+ "Fatal error in send consumer, will retry: %s", e, exc_info=True
374
+ )
375
+ raise
149
376
 
150
377
  async def shutdown(self) -> None:
151
378
  async with self.lock:
@@ -1,8 +1,12 @@
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
1
5
  from jararaca.presentation.websocket.base_types import WebSocketMessageBase
2
- from jararaca.presentation.websocket.context import use_ws_manager
6
+ from jararaca.presentation.websocket.context import use_ws_message_sender
3
7
 
4
8
 
5
9
  class WebSocketMessage(WebSocketMessageBase):
6
10
 
7
11
  async def send(self, *rooms: str) -> None:
8
- await use_ws_manager().send(list(rooms), self)
12
+ await use_ws_message_sender().send(list(rooms), self)