-
Notifications
You must be signed in to change notification settings - Fork 306
model: Add Unreads model, for tracking unread-message counts #304
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
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks! I haven't yet read the methods, but here's a few comments from reading the other files and down through the field definitions.
This is an area where it'll be great to do some explicit timing measurements; see detailed tips below. In particular, apart from using measurements to optimize specific areas of this code, it'll be good to have just a top-line number saying how long it takes at startup/load and at each key operation like getting a new message or marking some messages as read. That will guide how much further effort we spend on optimization.
For comparisons when I converted the corresponding data structures in zulip-mobile, see:
zulip/zulip-mobile@89cff7c
zulip/zulip-mobile@8dc3fe6
zulip/zulip-mobile@5725e7d
and the timings discussed in those commit messages.
(And it's a good thing I split those commits up, because the second one was buggy and had to be reverted: zulip/zulip-mobile@7c7f245.)
lib/model/narrow.dart
Outdated
factory DmNarrow.ofUnreadHuddleSnapshot(UnreadHuddleSnapshot snapshot, {required int selfUserId}) { | ||
final userIds = snapshot.userIdsString.split(',').map((id) => int.parse(id)); | ||
return DmNarrow(selfUserId: selfUserId, | ||
allRecipientIds: userIds.toList(growable: false)..sort()); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In zulip-mobile our notes say this list comes sorted:
/** All users, including self; numerically sorted, comma-separated. */
user_ids_string: string,
So that'd be a good thing to try to confirm in #api documentation
, and then we can skip sorting it ourselves.
lib/model/narrow.dart
Outdated
return DmNarrow(selfUserId: selfUserId, | ||
allRecipientIds: [...detail.userIds!, selfUserId]..sort()); | ||
} | ||
|
||
factory DmNarrow.withUser(int userId, {required int selfUserId}) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe this should get moved to right below the plain DmNarrow
constructor. It and ofMessage
and the plain one are the really general-purpose constructors; as the number of special-purpose constructors grows, it starts getting pushed down where it's harder to spot.
/// | ||
/// Includes messages with | ||
/// [MessageFlag.mentioned] or [MessageFlag.wildcardMentioned] or both. | ||
final Set<int> mentions; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(This one perhaps leave as a Set
; I don't think it shares the properties the others have, where removing from the set is almost all going to happen at the start of the list.)
lib/model/unreads.dart
Outdated
}); | ||
|
||
/// Unread stream messages, as: stream ID → topic → message ID. | ||
final Map<int, Map<String, Set<int>>> streams; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
- Change Sets of message IDs to sorted Lists, like in zulip-mobile,
for space efficiency (needs attention to make sure they stay
sorted and that operations are as time-efficient as possible)
Specifically they should be QueueList
, because that's a deque (as is the Immutable.List
we use in zulip-mobile). That way removing elements from the beginning, which is what typically happens when marking as read, is O(1).
lib/model/unreads.dart
Outdated
for (final unreadStreamSnapshot in initial.streams) { | ||
final streamId = unreadStreamSnapshot.streamId; | ||
final topic = unreadStreamSnapshot.topic; | ||
|
||
(streams[streamId] ??= {})[topic] = Set.of(unreadStreamSnapshot.unreadMessageIds); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In zulip-mobile we have:
// First, collect together all the data for a given stream, just in a
// plain old Array.
// […]
// Then, for each of those plain Arrays build an Immutable.Map from it
// all in one shot. This is quite a bit faster than building the Maps
// incrementally. For a user with lots of unreads in a busy org, we
// can be handling 50k message IDs here, across perhaps 2-5k threads
// in dozens of streams, so the effect is significant.
I'm not sure the same thing would apply when building a Dart Map
, but it might.
This is a good target for doing some empirical timing. Take a test user who has 50k+ unreads across chat.zulip.org; sprinkle some timing around key bits of the code; see how long it takes with different versions.
For example timing code, see the use of Stopwatch
in lib/model/store.dart
, around the registerQueue
call.
In order to get representative results, run in release mode or profile mode, and do so on a physical phone. Probably informative to do so, at least once, both on the now-low-end Android test device you have and on a more modern iPhone.
The absolute times will probably be longer on the lower-end device. The ratios between times for different versions of the code might be similar… or might be significantly different between devices. Such differences can definitely happen in principle (and be big), and I don't have a good sense of how likely they are in practice.
Once you have the general lay of the land for comparing the two devices, it's probably most expedient to do further exploration just on the more modern device. Definitely always in either release mode or profile mode, though. And always on a physical phone; I think between a physical phone and your laptop is probably more likely to have surprisingly sharp differences in how different versions of the code compare to each other than between different phone hardware.
lib/model/unreads.dart
Outdated
/// - unknown, because we're missing data on old unread messages | ||
/// (see [oldUnreadsMissing]). | ||
/// | ||
/// Messages in unsubscribed streams are generally considered unread by the |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
oops: meant to say "read" instead of "unread" in this line
…ctor As Greg points out: zulip#304 (comment) > It and `ofMessage` and the plain one are the really > general-purpose constructors; as the number of special-purpose > constructors grows, it starts getting pushed down where it's > harder to spot.
69e2a9d
to
28b5f99
Compare
Thanks for the review! I've pushed my latest work. I still need to do systematic timing and write tests, but I did an unscientific handful of trials, and I think probably for further efficiency work we might want to prioritize the event-handling code over the work done in the constructor from initial data. On CZO and Recurse, where I have many thousands of unreads, I did a few initial loads and didn't see more than 3ms spent in the constructor. But on CZO, scrolling on desktop through a few screenfuls of unreads (fast enough to produce an event with more than just a few unreads in it), I saw the mark-as-read handler take up to 21ms for an event. |
28b5f99
to
e127c5d
Compare
Great! I'd want to stress-test that by using a user that has the full 50k unreads, if you didn't already. One good way to get that is to take a test user on chat.zulip.org — either an old test user that already has tons of unreads, or you can make a fresh test user and then go use "mark as unread" in the all-messages narrow to gain a ton of unreads. That should be a good representative distribution, too (as would your own unreads in those two realms), which would otherwise be nontrivial to get on e.g. a test server. If it's 3ms, or even up to like 50ms, in that situation, then I think we don't need to worry any further about performance at construction time.
Cool, so that's telling us this case would be the one to think about optimizing. Looking back at those zulip-mobile commits I linked above, it looks like the times I saw were 30-50ms for this algorithm / these data structures. So the combination of Dart instead of JS/JSC, and whatever device you were timing on instead of the Pixel 2 XL I was using then, plus any small differences in your transcription, makes it roughly 2x faster. That may be about as good as one should expect. The much faster timings, of <1ms up to an occasional 7ms, were observed in zulip/zulip-mobile@8dc3fe6 which used Still, worth looking to see if there are any relatively easy ways we can optimize this pending that. I'm reading through the code now, and will look out for possibilities. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OK, and on reading the code for marking as read, I do see what looks like an opportunity to optimize! Let's try it out.
Still reading the other code.
lib/model/unreads.dart
Outdated
void _slowRemoveAllInStreams(Iterable<int> messageIds) { | ||
for (final messageId in messageIds) { | ||
for (final stream in streams.values) { | ||
for (final topic in stream.values) { | ||
topic.remove(messageId); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Try this:
void _slowRemoveAllInStreams(Iterable<int> messageIds) { | |
for (final messageId in messageIds) { | |
for (final stream in streams.values) { | |
for (final topic in stream.values) { | |
topic.remove(messageId); | |
void _slowRemoveAllInStreams(Iterable<int> messageIds) { | |
final messageIdSet = Set.of(messageIds); | |
for (final stream in streams.values) { | |
for (final topic in stream.values) { | |
topic.removeWhere((id) => messageIdSet.contains(id)); |
I'm curious how that compares in your timing of mark-as-read events.
That's a more direct transcription of the zulip-mobile version:
function deleteMessages(
state: UnreadStreamsState,
ids: $ReadOnlyArray<number>,
): UnreadStreamsState {
const idSet = new Set(ids);
const toDelete = id => idSet.has(id);
const emptyList: Immutable.List<number> = Immutable.List();
return updateAllAndPrune(state, Immutable.Map(), perStream =>
updateAllAndPrune(perStream, emptyList, (perTopic: Immutable.List<number>) =>
perTopic.find(toDelete) ? perTopic.filterNot(toDelete) : perTopic,
),
);
}
(The find ? … :
condition is an optimization to keep the resulting Immutable.List
identical when it's going to have the same contents; here where we're mutating the same object anyway, that isn't needed.)
If there are N unreads known, and we get an event to remove k of them, then the zulip-mobile version will spend
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could also improve a bit further by having this function take the Set<int>
as an argument — the caller can reuse it for going through DMs.
lib/model/unreads.dart
Outdated
for (final stream in streams.values) { | ||
for (final topic in stream.values) { | ||
topic.remove(messageId); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This should gain logic to remove per-topic lists that have become empty, and then to remove per-stream maps that have become empty. That corresponds to the "prune" part of updateAllAndPrune
in the zulip-mobile version (quoted in the preceding comment).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Makes sense. I'll add pruning to all the "remove" methods.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OK, I've read through the three prep commits, and all the handle-event methods (and their private helpers) in the main commit. Generally all looks good! Comments below; and I see also there's a TODO to handle message moves, and one for adding tests.
Left for a future round is the rest of the main commit: so the changes in other files, and model/unreads.dart
up to before handleMessageEvent
.
(Also see short review above focused on the mark-as-read algorithm, and the comment above that about measuring performance.)
case MessageFlag.mentioned: | ||
case MessageFlag.wildcardMentioned: | ||
// Empirically, we don't seem to get these events when a message is edited | ||
// to add/remove an @-mention, even though @-mention state is represented | ||
// as flags. Instead, we just get the [UpdateMessageEvent], and that | ||
// contains the new set of flags, which we'll use to update [mentions]. | ||
// (See our handling of [UpdateMessageEvent].) | ||
// | ||
// Handle the event anyway, using the meaning on the tin. | ||
// It might be used in a valid case we haven't thought of yet. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm, interesting. Sure.
lib/model/unreads.dart
Outdated
if (detail == null) { // TODO(log) if on Zulip 6.0+ | ||
// Happens as a bug in some cases before fixed in Zulip 6.0: | ||
// https://chat.zulip.org/#narrow/stream/378-api-design/topic/unreads.20in.20unsubscribed.20streams/near/1458467 | ||
// TODO(server-6) remove Zulip 6.0 comment | ||
return; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Perhaps better just continue
— if we run into a bug like this, seems best to carry on with any messages we do have the details for.
lib/model/unreads.dart
Outdated
((newlyUnreadInStreams[detail.streamId!] ??= {})[detail.topic!] ??= QueueList()) | ||
.add(messageId); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit:
((newlyUnreadInStreams[detail.streamId!] ??= {})[detail.topic!] ??= QueueList()) | |
.add(messageId); | |
final perStream = (newlyUnreadInStreams[detail.streamId!] ??= {}); | |
final perTopic = (perStream[detail.topic!] ??= QueueList()); | |
perTopic.add(messageId); |
Only one line longer, and I think unpacking the two ??=
onto separate lines is helpful for seeing everything that's happening.
lib/model/unreads.dart
Outdated
/// [messageIds] must be sorted ascending and without duplicates. | ||
void _addAllInStreamTopic(QueueList<int> messageIds, int streamId, String topic) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit:
/// [messageIds] must be sorted ascending and without duplicates. | |
void _addAllInStreamTopic(QueueList<int> messageIds, int streamId, String topic) { | |
// [messageIds] must be sorted ascending and without duplicates. | |
void _addAllInStreamTopic(QueueList<int> messageIds, int streamId, String topic) { |
because it doesn't have the form of a complete piece of documentation
lib/model/unreads.dart
Outdated
QueueList<int> updatedMessageIds = messageIds; | ||
if (topics[topic] != null) { | ||
// setUnion dedupes existing and incoming unread IDs, | ||
// so we tolerate zulip/zulip#22164, fixed in 6.0 | ||
// TODO(server-6) remove 6.0 comment | ||
updatedMessageIds = setUnion(topics[topic]!, messageIds); | ||
} | ||
topics[topic] = updatedMessageIds; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
QueueList<int> updatedMessageIds = messageIds; | |
if (topics[topic] != null) { | |
// setUnion dedupes existing and incoming unread IDs, | |
// so we tolerate zulip/zulip#22164, fixed in 6.0 | |
// TODO(server-6) remove 6.0 comment | |
updatedMessageIds = setUnion(topics[topic]!, messageIds); | |
} | |
topics[topic] = updatedMessageIds; | |
topics.update(topic, | |
ifAbsent: () => messageIds, | |
// setUnion dedupes existing and incoming unread IDs, | |
// so we tolerate zulip/zulip#22164, fixed in 6.0 | |
// TODO(server-6) remove 6.0 comment | |
(existing) => setUnion(existing, messageIds), | |
); |
This avoids repeated lookups of topic
in the map topics
. (I'd expect that the built-in Map implementation makes just one such lookup this way; pure Dart code using operators []
and []=
can manage two; the current revision makes up to three.) I think it's also a bit easier to read.
lib/model/unreads.dart
Outdated
// TODO (?) instead of making a one-off [Set], write and use `setDifference` | ||
// that operates on our kinds of sets (implemented as sorted lists) | ||
void _removeAllInStreamTopic(Set<int> messageIds, int streamId, String topic) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It looks like we're only using this for DeleteMessageEvent. Those aren't common, I think, so no need to do anything fancy to optimize. (Especially as we're already, thankfully, doing this only for a single conversation's list of messages.)
lib/model/unreads.dart
Outdated
if (event.messageIds.length == 1) { | ||
_removeInStreamTopic(event.messageIds.single, streamId, topic); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We can skip this optimization and just have the simplicity of always making a Set
and passing to the remove-all method. That's because this is something we're doing only once for this event, not e.g. once per stream and topic in the data structure.
logging code class Unreads extends ChangeNotifier {
factory Unreads({required UnreadMessagesSnapshot initial, required selfUserId}) {
+ final stopwatch = Stopwatch()..start();
+
final streams = <int, Map<String, QueueList<int>>>{};
final dms = <DmNarrow, QueueList<int>>{};
final mentions = Set.of(initial.mentions);
@@ -60,13 +62,18 @@ class Unreads extends ChangeNotifier {
dms[narrow] = QueueList.from(unreadHuddleSnapshot.unreadMessageIds);
}
- return Unreads._(
+ final result = Unreads._(
streams: streams,
dms: dms,
mentions: mentions,
oldUnreadsMissing: initial.oldUnreadsMissing,
selfUserId: selfUserId,
);
+
+ final t = (stopwatch..stop()).elapsed;
+ print('Unreads constructor time: ${t.inMilliseconds}ms');
+
+ return result;
} OK! I've just done this. With a test user who already had lots of unreads ( I did ten trials of this; here are the results in milliseconds: 6, 4, 4, 4, 5, 4, 6, 4, 4, 4 So, not a large distribution, and definitely well below 50ms. |
…ctor As Greg points out: zulip#304 (comment) > It and `ofMessage` and the plain one are the really > general-purpose constructors; as the number of special-purpose > constructors grows, it starts getting pushed down where it's > harder to spot.
e127c5d
to
8f30bfe
Compare
Just added a bunch of tests that are ready for an initial review pass 🙂 (I'm sure this is an area we'll want to iterate on). |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the revision! Just looked through the code covered in my last review — all looks good except a few small comments below.
And I am still curious how the timing works out for mark-as-read events after the algorithms change discussed above 🙂 : #304 (comment)
I'll look at the tests next.
} | ||
|
||
// (Moved messages will be handled here; | ||
// the TODO for that is just above the class declaration.) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(Hmm, I don't see that TODO.)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Indeed! Thanks! 😄
lib/model/unreads.dart
Outdated
newlyUnreadInStreams.forEach((incomingStreamId, incomingPerTopic) { | ||
incomingPerTopic.forEach((incomingTopic, incomingMessageIds) { | ||
_addAllInStreamTopic(incomingMessageIds..sort(), incomingStreamId, incomingTopic); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm, this uses "per topic" with a different meaning from in the code above:
final perStream = (newlyUnreadInStreams[detail.streamId!] ??= {});
final perTopic = (perStream[detail.topic!] ??= QueueList());
perTopic.add(messageId);
The incomingPerTopic
here corresponds to the perStream
there.
Perhaps that means that "per topic" is too ambiguous — so possibly best to avoid it on both sides. But in any case should avoid using it with two contrasting meanings. 🙂
lib/model/unreads.dart
Outdated
final perTopic = streams[streamId]; | ||
if (perTopic == null) return; | ||
final messageIds = perTopic[topic]; | ||
if (messageIds == null) return; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think calling the intermediate variable here topics
(like you used in the previous revision) instead of perTopic
would work well. It's parallel to streams
for the outer data structure, and messageIds
for the inner.
lib/model/unreads.dart
Outdated
/// [messageIds] must be sorted ascending and without duplicates. | ||
void _addAllInDm(QueueList<int> messageIds, DmNarrow dmNarrow) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit:
/// [messageIds] must be sorted ascending and without duplicates. | |
void _addAllInDm(QueueList<int> messageIds, DmNarrow dmNarrow) { | |
// [messageIds] must be sorted ascending and without duplicates. | |
void _addAllInDm(QueueList<int> messageIds, DmNarrow dmNarrow) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OK, and I've read through the tests. Generally they look good! Very thorough.
Comments below, including a few places where the implementation has some logic where it'd be easy for there to be a bug and so it'd be good to have tests targeting those specifically.
- several TODOs are scattered through the tests; can postpone most
of those?
Yeah, I think those can all be let be, except the "should not crash" cases. (And those don't need to be a lot of code — in particular it's fine to not cover all the possible combinations in areas where it seems like any reasonable implementation wouldn't look at a given field at all.)
- seems like an issue when the test passes a Message
object to the model, but the integrity of the test depends on
the model not mutating it? could be a problem but I've found it
makes certain things convenient
Hmm, yeah. I'm inclined to let this slide in these tests — partly because this data structure isn't expected to keep Message objects around, and so it's less likely it would mutate them.
If or when in some other test we find we do want to be careful about that, then probably one helpful tool would be to add a Message.copyWith
method. Could annotate it @visibleForTesting
, or even define it in test code as an extension method, to avoid absent-mindedly using it in the app code (where using it would probably be a bug).
test/model/unreads_test.dart
Outdated
assert((() { | ||
final messageIds = messages.map((m) => m.id); | ||
return Set.of(messageIds).length == messageIds.length; | ||
})(), 'checkMatchesMessages: duplicate messages in test input'); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: can tighten a bit:
assert((() { | |
final messageIds = messages.map((m) => m.id); | |
return Set.of(messageIds).length == messageIds.length; | |
})(), 'checkMatchesMessages: duplicate messages in test input'); | |
assert(Set.of(messages.map((m) => m.id)).length == messages.length, | |
'checkMatchesMessages: duplicate messages in test input'); |
test/model/unreads_test.dart
Outdated
for (final message in messages) { | ||
if (message.flags.contains(MessageFlag.read)) { | ||
return; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
continue
, right?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Uh oh! Yep! Looks like two test failures after fixing this; investigating.
isUnread ? 'unread' : 'read', | ||
isStream ? 'stream' : 'dm', | ||
isDirectMentioned ? 'direct mentioned' : 'not direct mentioned', | ||
isWildcardMentioned ? 'wildcard mentioned' : 'not wildcard mentioned', | ||
].join(' / '); | ||
test(description, () { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Neat.
test/model/unreads_test.dart
Outdated
if (!isUnread) MessageFlag.read, | ||
if (isDirectMentioned) MessageFlag.mentioned, | ||
if (isWildcardMentioned) MessageFlag.wildcardMentioned, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit:
if (!isUnread) MessageFlag.read, | |
if (isDirectMentioned) MessageFlag.mentioned, | |
if (isWildcardMentioned) MessageFlag.wildcardMentioned, | |
if (!isUnread) MessageFlag.read, | |
if (isDirectMentioned) MessageFlag.mentioned, | |
if (isWildcardMentioned) MessageFlag.wildcardMentioned, |
or else aligned
test/model/unreads_test.dart
Outdated
for (final [ | ||
oldStream as ZulipStream, newStream as ZulipStream, | ||
oldTopic as String, newTopic as String, | ||
] in [ | ||
[stream1, stream1, 'a', 'a'], |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can avoid the downcasts by using tuples instead of lists:
for (final [ | |
oldStream as ZulipStream, newStream as ZulipStream, | |
oldTopic as String, newTopic as String, | |
] in [ | |
[stream1, stream1, 'a', 'a'], | |
for (final (oldStream, newStream, oldTopic, newTopic) in [ | |
(stream1, stream1, 'a', 'a'), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cool; I've done this here and elsewhere.
test/model/unreads_test.dart
Outdated
userIds: DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId) | ||
.allRecipientIds | ||
.where((id) => id != eg.selfUser.userId) | ||
.toList(), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is something we often need on a DmNarrow, so there's a property for it:
userIds: DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId) | |
.allRecipientIds | |
.where((id) => id != eg.selfUser.userId) | |
.toList(), | |
userIds: DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId) | |
.otherRecipientIds, |
(and it's late final
, so it's lazily computed but memoized).
test/example_data.dart
Outdated
return DmMessage.fromJson({ | ||
return DmMessage.fromJson(deepToJson({ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Huh, was this just not working before? Surely we were using it…
aha, I guess the flags
argument probably stopped working here when we converted them from strings to an enum.
Anyway, good for a small prep commit so it can get its own quick commit message.
// setUnion dedupes existing and incoming unread IDs, | ||
// so we tolerate zulip/zulip#22164, fixed in 6.0 | ||
// TODO(server-6) remove 6.0 comment | ||
(existing) => setUnion(existing, messageIds), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This would be good to test. Similarly its streams counterpart.
_addAllInStreamTopic(incomingMessageIds..sort(), incomingStreamId, incomingTopic); | ||
}); | ||
}); | ||
newlyUnreadInDms.forEach((incomingDmNarrow, incomingMessageIds) { | ||
_addAllInDm(incomingMessageIds..sort(), incomingDmNarrow); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These sort
calls would be good to test.
if (detail == null) { // TODO(log) if on Zulip 6.0+ | ||
// Happens as a bug in some cases before fixed in Zulip 6.0: | ||
// https://chat.zulip.org/#narrow/stream/378-api-design/topic/unreads.20in.20unsubscribed.20streams/near/1458467 | ||
// TODO(server-6) remove Zulip 6.0 comment | ||
continue; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This would be good to test.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OK, and finished reading everything! (In particular the parts I left out in the previous round of review: #304 (review) .) Comments below, mostly on comments.
@@ -230,7 +230,7 @@ class UnreadMessagesSnapshot { | |||
final List<int> mentions; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Rereading the zulip-mobile type definitions here, I see we have:
// Unlike other lists of message IDs here, `mentions` is *not* sorted.
That warning is probably a good one to have in a comment here, too.
(It looks like the PR's data structure already has no trouble with that, as it promptly converts it to a Set
and stops caring about the order.)
lib/api/model/initial_snapshot.dart
Outdated
@@ -259,7 +259,7 @@ class UnreadDmSnapshot { | |||
|
|||
UnreadDmSnapshot({ | |||
required this.otherUserId, | |||
required this.unreadMessageIds, | |||
required this.unreadMessageIds, // TODO assert sorted? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For the "TODO assert sorted?" comments in initial_snapshot.dart,
see:
https://chat.zulip.org/#narrow/stream/412-api-documentation/topic/register.3A.20.60unread_message_ids.60.20sorted.3F/near/1647932
(before merging we should make sure the doc will guarantee sorting)
I think from the state of that thread we can already rely on this (note Tim thumbs-up'd Alex's comment), though it'd be good to bump the thread to try to get the guarantee into the docs proper.
It'd be reasonable to have an assert that these are sorted; that'd just mean that if there is someday a server bug that breaks that invariant, and we happen to run across it while using a debug build, then we'll learn about it.
Could do it as an assert in the constructor, like at DmNarrow
. Maybe take that same helper _isSortedWithoutDuplicates
and move it somewhere like algorithms.dart
to be shared.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think I was originally thinking a check that would actually throw in production, since it'll always be a problem if it happens. Like we throw in production when e.g. mark-as-unread events are missing a message_details
property:
factory UpdateMessageFlagsRemoveEvent.fromJson(Map<String, dynamic> json) {
final result = _$UpdateMessageFlagsRemoveEventFromJson(json);
// Crunchy-shell validation
if (
result.flag == MessageFlag.read
&& true // (we assume `event_types` has `message` and `update_message_flags`)
) {
result.messageDetails as Map<int, UpdateMessageFlagsMessageDetail>;
}
return result;
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unless I hear otherwise I'll make it a regular assert
in my next revision, but I'll add tests that the assert
is there, which we can easily adapt to logic that throws in production (in which case tests will be necessary)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think I was originally thinking a check that would actually throw in production, since it'll always be a problem if it happens.
Yeah, the reason I hesitate to add a check like that is that it's not free — it's a linear-time scan through each of these lists, so scanning through 50k elements at initial-fetch time in the case of a user with lots of unreads. That's something we could certainly afford if we had a good reason we needed it (we're unavoidably doing linear work on this data to process it, and this would be cheaper than other things we do); but since the server already gives us a guarantee, better not to spend time cross-checking it.
lib/model/unreads.dart
Outdated
/// In each component of this model ([streams], [dms], [mentions]), | ||
/// if a message is not represented, its status is either read | ||
/// or unknown to the component. Absence implies no other meaning, like muted. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't follow this last sentence. Perhaps unpack it into more words?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
lib/model/unreads.dart
Outdated
/// Unread DM messages, as: DM narrow → message ID. | ||
final Map<DmNarrow, QueueList<int>> dms; | ||
|
||
/// Messages with the self-user @-mentioned, directly or by wildcard. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
/// Messages with the self-user @-mentioned, directly or by wildcard. | |
/// Unread messages with the self-user @-mentioned, directly or by wildcard. |
lib/model/unreads.dart
Outdated
/// Implemented to track actual unread state as faithfully as possible. | ||
/// Callers should do their own filtering based on other state, like muting, | ||
/// as desired. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This remark applies to the whole class, right?
lib/model/unreads.dart
Outdated
/// If its state is actually unread, [mentions] recovers that knowledge when: | ||
/// a) the message is edited at all ([UpdateMessageEvent]), or | ||
/// b) the message gains a direct @-mention ([UpdateMessageFlagsEvent]), or | ||
/// c) (unimplemented) the user loads the message in the message list |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How about:
/// c) (unimplemented) the user loads the message in the message list | |
/// c) TODO unimplemented: the user loads the message in the message list |
That way when looking through TODO comments, there's one fewer layer of indirection to learn about the content of this one.
lib/model/unreads.dart
Outdated
/// Initialized to the value of [UnreadMessagesSnapshot.oldUnreadsMissing]. | ||
/// Is set to false when the user clears out all unreads at once | ||
/// (signaled by a [UpdateMessageFlagsAddEvent] with [MessageFlag.read] and | ||
/// `true` for [UpdateMessageFlagsAddEvent.all]). | ||
bool oldUnreadsMissing; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
/// Initialized to the value of [UnreadMessagesSnapshot.oldUnreadsMissing]. | |
/// Is set to false when the user clears out all unreads at once | |
/// (signaled by a [UpdateMessageFlagsAddEvent] with [MessageFlag.read] and | |
/// `true` for [UpdateMessageFlagsAddEvent.all]). | |
bool oldUnreadsMissing; | |
/// Initialized to the value of [UnreadMessagesSnapshot.oldUnreadsMissing], | |
/// and set to false if the user clears out all unreads at once. | |
bool oldUnreadsMissing; |
I think the details of how the latter is signaled are probably best read in the implementation, by just looking for references to this field.
(This is perhaps a difference from the style one wants in the upstream Flutter docs — those are meant to be read on the web, without consulting the implementation.)
(Or I guess a different angle: both this class, and its callers, are on the same side of the API this is describing — they're really both consumers of it. If at some point there's a reason for this logic to change, then this code will just unapologetically make the change; any other code that cares will change too, but it won't be because Unreads
changed, rather it'll be because of the same Zulip API change that caused Unreads
to change.)
lib/model/unreads.dart
Outdated
for (final unreadStreamSnapshot in initial.streams) { | ||
final streamId = unreadStreamSnapshot.streamId; | ||
final topic = unreadStreamSnapshot.topic; | ||
|
||
(streams[streamId] ??= {})[topic] = QueueList.from(unreadStreamSnapshot.unreadMessageIds); | ||
} | ||
|
||
for (final unreadDmSnapshot in initial.dms) { | ||
final otherUserId = unreadDmSnapshot.otherUserId; | ||
|
||
final narrow = DmNarrow.withUser(otherUserId, selfUserId: selfUserId); | ||
dms[narrow] = QueueList.from(unreadDmSnapshot.unreadMessageIds); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: each of these loops makes a nice single stanza:
for (final unreadStreamSnapshot in initial.streams) { | |
final streamId = unreadStreamSnapshot.streamId; | |
final topic = unreadStreamSnapshot.topic; | |
(streams[streamId] ??= {})[topic] = QueueList.from(unreadStreamSnapshot.unreadMessageIds); | |
} | |
for (final unreadDmSnapshot in initial.dms) { | |
final otherUserId = unreadDmSnapshot.otherUserId; | |
final narrow = DmNarrow.withUser(otherUserId, selfUserId: selfUserId); | |
dms[narrow] = QueueList.from(unreadDmSnapshot.unreadMessageIds); | |
} | |
for (final unreadStreamSnapshot in initial.streams) { | |
final streamId = unreadStreamSnapshot.streamId; | |
final topic = unreadStreamSnapshot.topic; | |
(streams[streamId] ??= {})[topic] = QueueList.from(unreadStreamSnapshot.unreadMessageIds); | |
} | |
for (final unreadDmSnapshot in initial.dms) { | |
final otherUserId = unreadDmSnapshot.otherUserId; | |
final narrow = DmNarrow.withUser(otherUserId, selfUserId: selfUserId); | |
dms[narrow] = QueueList.from(unreadDmSnapshot.unreadMessageIds); | |
} |
lib/model/unreads.dart
Outdated
// TODO explore a different strategy with help from timing measurements: | ||
// https://github.com/zulip/zulip-flutter/pull/304#discussion_r1326660466 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think you've now done the measurements to conclude this strategy is fine:
#304 (comment)
As Greg suggests: zulip#304 (comment)
…ctor As Greg points out (before withUsers was added, but it should apply to that too): zulip#304 (comment) > [withUser] and `ofMessage` and the plain one are the really > general-purpose constructors; as the number of special-purpose > constructors grows, it starts getting pushed down where it's > harder to spot.
8f30bfe
to
af399c6
Compare
Thanks for the review! Revision pushed, so you can look at the code, and I'll do some stopwatching next. |
Here's with a profile build running on my iPhone 13 Pro (iOS 16.6.1). This is with a test account on CZO with lots of unreads (the web app says 52,424 for "Inbox" after this experiment.) We see the time spent in the
|
OK, and now after marking all 526 of this test user's DMs unread, here's going through the
|
In two trials of this, the mark-as-unread operation took 2ms and 1ms to handle. |
Here's alternately marking the 728 latest messages in CZO's
(The 0ms is the mark as unread.) |
Here's sending messages as the self-user to
(Quick and easy, as expected; the model sees that these new incoming messages are read and early-returns.) And sending messages as a different user in the same topic, so that they're unread when they come in:
The code has more to do in this case, but nothing we've marked as being "slow" with a perf TODO, so it's unsurprising that those timings are acceptable. (In these trials, the topic had no unreads in it, but reading the implementation, I don't expect that to make a difference.) (Just tried another send-message-as-different user, this time mentioning the self-user test account. Also 0ms; we don't expect handling a new message with a mention to be expensive.) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
test/example_data.dart
Outdated
}) { | ||
return UpdateMessageFlagsRemoveEvent( | ||
id: 0, | ||
flag: MessageFlag.read, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This should be flag: flag
, right?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oops! Yep, thanks for the catch.
test/example_data.dart
Outdated
final mentioned = message.flags.contains(MessageFlag.mentioned) | ||
|| message.flags.contains(MessageFlag.wildcardMentioned); | ||
|
||
return MapEntry( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit:
final mentioned = message.flags.contains(MessageFlag.mentioned) | |
|| message.flags.contains(MessageFlag.wildcardMentioned); | |
return MapEntry( | |
final mentioned = message.flags.contains(MessageFlag.mentioned) | |
|| message.flags.contains(MessageFlag.wildcardMentioned); | |
return MapEntry( |
af399c6
to
04e199b
Compare
Thanks for those comments! Revision pushed, addressing them. |
Cool, thanks for those timings. It sounds like the mark-as-read handler takes about the same amount of time as you found in an earlier revision: #304 (comment)
in that it's now typically about 14ms, and you observed one at 21ms and one at 22ms. I'm a bit surprised that the change here: #304 (comment) didn't help; but that's how it goes sometimes when trying to optimize. (Especially when doing so without first looking at a profile of where the time is going.) Still not worse than zulip-mobile, though, as discussed here: I'll be fine with merging it with the current performance, but will definitely be interested in trying to optimize it later. Given how very fast the constructor is in particular, I feel like it should be possible to get mark-as-read to be much faster — in particular I think it fundamentally shouldn't be any slower than the constructor. And taking 14ms is enough that it'll often cause a dropped frame, so that's worth avoiding. |
Editing an unread message (as a non-self-user) in
Also expected to be quick, so no surprise here. (I assume that, as expected, the @-mention changes were signaled just by update-message events, not update-message-flags events.) |
A few delete-message events (of unread messages), for stream messages and DMs, just showed up as 0ms too. (That's not a really common case but we don't want it to be catastrophically slow.) |
Thanks for the quick revision! Merging that commit and one other like it: as: I tweaked the commit-message prefixes to match what we've been using, and added a commit on top with some further organization for that file |
For the mark-as-read handler (with |
As Greg suggests: zulip#304 (comment)
See discussion, which should produce a docs change soon: https://chat.zulip.org/#narrow/stream/412-api-documentation/topic/register.3A.20.60unread_message_ids.60.20sorted.3F/near/1647932
…ctor As Greg points out (before withUsers was added, but it should apply to that too): zulip#304 (comment) > [withUser] and `ofMessage` and the plain one are the really > general-purpose constructors; as the number of special-purpose > constructors grows, it starts getting pushed down where it's > harder to spot.
Transcribed from the same-named function in zulip-mobile: see src/immutableUtils.js.
04e199b
to
468d7fb
Compare
Thanks! Just rebased atop that and #330, removed the stopwatching commit, and unmarked as draft! 🎉 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks @chrisbobbe for all your work on this! All looks great modulo one small comment below.
test/model/unreads_test.dart
Outdated
]; | ||
|
||
group('DM narrow subtypes', () { | ||
for (final [desc as String, from as User, to as List<User>] in variousDms) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This and the next group have a handful of stragglers from #304 (comment) .
468d7fb
to
0266ff4
Compare
Thanks for the review! Revision pushed, fixing those stragglers. |
Thanks! Looks good; merging. |
Sometimes we get feedback from a lint rule -- https://dart.dev/tools/linter-rules/avoid_function_literals_in_foreach_calls -- whose documentation recommends using for loops instead of forEach. That rule didn't fire on these instances; shrug; but this file has had a mix of for loops and forEach loops, and it seems nicer to consistently use one or the other. Just in case there was a performance bug in a Map.forEach implementation, I tested mark-as-read (by scrolling through the web app) to see if we might get a free performance benefit there, where the norm on my device (with a profile build) has been to take about 14ms per event: zulip#304 (comment) But, unsurprisingly, it stayed at around 14ms as before.
Sometimes we get feedback from a lint rule -- https://dart.dev/tools/linter-rules/avoid_function_literals_in_foreach_calls -- whose documentation recommends using for loops instead of forEach. That rule didn't fire on these instances; not sure why. But anyway this file has had a mix of for loops and forEach loops, and it seems nicer to consistently use one or the other. Just in case there was a performance bug in a Map.forEach implementation, I tested mark-as-read (by scrolling through the web app) to see if we might get a free performance benefit there, where the norm on my device (with a profile build) has been to take about 14ms per event: #304 (comment) But, unsurprisingly, it stayed at around 14ms as before.
Sometimes we get feedback from a lint rule -- https://dart.dev/tools/linter-rules/avoid_function_literals_in_foreach_calls -- whose documentation recommends using for loops instead of forEach. That rule didn't fire on these instances; not sure why. But anyway this file has had a mix of for loops and forEach loops, and it seems nicer to consistently use one or the other. Just in case there was a performance bug in a Map.forEach implementation, I tested mark-as-read (by scrolling through the web app) to see if we might get a free performance benefit there, where the norm on my device (with a profile build) has been to take about 14ms per event: zulip#304 (comment) But, unsurprisingly, it stayed at around 14ms as before.
Fixes #253.
Contains my current revision of #299.
This is still in progress, but thought I'd check in with what I have so far, and see if it's going in the right direction. 🙂