perf(channels): batch N+1 queries for reactions and thread replies (#25831)
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
This commit is contained in:
2 files changed
+91
-8
No files matched your search
@@ -500,6 +500,74 @@ class MessageTable:
|
|||||||
|
|
||||||
return [Reactions(**reaction) for reaction in reactions.values()]
|
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(
|
async def remove_reaction_by_id_and_user_id_and_name(
|
||||||
self, id: str, user_id: str, name: str, db: Optional[AsyncSession] = None
|
self, id: str, user_id: str, name: str, db: Optional[AsyncSession] = None
|
||||||
) -> bool:
|
) -> bool:
|
||||||
|
|||||||
@@ -808,10 +808,14 @@ async def get_channel_messages(
|
|||||||
user_ids = list(set(m.user_id for m in message_list))
|
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)}
|
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 = []
|
messages = []
|
||||||
for message in message_list:
|
for message in message_list:
|
||||||
thread_replies = await Messages.get_thread_replies_by_message_id(message.id, db=db)
|
reply_count, latest_reply_at = all_reply_counts.get(message.id, (0, None))
|
||||||
latest_thread_reply_at = thread_replies[0].created_at if thread_replies else None
|
|
||||||
|
|
||||||
# Use message.user if present (for webhooks), otherwise look up by user_id
|
# Use message.user if present (for webhooks), otherwise look up by user_id
|
||||||
user_info = message.user
|
user_info = message.user
|
||||||
@@ -822,9 +826,9 @@ async def get_channel_messages(
|
|||||||
MessageUserResponse(
|
MessageUserResponse(
|
||||||
**{
|
**{
|
||||||
**message.model_dump(),
|
**message.model_dump(),
|
||||||
'reply_count': len(thread_replies),
|
'reply_count': reply_count,
|
||||||
'latest_reply_at': latest_thread_reply_at,
|
'latest_reply_at': latest_reply_at,
|
||||||
'reactions': await Messages.get_reactions_by_message_id(message.id, db=db),
|
'reactions': all_reactions.get(message.id, []),
|
||||||
'user': user_info,
|
'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))
|
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)}
|
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 = []
|
messages = []
|
||||||
for message in message_list:
|
for message in message_list:
|
||||||
# Check for webhook identity in meta
|
# Check for webhook identity in meta
|
||||||
@@ -892,7 +900,7 @@ async def get_pinned_channel_messages(
|
|||||||
MessageWithReactionsResponse(
|
MessageWithReactionsResponse(
|
||||||
**{
|
**{
|
||||||
**message.model_dump(),
|
**message.model_dump(),
|
||||||
'reactions': await Messages.get_reactions_by_message_id(message.id, db=db),
|
'reactions': all_reactions.get(message.id, []),
|
||||||
'user': user_info,
|
'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)
|
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:
|
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:
|
if enable_user_webhooks and u.settings:
|
||||||
webhook_url = u.settings.ui.get('notifications', {}).get('webhook_url', None)
|
webhook_url = u.settings.ui.get('notifications', {}).get('webhook_url', None)
|
||||||
if webhook_url:
|
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))
|
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)}
|
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 = []
|
messages = []
|
||||||
for message in message_list:
|
for message in message_list:
|
||||||
# Use message.user if present (for webhooks), otherwise look up by user_id
|
# 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(),
|
**message.model_dump(),
|
||||||
'reply_count': 0,
|
'reply_count': 0,
|
||||||
'latest_reply_at': None,
|
'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,
|
'user': user_info,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in new issue
Block a user