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.
Files changed (96) hide show
  1. README.md +121 -0
  2. jararaca/__init__.py +189 -17
  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 +915 -51
  8. jararaca/common/__init__.py +3 -0
  9. jararaca/core/__init__.py +3 -0
  10. jararaca/core/providers.py +8 -0
  11. jararaca/core/uow.py +41 -7
  12. jararaca/di.py +4 -0
  13. jararaca/files/entity.py.mako +4 -0
  14. jararaca/helpers/__init__.py +3 -0
  15. jararaca/helpers/global_scheduler/__init__.py +3 -0
  16. jararaca/helpers/global_scheduler/config.py +21 -0
  17. jararaca/helpers/global_scheduler/controller.py +42 -0
  18. jararaca/helpers/global_scheduler/registry.py +32 -0
  19. jararaca/lifecycle.py +6 -2
  20. jararaca/messagebus/__init__.py +4 -0
  21. jararaca/messagebus/bus_message_controller.py +4 -0
  22. jararaca/messagebus/consumers/__init__.py +3 -0
  23. jararaca/messagebus/decorators.py +121 -61
  24. jararaca/messagebus/implicit_headers.py +49 -0
  25. jararaca/messagebus/interceptors/__init__.py +3 -0
  26. jararaca/messagebus/interceptors/aiopika_publisher_interceptor.py +62 -11
  27. jararaca/messagebus/interceptors/message_publisher_collector.py +62 -0
  28. jararaca/messagebus/interceptors/publisher_interceptor.py +29 -3
  29. jararaca/messagebus/message.py +4 -0
  30. jararaca/messagebus/publisher.py +6 -0
  31. jararaca/messagebus/worker.py +1002 -459
  32. jararaca/microservice.py +113 -2
  33. jararaca/observability/constants.py +7 -0
  34. jararaca/observability/decorators.py +170 -13
  35. jararaca/observability/fastapi_exception_handler.py +37 -0
  36. jararaca/observability/hooks.py +109 -0
  37. jararaca/observability/interceptor.py +4 -0
  38. jararaca/observability/providers/__init__.py +3 -0
  39. jararaca/observability/providers/otel.py +225 -16
  40. jararaca/persistence/base.py +39 -3
  41. jararaca/persistence/exports.py +4 -0
  42. jararaca/persistence/interceptors/__init__.py +3 -0
  43. jararaca/persistence/interceptors/aiosqa_interceptor.py +86 -73
  44. jararaca/persistence/interceptors/constants.py +5 -0
  45. jararaca/persistence/interceptors/decorators.py +50 -0
  46. jararaca/persistence/session.py +3 -0
  47. jararaca/persistence/sort_filter.py +4 -0
  48. jararaca/persistence/utilities.py +73 -20
  49. jararaca/presentation/__init__.py +3 -0
  50. jararaca/presentation/decorators.py +88 -86
  51. jararaca/presentation/exceptions.py +23 -0
  52. jararaca/presentation/hooks.py +4 -0
  53. jararaca/presentation/http_microservice.py +4 -0
  54. jararaca/presentation/server.py +97 -45
  55. jararaca/presentation/websocket/__init__.py +3 -0
  56. jararaca/presentation/websocket/base_types.py +4 -0
  57. jararaca/presentation/websocket/context.py +4 -0
  58. jararaca/presentation/websocket/decorators.py +8 -41
  59. jararaca/presentation/websocket/redis.py +280 -53
  60. jararaca/presentation/websocket/types.py +4 -0
  61. jararaca/presentation/websocket/websocket_interceptor.py +46 -19
  62. jararaca/reflect/__init__.py +3 -0
  63. jararaca/reflect/controller_inspect.py +16 -10
  64. jararaca/reflect/decorators.py +252 -0
  65. jararaca/reflect/helpers.py +18 -0
  66. jararaca/reflect/metadata.py +34 -25
  67. jararaca/rpc/__init__.py +3 -0
  68. jararaca/rpc/http/__init__.py +101 -0
  69. jararaca/rpc/http/backends/__init__.py +14 -0
  70. jararaca/rpc/http/backends/httpx.py +43 -9
  71. jararaca/rpc/http/backends/otel.py +4 -0
  72. jararaca/rpc/http/decorators.py +380 -115
  73. jararaca/rpc/http/httpx.py +3 -0
  74. jararaca/scheduler/__init__.py +3 -0
  75. jararaca/scheduler/beat_worker.py +521 -105
  76. jararaca/scheduler/decorators.py +15 -22
  77. jararaca/scheduler/types.py +4 -0
  78. jararaca/tools/app_config/__init__.py +3 -0
  79. jararaca/tools/app_config/decorators.py +7 -19
  80. jararaca/tools/app_config/interceptor.py +6 -2
  81. jararaca/tools/typescript/__init__.py +3 -0
  82. jararaca/tools/typescript/decorators.py +120 -0
  83. jararaca/tools/typescript/interface_parser.py +1077 -174
  84. jararaca/utils/__init__.py +3 -0
  85. jararaca/utils/env_parse_utils.py +133 -0
  86. jararaca/utils/rabbitmq_utils.py +112 -39
  87. jararaca/utils/retry.py +19 -14
  88. jararaca-0.4.0a19.dist-info/LICENSE +674 -0
  89. jararaca-0.4.0a19.dist-info/LICENSES/GPL-3.0-or-later.txt +232 -0
  90. {jararaca-0.3.11a16.dist-info → jararaca-0.4.0a19.dist-info}/METADATA +12 -7
  91. jararaca-0.4.0a19.dist-info/RECORD +96 -0
  92. {jararaca-0.3.11a16.dist-info → jararaca-0.4.0a19.dist-info}/WHEEL +1 -1
  93. pyproject.toml +132 -0
  94. jararaca-0.3.11a16.dist-info/RECORD +0 -74
  95. /jararaca-0.3.11a16.dist-info/LICENSE → /LICENSE +0 -0
  96. {jararaca-0.3.11a16.dist-info → jararaca-0.4.0a19.dist-info}/entry_points.txt +0 -0
@@ -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
- async with self.lifecycle():
50
+ with providing_app_type("http"):
51
+ async with self.lifecycle():
35
52
 
36
- # websocket_interceptors = [
37
- # interceptor
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
- # for interceptor in websocket_interceptors:
43
- # router = interceptor.get_ws_router(
44
- # self.lifecycle.app, self.lifecycle.container, self.uow_provider
45
- # )
56
+ if controller is None:
57
+ continue
46
58
 
47
- # api.include_router(router)
59
+ instance: Any = self.lifecycle.container.get_by_type(controller_t)
48
60
 
49
- for controller_t in self.lifecycle.app.controllers:
50
- controller = RestController.get_controller(controller_t)
61
+ router = controller.get_router_factory()(self.lifecycle, instance)
51
62
 
52
- if controller is None:
53
- continue
63
+ api.include_router(router)
54
64
 
55
- instance: Any = self.lifecycle.container.get_by_type(controller_t)
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
- # dependencies: list[DependsCls] = []
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
- api.include_router(router)
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
- for middleware in self.http_app.middlewares:
69
- middleware_instance = self.lifecycle.container.get_by_type(
70
- middleware
71
- )
72
- api.router.dependencies.append(
73
- Depends(middleware_instance.intercept)
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
- yield
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
- async with self.uow_provider(
105
- AppTransactionContext(
106
- controller_member_reflect=member,
107
- transaction_data=(
108
- HttpTransactionData(request=request)
109
- if request
110
- else WebSocketTransactionData(websocket=websocket)
111
- ),
112
- )
113
- ):
114
- yield
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(
@@ -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: