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.
- README.md +121 -0
- jararaca/__init__.py +184 -12
- jararaca/__main__.py +4 -0
- jararaca/broker_backend/__init__.py +4 -0
- jararaca/broker_backend/mapper.py +4 -0
- jararaca/broker_backend/redis_broker_backend.py +9 -3
- jararaca/cli.py +272 -47
- jararaca/common/__init__.py +3 -0
- jararaca/core/__init__.py +3 -0
- jararaca/core/providers.py +4 -0
- jararaca/core/uow.py +41 -7
- jararaca/di.py +4 -0
- jararaca/files/entity.py.mako +4 -0
- jararaca/lifecycle.py +6 -2
- jararaca/messagebus/__init__.py +4 -0
- jararaca/messagebus/bus_message_controller.py +4 -0
- jararaca/messagebus/consumers/__init__.py +3 -0
- jararaca/messagebus/decorators.py +33 -67
- jararaca/messagebus/implicit_headers.py +49 -0
- jararaca/messagebus/interceptors/__init__.py +3 -0
- jararaca/messagebus/interceptors/aiopika_publisher_interceptor.py +13 -4
- jararaca/messagebus/interceptors/publisher_interceptor.py +4 -0
- jararaca/messagebus/message.py +4 -0
- jararaca/messagebus/publisher.py +6 -0
- jararaca/messagebus/worker.py +850 -383
- jararaca/microservice.py +110 -1
- jararaca/observability/constants.py +7 -0
- jararaca/observability/decorators.py +170 -13
- jararaca/observability/fastapi_exception_handler.py +37 -0
- jararaca/observability/hooks.py +109 -0
- jararaca/observability/interceptor.py +4 -0
- jararaca/observability/providers/__init__.py +3 -0
- jararaca/observability/providers/otel.py +202 -11
- jararaca/persistence/base.py +38 -2
- jararaca/persistence/exports.py +4 -0
- jararaca/persistence/interceptors/__init__.py +3 -0
- jararaca/persistence/interceptors/aiosqa_interceptor.py +86 -73
- jararaca/persistence/interceptors/constants.py +5 -0
- jararaca/persistence/interceptors/decorators.py +50 -0
- jararaca/persistence/session.py +3 -0
- jararaca/persistence/sort_filter.py +4 -0
- jararaca/persistence/utilities.py +50 -20
- jararaca/presentation/__init__.py +3 -0
- jararaca/presentation/decorators.py +88 -86
- jararaca/presentation/exceptions.py +23 -0
- jararaca/presentation/hooks.py +4 -0
- jararaca/presentation/http_microservice.py +4 -0
- jararaca/presentation/server.py +97 -45
- jararaca/presentation/websocket/__init__.py +3 -0
- jararaca/presentation/websocket/base_types.py +4 -0
- jararaca/presentation/websocket/context.py +4 -0
- jararaca/presentation/websocket/decorators.py +8 -41
- jararaca/presentation/websocket/redis.py +280 -53
- jararaca/presentation/websocket/types.py +4 -0
- jararaca/presentation/websocket/websocket_interceptor.py +46 -19
- jararaca/reflect/__init__.py +3 -0
- jararaca/reflect/controller_inspect.py +16 -10
- jararaca/reflect/decorators.py +238 -0
- jararaca/reflect/metadata.py +34 -25
- jararaca/rpc/__init__.py +3 -0
- jararaca/rpc/http/__init__.py +101 -0
- jararaca/rpc/http/backends/__init__.py +14 -0
- jararaca/rpc/http/backends/httpx.py +43 -9
- jararaca/rpc/http/backends/otel.py +4 -0
- jararaca/rpc/http/decorators.py +378 -113
- jararaca/rpc/http/httpx.py +3 -0
- jararaca/scheduler/__init__.py +3 -0
- jararaca/scheduler/beat_worker.py +521 -105
- jararaca/scheduler/decorators.py +15 -22
- jararaca/scheduler/types.py +4 -0
- jararaca/tools/app_config/__init__.py +3 -0
- jararaca/tools/app_config/decorators.py +7 -19
- jararaca/tools/app_config/interceptor.py +6 -2
- jararaca/tools/typescript/__init__.py +3 -0
- jararaca/tools/typescript/decorators.py +120 -0
- jararaca/tools/typescript/interface_parser.py +1074 -173
- jararaca/utils/__init__.py +3 -0
- jararaca/utils/rabbitmq_utils.py +65 -39
- jararaca/utils/retry.py +10 -3
- jararaca-0.4.0a5.dist-info/LICENSE +674 -0
- jararaca-0.4.0a5.dist-info/LICENSES/GPL-3.0-or-later.txt +232 -0
- {jararaca-0.3.11a16.dist-info → jararaca-0.4.0a5.dist-info}/METADATA +11 -7
- jararaca-0.4.0a5.dist-info/RECORD +88 -0
- {jararaca-0.3.11a16.dist-info → jararaca-0.4.0a5.dist-info}/WHEEL +1 -1
- pyproject.toml +131 -0
- jararaca-0.3.11a16.dist-info/RECORD +0 -74
- /jararaca-0.3.11a16.dist-info/LICENSE → /LICENSE +0 -0
- {jararaca-0.3.11a16.dist-info → jararaca-0.4.0a5.dist-info}/entry_points.txt +0 -0
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
72
|
-
self.
|
|
73
|
-
|
|
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
|
-
|
|
78
|
-
self.
|
|
79
|
-
|
|
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
|
-
|
|
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(
|
|
148
|
+
self.consume_send(self.send_func, self.shutdown_event)
|
|
92
149
|
)
|
|
93
150
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
102
|
-
|
|
156
|
+
asyncio.get_event_loop().create_task(add_task())
|
|
157
|
+
send_task.add_done_callback(self.handle_send_task_done)
|
|
103
158
|
|
|
104
|
-
|
|
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
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
242
|
+
if not self.shutdown_event.is_set():
|
|
243
|
+
logger.warning("Retrying send consumer setup...")
|
|
244
|
+
self.setup_send_consumer()
|
|
130
245
|
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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
|
-
|
|
143
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
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
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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.
|
|
252
|
+
rest_controller = RestController.get_last(controller_type)
|
|
226
253
|
controller: Any = container.get_by_type(controller_type)
|
|
227
254
|
|
|
228
|
-
members = inspect.
|
|
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.
|
|
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.
|
|
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.
|
|
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(
|
jararaca/reflect/__init__.py
CHANGED
|
@@ -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
|
|
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,
|
|
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,
|
|
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:
|
|
41
|
-
value=metadata.value,
|
|
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:
|
|
59
|
-
value=value.value,
|
|
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:
|
|
65
|
-
value=metadata.value,
|
|
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.
|
|
76
|
+
for name, member in inspect.getmembers_static(
|
|
77
|
+
controller, predicate=inspect.isfunction
|
|
78
|
+
)
|
|
73
79
|
}
|
|
74
80
|
|
|
75
81
|
return controller_reflect, members
|