From e69ce6e1c6ccf22c22eccd38d4f3369147a2cef9 Mon Sep 17 00:00:00 2001 From: G30 <50341825+silentoplayz@users.noreply.github.com> Date: Mon, 29 Jun 2026 03:05:16 -0400 Subject: [PATCH] perf(channels): batch N+1 queries for reactions and thread replies (#25831) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace per-message database queries with batch IN-clause queries in channel message handlers. This eliminates the N+1 query pattern that caused ~102 queries per channel page load (50 messages × 2 queries each). Changes: - Add get_reactions_by_message_ids() to MessageTable: single query fetches all reactions for multiple messages using IN clause with User JOIN, returns dict[message_id, list[Reactions]] - Add get_thread_reply_counts_by_message_ids() to MessageTable: single GROUP BY aggregate query returns (count, max_created_at) per parent, replacing full object loads just to call len() - Refactor get_channel_messages(): 102 → 4 queries per page - Refactor get_pinned_channel_messages(): 22 → 3 queries per page - Refactor get_channel_thread_messages(): 53 → 4 queries per page - Refactor send_notification(): N+1 membership check → batch set lookup --- backend/open_webui/models/messages.py | 68 ++++++++++++++++++++++++++ backend/open_webui/routers/channels.py | 31 +++++++++--- 2 files changed, 91 insertions(+), 8 deletions(-) diff --git a/backend/open_webui/models/messages.py b/backend/open_webui/models/messages.py index 342abed2f8..304276b3d9 100644 --- a/backend/open_webui/models/messages.py +++ b/backend/open_webui/models/messages.py @@ -500,6 +500,74 @@ class MessageTable: return [Reactions(**reaction) for reaction in reactions.values()] + async def get_reactions_by_message_ids( + self, ids: list[str], db: Optional[AsyncSession] = None + ) -> dict[str, list[Reactions]]: + """Batch-fetch reactions for multiple messages in a single query. + + Returns a dict mapping each message_id to its list of Reactions. + Messages with no reactions map to an empty list. + """ + if not ids: + return {} + + async with get_async_db_context(db) as db: + result = await db.execute( + select(MessageReaction, User) + .join(User, MessageReaction.user_id == User.id) + .filter(MessageReaction.message_id.in_(ids)) + ) + rows = result.all() + + # Group by (message_id, reaction_name) + grouped: dict[str, dict[str, dict]] = {mid: {} for mid in ids} + for reaction, user in rows: + mid = reaction.message_id + if mid not in grouped: + grouped[mid] = {} + if reaction.name not in grouped[mid]: + grouped[mid][reaction.name] = { + 'name': reaction.name, + 'users': [], + 'count': 0, + } + grouped[mid][reaction.name]['users'].append( + { + 'id': user.id, + 'name': user.name, + } + ) + grouped[mid][reaction.name]['count'] += 1 + + return { + mid: [Reactions(**r) for r in reactions.values()] + for mid, reactions in grouped.items() + } + + async def get_thread_reply_counts_by_message_ids( + self, ids: list[str], db: Optional[AsyncSession] = None + ) -> dict[str, tuple[int, int | None]]: + """Batch-fetch reply counts and latest reply timestamps for multiple parent messages. + + Returns a dict mapping each parent message_id to a + (reply_count, latest_reply_created_at) tuple. + Messages with no replies are omitted from the result. + """ + if not ids: + return {} + + async with get_async_db_context(db) as db: + result = await db.execute( + select( + Message.parent_id, + func.count(Message.id), + func.max(Message.created_at), + ) + .filter(Message.parent_id.in_(ids)) + .group_by(Message.parent_id) + ) + return {row[0]: (row[1], row[2]) for row in result.all()} + async def remove_reaction_by_id_and_user_id_and_name( self, id: str, user_id: str, name: str, db: Optional[AsyncSession] = None ) -> bool: diff --git a/backend/open_webui/routers/channels.py b/backend/open_webui/routers/channels.py index c3f6a93dbc..7a509b6b09 100644 --- a/backend/open_webui/routers/channels.py +++ b/backend/open_webui/routers/channels.py @@ -808,10 +808,14 @@ async def get_channel_messages( user_ids = list(set(m.user_id for m in message_list)) fetched_users = {u.id: u for u in await Users.get_users_by_user_ids(user_ids, db=db)} + # Batch fetch reactions and reply counts in 2 queries (fixes N+1) + message_ids = [m.id for m in message_list] + all_reactions = await Messages.get_reactions_by_message_ids(message_ids, db=db) + all_reply_counts = await Messages.get_thread_reply_counts_by_message_ids(message_ids, db=db) + messages = [] for message in message_list: - thread_replies = await Messages.get_thread_replies_by_message_id(message.id, db=db) - latest_thread_reply_at = thread_replies[0].created_at if thread_replies else None + reply_count, latest_reply_at = all_reply_counts.get(message.id, (0, None)) # Use message.user if present (for webhooks), otherwise look up by user_id user_info = message.user @@ -822,9 +826,9 @@ async def get_channel_messages( MessageUserResponse( **{ **message.model_dump(), - 'reply_count': len(thread_replies), - 'latest_reply_at': latest_thread_reply_at, - 'reactions': await Messages.get_reactions_by_message_id(message.id, db=db), + 'reply_count': reply_count, + 'latest_reply_at': latest_reply_at, + 'reactions': all_reactions.get(message.id, []), 'user': user_info, } ) @@ -873,6 +877,10 @@ async def get_pinned_channel_messages( user_ids = list(set(m.user_id for m in message_list)) fetched_users = {u.id: u for u in await Users.get_users_by_user_ids(user_ids, db=db)} + # Batch fetch reactions in 1 query (fixes N+1) + message_ids = [m.id for m in message_list] + all_reactions = await Messages.get_reactions_by_message_ids(message_ids, db=db) + messages = [] for message in message_list: # Check for webhook identity in meta @@ -892,7 +900,7 @@ async def get_pinned_channel_messages( MessageWithReactionsResponse( **{ **message.model_dump(), - 'reactions': await Messages.get_reactions_by_message_id(message.id, db=db), + 'reactions': all_reactions.get(message.id, []), 'user': user_info, } ) @@ -913,8 +921,11 @@ async def send_notification(request, channel, message, active_user_ids, db=None) users = await get_channel_users_with_access(channel, 'read', db=db) + # Batch fetch channel members in 1 query (fixes N+1) + member_ids = {m.user_id for m in await Channels.get_members_by_channel_id(channel.id, db=db)} + for u in users: - if (u.id not in active_user_ids) and await Channels.is_user_channel_member(channel.id, u.id, db=db): + if (u.id not in active_user_ids) and u.id in member_ids: if enable_user_webhooks and u.settings: webhook_url = u.settings.ui.get('notifications', {}).get('webhook_url', None) if webhook_url: @@ -1401,6 +1412,10 @@ async def get_channel_thread_messages( user_ids = list(set(m.user_id for m in message_list)) fetched_users = {u.id: u for u in await Users.get_users_by_user_ids(user_ids, db=db)} + # Batch fetch reactions in 1 query (fixes N+1) + message_ids = [m.id for m in message_list] + all_reactions = await Messages.get_reactions_by_message_ids(message_ids, db=db) + messages = [] for message in message_list: # Use message.user if present (for webhooks), otherwise look up by user_id @@ -1414,7 +1429,7 @@ async def get_channel_thread_messages( **message.model_dump(), 'reply_count': 0, 'latest_reply_at': None, - 'reactions': await Messages.get_reactions_by_message_id(message.id, db=db), + 'reactions': all_reactions.get(message.id, []), 'user': user_info, } )