Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve performance of ChatChannel database model conversions ~7 times #3325

Merged
merged 12 commits into from
Jul 25, 2024
Merged
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
# Upcoming

### 🔄 Changed
- Improve performance of `ChatChannel` database model conversions more than 4 times [#3322](https://github.com/GetStream/stream-chat-swift/pull/3322)
laevandus marked this conversation as resolved.
Show resolved Hide resolved
laevandus marked this conversation as resolved.
Show resolved Hide resolved

# [4.60.0](https://github.com/GetStream/stream-chat-swift/releases/tag/4.60.0)
_July 18, 2024_
Expand Down
98 changes: 52 additions & 46 deletions Sources/StreamChat/Database/DTOs/ChannelDTO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -436,70 +436,76 @@ extension ChatChannel {
}

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 = dto.messages
.sorted(by: { $0.createdAt.bridgeDate > $1.createdAt.bridgeDate })
.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
)
guard !dto.messages.isEmpty else { return [] }
let request = MessageDTO.channelMessagesRequest(
for: dto.cid,
limit: dto.managedObjectContext?.localCachingSettings?.chatChannel.latestMessagesLimit ?? 25,
deletedMessagesVisibility: dto.managedObjectContext?.deletedMessagesVisibility ?? .visibleForCurrentUser,
shouldShowShadowedMessages: dto.managedObjectContext?.shouldShowShadowedMessages ?? false
)
return dto.messages
.filtered(using: request)
laevandus marked this conversation as resolved.
Show resolved Hide resolved
.compactMap { try? $0.relationshipAsModel(depth: depth) }
}()

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? dto.messages
.sorted(by: { $0.createdAt.bridgeDate > $1.createdAt.bridgeDate })
laevandus marked this conversation as resolved.
Show resolved Hide resolved
.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)
laevandus marked this conversation as resolved.
Show resolved Hide resolved
.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? = {
guard let mute = dto.mute else { return nil }
return .init(
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
83 changes: 52 additions & 31 deletions Sources/StreamChat/Database/DTOs/MessageDTO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -403,14 +403,13 @@ class MessageDTO: NSManagedObject {
return request
}

static func load(
static func channelMessagesRequest(
for cid: String,
limit: Int,
offset: Int = 0,
deletedMessagesVisibility: ChatClientConfig.DeletedMessageVisibility,
shouldShowShadowedMessages: Bool,
context: NSManagedObjectContext
) -> [MessageDTO] {
shouldShowShadowedMessages: Bool
) -> NSFetchRequest<MessageDTO> {
let request = NSFetchRequest<MessageDTO>(entityName: entityName)
request.predicate = channelMessagesPredicate(
for: cid,
Expand All @@ -420,6 +419,24 @@ class MessageDTO: NSManagedObject {
request.sortDescriptors = [NSSortDescriptor(keyPath: \MessageDTO.createdAt, ascending: false)]
request.fetchLimit = limit
request.fetchOffset = offset
return request
}

static func load(
for cid: String,
limit: Int,
offset: Int = 0,
deletedMessagesVisibility: ChatClientConfig.DeletedMessageVisibility,
shouldShowShadowedMessages: Bool,
context: NSManagedObjectContext
) -> [MessageDTO] {
let request = Self.channelMessagesRequest(
for: cid,
limit: limit,
offset: offset,
deletedMessagesVisibility: deletedMessagesVisibility,
shouldShowShadowedMessages: shouldShowShadowedMessages
)
return load(by: request, context: context)
}

Expand Down Expand Up @@ -457,18 +474,28 @@ class MessageDTO: NSManagedObject {
return new
}

/// Load replies for the specified `parentMessageId`.
static func loadReplies(
static func loadRepliesRequest(
for messageId: MessageId,
limit: Int,
offset: Int = 0,
context: NSManagedObjectContext
) -> [MessageDTO] {
) -> NSFetchRequest<MessageDTO> {
let request = NSFetchRequest<MessageDTO>(entityName: entityName)
request.predicate = NSPredicate(format: "parentMessageId == %@", messageId)
request.sortDescriptors = [NSSortDescriptor(keyPath: \MessageDTO.createdAt, ascending: false)]
request.fetchLimit = limit
request.fetchOffset = offset
return request
}

/// Load replies for the specified `parentMessageId`.
static func loadReplies(
for messageId: MessageId,
limit: Int,
offset: Int = 0,
context: NSManagedObjectContext
) -> [MessageDTO] {
let request = loadRepliesRequest(for: messageId, limit: limit, offset: offset, context: context)
return load(by: request, context: context)
}

Expand Down Expand Up @@ -516,19 +543,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 +1323,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() }
)
nuno-vieira marked this conversation as resolved.
Show resolved Hide resolved
} else {
currentUserReactions = []
}
nuno-vieira marked this conversation as resolved.
Show resolved Hide resolved
} 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() }
)
nuno-vieira marked this conversation as resolved.
Show resolved Hide resolved
}()

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

latestReplies = {
guard !dto.replies.isEmpty else { return [] }
guard dto.replyCount > 0 else { return [] }
laevandus marked this conversation as resolved.
Show resolved Hide resolved
return MessageDTO.loadReplies(for: dto.id, limit: 5, context: context)
.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
39 changes: 39 additions & 0 deletions Sources/StreamChat/Utils/Database/NSManagedObject+Extensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -163,3 +163,42 @@ extension NSManagedObjectContext {
return objects
}
}

// MARK: - Runtime Filtering

extension Set where Element: NSManagedObject {
func filtered(using request: NSFetchRequest<Element>) -> [Element] {
guard !isEmpty else { return [] }
var allObjects = NSMutableArray(array: (self as NSSet).allObjects, copyItems: false)
if let sortDescriptors = request.sortDescriptors, !sortDescriptors.isEmpty {
allObjects.sort(using: sortDescriptors)
}
if request.fetchLimit > 0 {
let result = NSMutableArray(capacity: request.fetchLimit)
let batchSize = Swift.min(request.fetchLimit, 100)
for index in stride(from: 0, to: allObjects.count, by: batchSize) {
let range = NSRange(location: index, length: Swift.min(batchSize, allObjects.count - index))
let batch = allObjects.subarray(with: range)
if let predicate = request.predicate {
result.addObjects(from: (batch as NSArray).filtered(using: predicate))
} else {
result.addObjects(from: batch)
}
if result.count > request.fetchLimit {
let removeCount = result.count - request.fetchLimit
result.removeObjects(in: NSRange(location: result.count - removeCount, length: removeCount))
}
if result.count == request.fetchLimit {
break
}
}
allObjects = result
} else {
if let predicate = request.predicate {
allObjects.filter(using: predicate)
}
}

return allObjects as? [Element] ?? []
}
laevandus marked this conversation as resolved.
Show resolved Hide resolved
}
Loading