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.
- README.md +121 -0
- jararaca/__init__.py +267 -15
- jararaca/__main__.py +4 -0
- jararaca/broker_backend/__init__.py +106 -0
- jararaca/broker_backend/mapper.py +25 -0
- jararaca/broker_backend/redis_broker_backend.py +168 -0
- jararaca/cli.py +840 -103
- jararaca/common/__init__.py +3 -0
- jararaca/core/__init__.py +3 -0
- jararaca/core/providers.py +4 -0
- jararaca/core/uow.py +55 -16
- jararaca/di.py +4 -0
- jararaca/files/entity.py.mako +4 -0
- jararaca/lifecycle.py +6 -2
- jararaca/messagebus/__init__.py +5 -1
- jararaca/messagebus/bus_message_controller.py +4 -0
- jararaca/messagebus/consumers/__init__.py +3 -0
- jararaca/messagebus/decorators.py +90 -85
- jararaca/messagebus/implicit_headers.py +49 -0
- jararaca/messagebus/interceptors/__init__.py +3 -0
- jararaca/messagebus/interceptors/aiopika_publisher_interceptor.py +95 -37
- jararaca/messagebus/interceptors/publisher_interceptor.py +42 -0
- jararaca/messagebus/message.py +31 -0
- jararaca/messagebus/publisher.py +47 -4
- jararaca/messagebus/worker.py +1615 -135
- jararaca/microservice.py +248 -36
- jararaca/observability/constants.py +7 -0
- jararaca/observability/decorators.py +177 -16
- jararaca/observability/fastapi_exception_handler.py +37 -0
- jararaca/observability/hooks.py +109 -0
- jararaca/observability/interceptor.py +8 -2
- jararaca/observability/providers/__init__.py +3 -0
- jararaca/observability/providers/otel.py +213 -18
- jararaca/persistence/base.py +40 -3
- jararaca/persistence/exports.py +4 -0
- jararaca/persistence/interceptors/__init__.py +3 -0
- jararaca/persistence/interceptors/aiosqa_interceptor.py +187 -23
- 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 +74 -32
- jararaca/presentation/__init__.py +3 -0
- jararaca/presentation/decorators.py +170 -82
- jararaca/presentation/exceptions.py +23 -0
- jararaca/presentation/hooks.py +4 -0
- jararaca/presentation/http_microservice.py +4 -0
- jararaca/presentation/server.py +120 -41
- jararaca/presentation/websocket/__init__.py +3 -0
- jararaca/presentation/websocket/base_types.py +4 -0
- jararaca/presentation/websocket/context.py +34 -4
- jararaca/presentation/websocket/decorators.py +8 -41
- jararaca/presentation/websocket/redis.py +280 -53
- jararaca/presentation/websocket/types.py +6 -2
- jararaca/presentation/websocket/websocket_interceptor.py +74 -23
- jararaca/reflect/__init__.py +3 -0
- jararaca/reflect/controller_inspect.py +81 -0
- jararaca/reflect/decorators.py +238 -0
- jararaca/reflect/metadata.py +76 -0
- 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 +758 -0
- jararaca/scheduler/decorators.py +89 -28
- jararaca/scheduler/types.py +11 -0
- jararaca/tools/app_config/__init__.py +3 -0
- jararaca/tools/app_config/decorators.py +7 -19
- jararaca/tools/app_config/interceptor.py +10 -4
- jararaca/tools/typescript/__init__.py +3 -0
- jararaca/tools/typescript/decorators.py +120 -0
- jararaca/tools/typescript/interface_parser.py +1126 -189
- jararaca/utils/__init__.py +3 -0
- jararaca/utils/rabbitmq_utils.py +372 -0
- jararaca/utils/retry.py +148 -0
- 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.2.37a12.dist-info → jararaca-0.4.0a5.dist-info}/METADATA +14 -7
- jararaca-0.4.0a5.dist-info/RECORD +88 -0
- {jararaca-0.2.37a12.dist-info → jararaca-0.4.0a5.dist-info}/WHEEL +1 -1
- pyproject.toml +131 -0
- jararaca/messagebus/types.py +0 -30
- jararaca/scheduler/scheduler.py +0 -154
- jararaca/tools/metadata.py +0 -47
- jararaca-0.2.37a12.dist-info/RECORD +0 -63
- /jararaca-0.2.37a12.dist-info/LICENSE → /LICENSE +0 -0
- {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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
68
|
+
_ws_msg_sender_ctx.reset(token)
|
|
@@ -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,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
|
|
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
|
|
12
|
+
await use_ws_message_sender().send(list(rooms), self)
|