From 45e23c3ad0794d848c768f9f5f468776a68685ee Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Sat, 21 Feb 2026 21:53:31 +0100 Subject: [PATCH] perf: eliminate 2 redundant full chat deserialization on every message send (#21596) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf: eliminate 2 redundant full chat deserialization on every message send (#162) Problem: Every message send triggered get_chat_by_id_and_user_id which loads the entire Chat row — including the potentially massive JSON blob containing the full conversation history — even when the caller only needed a simple yes/no ownership check or a single column value. Two call sites in the message-send hot path were doing this: 1. main.py ownership verification: loaded the entire chat object including all message history JSON, then checked `if chat is None`. The JSON blob was immediately discarded — only the existence of the row mattered. 2. middleware.py folder check: loaded the entire chat object including all message history JSON, then read only `chat.folder_id` — a plain column on the chat table that requires zero JSON parsing. Fix: - Added `chat_exists_by_id_and_user_id()`: uses SQL EXISTS subquery which returns a boolean without loading any row data. The database can satisfy this from the primary key index alone. - Added `get_chat_folder_id()`: queries only the `folder_id` column via `db.query(Chat.folder_id)`, which tells SQLAlchemy to SELECT only that single column instead of the entire row. Both new methods preserve the same error handling semantics (return False/None on exception) and user_id filtering (ownership check) as the original get_chat_by_id_and_user_id. Impact: - Best case (typical): eliminates deserializing 2 full chat JSON blobs per message send. For long conversations (hundreds of messages with tool calls, images, file attachments), this blob can be multiple megabytes. - Worst case: no regression — the new queries are strictly cheaper than the old ones (less data transferred, less Python object construction, no Pydantic model_validate overhead). - The 3 remaining full chat loads in process_chat_payload (load_messages_from_db, add_file_context, chat_image_generation_handler) are left untouched as they genuinely need the full history and require separate analysis. * Address maintainer feedback: rename method and inline call (#166) - Rename chat_exists_by_id_and_user_id -> is_chat_owner - Remove intermediate chat_owned variable; call is_chat_owner directly in if condition --- backend/open_webui/main.py | 6 ++--- backend/open_webui/models/chats.py | 35 ++++++++++++++++++++++++++ backend/open_webui/utils/middleware.py | 7 +++--- 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 57660fe9e4..b95166a9fd 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -1760,9 +1760,9 @@ async def chat_completion( "local:" ): # temporary chats are not stored - # Verify chat ownership - chat = Chats.get_chat_by_id_and_user_id(metadata["chat_id"], user.id) - if chat is None and user.role != "admin": # admins can access any chat + # Verify chat ownership — lightweight EXISTS check avoids + # deserializing the full chat JSON blob just to confirm the row exists + if not Chats.is_chat_owner(metadata["chat_id"], user.id) and user.role != "admin": # admins can access any chat raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.DEFAULT(), diff --git a/backend/open_webui/models/chats.py b/backend/open_webui/models/chats.py index 42c8cb4508..84c67a5b15 100644 --- a/backend/open_webui/models/chats.py +++ b/backend/open_webui/models/chats.py @@ -974,6 +974,41 @@ class ChatTable: except Exception: return None + def is_chat_owner( + self, id: str, user_id: str, db: Optional[Session] = None + ) -> bool: + """ + Lightweight ownership check — uses EXISTS subquery instead of loading + the full Chat row (which includes the potentially large JSON blob). + """ + try: + with get_db_context(db) as db: + return db.query( + exists().where( + and_(Chat.id == id, Chat.user_id == user_id) + ) + ).scalar() + except Exception: + return False + + def get_chat_folder_id( + self, id: str, user_id: str, db: Optional[Session] = None + ) -> Optional[str]: + """ + Fetch only the folder_id column for a chat, without loading the full + JSON blob. Returns None if chat doesn't exist or doesn't belong to user. + """ + try: + with get_db_context(db) as db: + result = ( + db.query(Chat.folder_id) + .filter_by(id=id, user_id=user_id) + .first() + ) + return result[0] if result else None + except Exception: + return None + def get_chats( self, skip: int = 0, limit: int = 50, db: Optional[Session] = None ) -> list[ChatModel]: diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index c2b2b07cb1..218deed17e 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -2078,11 +2078,12 @@ async def process_chat_payload(request, form_data, user, metadata, model): # Folder "Project" handling # Check if the request has chat_id and is inside of a folder + # Uses lightweight column query — only fetches folder_id, not the full chat JSON blob chat_id = metadata.get("chat_id", None) if chat_id and user: - chat = Chats.get_chat_by_id_and_user_id(chat_id, user.id) - if chat and chat.folder_id: - folder = Folders.get_folder_by_id_and_user_id(chat.folder_id, user.id) + folder_id = Chats.get_chat_folder_id(chat_id, user.id) + if folder_id: + folder = Folders.get_folder_by_id_and_user_id(folder_id, user.id) if folder and folder.data: if "system_prompt" in folder.data: