jararaca 0.3.17__py3-none-any.whl → 0.3.18__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.
Potentially problematic release.
This version of jararaca might be problematic. Click here for more details.
- jararaca/presentation/websocket/redis.py +164 -49
- jararaca/presentation/websocket/websocket_interceptor.py +35 -12
- {jararaca-0.3.17.dist-info → jararaca-0.3.18.dist-info}/METADATA +1 -1
- {jararaca-0.3.17.dist-info → jararaca-0.3.18.dist-info}/RECORD +8 -8
- pyproject.toml +1 -1
- {jararaca-0.3.17.dist-info → jararaca-0.3.18.dist-info}/LICENSE +0 -0
- {jararaca-0.3.17.dist-info → jararaca-0.3.18.dist-info}/WHEEL +0 -0
- {jararaca-0.3.17.dist-info → jararaca-0.3.18.dist-info}/entry_points.txt +0 -0
|
@@ -59,6 +59,7 @@ class RedisWebSocketConnectionBackend(WebSocketConnectionBackend):
|
|
|
59
59
|
consume_broadcast_timeout: int = 1,
|
|
60
60
|
consume_send_timeout: int = 1,
|
|
61
61
|
retry_delay: float = 5.0,
|
|
62
|
+
max_concurrent_tasks: int = 1000,
|
|
62
63
|
) -> None:
|
|
63
64
|
|
|
64
65
|
self.redis = conn
|
|
@@ -67,6 +68,8 @@ class RedisWebSocketConnectionBackend(WebSocketConnectionBackend):
|
|
|
67
68
|
|
|
68
69
|
self.lock = asyncio.Lock()
|
|
69
70
|
self.tasks: set[asyncio.Task[Any]] = set()
|
|
71
|
+
self.max_concurrent_tasks = max_concurrent_tasks
|
|
72
|
+
self.task_semaphore = asyncio.Semaphore(max_concurrent_tasks)
|
|
70
73
|
|
|
71
74
|
self.consume_broadcast_timeout = consume_broadcast_timeout
|
|
72
75
|
self.consume_send_timeout = consume_send_timeout
|
|
@@ -101,16 +104,26 @@ class RedisWebSocketConnectionBackend(WebSocketConnectionBackend):
|
|
|
101
104
|
return self.__broadcast_func
|
|
102
105
|
|
|
103
106
|
async def broadcast(self, message: bytes) -> None:
|
|
104
|
-
|
|
105
|
-
self.
|
|
106
|
-
|
|
107
|
-
|
|
107
|
+
try:
|
|
108
|
+
await self.redis.publish(
|
|
109
|
+
self.broadcast_pubsub_channel,
|
|
110
|
+
BroadcastMessage.from_message(message).encode(),
|
|
111
|
+
)
|
|
112
|
+
except Exception as e:
|
|
113
|
+
logger.error(
|
|
114
|
+
f"Failed to publish broadcast message to Redis: {e}", exc_info=True
|
|
115
|
+
)
|
|
116
|
+
raise
|
|
108
117
|
|
|
109
118
|
async def send(self, rooms: list[str], message: bytes) -> None:
|
|
110
|
-
|
|
111
|
-
self.
|
|
112
|
-
|
|
113
|
-
|
|
119
|
+
try:
|
|
120
|
+
await self.redis.publish(
|
|
121
|
+
self.send_pubsub_channel,
|
|
122
|
+
SendToRoomsMessage.from_message(rooms, message).encode(),
|
|
123
|
+
)
|
|
124
|
+
except Exception as e:
|
|
125
|
+
logger.error(f"Failed to publish send message to Redis: {e}", exc_info=True)
|
|
126
|
+
raise
|
|
114
127
|
|
|
115
128
|
def configure(
|
|
116
129
|
self, broadcast: BroadcastFunc, send: SendFunc, shutdown_event: asyncio.Event
|
|
@@ -129,7 +142,12 @@ class RedisWebSocketConnectionBackend(WebSocketConnectionBackend):
|
|
|
129
142
|
self.consume_send(self.send_func, self.shutdown_event)
|
|
130
143
|
)
|
|
131
144
|
|
|
132
|
-
|
|
145
|
+
# Use lock when modifying tasks set to prevent race conditions
|
|
146
|
+
async def add_task() -> None:
|
|
147
|
+
async with self.lock:
|
|
148
|
+
self.tasks.add(send_task)
|
|
149
|
+
|
|
150
|
+
asyncio.get_event_loop().create_task(add_task())
|
|
133
151
|
send_task.add_done_callback(self.handle_send_task_done)
|
|
134
152
|
|
|
135
153
|
def setup_broadcast_consumer(self) -> None:
|
|
@@ -138,11 +156,23 @@ class RedisWebSocketConnectionBackend(WebSocketConnectionBackend):
|
|
|
138
156
|
self.consume_broadcast(self.broadcast_func, self.shutdown_event)
|
|
139
157
|
)
|
|
140
158
|
|
|
141
|
-
|
|
159
|
+
# Use lock when modifying tasks set to prevent race conditions
|
|
160
|
+
async def add_task() -> None:
|
|
161
|
+
async with self.lock:
|
|
162
|
+
self.tasks.add(broadcast_task)
|
|
163
|
+
|
|
164
|
+
asyncio.get_event_loop().create_task(add_task())
|
|
142
165
|
|
|
143
166
|
broadcast_task.add_done_callback(self.handle_broadcast_task_done)
|
|
144
167
|
|
|
145
168
|
def handle_broadcast_task_done(self, task: asyncio.Task[Any]) -> None:
|
|
169
|
+
# Remove task from set safely with lock
|
|
170
|
+
async def remove_task() -> None:
|
|
171
|
+
async with self.lock:
|
|
172
|
+
self.tasks.discard(task)
|
|
173
|
+
|
|
174
|
+
asyncio.get_event_loop().create_task(remove_task())
|
|
175
|
+
|
|
146
176
|
if task.cancelled():
|
|
147
177
|
logger.warning("Broadcast task was cancelled.")
|
|
148
178
|
elif task.exception() is not None:
|
|
@@ -162,6 +192,13 @@ class RedisWebSocketConnectionBackend(WebSocketConnectionBackend):
|
|
|
162
192
|
)
|
|
163
193
|
|
|
164
194
|
def handle_send_task_done(self, task: asyncio.Task[Any]) -> None:
|
|
195
|
+
# Remove task from set safely with lock
|
|
196
|
+
async def remove_task() -> None:
|
|
197
|
+
async with self.lock:
|
|
198
|
+
self.tasks.discard(task)
|
|
199
|
+
|
|
200
|
+
asyncio.get_event_loop().create_task(remove_task())
|
|
201
|
+
|
|
165
202
|
if task.cancelled():
|
|
166
203
|
logger.warning("Send task was cancelled.")
|
|
167
204
|
elif task.exception() is not None:
|
|
@@ -204,54 +241,132 @@ class RedisWebSocketConnectionBackend(WebSocketConnectionBackend):
|
|
|
204
241
|
self, broadcast: BroadcastFunc, shutdown_event: asyncio.Event
|
|
205
242
|
) -> None:
|
|
206
243
|
logger.info("Starting broadcast consumer...")
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
244
|
+
try:
|
|
245
|
+
# Validate Redis connection before starting
|
|
246
|
+
try:
|
|
247
|
+
await self.redis.ping()
|
|
248
|
+
logger.info("Redis connection validated for broadcast consumer")
|
|
249
|
+
except Exception as e:
|
|
250
|
+
logger.error(f"Redis connection validation failed: {e}", exc_info=True)
|
|
251
|
+
raise
|
|
252
|
+
|
|
253
|
+
async with self.redis.pubsub() as pubsub:
|
|
254
|
+
await pubsub.subscribe(self.broadcast_pubsub_channel)
|
|
255
|
+
logger.info(
|
|
256
|
+
f"Subscribed to broadcast channel: {self.broadcast_pubsub_channel}"
|
|
214
257
|
)
|
|
215
258
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
async with self.lock:
|
|
222
|
-
task = asyncio.get_event_loop().create_task(
|
|
223
|
-
broadcast(message=broadcast_message.message)
|
|
259
|
+
while not shutdown_event.is_set():
|
|
260
|
+
message: dict[str, Any] | None = await pubsub.get_message(
|
|
261
|
+
ignore_subscribe_messages=True,
|
|
262
|
+
timeout=self.consume_broadcast_timeout,
|
|
224
263
|
)
|
|
225
264
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
265
|
+
if message is None:
|
|
266
|
+
continue
|
|
267
|
+
|
|
268
|
+
broadcast_message = BroadcastMessage.decode(message["data"])
|
|
269
|
+
|
|
270
|
+
# Use semaphore for backpressure control
|
|
271
|
+
acquired = False
|
|
272
|
+
try:
|
|
273
|
+
await self.task_semaphore.acquire()
|
|
274
|
+
acquired = True
|
|
275
|
+
|
|
276
|
+
async def broadcast_with_cleanup(msg: bytes) -> None:
|
|
277
|
+
try:
|
|
278
|
+
await broadcast(message=msg)
|
|
279
|
+
finally:
|
|
280
|
+
self.task_semaphore.release()
|
|
281
|
+
|
|
282
|
+
async with self.lock:
|
|
283
|
+
task = asyncio.get_event_loop().create_task(
|
|
284
|
+
broadcast_with_cleanup(broadcast_message.message)
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
self.tasks.add(task)
|
|
288
|
+
|
|
289
|
+
task.add_done_callback(self.tasks.discard)
|
|
290
|
+
except Exception as e:
|
|
291
|
+
# Release semaphore if we acquired it but failed to create task
|
|
292
|
+
if acquired:
|
|
293
|
+
self.task_semaphore.release()
|
|
294
|
+
logger.error(
|
|
295
|
+
f"Error processing broadcast message: {e}", exc_info=True
|
|
296
|
+
)
|
|
297
|
+
# Continue processing other messages
|
|
298
|
+
continue
|
|
299
|
+
except Exception as e:
|
|
300
|
+
logger.error(
|
|
301
|
+
f"Fatal error in broadcast consumer, will retry: {e}", exc_info=True
|
|
302
|
+
)
|
|
303
|
+
raise
|
|
229
304
|
|
|
230
305
|
async def consume_send(self, send: SendFunc, shutdown_event: asyncio.Event) -> None:
|
|
231
306
|
logger.info("Starting send consumer...")
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
send(send_message.rooms, send_message.message)
|
|
307
|
+
try:
|
|
308
|
+
# Validate Redis connection before starting
|
|
309
|
+
try:
|
|
310
|
+
await self.redis.ping()
|
|
311
|
+
logger.info("Redis connection validated for send consumer")
|
|
312
|
+
except Exception as e:
|
|
313
|
+
logger.error(f"Redis connection validation failed: {e}", exc_info=True)
|
|
314
|
+
raise
|
|
315
|
+
|
|
316
|
+
async with self.redis.pubsub() as pubsub:
|
|
317
|
+
await pubsub.subscribe(self.send_pubsub_channel)
|
|
318
|
+
logger.info(f"Subscribed to send channel: {self.send_pubsub_channel}")
|
|
319
|
+
|
|
320
|
+
while not shutdown_event.is_set():
|
|
321
|
+
message: dict[str, Any] | None = await pubsub.get_message(
|
|
322
|
+
ignore_subscribe_messages=True,
|
|
323
|
+
timeout=self.consume_send_timeout,
|
|
250
324
|
)
|
|
251
325
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
326
|
+
if message is None:
|
|
327
|
+
continue
|
|
328
|
+
|
|
329
|
+
send_message = SendToRoomsMessage.decode(message["data"])
|
|
330
|
+
|
|
331
|
+
# Use semaphore for backpressure control
|
|
332
|
+
acquired = False
|
|
333
|
+
try:
|
|
334
|
+
await self.task_semaphore.acquire()
|
|
335
|
+
acquired = True
|
|
336
|
+
|
|
337
|
+
async def send_with_cleanup(
|
|
338
|
+
rooms: list[str], msg: bytes
|
|
339
|
+
) -> None:
|
|
340
|
+
try:
|
|
341
|
+
await send(rooms, msg)
|
|
342
|
+
finally:
|
|
343
|
+
self.task_semaphore.release()
|
|
344
|
+
|
|
345
|
+
async with self.lock:
|
|
346
|
+
|
|
347
|
+
task = asyncio.get_event_loop().create_task(
|
|
348
|
+
send_with_cleanup(
|
|
349
|
+
send_message.rooms, send_message.message
|
|
350
|
+
)
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
self.tasks.add(task)
|
|
354
|
+
|
|
355
|
+
task.add_done_callback(self.tasks.discard)
|
|
356
|
+
except Exception as e:
|
|
357
|
+
# Release semaphore if we acquired it but failed to create task
|
|
358
|
+
if acquired:
|
|
359
|
+
self.task_semaphore.release()
|
|
360
|
+
logger.error(
|
|
361
|
+
f"Error processing send message: {e}", exc_info=True
|
|
362
|
+
)
|
|
363
|
+
# Continue processing other messages
|
|
364
|
+
continue
|
|
365
|
+
except Exception as e:
|
|
366
|
+
logger.error(
|
|
367
|
+
f"Fatal error in send consumer, will retry: {e}", exc_info=True
|
|
368
|
+
)
|
|
369
|
+
raise
|
|
255
370
|
|
|
256
371
|
async def shutdown(self) -> None:
|
|
257
372
|
async with self.lock:
|
|
@@ -85,13 +85,24 @@ class WebSocketConnectionManagerImpl(WebSocketConnectionManager):
|
|
|
85
85
|
await self.backend.broadcast(message)
|
|
86
86
|
|
|
87
87
|
async def _broadcast_from_backend(self, message: bytes) -> None:
|
|
88
|
-
|
|
88
|
+
# Create a copy of the websockets set to avoid modification during iteration
|
|
89
|
+
async with self.lock:
|
|
90
|
+
websockets_to_send = list(self.all_websockets)
|
|
91
|
+
|
|
92
|
+
disconnected_websockets: list[WebSocket] = []
|
|
93
|
+
|
|
94
|
+
for websocket in websockets_to_send:
|
|
89
95
|
try:
|
|
90
96
|
if websocket.client_state == WebSocketState.CONNECTED:
|
|
91
97
|
await websocket.send_bytes(message)
|
|
92
98
|
except WebSocketDisconnect:
|
|
93
|
-
|
|
94
|
-
|
|
99
|
+
disconnected_websockets.append(websocket)
|
|
100
|
+
|
|
101
|
+
# Clean up disconnected websockets in a single lock acquisition
|
|
102
|
+
if disconnected_websockets:
|
|
103
|
+
async with self.lock:
|
|
104
|
+
for websocket in disconnected_websockets:
|
|
105
|
+
self.all_websockets.discard(websocket)
|
|
95
106
|
|
|
96
107
|
async def send(self, rooms: list[str], message: WebSocketMessageBase) -> None:
|
|
97
108
|
|
|
@@ -103,16 +114,28 @@ class WebSocketConnectionManagerImpl(WebSocketConnectionManager):
|
|
|
103
114
|
)
|
|
104
115
|
|
|
105
116
|
async def _send_from_backend(self, rooms: list[str], message: bytes) -> None:
|
|
117
|
+
# Create a copy of room memberships to avoid modification during iteration
|
|
118
|
+
async with self.lock:
|
|
119
|
+
room_websockets: dict[str, list[WebSocket]] = {
|
|
120
|
+
room: list(self.rooms.get(room, set())) for room in rooms
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
disconnected_by_room: dict[str, list[WebSocket]] = {room: [] for room in rooms}
|
|
124
|
+
|
|
125
|
+
for room, websockets in room_websockets.items():
|
|
126
|
+
for websocket in websockets:
|
|
127
|
+
try:
|
|
128
|
+
if websocket.client_state == WebSocketState.CONNECTED:
|
|
129
|
+
await websocket.send_bytes(message)
|
|
130
|
+
except WebSocketDisconnect:
|
|
131
|
+
disconnected_by_room[room].append(websocket)
|
|
132
|
+
|
|
133
|
+
# Clean up disconnected websockets in a single lock acquisition
|
|
106
134
|
async with self.lock:
|
|
107
|
-
for room in
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
await websocket.send_bytes(message)
|
|
112
|
-
except WebSocketDisconnect:
|
|
113
|
-
async with self.lock:
|
|
114
|
-
if websocket in self.rooms[room]:
|
|
115
|
-
self.rooms[room].remove(websocket)
|
|
135
|
+
for room, disconnected_websockets in disconnected_by_room.items():
|
|
136
|
+
if room in self.rooms:
|
|
137
|
+
for websocket in disconnected_websockets:
|
|
138
|
+
self.rooms[room].discard(websocket)
|
|
116
139
|
|
|
117
140
|
async def join(self, rooms: list[str], websocket: WebSocket) -> None:
|
|
118
141
|
for room in rooms:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
|
|
2
2
|
README.md,sha256=YmCngjU8llW0l7L3tuXkkfr8qH7V9aBMgfp2jEzeiKg,3517
|
|
3
|
-
pyproject.toml,sha256=
|
|
3
|
+
pyproject.toml,sha256=qmLCDQEsY1Me9LPJoJM7-ZQM5qJZKE_uzyOazfgOP4I,2832
|
|
4
4
|
jararaca/__init__.py,sha256=IMnvfDoyNWTGVittF_wq2Uxtv_BY_wLN5Om6C3vUsCw,22302
|
|
5
5
|
jararaca/__main__.py,sha256=-O3vsB5lHdqNFjUtoELDF81IYFtR-DSiiFMzRaiSsv4,67
|
|
6
6
|
jararaca/broker_backend/__init__.py,sha256=GzEIuHR1xzgCJD4FE3harNjoaYzxHMHoEL0_clUaC-k,3528
|
|
@@ -47,9 +47,9 @@ jararaca/presentation/websocket/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm
|
|
|
47
47
|
jararaca/presentation/websocket/base_types.py,sha256=AvUeeZ1TFhSiRMcYqZU1HaQNqSrcgTkC5R0ArP5dGmA,146
|
|
48
48
|
jararaca/presentation/websocket/context.py,sha256=A6K5W3kqo9Hgeh1m6JiI7Cdz5SfbXcaICSVX7u1ARZo,1903
|
|
49
49
|
jararaca/presentation/websocket/decorators.py,sha256=ZNd5aoA9UkyfHOt1C8D2Ffy2gQUNDEsusVnQuTgExgs,2157
|
|
50
|
-
jararaca/presentation/websocket/redis.py,sha256=
|
|
50
|
+
jararaca/presentation/websocket/redis.py,sha256=1vykr3mcdSDGpSu1rbb4vGnUZNZEvjRfXlIR7TiSho8,13931
|
|
51
51
|
jararaca/presentation/websocket/types.py,sha256=M8snAMSdaQlKrwEM2qOgF2qrefo5Meio_oOw620Joc8,308
|
|
52
|
-
jararaca/presentation/websocket/websocket_interceptor.py,sha256=
|
|
52
|
+
jararaca/presentation/websocket/websocket_interceptor.py,sha256=c5q8sBi82jXidK4m9KJo-OXmwb-nKsW-dK1DfRqJnlc,10124
|
|
53
53
|
jararaca/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
54
54
|
jararaca/reflect/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
55
55
|
jararaca/reflect/controller_inspect.py,sha256=UtV4pRIOqCoK4ogBTXQE0dyopEQ5LDFhwm-1iJvrkJc,2326
|
|
@@ -74,8 +74,8 @@ jararaca/tools/typescript/interface_parser.py,sha256=yOSuOXKOeG0soGFo0fKiZIabu4Y
|
|
|
74
74
|
jararaca/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
75
75
|
jararaca/utils/rabbitmq_utils.py,sha256=ytdAFUyv-OBkaVnxezuJaJoLrmN7giZgtKeet_IsMBs,10918
|
|
76
76
|
jararaca/utils/retry.py,sha256=DzPX_fXUvTqej6BQ8Mt2dvLo9nNlTBm7Kx2pFZ26P2Q,4668
|
|
77
|
-
jararaca-0.3.
|
|
78
|
-
jararaca-0.3.
|
|
79
|
-
jararaca-0.3.
|
|
80
|
-
jararaca-0.3.
|
|
81
|
-
jararaca-0.3.
|
|
77
|
+
jararaca-0.3.18.dist-info/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
|
|
78
|
+
jararaca-0.3.18.dist-info/METADATA,sha256=5BT5cExvZh792lstVc-YrVbWM51tGXEG1EE6VD-EH4k,5149
|
|
79
|
+
jararaca-0.3.18.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
|
|
80
|
+
jararaca-0.3.18.dist-info/entry_points.txt,sha256=WIh3aIvz8LwUJZIDfs4EeH3VoFyCGEk7cWJurW38q0I,45
|
|
81
|
+
jararaca-0.3.18.dist-info/RECORD,,
|
pyproject.toml
CHANGED
|
File without changes
|
|
File without changes
|
|
File without changes
|