jararaca 0.3.11a16__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 (88) hide show
  1. README.md +121 -0
  2. jararaca/__init__.py +184 -12
  3. jararaca/__main__.py +4 -0
  4. jararaca/broker_backend/__init__.py +4 -0
  5. jararaca/broker_backend/mapper.py +4 -0
  6. jararaca/broker_backend/redis_broker_backend.py +9 -3
  7. jararaca/cli.py +272 -47
  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 +41 -7
  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 +4 -0
  16. jararaca/messagebus/bus_message_controller.py +4 -0
  17. jararaca/messagebus/consumers/__init__.py +3 -0
  18. jararaca/messagebus/decorators.py +33 -67
  19. jararaca/messagebus/implicit_headers.py +49 -0
  20. jararaca/messagebus/interceptors/__init__.py +3 -0
  21. jararaca/messagebus/interceptors/aiopika_publisher_interceptor.py +13 -4
  22. jararaca/messagebus/interceptors/publisher_interceptor.py +4 -0
  23. jararaca/messagebus/message.py +4 -0
  24. jararaca/messagebus/publisher.py +6 -0
  25. jararaca/messagebus/worker.py +850 -383
  26. jararaca/microservice.py +110 -1
  27. jararaca/observability/constants.py +7 -0
  28. jararaca/observability/decorators.py +170 -13
  29. jararaca/observability/fastapi_exception_handler.py +37 -0
  30. jararaca/observability/hooks.py +109 -0
  31. jararaca/observability/interceptor.py +4 -0
  32. jararaca/observability/providers/__init__.py +3 -0
  33. jararaca/observability/providers/otel.py +202 -11
  34. jararaca/persistence/base.py +38 -2
  35. jararaca/persistence/exports.py +4 -0
  36. jararaca/persistence/interceptors/__init__.py +3 -0
  37. jararaca/persistence/interceptors/aiosqa_interceptor.py +86 -73
  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 +50 -20
  43. jararaca/presentation/__init__.py +3 -0
  44. jararaca/presentation/decorators.py +88 -86
  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 +97 -45
  49. jararaca/presentation/websocket/__init__.py +3 -0
  50. jararaca/presentation/websocket/base_types.py +4 -0
  51. jararaca/presentation/websocket/context.py +4 -0
  52. jararaca/presentation/websocket/decorators.py +8 -41
  53. jararaca/presentation/websocket/redis.py +280 -53
  54. jararaca/presentation/websocket/types.py +4 -0
  55. jararaca/presentation/websocket/websocket_interceptor.py +46 -19
  56. jararaca/reflect/__init__.py +3 -0
  57. jararaca/reflect/controller_inspect.py +16 -10
  58. jararaca/reflect/decorators.py +238 -0
  59. jararaca/reflect/metadata.py +34 -25
  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 +521 -105
  69. jararaca/scheduler/decorators.py +15 -22
  70. jararaca/scheduler/types.py +4 -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 +6 -2
  74. jararaca/tools/typescript/__init__.py +3 -0
  75. jararaca/tools/typescript/decorators.py +120 -0
  76. jararaca/tools/typescript/interface_parser.py +1074 -173
  77. jararaca/utils/__init__.py +3 -0
  78. jararaca/utils/rabbitmq_utils.py +65 -39
  79. jararaca/utils/retry.py +10 -3
  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.3.11a16.dist-info → jararaca-0.4.0a5.dist-info}/METADATA +11 -7
  83. jararaca-0.4.0a5.dist-info/RECORD +88 -0
  84. {jararaca-0.3.11a16.dist-info → jararaca-0.4.0a5.dist-info}/WHEEL +1 -1
  85. pyproject.toml +131 -0
  86. jararaca-0.3.11a16.dist-info/RECORD +0 -74
  87. /jararaca-0.3.11a16.dist-info/LICENSE → /LICENSE +0 -0
  88. {jararaca-0.3.11a16.dist-info → jararaca-0.4.0a5.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,3 @@
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
@@ -1,3 +1,7 @@
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
1
5
  from typing import ClassVar
2
6
 
3
7
  from pydantic import BaseModel
@@ -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
@@ -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,3 +1,7 @@
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
6
  from jararaca.presentation.websocket.context import use_ws_message_sender
3
7
 
@@ -1,3 +1,7 @@
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
  from contextlib import asynccontextmanager
@@ -85,13 +89,24 @@ class WebSocketConnectionManagerImpl(WebSocketConnectionManager):
85
89
  await self.backend.broadcast(message)
86
90
 
87
91
  async def _broadcast_from_backend(self, message: bytes) -> None:
88
- for websocket in self.all_websockets:
92
+ # Create a copy of the websockets set to avoid modification during iteration
93
+ async with self.lock:
94
+ websockets_to_send = list(self.all_websockets)
95
+
96
+ disconnected_websockets: list[WebSocket] = []
97
+
98
+ for websocket in websockets_to_send:
89
99
  try:
90
100
  if websocket.client_state == WebSocketState.CONNECTED:
91
101
  await websocket.send_bytes(message)
92
102
  except WebSocketDisconnect:
93
- async with self.lock: # TODO: check if this can cause concurrency slowdown issues
94
- self.all_websockets.remove(websocket)
103
+ disconnected_websockets.append(websocket)
104
+
105
+ # Clean up disconnected websockets in a single lock acquisition
106
+ if disconnected_websockets:
107
+ async with self.lock:
108
+ for websocket in disconnected_websockets:
109
+ self.all_websockets.discard(websocket)
95
110
 
96
111
  async def send(self, rooms: list[str], message: WebSocketMessageBase) -> None:
97
112
 
@@ -103,16 +118,28 @@ class WebSocketConnectionManagerImpl(WebSocketConnectionManager):
103
118
  )
