mirror of
https://github.com/open-webui/open-webui.git
synced 2026-11-15 18:55:07 +03:00
0d17660535
Since 0.12.3, with the Redis websocket manager, WEBSOCKET_REDIS_ROOM_CHANNELS on (the default) and retry_on_timeout in WEBSOCKET_REDIS_OPTIONS (as in the documented example), an instance could stop accepting new browser connections until it was restarted. Before accepting a browser, the instance asks Redis to confirm its subscriptions. If its Redis connection dropped while that confirmation was still on its way, the Redis client reconnected silently and the confirmation was lost, so that browser and every later one waited forever. The instance now asks Redis again after the reconnect, so the waiting browser connects as soon as Redis is back. Fixes #33008
166 lines
7.5 KiB
Python
166 lines
7.5 KiB
Python
"""Per-room redis channels let instances skip the decode and packet encode for rooms with no local members."""
|
|
|
|
import asyncio
|
|
|
|
from redis.exceptions import NoPermissionError
|
|
from socketio import AsyncRedisManager
|
|
|
|
|
|
class AsyncRedisRoomChannelManager(AsyncRedisManager):
|
|
name = 'aioredisroomchannel'
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
self._subscription_queue = asyncio.Queue()
|
|
|
|
# collision-free while namespaces contain no '#' (socket.io default '/'); rooms may contain '#'
|
|
def _room_channel(self, namespace, room):
|
|
return f'{self.channel}#{namespace}#{room}'.encode()
|
|
|
|
def basic_enter_room(self, sid, namespace, room, eio_sid=None):
|
|
super().basic_enter_room(sid, namespace, room, eio_sid=eio_sid)
|
|
if room is not None:
|
|
self._subscription_queue.put_nowait(asyncio.Event())
|
|
|
|
def basic_leave_room(self, sid, namespace, room):
|
|
super().basic_leave_room(sid, namespace, room)
|
|
if room is not None and room not in self.rooms.get(namespace, {}):
|
|
self._subscription_queue.put_nowait(asyncio.Event())
|
|
|
|
async def _wait_for_rooms(self):
|
|
ready = asyncio.Event()
|
|
self._subscription_queue.put_nowait(ready)
|
|
await ready.wait()
|
|
|
|
async def connect(self, eio_sid, namespace):
|
|
sid = await super().connect(eio_sid, namespace)
|
|
if sid is not None:
|
|
await self._wait_for_rooms()
|
|
return sid
|
|
|
|
async def enter_room(self, sid, namespace, room, eio_sid=None):
|
|
local = self.is_connected(sid, namespace)
|
|
await super().enter_room(sid, namespace, room, eio_sid=eio_sid)
|
|
if local and room is not None:
|
|
await self._wait_for_rooms()
|
|
|
|
async def _publish(self, data):
|
|
if data.get('method') == 'emit' and isinstance(data.get('room'), str):
|
|
channel = self._room_channel(data['namespace'], data['room'])
|
|
else:
|
|
channel = self.channel
|
|
_, error = self._get_redis_module_and_error()
|
|
for retries_left in range(1, -1, -1): # 2 attempts
|
|
try:
|
|
if not self.connected:
|
|
self._redis_connect()
|
|
return await self.redis.publish(channel, self.json.dumps(data))
|
|
except error as exc:
|
|
if isinstance(exc, NoPermissionError):
|
|
self._get_logger().error(
|
|
'Redis denied publishing: %s. Check PUBLISH permission and channel ACLs '
|
|
'(&%s and &%s#*). To disable room channels, set '
|
|
'WEBSOCKET_REDIS_ROOM_CHANNELS=False on every instance and fully restart the fleet.',
|
|
exc,
|
|
self.channel,
|
|
self.channel,
|
|
)
|
|
if retries_left > 0:
|
|
self._get_logger().error('Cannot publish to redis... retrying', extra={'redis_exception': str(exc)})
|
|
self.connected = False
|
|
else:
|
|
self._get_logger().error(
|
|
'Cannot publish to redis... giving up', extra={'redis_exception': str(exc)}
|
|
)
|
|
break
|
|
|
|
async def _sync_rooms(self, pubsub):
|
|
wanted = {self.channel.encode()} | {
|
|
self._room_channel(namespace, room)
|
|
for namespace, rooms in self.rooms.items()
|
|
for room in rooms
|
|
if room is not None
|
|
}
|
|
if added := wanted - (pubsub.channels.keys() - pubsub.pending_unsubscribe_channels):
|
|
await pubsub.subscribe(*added)
|
|
if removed := pubsub.channels.keys() - wanted - pubsub.pending_unsubscribe_channels:
|
|
await pubsub.unsubscribe(*removed)
|
|
|
|
async def _listen_to_rooms(self, pubsub):
|
|
pubsub.ignore_subscribe_messages = False
|
|
self._subscription_queue.put_nowait(asyncio.Event())
|
|
changed = asyncio.create_task(self._subscription_queue.get())
|
|
message = asyncio.create_task(pubsub.get_message(timeout=None))
|
|
waiters = []
|
|
serial = 0
|
|
try:
|
|
while True:
|
|
await asyncio.wait([message] if waiters else [message, changed], return_when=asyncio.FIRST_COMPLETED)
|
|
if not waiters and changed.done():
|
|
waiters.append(changed.result())
|
|
while not self._subscription_queue.empty():
|
|
waiters.append(self._subscription_queue.get_nowait())
|
|
changed = asyncio.create_task(self._subscription_queue.get())
|
|
await self._sync_rooms(pubsub)
|
|
# PONG confirms Redis processed the subscriptions before a join returns.
|
|
serial += 1
|
|
await pubsub.ping(str(serial).encode())
|
|
if message.done():
|
|
data = message.result() or {}
|
|
message = asyncio.create_task(pubsub.get_message(timeout=None))
|
|
if data.get('type') == 'subscribe':
|
|
# redis-py can reconnect internally and restore a pending removal.
|
|
self._subscription_queue.put_nowait(asyncio.Event())
|
|
# Ping once per (re)subscribe, not per channel: a silent reconnect drops the pending PONG.
|
|
if data['channel'] == self.channel.encode():
|
|
await pubsub.ping(str(serial).encode())
|
|
elif data.get('type') == 'pong' and data['data'] == str(serial).encode():
|
|
for ready in waiters:
|
|
ready.set()
|
|
waiters.clear()
|
|
else:
|
|
yield data
|
|
finally:
|
|
if changed.done() and not changed.cancelled():
|
|
waiters.append(changed.result())
|
|
for ready in waiters:
|
|
self._subscription_queue.put_nowait(ready)
|
|
message.cancel()
|
|
changed.cancel()
|
|
await asyncio.gather(message, changed, return_exceptions=True)
|
|
await pubsub.aclose()
|
|
|
|
async def _redis_listen_with_retries(self):
|
|
_, error = self._get_redis_module_and_error()
|
|
retry_sleep = 1
|
|
while True:
|
|
try:
|
|
self._redis_connect()
|
|
await self.pubsub.subscribe(self.channel)
|
|
retry_sleep = 1
|
|
async for message in self._listen_to_rooms(self.pubsub):
|
|
yield message
|
|
except error as exc:
|
|
if isinstance(exc, NoPermissionError):
|
|
self._get_logger().error(
|
|
'Redis denied subscribing: %s. Check SUBSCRIBE/UNSUBSCRIBE/PING permissions and channel ACLs '
|
|
'(&%s and &%s#*). To disable room channels, set '
|
|
'WEBSOCKET_REDIS_ROOM_CHANNELS=False on every instance and fully restart the fleet.',
|
|
exc,
|
|
self.channel,
|
|
self.channel,
|
|
)
|
|
self._get_logger().error(
|
|
f'Cannot receive from redis... retrying in {retry_sleep} secs',
|
|
extra={'redis_exception': str(exc)},
|
|
)
|
|
await asyncio.sleep(retry_sleep)
|
|
retry_sleep *= 2
|
|
if retry_sleep > 60:
|
|
retry_sleep = 60
|
|
|
|
async def _listen(self):
|
|
async for message in self._redis_listen_with_retries():
|
|
if message.get('type') == 'message':
|
|
yield message['data']
|