jararaca 0.3.11a16__py3-none-any.whl → 0.4.0a19__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- README.md +121 -0
- jararaca/__init__.py +189 -17
- 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 +915 -51
- jararaca/common/__init__.py +3 -0
- jararaca/core/__init__.py +3 -0
- jararaca/core/providers.py +8 -0
- jararaca/core/uow.py +41 -7
- jararaca/di.py +4 -0
- jararaca/files/entity.py.mako +4 -0
- jararaca/helpers/__init__.py +3 -0
- jararaca/helpers/global_scheduler/__init__.py +3 -0
- jararaca/helpers/global_scheduler/config.py +21 -0
- jararaca/helpers/global_scheduler/controller.py +42 -0
- jararaca/helpers/global_scheduler/registry.py +32 -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 +121 -61
- jararaca/messagebus/implicit_headers.py +49 -0
- jararaca/messagebus/interceptors/__init__.py +3 -0
- jararaca/messagebus/interceptors/aiopika_publisher_interceptor.py +62 -11
- jararaca/messagebus/interceptors/message_publisher_collector.py +62 -0
- jararaca/messagebus/interceptors/publisher_interceptor.py +29 -3
- jararaca/messagebus/message.py +4 -0
- jararaca/messagebus/publisher.py +6 -0
- jararaca/messagebus/worker.py +1002 -459
- jararaca/microservice.py +113 -2
- 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 +225 -16
- jararaca/persistence/base.py +39 -3
- 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 +73 -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 +252 -0
- jararaca/reflect/helpers.py +18 -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 +380 -115
- 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 +1077 -174
- jararaca/utils/__init__.py +3 -0
- jararaca/utils/env_parse_utils.py +133 -0
- jararaca/utils/rabbitmq_utils.py +112 -39
- jararaca/utils/retry.py +19 -14
- jararaca-0.4.0a19.dist-info/LICENSE +674 -0
- jararaca-0.4.0a19.dist-info/LICENSES/GPL-3.0-or-later.txt +232 -0
- {jararaca-0.3.11a16.dist-info → jararaca-0.4.0a19.dist-info}/METADATA +12 -7
- jararaca-0.4.0a19.dist-info/RECORD +96 -0
- {jararaca-0.3.11a16.dist-info → jararaca-0.4.0a19.dist-info}/WHEEL +1 -1
- pyproject.toml +132 -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.0a19.dist-info}/entry_points.txt +0 -0
jararaca/presentation/server.py
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2025 Lucas S
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import signal
|
|
9
|
+
import threading
|
|
1
10
|
from contextlib import asynccontextmanager
|
|
11
|
+
from signal import SIGINT, SIGTERM
|
|
2
12
|
from typing import Any, AsyncGenerator
|
|
3
13
|
|
|
4
|
-
from fastapi import Depends, FastAPI, Request, WebSocket
|
|
14
|
+
from fastapi import Depends, FastAPI, HTTPException, Request, Response, WebSocket
|
|
5
15
|
from starlette.types import ASGIApp
|
|
6
16
|
|
|
7
17
|
from jararaca.core.uow import UnitOfWorkContextProvider
|
|
@@ -10,12 +20,18 @@ from jararaca.lifecycle import AppLifecycle
|
|
|
10
20
|
from jararaca.microservice import (
|
|
11
21
|
AppTransactionContext,
|
|
12
22
|
HttpTransactionData,
|
|
23
|
+
ShutdownState,
|
|
13
24
|
WebSocketTransactionData,
|
|
25
|
+
provide_shutdown_state,
|
|
26
|
+
providing_app_type,
|
|
14
27
|
)
|
|
15
28
|
from jararaca.presentation.decorators import RestController
|
|
29
|
+
from jararaca.presentation.exceptions import PresentationException
|
|
16
30
|
from jararaca.presentation.http_microservice import HttpMicroservice
|
|
17
31
|
from jararaca.reflect.controller_inspect import ControllerMemberReflect
|
|
18
32
|
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
19
35
|
|
|
20
36
|
class HttpAppLifecycle:
|
|
21
37
|
|
|
@@ -31,58 +47,83 @@ class HttpAppLifecycle:
|
|
|
31
47
|
|
|
32
48
|
@asynccontextmanager
|
|
33
49
|
async def __call__(self, api: FastAPI) -> AsyncGenerator[None, None]:
|
|
34
|
-
|
|
50
|
+
with providing_app_type("http"):
|
|
51
|
+
async with self.lifecycle():
|
|
35
52
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
# for interceptor in self.lifecycle.initialized_interceptors
|
|
39
|
-
# if isinstance(interceptor, WebSocketInterceptor)
|
|
40
|
-
# ]
|
|
53
|
+
for controller_t in self.lifecycle.app.controllers:
|
|
54
|
+
controller = RestController.get_last(controller_t)
|
|
41
55
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
# self.lifecycle.app, self.lifecycle.container, self.uow_provider
|
|
45
|
-
# )
|
|
56
|
+
if controller is None:
|
|
57
|
+
continue
|
|
46
58
|
|
|
47
|
-
|
|
59
|
+
instance: Any = self.lifecycle.container.get_by_type(controller_t)
|
|
48
60
|
|
|
49
|
-
|
|
50
|
-
controller = RestController.get_controller(controller_t)
|
|
61
|
+
router = controller.get_router_factory()(self.lifecycle, instance)
|
|
51
62
|
|
|
52
|
-
|
|
53
|
-
continue
|
|
63
|
+
api.include_router(router)
|
|
54
64
|
|
|
55
|
-
|
|
65
|
+
for middleware in self.http_app.middlewares:
|
|
66
|
+
middleware_instance = self.lifecycle.container.get_by_type(
|
|
67
|
+
middleware
|
|
68
|
+
)
|
|
69
|
+
api.router.dependencies.append(
|
|
70
|
+
Depends(middleware_instance.intercept)
|
|
71
|
+
)
|
|
56
72
|
|
|
57
|
-
|
|
58
|
-
# for middleware in controller.middlewares:
|
|
59
|
-
# middleware_instance = self.lifecycle.container.get_by_type(
|
|
60
|
-
# middleware
|
|
61
|
-
# )
|
|
62
|
-
# dependencies.append(Depends(middleware_instance.intercept))
|
|
73
|
+
yield
|
|
63
74
|
|
|
64
|
-
router = controller.get_router_factory()(self.lifecycle, instance)
|
|
65
75
|
|
|
66
|
-
|
|
76
|
+
class HttpShutdownState(ShutdownState):
|
|
77
|
+
def __init__(self) -> None:
|
|
78
|
+
self._requested = False
|
|
79
|
+
self.old_signal_handlers = {
|
|
80
|
+
SIGINT: signal.getsignal(SIGINT),
|
|
81
|
+
SIGTERM: signal.getsignal(SIGTERM),
|
|
82
|
+
}
|
|
83
|
+
self.thread_lock = threading.Lock()
|
|
84
|
+
self.aevent = asyncio.Event()
|
|
67
85
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
86
|
+
def request_shutdown(self) -> None:
|
|
87
|
+
if not self._requested:
|
|
88
|
+
self._requested = True
|
|
89
|
+
os.kill(os.getpid(), SIGINT)
|
|
90
|
+
|
|
91
|
+
def is_shutdown_requested(self) -> bool:
|
|
92
|
+
return self._requested
|
|
93
|
+
|
|
94
|
+
def handle_signal(self, signum: int, frame: Any) -> None:
|
|
95
|
+
logger.warning(f"Received signal {signum}, initiating shutdown...")
|
|
96
|
+
self.aevent.set()
|
|
97
|
+
if self._requested:
|
|
98
|
+
logger.warning("Shutdown already requested, ignoring signal.")
|
|
99
|
+
return
|
|
100
|
+
logger.warning("Requesting shutdown...")
|
|
101
|
+
self._requested = True
|
|
75
102
|
|
|
76
|
-
|
|
103
|
+
# remove the signal handler to prevent recursion
|
|
104
|
+
for sig in (SIGINT, SIGTERM):
|
|
105
|
+
if self.old_signal_handlers[sig] is not None:
|
|
106
|
+
signal.signal(sig, self.old_signal_handlers[sig])
|
|
107
|
+
|
|
108
|
+
signal.raise_signal(signum)
|
|
109
|
+
|
|
110
|
+
async def wait_for_shutdown(self) -> None:
|
|
111
|
+
await self.aevent.wait()
|
|
112
|
+
|
|
113
|
+
def setup_signal_handlers(self) -> None:
|
|
114
|
+
signal.signal(SIGINT, self.handle_signal)
|
|
115
|
+
signal.signal(SIGTERM, self.handle_signal)
|
|
77
116
|
|
|
78
117
|
|
|
79
118
|
class HttpUowContextProviderDependency:
|
|
80
119
|
|
|
81
120
|
def __init__(self, uow_provider: UnitOfWorkContextProvider) -> None:
|
|
82
121
|
self.uow_provider = uow_provider
|
|
122
|
+
self.shutdown_state = HttpShutdownState()
|
|
123
|
+
self.shutdown_state.setup_signal_handlers()
|
|
83
124
|
|
|
84
125
|
async def __call__(
|
|
85
|
-
self, websocket: WebSocket = None, request: Request = None # type: ignore
|
|
126
|
+
self, websocket: WebSocket = None, request: Request = None, response: Response = None # type: ignore
|
|
86
127
|
) -> AsyncGenerator[None, None]:
|
|
87
128
|
if request:
|
|
88
129
|
endpoint = request.scope["endpoint"]
|
|
@@ -101,17 +142,28 @@ class HttpUowContextProviderDependency:
|
|
|
101
142
|
"ControllerMemberReflect, but got: {}".format(type(member))
|
|
102
143
|
)
|
|
103
144
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
145
|
+
with provide_shutdown_state(self.shutdown_state):
|
|
146
|
+
async with self.uow_provider(
|
|
147
|
+
AppTransactionContext(
|
|
148
|
+
controller_member_reflect=member,
|
|
149
|
+
transaction_data=(
|
|
150
|
+
HttpTransactionData(request=request, response=response)
|
|
151
|
+
if request
|
|
152
|
+
else WebSocketTransactionData(websocket=websocket)
|
|
153
|
+
),
|
|
154
|
+
)
|
|
155
|
+
):
|
|
156
|
+
try:
|
|
157
|
+
yield
|
|
158
|
+
except HTTPException:
|
|
159
|
+
raise
|
|
160
|
+
except Exception as e:
|
|
161
|
+
raise PresentationException(
|
|
162
|
+
original_exception=e,
|
|
163
|
+
request=request,
|
|
164
|
+
response=response,
|
|
165
|
+
websocket=websocket,
|
|
166
|
+
)
|
|
115
167
|
|
|
116
168
|
|
|
117
169
|
def create_http_server(
|
|
@@ -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:
|