perf: eliminate 2 redundant full chat deserialization on every message send (#21596)

* 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
This commit is contained in:
Classic298
2026-02-21 21:53:31 +01:00
committed by GitHub
parent 5759917f54
commit 45e23c3ad0
3 changed files with 42 additions and 6 deletions

View File

@@ -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(),

View File

@@ -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]:

View File

@@ -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: