perf: 40% LESS CPU usage: cut per-instance CPU cost of shared socket.io Redis pub/sub channel (#27282)

* perf: cut per-instance CPU cost of shared socket.io Redis pub/sub channel

Profiling a multi-instance deployment (py-spy --gil) showed ~44% of worker
CPU in the socket.io pub/sub listener. Two causes, two fixes:

- Add hiredis so redis-py parses the RESP protocol in C instead of pure
  Python (redis/_parsers/resp3.py alone accounted for ~28% of GIL samples;
  redis-py auto-selects the hiredis parser when importable).

- Subclass AsyncRedisManager to drop emits whose target room has no local
  participants before upstream _handle_emit re-encodes the full packet.
  Every instance receives every emit published on the shared channel, so
  with N instances all but the hosting one were paying full packet
  re-serialization per message just to deliver it to nobody. Broadcasts
  (room=None) are unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q9CQ9qnp3sZGYQQwztsCJT

* fix: restrict pub/sub emit early-out to string rooms

Adversarial review against python-socketio 5.16.2 found one divergence
from upstream: for a degenerate empty-sequence room (emit to room=[]) on
an instance whose namespace has no local clients, the filter's
get_participants probe raises IndexError from room[0] where upstream
returns silently at the namespace guard and still publishes to Redis.
Open WebUI only ever emits to scalar string rooms or room=None, so the
case is unreachable today; guard on isinstance(room, str) anyway so any
non-string room shape passes through to upstream behavior unchanged.

Every open-webui emit uses a string room, so the fast path still covers
all real traffic.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q9CQ9qnp3sZGYQQwztsCJT

* Update requirements.txt

* Update pyproject.toml

* Update requirements-min.txt

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Classic298
2026-07-23 18:11:38 +02:00
committed by GitHub
parent aaf2834db7
commit 32242a6788
4 changed files with 29 additions and 3 deletions
+24 -1
View File
@@ -57,6 +57,29 @@ REDIS = None
# Configure CORS for Socket.IO
SOCKETIO_CORS_ORIGINS = '*' if CORS_ALLOW_ORIGIN == ['*'] else CORS_ALLOW_ORIGIN
class LocalFilteredRedisManager(socketio.AsyncRedisManager):
"""AsyncRedisManager that drops pub/sub emits with no local recipients.
Every instance subscribed to the shared Socket.IO channel receives every
emit published by the whole fleet. Upstream ``_handle_emit`` re-encodes
the full packet before discovering the target room has no participants on
this instance, so each instance burns CPU serializing payloads addressed
to sessions it does not host — a cost that grows with instance count.
Bail out before that work when the room is empty here. Only string
rooms take the fast path; broadcasts (``room=None``) and any other
room shape (e.g. lists, which upstream indexes before validating)
always pass through unchanged.
"""
async def _handle_emit(self, message):
room = message.get('room')
if isinstance(room, str):
namespace = message.get('namespace') or '/'
if next(self.get_participants(namespace, room), None) is None:
return
await super()._handle_emit(message)
if WEBSOCKET_MANAGER == 'redis':
sentinel_hosts = WEBSOCKET_SENTINEL_HOSTS or ''
ws_redis_url = (
@@ -64,7 +87,7 @@ if WEBSOCKET_MANAGER == 'redis':
if sentinel_hosts
else WEBSOCKET_REDIS_URL
)
redis_manager = socketio.AsyncRedisManager(ws_redis_url, redis_options=WEBSOCKET_REDIS_OPTIONS)
redis_manager = LocalFilteredRedisManager(ws_redis_url, redis_options=WEBSOCKET_REDIS_OPTIONS)
sio = socketio.AsyncServer(
cors_allowed_origins=SOCKETIO_CORS_ORIGINS,
async_mode='asgi',
+1
View File
@@ -33,6 +33,7 @@ alembic==1.18.4
pycrdt==0.13.1
redis
hiredis
APScheduler==3.11.2
RestrictedPython==8.2
+2 -1
View File
@@ -30,7 +30,8 @@ psycopg[binary]==3.3.4
alembic==1.18.4
pycrdt==0.13.1
redis==8.0.0
redis==8.0.1
hiredis==3.4.0
APScheduler==3.11.2
RestrictedPython==8.2
+2 -1
View File
@@ -38,7 +38,8 @@ dependencies = [
"alembic==1.18.4",
"pycrdt==0.13.1",
"redis==8.0.0",
"redis==8.0.1",
"hiredis==3.4.0",
# "valkey-glide-sync==2.3.1", # optional: install manually if VECTOR_DB=valkey
"pytz==2026.2",