perf: stop rescanning the whole chat JSON on every streamed event write

Every streamed event that persists to a chat (status updates, citations,
file attachments, message content) serialized the entire conversation JSON
three times: a null-byte check of the stored row, a second sanitize of the
whole blob after merging in the event payload and the flush of the UPDATE
itself. The middle pass rescans megabytes of already-clean history for null
bytes that can only come from the small incoming payload, so long chats pay
for their full history on every single event.

The write paths now sanitize just the incoming message, message id and
status dict and keep the row-level sanitize, so legacy rows with null bytes
still self-heal as before. Median per-event write time (sqlite, orjson):
1 MB chat 15.0 ms to 11.1 ms, 4 MB 65.2 ms to 52.4 ms, 10 MB 159.9 ms to
124.5 ms, roughly 20 percent less per event. As a side effect the
chat_message dual write now receives the sanitized message; previously null
bytes in non-content fields were cleaned in the blob but written raw to
chat_message, which failed that insert on PostgreSQL. Verified byte-identical
rows against the previous implementation across nine scenarios covering null
bytes in every input, legacy dirty rows, a missing title and a NULL chat
column.
This commit is contained in:
Classic298 2026-08-19 22:50:49 +02:00
parent 7cf6051a74
commit af23d4e272

View file

@ -398,6 +398,9 @@ 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
@ -1052,9 +1055,8 @@ class ChatTable:
if output_text:
message['content'] = output_text
# Sanitize message content for null characters before upserting
if isinstance(message.get('content'), str):
message['content'] = sanitize_text_for_db(message['content'])
message = self._clean_null_bytes(message)
message_id = self._clean_null_bytes(message_id)
try:
async with get_async_db_context() as session:
@ -1069,10 +1071,9 @@ class ChatTable:
history = chat.get('history', {})
saved_message = self.upsert_message_to_history(history, message_id, message)
chat['history'] = history
clean_chat = self._clean_null_bytes(chat)
chat_item.chat = clean_chat
chat_item.title = self._clean_null_bytes(clean_chat['title']) if 'title' in clean_chat else 'New Chat'
chat_item.current_message_id = self.get_current_message_id(clean_chat)
chat_item.chat = chat # chat is a fresh dict when the column was empty
chat_item.title = chat.get('title', 'New Chat')
chat_item.current_message_id = self.get_current_message_id(chat)
flag_modified(chat_item, 'chat')
if touch:
@ -1111,22 +1112,18 @@ class ChatTable:
history = chat.get('history', {})
deleted_ids = self.delete_message_from_history(history, message_id)
if not deleted_ids:
clean_chat = self._clean_null_bytes(chat)
chat_item.chat = clean_chat
chat_item.title = (
self._clean_null_bytes(clean_chat['title']) if 'title' in clean_chat else 'New Chat'
)
chat_item.current_message_id = self.get_current_message_id(clean_chat)
chat_item.chat = chat
chat_item.title = chat.get('title', 'New Chat')
chat_item.current_message_id = self.get_current_message_id(chat)
flag_modified(chat_item, 'chat')
await session.commit()
return ChatModel.model_validate(chat_item)
messages = history.get('messages') or {}
chat['history'] = history
clean_chat = self._clean_null_bytes(chat)
chat_item.chat = clean_chat
chat_item.title = self._clean_null_bytes(clean_chat['title']) if 'title' in clean_chat else 'New Chat'
chat_item.current_message_id = self.get_current_message_id(clean_chat)
chat_item.chat = chat
chat_item.title = 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())
await session.commit()
@ -1144,6 +1141,7 @@ class ChatTable:
self, id: str, message_id: str, status: dict
) -> ChatModel | None:
try:
status = self._clean_null_bytes(status)
async with get_async_db_context() as session:
chat_item = await session.get(Chat, id)
if chat_item is None:
@ -1160,10 +1158,9 @@ class ChatTable:
history['messages'][message_id]['statusHistory'] = status_history
chat['history'] = history
clean_chat = self._clean_null_bytes(chat)
chat_item.chat = clean_chat
chat_item.title = self._clean_null_bytes(clean_chat['title']) if 'title' in clean_chat else 'New Chat'
chat_item.current_message_id = self.get_current_message_id(clean_chat)
chat_item.chat = chat
chat_item.title = chat.get('title', 'New Chat')
chat_item.current_message_id = self.get_current_message_id(chat)
flag_modified(chat_item, 'chat')
await session.commit()