Skip to content

Commit

Permalink
Improve performance of ChatChannel database model conversions ~7 ti…
Browse files Browse the repository at this point in the history
…mes (#3325)
  • Loading branch information
laevandus authored Jul 25, 2024
1 parent 13606da commit 5ea8062
Show file tree
Hide file tree
Showing 8 changed files with 77 additions and 107 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

## StreamChat
### ⚡ Performance
- Improve performance of `ChatChannel` database model conversions more than 7 times [#3322](https://github.com/GetStream/stream-chat-swift/pull/3322)
- Improve performance of `ChatChannel` and `ChatMessage` equality checks [#3335](https://github.com/GetStream/stream-chat-swift/pull/3335)
### ✅ Added
- Expose `MissingConnectionId` + `InvalidURL` + `InvalidJSON` Errors [#3332](https://github.com/GetStream/stream-chat-swift/pull/3332)
Expand Down
101 changes: 53 additions & 48 deletions Sources/StreamChat/Database/DTOs/ChannelDTO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -434,70 +434,75 @@ extension ChatChannel {
)
extraData = [:]
}


let sortedMessageDTOs = dto.messages.sorted(by: { $0.createdAt.bridgeDate > $1.createdAt.bridgeDate })
let reads: [ChatChannelRead] = try dto.reads.map { try $0.asModel() }

let unreadCount: ChannelUnreadCount = {
guard let currentUser = context.currentUser else {
guard let currentUserDTO = context.currentUser else {
return .noUnread
}

let currentUserRead = reads.first(where: { $0.user.id == currentUser.user.id })

let currentUserRead = reads.first(where: { $0.user.id == currentUserDTO.user.id })
let allUnreadMessages = currentUserRead?.unreadMessagesCount ?? 0

// Fetch count of all mentioned messages after last read
// (this is not 100% accurate but it's the best we have)
let unreadMentionsRequest = NSFetchRequest<MessageDTO>(entityName: MessageDTO.entityName)
unreadMentionsRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
MessageDTO.channelMessagesPredicate(
for: dto.cid,
deletedMessagesVisibility: context.deletedMessagesVisibility ?? .visibleForCurrentUser,
shouldShowShadowedMessages: context.shouldShowShadowedMessages ?? false
),
NSPredicate(format: "createdAt > %@", currentUserRead?.lastReadAt.bridgeDate ?? DBDate(timeIntervalSince1970: 0)),
NSPredicate(format: "%@ IN mentionedUsers", currentUser.user)
])

do {
return ChannelUnreadCount(
messages: allUnreadMessages,
mentions: try context.count(for: unreadMentionsRequest)
)
} catch {
log.error("Failed to fetch unread counts for channel `\(cid)`. Error: \(error)")
// Therefore, no unread messages with mentions and we can skip the fetch
if allUnreadMessages == 0 {
return .noUnread
}
let unreadMentionsCount = sortedMessageDTOs
.prefix(allUnreadMessages)
.filter { $0.mentionedUsers.contains(currentUserDTO.user) }
.count
return ChannelUnreadCount(
messages: allUnreadMessages,
mentions: unreadMentionsCount
)
}()

let messages: [ChatMessage] = {
MessageDTO
.load(
for: dto.cid,
limit: dto.managedObjectContext?.localCachingSettings?.chatChannel.latestMessagesLimit ?? 25,
deletedMessagesVisibility: dto.managedObjectContext?.deletedMessagesVisibility ?? .visibleForCurrentUser,
shouldShowShadowedMessages: dto.managedObjectContext?.shouldShowShadowedMessages ?? false,
context: context
)
let latestMessages: [ChatMessage] = {
var messages = sortedMessageDTOs
.prefix(dto.managedObjectContext?.localCachingSettings?.chatChannel.latestMessagesLimit ?? 25)
.compactMap { try? $0.relationshipAsModel(depth: depth) }
if let oldest = dto.oldestMessageAt?.bridgeDate {
messages = messages.filter { $0.createdAt >= oldest }
}
if let truncated = dto.truncatedAt?.bridgeDate {
messages = messages.filter { $0.createdAt >= truncated }
}
return messages
}()

let latestMessageFromUser: ChatMessage? = {
guard let currentUser = context.currentUser else { return nil }

return try? MessageDTO
.loadLastMessage(
from: currentUser.user.id,
in: dto.cid,
context: context
)?
guard let currentUserId = context.currentUser?.user.id else { return nil }
return try? sortedMessageDTOs
.first(where: { messageDTO in
guard messageDTO.user.id == currentUserId else { return false }
guard messageDTO.localMessageState == nil else { return false }
return messageDTO.type != MessageType.ephemeral.rawValue
})?
.relationshipAsModel(depth: depth)
}()

let watchers = UserDTO.loadLastActiveWatchers(cid: cid, context: context)

let watchers = dto.watchers
.sorted { lhs, rhs in
let lhsActivity = lhs.lastActivityAt?.bridgeDate ?? .distantPast
let rhsActivity = rhs.lastActivityAt?.bridgeDate ?? .distantPast
if lhsActivity == rhsActivity {
return lhs.id > rhs.id
}
return lhsActivity > rhsActivity
}
.prefix(context.localCachingSettings?.chatChannel.lastActiveWatchersLimit ?? 100)
.compactMap { try? $0.asModel() }

let members = MemberDTO.loadLastActiveMembers(cid: cid, context: context)
let members = dto.members
.sorted { lhs, rhs in
let lhsActivity = lhs.user.lastActivityAt?.bridgeDate ?? .distantPast
let rhsActivity = rhs.user.lastActivityAt?.bridgeDate ?? .distantPast
if lhsActivity == rhsActivity {
return lhs.id > rhs.id
}
return lhsActivity > rhsActivity
}
.prefix(context.localCachingSettings?.chatChannel.lastActiveMembersLimit ?? 100)
.compactMap { try? $0.asModel() }

let muteDetails: MuteDetails? = {
Expand Down Expand Up @@ -539,7 +544,7 @@ extension ChatChannel {
reads: reads,
cooldownDuration: Int(dto.cooldownDuration),
extraData: extraData,
latestMessages: messages,
latestMessages: latestMessages,
lastMessageFromCurrentUser: latestMessageFromUser,
pinnedMessages: pinnedMessages,
muteDetails: muteDetails,
Expand Down
11 changes: 0 additions & 11 deletions Sources/StreamChat/Database/DTOs/MemberModelDTO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -92,17 +92,6 @@ extension MemberDTO {
new.id = memberId
return new
}

static func loadLastActiveMembers(cid: ChannelId, context: NSManagedObjectContext) -> [MemberDTO] {
let request = NSFetchRequest<MemberDTO>(entityName: MemberDTO.entityName)
request.predicate = NSPredicate(format: "channel.cid == %@", cid.rawValue)
request.sortDescriptors = [
ChannelMemberListSortingKey.lastActiveSortDescriptor,
ChannelMemberListSortingKey.defaultSortDescriptor
]
request.fetchLimit = context.localCachingSettings?.chatChannel.lastActiveMembersLimit ?? 100
return load(by: request, context: context)
}
}

extension NSManagedObjectContext {
Expand Down
46 changes: 21 additions & 25 deletions Sources/StreamChat/Database/DTOs/MessageDTO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -516,19 +516,6 @@ class MessageDTO: NSManagedObject {
return (try? context.count(for: request)) ?? 0
}

static func loadLastMessage(from userId: String, in cid: String, context: NSManagedObjectContext) -> MessageDTO? {
let request = NSFetchRequest<MessageDTO>(entityName: entityName)
request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
channelPredicate(with: cid),
.init(format: "user.id == %@", userId),
.init(format: "type != %@", MessageType.ephemeral.rawValue),
messageSentPredicate()
])
request.sortDescriptors = [NSSortDescriptor(keyPath: \MessageDTO.createdAt, ascending: false)]
request.fetchLimit = 1
return load(by: request, context: context).first
}

static func loadSendingMessages(context: NSManagedObjectContext) -> [MessageDTO] {
let request = NSFetchRequest<MessageDTO>(entityName: MessageDTO.entityName)
request.sortDescriptors = [NSSortDescriptor(keyPath: \MessageDTO.locallyCreatedAt, ascending: false)]
Expand Down Expand Up @@ -1309,21 +1296,28 @@ private extension ChatMessage {

if let currentUser = context.currentUser {
isSentByCurrentUser = currentUser.user.id == dto.user.id
currentUserReactions = Set(
MessageReactionDTO
.loadReactions(ids: dto.ownReactions, context: context)
.compactMap { try? $0.asModel() }
)
if !dto.ownReactions.isEmpty {
currentUserReactions = Set(
MessageReactionDTO
.loadReactions(ids: dto.ownReactions, context: context)
.compactMap { try? $0.asModel() }
)
} else {
currentUserReactions = []
}
} else {
isSentByCurrentUser = false
currentUserReactions = []
}

latestReactions = Set(
MessageReactionDTO
.loadReactions(ids: dto.latestReactions, context: context)
.compactMap { try? $0.asModel() }
)
latestReactions = {
guard !dto.latestReactions.isEmpty else { return Set() }
return Set(
MessageReactionDTO
.loadReactions(ids: dto.latestReactions, context: context)
.compactMap { try? $0.asModel() }
)
}()

threadParticipants = dto.threadParticipants.array
.compactMap { $0 as? UserDTO }
Expand All @@ -1337,8 +1331,10 @@ private extension ChatMessage {
.sorted { $0.id.index < $1.id.index }

latestReplies = {
guard !dto.replies.isEmpty else { return [] }
return MessageDTO.loadReplies(for: dto.id, limit: 5, context: context)
guard dto.replyCount > 0 else { return [] }
return dto.replies
.sorted(by: { $0.createdAt.bridgeDate > $1.createdAt.bridgeDate })
.prefix(5)
.compactMap { try? ChatMessage(fromDTO: $0, depth: depth) }
}()

Expand Down
11 changes: 0 additions & 11 deletions Sources/StreamChat/Database/DTOs/UserDTO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -115,17 +115,6 @@ extension UserDTO {
new.teams = []
return new
}

static func loadLastActiveWatchers(cid: ChannelId, context: NSManagedObjectContext) -> [UserDTO] {
let request = NSFetchRequest<UserDTO>(entityName: UserDTO.entityName)
request.sortDescriptors = [
UserListSortingKey.lastActiveSortDescriptor,
UserListSortingKey.defaultSortDescriptor
]
request.predicate = NSPredicate(format: "ANY watchedChannels.cid == %@", cid.rawValue)
request.fetchLimit = context.localCachingSettings?.chatChannel.lastActiveWatchersLimit ?? 100
return load(by: request, context: context)
}
}

extension NSManagedObjectContext: UserDatabaseSession {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,6 @@ extension ChannelMemberListSortingKey {
return .init(keyPath: dateKeyPath, ascending: false)
}()

static let lastActiveSortDescriptor: NSSortDescriptor = {
let dateKeyPath: KeyPath<MemberDTO, DBDate?> = \MemberDTO.user.lastActivityAt
return .init(keyPath: dateKeyPath, ascending: false)
}()

func sortDescriptor(isAscending: Bool) -> NSSortDescriptor {
.init(key: rawValue, ascending: isAscending)
}
Expand Down
5 changes: 0 additions & 5 deletions Sources/StreamChat/Query/Sorting/UserListSortingKey.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,6 @@ extension UserListSortingKey {
return .init(keyPath: stringKeyPath, ascending: false)
}()

static let lastActiveSortDescriptor: NSSortDescriptor = {
let dateKeyPath: KeyPath<UserDTO, DBDate?> = \UserDTO.lastActivityAt
return .init(keyPath: dateKeyPath, ascending: false)
}()

func sortDescriptor(isAscending: Bool) -> NSSortDescriptor? {
.init(key: rawValue, ascending: isAscending)
}
Expand Down
4 changes: 2 additions & 2 deletions Tests/StreamChatTests/Database/DTOs/ChannelDTO_Tests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1322,7 +1322,7 @@ final class ChannelDTO_Tests: XCTestCase {
XCTAssertEqual(channel.unreadCount.messages, 0)
}

func test_asModel_populatesLatestMessage() throws {
func test_asModel_populatesLatestMessage_withoutFilteringDeletedMessages() throws {
// GIVEN
database = DatabaseContainer_Spy(
kind: .inMemory,
Expand Down Expand Up @@ -1411,7 +1411,7 @@ final class ChannelDTO_Tests: XCTestCase {
// THEN
XCTAssertEqual(
Set(channel.latestMessages.map(\.id)),
Set([message1.id, deletedMessageFromCurrentUser.id, shadowedMessageFromAnotherUser.id])
Set([message1.id, deletedMessageFromCurrentUser.id, deletedMessageFromAnotherUser.id])
)
}

Expand Down

0 comments on commit 5ea8062

Please sign in to comment.