From 4ea05219aeb3aa0ff3a3e1d4f6203dbe4adc4a89 Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Thu, 20 Aug 2026 18:59:54 +0200 Subject: [PATCH] perf: write task payloads to Redis as bytes Saving a streaming response serialized the payload with orjson, decoded it to str, scanned it for the three Unicode line separators and let redis-py encode it straight back to UTF-8: on an 8 MB non-ASCII chat that is 6.9 ms and ~22 MB of transient buffers per write, synchronously on the event loop. json_codec now exposes dumps_bytes, which returns the serialized payload as UTF-8 bytes without the line-separator escaping, and the two Redis writes in tasks.py use it. That escaping only protects line-framed protocols such as SSE; every reader of these Redis values re-parses them before anything is served, and the escaped and raw forms parse identically, so mixed versions during a rolling deploy interoperate both ways. The same write drops to 0.9 ms and one 8 MB buffer (7.5x), with 31-66% saved on KB-sized writes. With ENABLE_ORJSON off, dumps_bytes wraps stdlib json, behaviour unchanged. The str path keeps the escaping but applies it with chained str.replace instead of a translate table, cutting a separator-containing 8 MB payload from 312 ms to 5.7 ms with byte-identical output. --- backend/open_webui/tasks.py | 6 +++--- backend/open_webui/utils/json_codec.py | 27 ++++++++++++++++++-------- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/backend/open_webui/tasks.py b/backend/open_webui/tasks.py index 81b45ebdcd..f60a5b2c86 100644 --- a/backend/open_webui/tasks.py +++ b/backend/open_webui/tasks.py @@ -6,7 +6,7 @@ from uuid import uuid4 from redis.asyncio import Redis from open_webui.env import REDIS_KEY_PREFIX -from open_webui.utils.json_codec import JSONCodec +from open_webui.utils.json_codec import JSONCodec, dumps_bytes log = logging.getLogger(__name__) @@ -77,7 +77,7 @@ async def redis_list_item_tasks(redis: Redis, item_id: str) -> list[str]: async def redis_send_command(redis: Redis, command: dict): - command_json = JSONCodec.dumps(command) + command_json = dumps_bytes(command) # RedisCluster doesn't expose publish() directly, but the # PUBLISH command broadcasts across all cluster nodes server-side. if hasattr(redis, 'nodes_manager'): @@ -163,7 +163,7 @@ async def save_response_stream( } if redis: - await redis.hset(REDIS_RESPONSE_STREAMS_KEY, task_id, JSONCodec.dumps(data)) + await redis.hset(REDIS_RESPONSE_STREAMS_KEY, task_id, dumps_bytes(data)) else: response_streams[task_id] = data diff --git a/backend/open_webui/utils/json_codec.py b/backend/open_webui/utils/json_codec.py index 068caa7956..c3a42d95cb 100644 --- a/backend/open_webui/utils/json_codec.py +++ b/backend/open_webui/utils/json_codec.py @@ -3,7 +3,10 @@ Every module that would otherwise reach for stdlib ``json`` imports ``JSONCodec`` from here, so the whole app switches implementation from a single flag. With the flag off these are stdlib ``json`` and engineio's codec verbatim, so the default -behaviour is exactly what it was before orjson entered the picture. +behaviour is exactly what it was before orjson entered the picture. ``dumps_bytes`` +returns UTF-8 bytes for sinks that re-parse the payload; under orjson it skips +both the str round trip and the line-separator escaping ``dumps`` applies, so +never feed it to line-framed output such as SSE. """ from __future__ import annotations @@ -16,11 +19,6 @@ from open_webui.env import ENABLE_ORJSON if ENABLE_ORJSON: import orjson - # orjson emits these raw and Python treats all three as line boundaries: one raw - # separator splits an SSE frame reassembled with ``splitlines()``. Escaped even - # where stdlib would not. - LINE_SEPARATOR_ESCAPES = str.maketrans({'\u2028': '\\u2028', '\u2029': '\\u2029', '\x85': '\\u0085'}) - # Module-level because CPython rebuilds these dicts on every call. FAST_PATH_KWARGS = ({'separators': (',', ':')}, {'ensure_ascii': False}) @@ -29,7 +27,7 @@ if ENABLE_ORJSON: The fast path is not byte-for-byte stdlib: it is always compact, formats floats orjson's way (``1e16``, not ``1e+16``), and is raw UTF-8 apart from - the three line separators escaped above, so a ``separators`` caller loses + the three line separators ``dumps`` escapes, so a ``separators`` caller loses stdlib's ASCII escaping and an ``ensure_ascii=False`` caller loses its spacing. ``dumps`` also serializes ``datetime``/``UUID``/dataclasses that stdlib refuses, and encodes ``NaN``/``Infinity`` as ``null``. ``loads`` @@ -51,8 +49,10 @@ if ENABLE_ORJSON: serialized = orjson.dumps(obj).decode('utf-8') except (TypeError, ValueError): return engineio_json.dumps(obj, *args, **kwargs) + # Raw, these three split an SSE frame reassembled with ``splitlines()``. + # A dict-table translate walks char by char; chained replace runs on C fast paths. if '\u2028' in serialized or '\u2029' in serialized or '\x85' in serialized: - return serialized.translate(LINE_SEPARATOR_ESCAPES) + return serialized.replace('\u2028', '\\u2028').replace('\u2029', '\\u2029').replace('\x85', '\\u0085') return serialized @staticmethod @@ -68,6 +68,17 @@ if ENABLE_ORJSON: JSONCodec = ORJSONCodec # Codec handed to the socket.io/engineio managers, which default to their own. SOCKETIO_JSON = ORJSONCodec + + def dumps_bytes(obj) -> bytes: + """JSON as UTF-8 bytes, skipping the str round trip and the escaping ``dumps`` does.""" + try: + return orjson.dumps(obj) + except (TypeError, ValueError): + return engineio_json.dumps(obj).encode('utf-8') else: JSONCodec = stdlib_json SOCKETIO_JSON = engineio_json + + def dumps_bytes(obj) -> bytes: + """JSON as UTF-8 bytes; here simply ``dumps`` encoded.""" + return stdlib_json.dumps(obj).encode('utf-8')