104
119
 
105
120
  async def _send_from_backend(self, rooms: list[str], message: bytes) -> None:
121
+ # Create a copy of room memberships to avoid modification during iteration
106
122
  async with self.lock:
107
- for room in rooms:
108
- for websocket in self.rooms.get(room, set()):
109
- try:
110
- if websocket.client_state == WebSocketState.CONNECTED:
111
- await websocket.send_bytes(message)
112
- except WebSocketDisconnect:
113
- async with self.lock:
114
- if websocket in self.rooms[room]:
115
- self.rooms[room].remove(websocket)
123
+ room_websockets: dict[str, list[WebSocket]] = {
124
+ room: list(self.rooms.get(room, set())) for room in rooms
125
+ }
126
+
127
+ disconnected_by_room: dict[str, list[WebSocket]] = {room: [] for room in rooms}
128
+
129
+ for room, websockets in room_websockets.items():
130
+ for websocket in websockets:
131
+ try:
132
+ if websocket.client_state == WebSocketState.CONNECTED:
133
+ await websocket.send_bytes(message)
134
+ except WebSocketDisconnect:
135
+ disconnected_by_room[room].append(websocket)
136
+
137
+ # Clean up disconnected websockets in a single lock acquisition
138
+ async with self.lock:
139
+ for room, disconnected_websockets in disconnected_by_room.items():
140
+ if room in self.rooms:
141
+ for websocket in disconnected_websockets:
142
+ self.rooms[room].discard(websocket)
116
143
 
117
144
  async def join(self, rooms: list[str], websocket: WebSocket) -> None:
118
145
  for room in rooms:
@@ -222,13 +249,15 @@ class WebSocketInterceptor(AppInterceptor, AppInterceptorWithLifecycle):
222
249
 
223
250
  for controller_type in app.controllers:
224
251
 
225
- rest_controller = RestController.get_controller(controller_type)
252
+ rest_controller = RestController.get_last(controller_type)
226
253
  controller: Any = container.get_by_type(controller_type)
227
254
 
228
- members = inspect.getmembers(controller_type, predicate=inspect.isfunction)
255
+ members = inspect.getmembers_static(
256
+ controller_type, predicate=inspect.isfunction
257
+ )
229
258
 
230
259
  for name, member in members:
