perf: stop round-tripping the whole chat to read or write one message (#28184)

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

<!--
🚨 DO NOT DELETE THE TEXT BELOW 🚨
Keep the "Contributor License Agreement" confirmation text intact.
Deleting it will trigger the CLA-Bot to INVALIDATE your PR.

Your PR will NOT be reviewed or merged until you check the box below confirming that you have read and agree to the terms of the CLA.
-->

- [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.
This commit is contained in:
Classic298
2026-09-21 10:11:38 -04:00
committed by GitHub
parent 5fb869db22
commit 49b25506dd
+12 -13
View File
@@ -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()