fix: index the chat queries that make large SQLite instances unusable (#27663)

The timer scheduler polls once a second and cancels on every message send and chat open, the sidebar lists chats ordered by `updated_at`, and the folder badges count unread chats per folder. None of those could be served by an index, so each call read most of the `chat` table, and because `meta` sits after the chat payload column SQLite had to walk every row's overflow pages to get there. On a large history that stalls the sidebar, every chat switch and every send, and the idle poll alone burns about a quarter of a CPU core.

Timers now keep their due time in a dedicated `chat.timer_at` column behind a partial index, and the chat list, unread and unfinished-reply queries each get an index matching their filter and ordering. Existing pending timers are backfilled from their meta by the migration. Dropping the `internal` and `type` checks also makes a forked timer chat inert, where a fork used to copy `meta` verbatim and become a second claim target that could fire a duplicate timer.

Measured on SQLite, same rows returned:

| query | before | after |
|---|---|---|
| idle timer poll (2000 chats, 0.43 GB) | 170 ms | 0.04 ms |
| cancel on send and chat open (4000 chats, 377 MB) | 200 ms | 0.04 ms |
| sidebar chat list (15000 chats, 1.26 GB) | 157 ms | 1.8 ms |
| folder unread badges (15000 chats, 1.4 GB) | 54 ms | 0.2 ms |

PostgreSQL 17 serves all of them as index-only scans with no sort node. Exercised through fresh install, upgrade with seeded data, downgrade and re-upgrade on SQLite and PostgreSQL 17.

Fixes #27622
This commit is contained in:
Classic298 authored and GitHub committed 2026-08-23 16:11:54 -05:00
1 parent 29e8d7db67
commit 16c2a9eda4
4 files changed
+103 -9

No files matched your search

@@ -168,6 +168,7 @@ class ChatMessage(Base):
Index('chat_message_chat_parent_idx', 'chat_id', 'parent_id'),
Index('chat_message_model_created_idx', 'model_id', 'created_at'),
Index('chat_message_user_created_idx', 'user_id', 'created_at'),
Index('chat_message_chat_role_done_idx', 'chat_id', 'role', 'done'), # unfinished-assistant probe
)
+21
View File
@@ -149,6 +149,7 @@ class Chat(Base): # database table mapping for chat entity
current_message_id = Column(Text, nullable=True)
last_read_at = Column(BigInteger, nullable=True)
timer_at = Column(BigInteger, nullable=True) # ns due time, set only while a timer chat waits to be claimed
__table_args__ = (
# Performance indexes for common queries
@@ -157,6 +158,23 @@ class Chat(Base): # database table mapping for chat entity
Index('user_id_archived_idx', 'user_id', 'archived'),
Index('updated_at_user_id_idx', 'updated_at', 'user_id'),
Index('folder_id_user_id_idx', 'folder_id', 'user_id'),
Index('user_id_updated_at_id_idx', 'user_id', updated_at.desc(), 'id'),
Index(
'timer_at_idx',
'timer_at',
sqlite_where=text('timer_at IS NOT NULL'),
postgresql_where=text('timer_at IS NOT NULL'),
),
# timer_at key column turns the IS NOT NULL into a seek, so this beats the plain user_id indexes
Index(
'user_id_timer_at_idx',
'user_id',
'timer_at',
sqlite_where=text('timer_at IS NOT NULL'),
postgresql_where=text('timer_at IS NOT NULL'),
),
# covering index: lets SQLite serve count_unread_by_folder_ids without reading chat rows
Index('user_id_folder_unread_idx', 'user_id', 'folder_id', 'archived', 'updated_at', 'last_read_at', 'id'),
)
@@ -187,6 +205,7 @@ class ChatModel(BaseModel):
current_message_id: str | None = None
last_read_at: int | None = None
timer_at: int | None = None
@field_validator('variables', mode='before')
@classmethod
@@ -502,6 +521,7 @@ class ChatTable:
db: AsyncSession | None = None,
*,
internal_meta: dict | None = None,
timer_at: int | None = None,
) -> ChatModel | None:
async with get_async_db_context(db) as session:
chat = ChatModel(
@@ -514,6 +534,7 @@ class ChatTable:
'chat': self._clean_null_bytes(form_data.chat),
'folder_id': form_data.folder_id,
'meta': internal_meta or {},
'timer_at': timer_at,
'variables': form_data.variables or {},
'current_message_id': self.get_current_message_id(form_data.chat),
'created_at': int(time.time()),