From 932afbb32b3260ff863392b630c70e54542858e5 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 16 Jul 2026 15:00:35 +0200 Subject: [PATCH] perf(persistence): Speed up hydrating a page of cached messages Co-Authored-By: Claude Opus 4.8 --- packages/stream_chat_persistence/CHANGELOG.md | 1 + .../lib/src/dao/message_dao.dart | 132 +++++++++++++---- .../lib/src/dao/pinned_message_dao.dart | 134 ++++++++++++++---- 3 files changed, 215 insertions(+), 52 deletions(-) diff --git a/packages/stream_chat_persistence/CHANGELOG.md b/packages/stream_chat_persistence/CHANGELOG.md index 1f215d683..356fb2811 100644 --- a/packages/stream_chat_persistence/CHANGELOG.md +++ b/packages/stream_chat_persistence/CHANGELOG.md @@ -4,6 +4,7 @@ - Add indices on the `channel_cid` column on the `Messages`, `Members`, and `Reads` tables to improve read times on large databases. - Add indices on the `message_id` column on the `Reactions` table to improve read times on large databases. +- Speed up hydrating a page of cached messages (`MessageDao`/`PinnedMessageDao`): derive each message's own reactions from the already-fetched reactions instead of issuing a second query, and stop re-fetching/re-hydrating quoted messages that are already part of the loaded page. 🐞 Fixed diff --git a/packages/stream_chat_persistence/lib/src/dao/message_dao.dart b/packages/stream_chat_persistence/lib/src/dao/message_dao.dart index 0a86a3b8f..4425a4082 100644 --- a/packages/stream_chat_persistence/lib/src/dao/message_dao.dart +++ b/packages/stream_chat_persistence/lib/src/dao/message_dao.dart @@ -61,10 +61,10 @@ class MessageDao extends DatabaseAccessor with _$MessageDaoMi } final results = await Future.wait([ - // Reactions + // Reactions. We fetch every reaction for these messages once; own + // reactions are the current-user subset and are derived in-memory below + // instead of issuing a second, near-identical table scan. _db.reactionDao.getReactionsForMessages(messageIds), - // Own reactions - _db.reactionDao.getReactionsForMessagesByUserId(messageIds, _db.userId), // Polls if (pollIds.isNotEmpty) _db.pollDao.getPollsByIds(pollIds) else Future.value(const {}), // Drafts @@ -83,29 +83,24 @@ class MessageDao extends DatabaseAccessor with _$MessageDaoMi ]); final latestReactionsByMsg = results[0] as Map>; - final ownReactionsByMsg = results[1] as Map>; - final pollsById = results[2] as Map; - final draftsByCidByParentId = results[3] as Map>; - final locationsByMsg = results[4] as Map; - - final quotedById = {}; - if (fetchQuotedMessage && quotedIds.isNotEmpty) { - final quoteRows = await (select(messages).join([ - leftOuterJoin(_users, messages.userId.equalsExp(_users.id)), - leftOuterJoin( - _pinnedByUsers, - messages.pinnedByUserId.equalsExp(_pinnedByUsers.id), - ), - ])..where(messages.id.isIn(quotedIds))).get(); - final quotedMessages = await _messagesFromJoinRows( - quoteRows, - fetchQuotedMessage: false, - fetchSharedLocation: true, - ); - for (final m in quotedMessages) { - quotedById[m.id] = m; - } - } + final pollsById = results[1] as Map; + final draftsByCidByParentId = results[2] as Map>; + final locationsByMsg = results[3] as Map; + + final ownReactionsByMsg = _ownReactionsFrom(latestReactionsByMsg); + + final quotedById = fetchQuotedMessage && quotedIds.isNotEmpty + ? await _resolveQuotedMessages( + rows, + messageIds, + quotedIds, + latestReactionsByMsg: latestReactionsByMsg, + ownReactionsByMsg: ownReactionsByMsg, + pollsById: pollsById, + locationsByMsg: locationsByMsg, + fetchSharedLocation: fetchSharedLocation, + ) + : const {}; return [ for (final row in rows) @@ -159,6 +154,91 @@ class MessageDao extends DatabaseAccessor with _$MessageDaoMi ); } + /// Derives each message's own reactions — those authored by the current + /// user — from the already-fetched [latestReactionsByMsg], avoiding a second, + /// near-identical table scan. + Map> _ownReactionsFrom( + Map> latestReactionsByMsg, + ) { + return { + for (final MapEntry(:key, :value) in latestReactionsByMsg.entries) + key: [ + for (final reaction in value) + if (reaction.userId == _db.userId) reaction, + ], + }; + } + + /// Resolves the quoted-message previews referenced by a page of messages, + /// keyed by quoted-message id. + /// + /// Quotes already present in [rows] are rebuilt from the maps already loaded + /// for the page (no extra queries); only quotes outside the page are fetched + /// from the DB. Either way a quote is hydrated a single level deep and never + /// carries a draft, and always includes its shared location. + Future> _resolveQuotedMessages( + List rows, + List messageIds, + List quotedIds, { + required Map> latestReactionsByMsg, + required Map> ownReactionsByMsg, + required Map pollsById, + required Map locationsByMsg, + required bool fetchSharedLocation, + }) async { + final quotedById = {}; + final pageIds = messageIds.toSet(); + + // A quote already in this page can be rebuilt from the maps we loaded for + // the page instead of re-querying it — but only when the page fetched + // everything the quote needs. Reactions and polls are always loaded for the + // page; the shared location is only loaded when [fetchSharedLocation] is + // set, and that location is the one field a rebuilt quote relies on. So + // reuse in-page quotes only in that case; otherwise let the DB branch below + // fetch them (it always loads locations — quotes keep their location even + // when the caller opted out of locations for the page). + final inPageQuotedIds = fetchSharedLocation ? quotedIds.where(pageIds.contains).toSet() : const {}; + if (inPageQuotedIds.isNotEmpty) { + final rowById = {for (final row in rows) row.readTable(messages).id: row}; + for (final id in inPageQuotedIds) { + final row = rowById[id]; + if (row == null) continue; + quotedById[id] = _buildMessage( + row, + latestReactionsByMsg: latestReactionsByMsg, + ownReactionsByMsg: ownReactionsByMsg, + pollsById: pollsById, + quotedById: const {}, // quotes are hydrated a single level only + draftsByCidByParentId: const {}, // quotes never carry a draft + locationsByMsg: locationsByMsg, + ); + } + } + + // Everything not rebuilt above — including all quotes when + // fetchSharedLocation is false — is fetched + hydrated from the DB. + final outOfPageQuotedIds = quotedIds.toSet().difference(inPageQuotedIds).toList(); + if (outOfPageQuotedIds.isNotEmpty) { + final quoteRows = await (select(messages).join([ + leftOuterJoin(_users, messages.userId.equalsExp(_users.id)), + leftOuterJoin( + _pinnedByUsers, + messages.pinnedByUserId.equalsExp(_pinnedByUsers.id), + ), + ])..where(messages.id.isIn(outOfPageQuotedIds))).get(); + final quotedMessages = await _messagesFromJoinRows( + quoteRows, + fetchQuotedMessage: false, + fetchSharedLocation: true, + ); + for (final m in quotedMessages) { + quotedById[m.id] = m; + } + } + + return quotedById; + } + /// Returns a single message by matching the [Messages.id] with [id]. /// /// If [fetchDraft] is true, it will also fetch the draft message for the diff --git a/packages/stream_chat_persistence/lib/src/dao/pinned_message_dao.dart b/packages/stream_chat_persistence/lib/src/dao/pinned_message_dao.dart index 858dd5f5b..ce14aef57 100644 --- a/packages/stream_chat_persistence/lib/src/dao/pinned_message_dao.dart +++ b/packages/stream_chat_persistence/lib/src/dao/pinned_message_dao.dart @@ -60,10 +60,10 @@ class PinnedMessageDao extends DatabaseAccessor with _$Pinned } final results = await Future.wait([ - // Reactions + // Reactions. We fetch every reaction for these messages once; own + // reactions are the current-user subset and are derived in-memory below + // instead of issuing a second, near-identical table scan. _db.pinnedMessageReactionDao.getReactionsForMessages(messageIds), - // Own reactions - _db.pinnedMessageReactionDao.getReactionsForMessagesByUserId(messageIds, _db.userId), // Polls if (pollIds.isNotEmpty) _db.pollDao.getPollsByIds(pollIds) else Future.value(const {}), // Drafts @@ -82,29 +82,24 @@ class PinnedMessageDao extends DatabaseAccessor with _$Pinned ]); final latestReactionsByMsg = results[0] as Map>; - final ownReactionsByMsg = results[1] as Map>; - final pollsById = results[2] as Map; - final draftsByCidByParentId = results[3] as Map>; - final locationsByMsg = results[4] as Map; - - final quotedById = {}; - if (fetchQuotedMessage && quotedIds.isNotEmpty) { - final quoteRows = await (select(pinnedMessages).join([ - leftOuterJoin(_users, pinnedMessages.userId.equalsExp(_users.id)), - leftOuterJoin( - _pinnedByUsers, - pinnedMessages.pinnedByUserId.equalsExp(_pinnedByUsers.id), - ), - ])..where(pinnedMessages.id.isIn(quotedIds))).get(); - final quotedMessages = await _messagesFromJoinRows( - quoteRows, - fetchQuotedMessage: false, - fetchSharedLocation: true, - ); - for (final m in quotedMessages) { - quotedById[m.id] = m; - } - } + final pollsById = results[1] as Map; + final draftsByCidByParentId = results[2] as Map>; + final locationsByMsg = results[3] as Map; + + final ownReactionsByMsg = _ownReactionsFrom(latestReactionsByMsg); + + final quotedById = fetchQuotedMessage && quotedIds.isNotEmpty + ? await _resolveQuotedMessages( + rows, + messageIds, + quotedIds, + latestReactionsByMsg: latestReactionsByMsg, + ownReactionsByMsg: ownReactionsByMsg, + pollsById: pollsById, + locationsByMsg: locationsByMsg, + fetchSharedLocation: fetchSharedLocation, + ) + : const {}; return [ for (final row in rows) @@ -156,6 +151,93 @@ class PinnedMessageDao extends DatabaseAccessor with _$Pinned ); } + /// Derives each message's own reactions — those authored by the current + /// user — from the already-fetched [latestReactionsByMsg], avoiding a second, + /// near-identical table scan. + Map> _ownReactionsFrom( + Map> latestReactionsByMsg, + ) { + return { + for (final MapEntry(:key, :value) in latestReactionsByMsg.entries) + key: [ + for (final reaction in value) + if (reaction.userId == _db.userId) reaction, + ], + }; + } + + /// Resolves the quoted-message previews referenced by a page of pinned + /// messages, keyed by quoted-message id. + /// + /// Quotes already present in [rows] are rebuilt from the maps already loaded + /// for the page (no extra queries); only quotes outside the page are fetched + /// from the DB. Either way a quote is hydrated a single level deep and never + /// carries a draft, and always includes its shared location. + Future> _resolveQuotedMessages( + List rows, + List messageIds, + List quotedIds, { + required Map> latestReactionsByMsg, + required Map> ownReactionsByMsg, + required Map pollsById, + required Map locationsByMsg, + required bool fetchSharedLocation, + }) async { + final quotedById = {}; + final pageIds = messageIds.toSet(); + + // A quote already in this page can be rebuilt from the maps we loaded for + // the page instead of re-querying it — but only when the page fetched + // everything the quote needs. Reactions and polls are always loaded for the + // page; the shared location is only loaded when [fetchSharedLocation] is + // set, and that location is the one field a rebuilt quote relies on. So + // reuse in-page quotes only in that case; otherwise let the DB branch below + // fetch them (it always loads locations — quotes keep their location even + // when the caller opted out of locations for the page). + final inPageQuotedIds = fetchSharedLocation ? quotedIds.where(pageIds.contains).toSet() : const {}; + if (inPageQuotedIds.isNotEmpty) { + final rowById = { + for (final row in rows) row.readTable(pinnedMessages).id: row, + }; + for (final id in inPageQuotedIds) { + final row = rowById[id]; + if (row == null) continue; + quotedById[id] = _buildMessage( + row, + latestReactionsByMsg: latestReactionsByMsg, + ownReactionsByMsg: ownReactionsByMsg, + pollsById: pollsById, + quotedById: const {}, // quotes are hydrated a single level only + draftsByCidByParentId: const {}, // quotes never carry a draft + locationsByMsg: locationsByMsg, + ); + } + } + + // Everything not rebuilt above — including all quotes when + // fetchSharedLocation is false — is fetched + hydrated from the DB. + final outOfPageQuotedIds = quotedIds.toSet().difference(inPageQuotedIds).toList(); + if (outOfPageQuotedIds.isNotEmpty) { + final quoteRows = await (select(pinnedMessages).join([ + leftOuterJoin(_users, pinnedMessages.userId.equalsExp(_users.id)), + leftOuterJoin( + _pinnedByUsers, + pinnedMessages.pinnedByUserId.equalsExp(_pinnedByUsers.id), + ), + ])..where(pinnedMessages.id.isIn(outOfPageQuotedIds))).get(); + final quotedMessages = await _messagesFromJoinRows( + quoteRows, + fetchQuotedMessage: false, + fetchSharedLocation: true, + ); + for (final m in quotedMessages) { + quotedById[m.id] = m; + } + } + + return quotedById; + } + /// Returns a single message by matching the [PinnedMessages.id] with [id] Future getMessageById( String id, {