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

Sorting order in lists with equal sorting criteria #3419

Open
wants to merge 6 commits into
base: develop
Choose a base branch
from

Conversation

laevandus
Copy link
Contributor

@laevandus laevandus commented Sep 11, 2024

🔗 Issue Links

Resolves: PBE-5993

🎯 Goal

When sorting by key(s) with equal values the sort order in SDK models is kept unchanged

📝 Summary

Recommendation: Always try to add sorting criteria what contains one sorting key with unique values.

  • Fix an issue where microseconds in timestamps were ignored causing channel list sorting to have many channels with the same lastMessageAt timestamp (triggers active reordering)
  • Fix an issue where just sorting by unreadCount or other Int or Bool keys led to unexpected reordering in the channel list (many channels with equal sorting value). Break this by adding updatedAt sorting key internally.
  • Fix an issue of runtime sorting (post FRC sorting used when FRC does not support sorting key) returning incorrect order
  • Add ChannelMemberListSortingKey.userId for sorting channel members by id

🛠 Implementation

Microseconds parsing

DateFormatter supports milliseconds and ignores microseconds and nanoseconds. This led to date properties to lose precision and causing many equal values when sorting the list.

Equal values and sorting

Our SDK returns items sorted by Core Data sort descriptors (or by runtime sorting if custom sorting keys are used). When we sort over non-unique values and there are many items with the same value, the order returned by the SDK is inconsistent: channel1, channel2, channel3 can change to channel2, channel3, channel1 when DB changes happen. API responses, on the other hand, return consistent order even in cases like these. If we paginate, then we also do not want to see any reordering happening when the next page of channels is inserted into the DB. One way is to internally add a tie breaker.

The issue with runtime sorting

Runtime sorting is used when we can't sort on the Core Data model layer (DTO does not have a key for that field). I found that runtime sorting does not match with how NSSortDescriptor sorts items (nil values, multiple sort criteria). This PR fixes these issues.

🎨 Showcase

Channel list sorted by unread count
Before After
img img

🧪 Manual Testing Notes

Case: sort channel list by unread message count

Prerequisites

Open DemoAppCoordinator+DemoApp.swift:L105 and change default sorting to sort by unread count (gives many equal values)

channelListQuery = .init(
  filter: .containMembers(userIds: [userCredentials.id]),
  sort: [.init(key: .unreadCount, isAscending: false)]
)
Steps
  1. Open the demo app and log in with an user with many channels
  2. Scroll down to load more channels
    Result: Channels do not reorder

☑️ Contributor Checklist

  • I have signed the Stream CLA (required)
  • This change should be manually QAed
  • Changelog is updated with client-facing changes
  • Changelog is updated with new localization keys
  • New code is covered by unit tests
  • Comparison screenshots added for visual changes
  • Affected documentation updated (docusaurus, tutorial, CMS)

🎁 Meme

Provide a funny gif or image that relates to your work on this pull request. (Optional)

@laevandus laevandus added 🐞 Bug An issue or PR related to a bug 🌐 SDK: StreamChat (LLC) Tasks related to the StreamChat LLC SDK labels Sep 11, 2024
@laevandus laevandus requested a review from a team as a code owner September 11, 2024 13:11
@laevandus laevandus changed the title Runtime sorting gave inconsistent sort order and did not always match with NSSortDescriptor order [WIP] Runtime sorting gave inconsistent sort order and did not always match with NSSortDescriptor order Sep 11, 2024
@laevandus laevandus added the 🔧 WIP A PR that is Work in Progress label Sep 11, 2024
@Stream-SDK-Bot
Copy link
Collaborator

SDK Size

title develop branch diff status
StreamChat 6.83 MB 6.85 MB +17 KB 🟢
StreamChatUI 4.42 MB 4.42 MB 0 KB 🟢

@Stream-SDK-Bot
Copy link
Collaborator

SDK Performance

target metric benchmark branch performance status
MessageList Hitches total duration 10 ms 4.17 ms 58.3% 🔼 🟢
Duration 2.6 s 2.54 s 2.31% 🔼 🟢
Hitch time ratio 4 ms per s 1.65 ms per s 58.75% 🔼 🟢
Frame rate 75 fps 78.06 fps 4.08% 🔼 🟢
Number of hitches 1 0.4 60.0% 🔼 🟢
ChannelList Hitches total duration 12.5 ms 14.19 ms -13.52% 🔽 🔴
Duration 2.6 s 2.54 s 2.31% 🔼 🟢
Hitch time ratio 5 ms per s 5.59 ms per s -11.8% 🔽 🔴
Frame rate 72 fps 74.49 fps 3.46% 🔼 🟢
Number of hitches 1.2 1.4 -16.67% 🔽 🔴