231
- if (ws_endpoint := WebSocketEndpoint.get(member)) is not None:
260
+ if (ws_endpoint := WebSocketEndpoint.get_last(member)) is not None:
232
261
  final_path = (
233
262
  rest_controller.path + ws_endpoint.path
234
263
  if rest_controller
@@ -236,7 +265,7 @@ class WebSocketInterceptor(AppInterceptor, AppInterceptorWithLifecycle):
236
265
  )
237
266
 
238
267
  route_dependencies: list[Depends] = []
239
- for middlewares_by_hook in UseMiddleware.get_middlewares(
268
+ for middlewares_by_hook in UseMiddleware.get(
240
269
  getattr(controller_type, name)
241
270
  ):
242
271
  middleware_instance = container.get_by_type(
@@ -246,9 +275,7 @@ class WebSocketInterceptor(AppInterceptor, AppInterceptorWithLifecycle):
246
275
  Depends(middleware_instance.intercept)
247
276
  )
248
277
 
249
- for dependency in UseDependency.get_dependencies(
250
- getattr(controller_type, name)
251
- ):
278
+ for dependency in UseDependency.get(getattr(controller_type, name)):
252
279
  route_dependencies.append(DependsF(dependency.dependency))
253
280
 
254
281
  api_router.add_api_websocket_route(
@@ -0,0 +1,3 @@
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
@@ -1,24 +1,28 @@
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
1
5
  import inspect
2
6
  from dataclasses import dataclass
3
7
  from typing import Any, Callable, Mapping, Tuple, Type
4
8
 
5
9
  from frozendict import frozendict
6
10
 
7
- from jararaca.reflect.metadata import ControllerInstanceMetadata, SetMetadata
11
+ from jararaca.reflect.metadata import SetMetadata, TransactionMetadata
8
12
 
9
13
 
10
14
  @dataclass(frozen=True)
11
15
  class ControllerReflect:
12
16
 
13
17
  controller_class: Type[Any]
14
- metadata: Mapping[str, ControllerInstanceMetadata]
18
+ metadata: Mapping[str, TransactionMetadata]
15
19
 
16
20
 
17
21
  @dataclass(frozen=True)
18
22
  class ControllerMemberReflect:
19
23
  controller_reflect: ControllerReflect
20
24
  member_function: Callable[..., Any]
21
- metadata: Mapping[str, ControllerInstanceMetadata]
25
+ metadata: Mapping[str, TransactionMetadata]
22
26
 
23
27
 
24
28
  def inspect_controller(
@@ -37,8 +41,8 @@ def inspect_controller(
37
41
 
38
42
  controller_metadata_map = frozendict(
39
43
  {
40
- metadata.key: ControllerInstanceMetadata(
41
- value=metadata.value, inherited=False
44
+ metadata.key: TransactionMetadata(
45
+ value=metadata.value, inherited_from_controller=False
42
46
  )
43
47
  for metadata in controller_metadata_list
44
48
  }
@@ -55,21 +59,23 @@ def inspect_controller(
55
59
  metadata=frozendict(
56
60
  {
57
61
  **{
58
- key: ControllerInstanceMetadata(
59
- value=value.value, inherited=True
62
+ key: TransactionMetadata(
63
+ value=value.value, inherited_from_controller=True
60
64
  )
61
65
  for key, value in controller_metadata_map.items()
62
66
  },
63
67
  **{
64
- metadata.key: ControllerInstanceMetadata(
65
- value=metadata.value, inherited=False
68
+ metadata.key: TransactionMetadata(
69
+ value=metadata.value, inherited_from_controller=False
66
70
  )
67
71
  for metadata in SetMetadata.get(member)
68
72
  },
69
73
  }
70
74
  ),
71
75
  )
72
- for name, member in inspect.getmembers(controller, predicate=inspect.isfunction)
76
+ for name, member in inspect.getmembers_static(
77
+ controller, predicate=inspect.isfunction
78
+ )
73
79
  }
74
80
 
75
81
  return controller_reflect, members