mirror of
https://github.com/open-webui/open-webui.git
synced 2026-09-02 20:24:53 +02:00
* perf: stop the Socket.IO session pool blocking the websocket event loop With WEBSOCKET_MANAGER=redis the session pool is a synchronous Redis client, so every call into it blocks the whole worker's event loop, not just the caller. Two paths did it constantly: the orphan reaper walked the pool one round trip per session with no await anywhere, freezing the loop for the entire sweep every cycle, and nearly every socket event re-read the sender's session back out of Redis. Other users' events and every in-flight generation on that pod wait behind both. The reaper now walks the pool in HSCAN batches and deletes in bulk, yielding between batches, and no longer sleeps past half the lock TTL, which previously guaranteed a failed renew every cycle. The per-event reads are gone: Socket.IO events only reach the worker holding the connection, and that worker already saved the same session dict locally when the user authenticated, so it was asking Redis for its own data. The writes stay, since those are what other pods read. Measured at 4000 users / 16 containers, Redis 1.1 ms away: | | before | after | |---|---|---| | reaper sweep, 5k sessions | 5.6 s, loop frozen throughout | 62 ms, 4.2 ms worst block | | same, crash recovery with every session expired | 11.5 s | 96 ms | | heartbeat / usage ping / disconnect | 2 / 3 / 2 round trips | 1 / 2 / 1 | | 50-member channel post | 50 round trips, 57.2 ms block | 0 round trips, 0.02 ms | | loop time per wall second at rest | 103 ms (10.3%) | 67 ms (6.7%) | The alternative, converting RedisDict to the async client, fixes the same paths with a far larger blast radius (every call site gains await, and `in`/`[]`/`del` cannot be awaited so the dict interface goes) and still round-trips for data already in memory. Two deliberate behaviour changes: a heartbeat re-adds a session the reaper already removed, so a tab that survives a stall recovers instead of staying out of the pool until it reconnects; and disconnect no longer skips Yjs document cleanup when the pool entry is already gone, which previously leaked that document's update log forever. Closes #28172 * perf: cut disconnect and user session lookup pool round trips, harden the session reaper Follow-up on top of the session pool reaper branch. With WEBSOCKET_MANAGER=redis two paths still blocked the worker's event loop on synchronous Redis calls. Every disconnect listed all models in use cluster-wide and fetched each one individually, one blocking round trip per model. Disconnecting all sessions of a user (admin role change or deletion) pulled the entire session pool in one HGETALL and decoded every entry in a single uninterrupted block. Disconnect now fetches the usage pool once with items(), going from 2+N+M round trips to 2+M (N models in use cluster-wide, M models the session used), and its delete of an emptied model entry is KeyError-guarded because another node can remove the same key between snapshot and delete; unguarded, that race aborted the handler and skipped its Yjs document cleanup. The user session lookup reuses the reaper's HSCAN batches and yields to the loop between pages. The reaper previously died permanently on the first Redis connection error, on every node at once during an outage; it now logs, releases the lock and returns to retrying acquisition. * refac: keep the socket pool perf work to the round trips A review pass on this branch turned up four changes riding along with the round-trip work without belonging to it, so they are backed out here. The `disconnect` handler keeps its `if sid in SESSION_POOL:` guard, so USAGE_POOL and ydoc cleanup stay off the path for sockets that never authenticated. `RedisDict.set()` keeps its own inline HDEL. `get_session_ids_by_user_id` stays synchronous over one HGETALL, since it runs on user delete and role change rather than per message. The crash-resilience wrapper around the reaper loop is dropped; if that guard is worth having, it belongs in its own change. What stays is the perf part. The reaper now sweeps the pool in bounded HSCAN batches and deletes expired sids with one HDEL per batch, down from HKEYS plus an HGET and a per-sid HDEL across the whole pool. The `disconnect` handler reads USAGE_POOL with a single HGETALL, down from HKEYS plus one HGET per model in use. Session lookups in the socket handlers come from the local Socket.IO store, which removes one Redis GET from every heartbeat, usage, channel and ydoc event. Naming and annotations follow the file: `get_session_pool_batches` for the module's `get_` prefix, `RedisDict.pop_many` so both reaper branches use one word for removing keys, a named `SCAN_BATCH_SIZE`, and types on the new helpers. * fix: invalidate the RedisDict write signature on batch delete RedisDict.set() skips the write when the payload fingerprint matches the last one this process wrote, so a mutation that goes around set() has to clear that fingerprint. The new batch delete did not, leaving a stale fingerprint behind: the next refresh with identical content is treated as already written and silently skipped, so the hash stays empty. Renamed pop_many to delete_many. In a dict emulation pop removes and returns; this returns nothing and cannot without an extra HMGET, so the name promised something it does not do. delete_many matches __delitem__ and the HDEL underneath. Its only call site is the session pool reaper, whose behaviour is unchanged: same fields deleted, same batching, same return.
346 lines
12 KiB
Python
346 lines
12 KiB
Python
"""Redis-backed distributed data structures for WebSocket state management."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import uuid
|
|
|
|
import pycrdt as Y
|
|
from open_webui.env import REDIS_KEY_PREFIX
|
|
from open_webui.utils.json_codec import JSONCodec
|
|
from open_webui.utils.redis import get_redis_connection
|
|
|
|
YDOC_KEY_PREFIX = f'{REDIS_KEY_PREFIX}:ydoc:documents'
|
|
SCAN_BATCH_SIZE = 200
|
|
|
|
|
|
class RedisLock:
|
|
"""Distributed lock backed by a Redis SET with NX/EX semantics."""
|
|
|
|
_RENEW_SCRIPT = """
|
|
if redis.call('get', KEYS[1]) == ARGV[1] then
|
|
return redis.call('expire', KEYS[1], ARGV[2])
|
|
end
|
|
return 0
|
|
"""
|
|
_RELEASE_SCRIPT = """
|
|
if redis.call('get', KEYS[1]) == ARGV[1] then
|
|
return redis.call('del', KEYS[1])
|
|
end
|
|
return 0
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
redis_url,
|
|
lock_name,
|
|
timeout_secs,
|
|
redis_sentinels=[],
|
|
redis_cluster=False,
|
|
):
|
|
self.lock_name = lock_name
|
|
self.lock_id = str(uuid.uuid4())
|
|
self.timeout_secs = timeout_secs
|
|
self.lock_obtained = False
|
|
self.redis = get_redis_connection(
|
|
redis_url,
|
|
redis_sentinels,
|
|
redis_cluster=redis_cluster,
|
|
decode_responses=True,
|
|
)
|
|
|
|
def aquire_lock(self):
|
|
# nx=True will only set this key if it _hasn't_ already been set
|
|
self.lock_obtained = self.redis.set(self.lock_name, self.lock_id, nx=True, ex=self.timeout_secs)
|
|
return self.lock_obtained
|
|
|
|
def renew_lock(self):
|
|
return bool(self.redis.eval(self._RENEW_SCRIPT, 1, self.lock_name, self.lock_id, self.timeout_secs))
|
|
|
|
def release_lock(self):
|
|
self.redis.eval(self._RELEASE_SCRIPT, 1, self.lock_name, self.lock_id)
|
|
|
|
|
|
class RedisDict:
|
|
def __init__(
|
|
self,
|
|
name,
|
|
redis_url,
|
|
redis_sentinels=[],
|
|
redis_cluster=False,
|
|
cache_set_signature=False,
|
|
):
|
|
self.name = name
|
|
self._signature_name = f'{name}:signature' if cache_set_signature else None
|
|
self.redis = get_redis_connection(
|
|
redis_url,
|
|
redis_sentinels,
|
|
redis_cluster=redis_cluster,
|
|
decode_responses=True,
|
|
)
|
|
|
|
def __setitem__(self, key, value):
|
|
serialized_value = JSONCodec.dumps(value)
|
|
self.redis.hset(self.name, key, serialized_value)
|
|
if self._signature_name:
|
|
self.redis.delete(self._signature_name)
|
|
|
|
def __getitem__(self, key):
|
|
value = self.redis.hget(self.name, key)
|
|
if value is None:
|
|
raise KeyError(key)
|
|
return JSONCodec.loads(value)
|
|
|
|
def __delitem__(self, key):
|
|
result = self.redis.hdel(self.name, key)
|
|
if result == 0:
|
|
raise KeyError(key)
|
|
if self._signature_name:
|
|
self.redis.delete(self._signature_name)
|
|
|
|
def __contains__(self, key):
|
|
return self.redis.hexists(self.name, key)
|
|
|
|
def __len__(self):
|
|
return self.redis.hlen(self.name)
|
|
|
|
def keys(self):
|
|
return self.redis.hkeys(self.name)
|
|
|
|
def values(self):
|
|
return [JSONCodec.loads(v) for v in self.redis.hvals(self.name)]
|
|
|
|
def items(self):
|
|
return [(k, JSONCodec.loads(v)) for k, v in self.redis.hgetall(self.name).items()]
|
|
|
|
def scan_batches(self):
|
|
"""Yield lists of (key, value) pairs via incremental HSCAN; a field may repeat across batches."""
|
|
cursor = 0
|
|
while True:
|
|
cursor, batch = self.redis.hscan(self.name, cursor, count=SCAN_BATCH_SIZE)
|
|
if batch:
|
|
yield [(k, JSONCodec.loads(v)) for k, v in batch.items()]
|
|
if cursor == 0:
|
|
break
|
|
|
|
def delete_many(self, *keys):
|
|
"""Delete fields in one HDEL; no keys is a no-op (HDEL rejects an empty field list)."""
|
|
if keys:
|
|
self.redis.hdel(self.name, *keys)
|
|
self._last_signature = None
|
|
|
|
def set(self, mapping: dict):
|
|
if not mapping:
|
|
self.clear()
|
|
return
|
|
|
|
# Serialize values once — reused for both the fingerprint and the write.
|
|
serialized = {k: JSONCodec.dumps(v) for k, v in mapping.items()}
|
|
digest = hashlib.sha256()
|
|
for key in sorted(serialized):
|
|
digest.update(key.encode())
|
|
digest.update(b'\0')
|
|
digest.update(serialized[key].encode())
|
|
digest.update(b'\0')
|
|
signature = digest.hexdigest()
|
|
|
|
if self._signature_name and self.redis.get(self._signature_name) == signature:
|
|
return
|
|
|
|
# Fetch existing keys before writing so we know which ones to remove.
|
|
# HKEYS is cheap — it transfers only short key strings, not large JSON values.
|
|
existing_keys = set(self.redis.hkeys(self.name))
|
|
new_keys = set(mapping.keys())
|
|
keys_to_remove = existing_keys - new_keys
|
|
|
|
# HSET first (add/update all new values), then HDEL (remove stale keys).
|
|
# We never DELETE the whole hash — this eliminates the race window
|
|
# where concurrent readers would see an empty models dict.
|
|
self.redis.hset(self.name, mapping=serialized)
|
|
if keys_to_remove:
|
|
self.redis.hdel(self.name, *keys_to_remove)
|
|
|
|
if self._signature_name:
|
|
self.redis.set(self._signature_name, signature)
|
|
|
|
def get(self, key, default=None):
|
|
try:
|
|
return self[key]
|
|
except KeyError:
|
|
return default
|
|
|
|
def clear(self):
|
|
if self._signature_name:
|
|
self.redis.delete(self.name)
|
|
self.redis.delete(self._signature_name)
|
|
else:
|
|
self.redis.delete(self.name)
|
|
|
|
def update(self, other=None, **kwargs):
|
|
if other is not None:
|
|
for k, v in other.items() if hasattr(other, 'items') else other:
|
|
self[k] = v
|
|
for k, v in kwargs.items():
|
|
self[k] = v
|
|
|
|
def setdefault(self, key, default=None):
|
|
if key not in self:
|
|
self[key] = default
|
|
return self[key]
|
|
|
|
|
|
class YdocManager:
|
|
COMPACTION_THRESHOLD = 500
|
|
|
|
def __init__(
|
|
self,
|
|
redis=None,
|
|
redis_key_prefix: str = YDOC_KEY_PREFIX,
|
|
):
|
|
self._updates = {}
|
|
self._users = {}
|
|
self._redis = redis
|
|
self._redis_key_prefix = redis_key_prefix
|
|
|
|
async def append_to_updates(self, document_id: str, update: bytes):
|
|
document_id = document_id.replace(':', '_')
|
|
if self._redis:
|
|
redis_key = f'{self._redis_key_prefix}:{document_id}:updates'
|
|
await self._redis.rpush(redis_key, JSONCodec.dumps(list(update)))
|
|
list_len = await self._redis.llen(redis_key)
|
|
if list_len >= self.COMPACTION_THRESHOLD:
|
|
await self._compact_updates_redis(document_id)
|
|
else:
|
|
if document_id not in self._updates:
|
|
self._updates[document_id] = []
|
|
self._updates[document_id].append(update)
|
|
if len(self._updates[document_id]) >= self.COMPACTION_THRESHOLD:
|
|
self._compact_updates_memory(document_id)
|
|
|
|
async def _compact_updates_redis(self, document_id: str):
|
|
"""Rolling compaction: squash oldest half into one snapshot."""
|
|
redis_key = f'{self._redis_key_prefix}:{document_id}:updates'
|
|
all_updates = await self._redis.lrange(redis_key, 0, -1)
|
|
if len(all_updates) <= 1:
|
|
return
|
|
mid = len(all_updates) // 2
|
|
ydoc = Y.Doc()
|
|
for raw in all_updates[:mid]:
|
|
ydoc.apply_update(bytes(JSONCodec.loads(raw)))
|
|
snapshot = JSONCodec.dumps(list(ydoc.get_update()))
|
|
pipe = self._redis.pipeline()
|
|
pipe.delete(redis_key)
|
|
pipe.rpush(redis_key, snapshot, *all_updates[mid:])
|
|
await pipe.execute()
|
|
|
|
def _compact_updates_memory(self, document_id: str):
|
|
"""Rolling compaction: squash oldest half into one snapshot."""
|
|
updates = self._updates.get(document_id, [])
|
|
if len(updates) <= 1:
|
|
return
|
|
mid = len(updates) // 2
|
|
ydoc = Y.Doc()
|
|
for update in updates[:mid]:
|
|
ydoc.apply_update(bytes(update))
|
|
self._updates[document_id] = [ydoc.get_update()] + updates[mid:]
|
|
|
|
async def get_updates(self, document_id: str) -> list[bytes]:
|
|
document_id = document_id.replace(':', '_')
|
|
|
|
if self._redis:
|
|
redis_key = f'{self._redis_key_prefix}:{document_id}:updates'
|
|
updates = await self._redis.lrange(redis_key, 0, -1)
|
|
return [bytes(JSONCodec.loads(update)) for update in updates]
|
|
else:
|
|
return self._updates.get(document_id, [])
|
|
|
|
async def document_exists(self, document_id: str) -> bool:
|
|
document_id = document_id.replace(':', '_')
|
|
|
|
if self._redis:
|
|
redis_key = f'{self._redis_key_prefix}:{document_id}:updates'
|
|
return await self._redis.exists(redis_key) > 0
|
|
else:
|
|
return document_id in self._updates
|
|
|
|
async def get_users(self, document_id: str) -> list[str]:
|
|
document_id = document_id.replace(':', '_')
|
|
|
|
if self._redis:
|
|
redis_key = f'{self._redis_key_prefix}:{document_id}:users'
|
|
users = await self._redis.smembers(redis_key)
|
|
return list(users)
|
|
else:
|
|
return self._users.get(document_id, [])
|
|
|
|
async def add_user(self, document_id: str, user_id: str):
|
|
document_id = document_id.replace(':', '_')
|
|
|
|
if self._redis:
|
|
redis_key = f'{self._redis_key_prefix}:{document_id}:users'
|
|
await self._redis.sadd(redis_key, user_id)
|
|
# Maintain a per-session reverse index so disconnect cleanup
|
|
# can look up only the documents this session joined, instead
|
|
# of issuing a cluster-wide SCAN over the entire keyspace.
|
|
session_key = f'{self._redis_key_prefix}:session:{user_id}:documents'
|
|
await self._redis.sadd(session_key, document_id)
|
|
else:
|
|
if document_id not in self._users:
|
|
self._users[document_id] = set()
|
|
self._users[document_id].add(user_id)
|
|
|
|
async def remove_user(self, document_id: str, user_id: str):
|
|
document_id = document_id.replace(':', '_')
|
|
|
|
if self._redis:
|
|
redis_key = f'{self._redis_key_prefix}:{document_id}:users'
|
|
await self._redis.srem(redis_key, user_id)
|
|
# Keep the reverse index in sync.
|
|
session_key = f'{self._redis_key_prefix}:session:{user_id}:documents'
|
|
await self._redis.srem(session_key, document_id)
|
|
else:
|
|
if document_id in self._users and user_id in self._users[document_id]:
|
|
self._users[document_id].remove(user_id)
|
|
|
|
async def remove_user_from_all_documents(self, user_id: str):
|
|
if self._redis:
|
|
# Use the per-session reverse index instead of a cluster-wide
|
|
# SCAN. This set contains only the document IDs that this
|
|
# session actually joined, so the cost is proportional to
|
|
# the session's footprint — not the total number of documents.
|
|
session_key = f'{self._redis_key_prefix}:session:{user_id}:documents'
|
|
document_ids = await self._redis.smembers(session_key)
|
|
|
|
for document_id in document_ids:
|
|
users_key = f'{self._redis_key_prefix}:{document_id}:users'
|
|
await self._redis.srem(users_key, user_id)
|
|
|
|
if len(await self.get_users(document_id)) == 0:
|
|
await self.clear_document(document_id)
|
|
|
|
# Clean up the reverse index itself.
|
|
await self._redis.delete(session_key)
|
|
|
|
else:
|
|
for document_id in list(self._users.keys()):
|
|
if user_id in self._users[document_id]:
|
|
self._users[document_id].remove(user_id)
|
|
if not self._users[document_id]:
|
|
del self._users[document_id]
|
|
|
|
await self.clear_document(document_id)
|
|
|
|
async def clear_document(self, document_id: str):
|
|
document_id = document_id.replace(':', '_')
|
|
|
|
if self._redis:
|
|
redis_key = f'{self._redis_key_prefix}:{document_id}:updates'
|
|
await self._redis.delete(redis_key)
|
|
redis_users_key = f'{self._redis_key_prefix}:{document_id}:users'
|
|
await self._redis.delete(redis_users_key)
|
|
else:
|
|
if document_id in self._updates:
|
|
del self._updates[document_id]
|
|
if document_id in self._users:
|
|
del self._users[document_id]
|