From 49b25506dd203e73b681a6b53848196ce1c3454f Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:11:38 +0200 Subject: [PATCH] perf: stop round-tripping the whole chat to read or write one message (#28184) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Long conversations get progressively more expensive to stream into. Every message-level write reloads the entire chat JSON, walks every string in it for a null-byte sanitisation pass and rewrites the whole column, and reading a single message loads and validates the whole chat too. The websocket event emitter reads and then writes, so one message event round-trips the full conversation twice to change a few hundred bytes. Reading one message now selects only history.messages out of the JSON column and indexes it in Python, so the rest of the chat is neither loaded nor model-validated. Writing one message now sanitises only what is actually entering the chat, plus the title column that is mirrored off the blob, instead of re-walking a conversation that was already sanitised when it was written. Legacy rows are still healed on read by get_chat_by_id, which is unchanged. One behaviour change: chats written before the null-byte sanitisation existed can still hold null bytes in the stored JSON. Those rows used to be rewritten clean as a side effect of any message write, and are now cleaned when the chat is read instead. The visible consequence is that the message write and delete endpoints echo back the updated chat, and for such a legacy row that echo now carries the raw null bytes rather than stripped ones, until the next read of that chat heals it. A GET of the chat is unaffected, and title, the one text column that PostgreSQL cannot store a null byte in, is still sanitised on every write. Measured on SQLite with a 10.8 MB chat (3000 messages): a single-message upsert is 227 ms before the branch and 173 ms after, and the legacy single-message read is 135 ms before and 79 ms after. The whole-blob scan the delete path used to run costs 5.40 ms on a 1.8 MB chat and 38.79 ms on the 10.8 MB one, and is gone. Ref https://github.com/open-webui/open-webui/issues/28169 ### Contributor License Agreement - [x] By submitting this pull request, I confirm that I have read and fully agree to the [Contributor License Agreement (CLA)](https://github.com/open-webui/open-webui/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT), and I am providing my contributions under its terms. > [!NOTE] > Deleting the CLA section will lead to immediate closure of your PR and it will not be merged in. --- backend/open_webui/models/chats.py | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/backend/open_webui/models/chats.py b/backend/open_webui/models/chats.py index 2af71fd944..420c414978 100644 --- a/backend/open_webui/models/chats.py +++ b/backend/open_webui/models/chats.py @@ -417,9 +417,6 @@ class ChatTable: """ Clean a Chat SQLAlchemy model's title + chat JSON, and return True if anything changed. - - The message write paths (upsert/status/delete) rely on this - leaving the blob clean and sanitize only the data they add. """ changed = False @@ -1106,11 +1103,16 @@ class ChatTable: if messages_map and message_id in messages_map: return messages_map[message_id] - chat = await self.get_chat_by_id(id) - if chat is None: + # Messages the frontend saved straight into the chat blob have no chat_message row yet. + async with get_async_db_context() as session: + result = await session.execute(select(Chat.chat[('history', 'messages')]).filter_by(id=id)) + row = result.one_or_none() + + if row is None: return None - return chat.chat.get('history', {}).get('messages', {}).get(message_id, {}) + messages = row[0] or {} + return self._clean_null_bytes(messages.get(message_id, {})) async def get_message_metadata( self, @@ -1158,7 +1160,6 @@ class ChatTable: if chat_item is None: return None - self._sanitize_chat_row(chat_item) chat = chat_item.chat or {} self._repair_chat_current_id(chat) @@ -1166,7 +1167,7 @@ class ChatTable: saved_message = self.upsert_message_to_history(history, message_id, message) chat['history'] = history chat_item.chat = chat # chat is a fresh dict when the column was empty - chat_item.title = chat.get('title', 'New Chat') + chat_item.title = self._clean_null_bytes(chat.get('title', 'New Chat')) chat_item.current_message_id = self.get_current_message_id(chat) flag_modified(chat_item, 'chat') @@ -1204,7 +1205,6 @@ class ChatTable: if chat_item is None: return None - self._sanitize_chat_row(chat_item) chat = chat_item.chat or {} self._repair_chat_current_id(chat) @@ -1212,7 +1212,7 @@ class ChatTable: deleted_ids = self.delete_message_from_history(history, message_id) if not deleted_ids: chat_item.chat = chat - chat_item.title = chat.get('title', 'New Chat') + chat_item.title = self._clean_null_bytes(chat.get('title', 'New Chat')) chat_item.current_message_id = self.get_current_message_id(chat) flag_modified(chat_item, 'chat') await session.commit() @@ -1221,7 +1221,7 @@ class ChatTable: messages = history.get('messages') or {} chat['history'] = history chat_item.chat = chat - chat_item.title = chat.get('title', 'New Chat') + chat_item.title = self._clean_null_bytes(chat.get('title', 'New Chat')) chat_item.current_message_id = self.get_current_message_id(chat) flag_modified(chat_item, 'chat') chat_item.updated_at = int(time.time()) @@ -1251,7 +1251,6 @@ class ChatTable: if chat_item is None: return None - self._sanitize_chat_row(chat_item) chat = chat_item.chat or {} self._repair_chat_current_id(chat) history = chat.get('history', {}) @@ -1263,7 +1262,7 @@ class ChatTable: chat['history'] = history chat_item.chat = chat - chat_item.title = chat.get('title', 'New Chat') + chat_item.title = self._clean_null_bytes(chat.get('title', 'New Chat')) chat_item.current_message_id = self.get_current_message_id(chat) flag_modified(chat_item, 'chat') await session.commit()