@Stream-SDK-Bot
Copy link
Collaborator

Stream-SDK-Bot commented Sep 13, 2024

SDK Size

title develop branch diff status
StreamChat 6.83 MB 6.85 MB +17 KB 🟢
StreamChatUI 4.42 MB 4.42 MB 0 KB 🟢

@laevandus laevandus changed the title [WIP] Runtime sorting gave inconsistent sort order and did not always match with NSSortDescriptor order Sorting order in lists with equal sorting criterias Sep 16, 2024
@laevandus laevandus changed the title Sorting order in lists with equal sorting criterias Sorting order in lists with equal sorting criteria Sep 16, 2024
@@ -303,6 +305,26 @@ protocol ChannelDatabaseSession {
func removeChannels(cids: Set<ChannelId>)
}

extension ChannelDatabaseSession {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Without it, there are so many errors because of missing the offset argument (most of it is in tests). If we want to avoid this extension, I can take the time to fill it in.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can't we provide a default value for the offset?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or maybe move these to the test target?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately can't set a default value in protocol but moving the extension to test target is a good idea.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved it

Comment on lines 45 to 51
func isAnyNil(_ value: Any) -> Bool {
let mirror = Mirror(reflecting: value)
return mirror.displayStyle == .optional && mirror.children.isEmpty
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a better way?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can't we do something like this instead:

func isAnyNil(_ value: Any) -> Bool {
     return (value as Any?) == nil
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This also does not work.

func test() {
    struct Item {
        let index: Int
        let date: Date?
    }

    let item1 = Item(index: 1, date: nil)
    let item2 = Item(index: 2, date: Date())
    let keyPath: PartialKeyPath<Item> = \.date

    let lhsValue = item1[keyPath: keyPath]
    let rhsValue = item2[keyPath: keyPath]

    print(lhsValue, rhsValue)
    print(isAnyNil(lhsValue), isAnyNil(rhsValue))
    print((lhsValue as Any?) == nil, (rhsValue as Any?) == nil)
}

func isAnyNil(_ value: Any) -> Bool {
    let mirror = Mirror(reflecting: value)
    return mirror.displayStyle == .optional && mirror.children.isEmpty
}

gives

nil Optional(2024-09-17 10:18:17 +0000)
true false
false false

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe String(describing: value) == "nil"? 😅

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@laevandus You can check DebugObjectViewController, we do nil comparison there.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I want to avoid reflection though, this will be run for every channel so it might impact performance.

extension Collection {
extension Sequence {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needed it for sorting .enumerated()

@@ -10,49 +10,54 @@ struct SortValue<T> {
}

extension Array {
func sort(using sorting: [SortValue<Element>]) -> [Element] {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed it to sorted because sort means in-place in the Foundation APIs.

@laevandus laevandus removed the 🔧 WIP A PR that is Work in Progress label Sep 17, 2024
@laevandus laevandus changed the title Sorting order in lists with equal sorting criteria [WIP] Sorting order in lists with equal sorting criteria Sep 17, 2024
@laevandus laevandus changed the title [WIP] Sorting order in lists with equal sorting criteria Sorting order in lists with equal sorting criteria Sep 17, 2024
Copy link
Contributor

@martinmitrevski martinmitrevski left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tested it also on SwiftUI and I still get an occasional jump. Have you tried it there?

@@ -303,6 +305,26 @@ protocol ChannelDatabaseSession {
func removeChannels(cids: Set<ChannelId>)
}

extension ChannelDatabaseSession {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can't we provide a default value for the offset?

// Note: When the channel is part of multiple channel list controllers,
// it might still cause some movement because the property is shared.
// Recommendation: use at least one sorting parameter with unique values.
@NSManaged var sortIndex: Int
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

another option can be to keep a map of the queries and the indexes, but I guess it's not worth it

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I considered it but it felt like it gets too complex and also sort descriptor wants a key-path.

@@ -303,6 +305,26 @@ protocol ChannelDatabaseSession {
func removeChannels(cids: Set<ChannelId>)
}

extension ChannelDatabaseSession {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or maybe move these to the test target?

Comment on lines 45 to 51
func isAnyNil(_ value: Any) -> Bool {
let mirror = Mirror(reflecting: value)
return mirror.displayStyle == .optional && mirror.children.isEmpty
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can't we do something like this instead:

func isAnyNil(_ value: Any) -> Bool {
     return (value as Any?) == nil
}

@laevandus laevandus changed the title Sorting order in lists with equal sorting criteria [WIP] Sorting order in lists with equal sorting criteria Sep 17, 2024
@laevandus laevandus force-pushed the fix/sorting-consistency branch 2 times, most recently from 2820a2a to d56904c Compare September 18, 2024 12:36
Copy link

1 Warning
⚠️ Big PR

Generated by 🚫 Danger

@laevandus laevandus changed the title [WIP] Sorting order in lists with equal sorting criteria Sorting order in lists with equal sorting criteria Sep 19, 2024
Comment on lines +15 to +36
/// The first `SortValue` is the primary key and subsequent `SortValue`s are for breaking the tie.
func sorted(using sortValues: [SortValue<Element>]) -> [Element] {
guard !sortValues.isEmpty else { return self }
return sorted { lhs, rhs in
for sortValue in sortValues {
let lhsValue = lhs[keyPath: sortValue.keyPath]
let rhsValue = rhs[keyPath: sortValue.keyPath]
let isAscending = sortValue.isAscending
// These are type-erased, therefore we need to manually cast them
if let result = nilComparison(lhs: lhsValue, rhs: rhsValue, isAscending: isAscending) {
return result
} else if let result = areInIncreasingOrder(lhs: lhsValue, rhs: rhsValue, type: Date.self, isAscending: isAscending) {
return result
} else if let result = areInIncreasingOrder(lhs: lhsValue, rhs: rhsValue, type: String.self, isAscending: isAscending) {
return result
} else if let result = areInIncreasingOrder(lhs: lhsValue, rhs: rhsValue, type: Int.self, isAscending: isAscending) {
return result
} else if let result = areInIncreasingOrder(lhs: lhsValue, rhs: rhsValue, type: Double.self, isAscending: isAscending) {
return result
} else if let lBool = lhsValue as? Bool, let rBool = rhsValue as? Bool, lBool != rBool {
// Backend considers boolean sorting in reversed order.
return isAscending ? lBool && !rBool : !lBool && rBool
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we still need these changes, after we changed the strategy to use an additional sort descriptor for the tie breaker?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We still need, this code-path is triggered when someone uses a custom sorting key which can't be represented by the FRC's sort descriptors. Most of the time, this code never runs, only these specific cases when someone goes for custom sorting keys.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorting keys what have localKey nil. Nil means we can't use sort descriptor and need to resort post-FRC sorting.

public static let hasUnread = Self(
        keyPath: \.hasUnread,
        localKey: nil,
        remoteKey: "has_unread"
    )
    public static func custom<T>(keyPath: KeyPath<ChatChannel, T>, key: String) -> Self {
        .init(keyPath: keyPath, localKey: nil, remoteKey: key)
    }

Copy link
Member

@nuno-vieira nuno-vieira left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new approach sounds much better 👍 Just added minor comments but overall LGTM!

@@ -62,7 +62,7 @@ public struct ChannelListSortingKey: SortingKey, Equatable {

/// Sort channels by their unread count.
public static let unreadCount = Self(
keyPath: \.unreadCount,
keyPath: \.unreadCount.messages,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unreadCount is a struct, not Int, this is used for runtime sorting

Comment on lines 175 to 184
guard let index = string.lastIndex(of: ".") else { return date }
let range = string.suffix(from: index)
.dropFirst(4) // . and ms part
.dropLast() // Z
var fractionWithoutMilliseconds = String(range)
if fractionWithoutMilliseconds.count < 3 {
fractionWithoutMilliseconds = fractionWithoutMilliseconds.padding(toLength: 3, withPad: "0", startingAt: 0)
}
guard let microseconds = TimeInterval("0.000".appending(fractionWithoutMilliseconds)) else { return date }
return date.addingTimeInterval(microseconds)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe not the most beautiful but it needs to handle cases like

2024-09-18T13:49:11.3241Z
2024-09-18T13:49:11.32412Z
2024-09-18T13:49:11.324123Z
2024-09-18T13:49:11.3241234Z
2024-09-18T13:49:11.32412345Z
2024-09-18T13:49:11.324123456Z

Copy link

sonarcloud bot commented Sep 20, 2024

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
🐞 Bug An issue or PR related to a bug 🌐 SDK: StreamChat (LLC) Tasks related to the StreamChat LLC SDK
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants