-
Notifications
You must be signed in to change notification settings - Fork 350
message: Consider unsubscribed channels in reconcileMessages #1912
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
message: Consider unsubscribed channels in reconcileMessages #1912
Conversation
|
(Oops, I'll need to rethink this a bit following some discussion with Greg in the office.) |
3612e86 to
4fa0a1a
Compare
gnprice
left a 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.
Thanks! At #1798 (comment) you asked for an initial look at this logic, so comments on that below.
lib/model/message.dart
Outdated
| // If the message is one we already know about (from a fetch), | ||
| // clobber it with the one from the event system. | ||
| // See [fetchedMessages] for reasoning. |
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.
(Is this existing comment meant to refer to reconcileMessages? I think fetchedMessages was a previous name for that.)
| /// (We have seen a few such events, actually -- | ||
| /// maybe because the channel was recently subscribed? -- | ||
| /// but not consistently, and we're not supposed to rely on them.) |
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 have seen a few such events, actually -- | |
| /// maybe because the channel was recently subscribed? -- | |
| /// but not consistently, and we're not supposed to rely on them.) | |
| /// (We have seen a few such events, actually -- | |
| /// maybe because the channel was recently unsubscribed? -- | |
| /// but not consistently, and we're not supposed to rely on them.) |
?
lib/model/message.dart
Outdated
| // [1] With one exception: if the version we have was in an unsubscribed | ||
| // channel when we got it or sometime since, we take the fetched version |
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 like this formulation of which messages go in _maybeStaleChannelMessages. It'd be good to put it in that field's doc.
lib/model/message.dart
Outdated
| // Side effect: update our "potentially stale" knowledge based on | ||
| // whether the channel is subscribed right now. | ||
| && (subscriptions[message.streamId] != null | ||
| ? _maybeStaleChannelMessages.remove(message.id) | ||
| : !_maybeStaleChannelMessages.add(message.id)); |
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's a bit confusing that we need this side effect on the maybe-stale set here. I think this logic could benefit from some unpacking.
In particular, it seems like there are really four cases here:
- currently subscribed vs. not;
- for each of those, currently in the maybe-stale set or not.
Thanks to Greg for pointing this out: zulip#1912 (comment)
4fa0a1a to
6ed2244
Compare
|
Thanks for taking a look! Revision pushed. |
e8f8054 to
a9b4906
Compare
gnprice
left a 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.
Thanks! I think that's helpful. A couple of comments below on the new version.
| Message _stripMatchFields(Message message) { | ||
| message.matchContent = null; | ||
| message.matchTopic = null; | ||
| return message; | ||
| } |
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.
The factoring out of this step could happen in a prep commit. That would simplify the complexity of the main commit a bit — it'd be moving around where an expression like _stripMatchFields(incoming) appears, and adding a case where something else gets used instead, but the expression itself would remain the same.
(Or maybe the main commit would change from "message" to "incoming", but anyway that's a rather smaller change.)
lib/model/message.dart
Outdated
| Message _reconcileWhenPresent(Message current, Message incoming) { | ||
| bool currentIsMaybeStale = false; | ||
| if (incoming is StreamMessage) { | ||
| if (subscriptions[incoming.streamId] != null) { | ||
| // The incoming message won't grow stale; it's in a subscribed channel. | ||
| // Remove it from _maybeStaleChannelMessages if it was there. | ||
| currentIsMaybeStale = _maybeStaleChannelMessages.remove(incoming.id); | ||
| } else { | ||
| assert(_maybeStaleChannelMessages.contains(incoming.id)); | ||
| currentIsMaybeStale = true; | ||
| } | ||
| } | ||
|
|
||
| return currentIsMaybeStale | ||
| ? _stripMatchFields(incoming) | ||
| : current; |
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 this logic would benefit from having the explanation in the long comment above worked into it.
Probably that mostly means just moving that comment from reconcileMessages into this method. After all, it starts with:
What to do when some of the just-fetched messages are already known?
so it's entirely about this "present" case.
Beyond that, I think once the comment is here in this method, the "normally" vs. "with one exception" cases can be worked a bit into the corresponding cases in this code's control flow.
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.
That makes a lot of sense, thanks!!
lib/model/message.dart
Outdated
| } | ||
| } | ||
|
|
||
| Message _reconcileWhenPresent(Message current, Message incoming) { |
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: maybe give these names that are a bit more semantic: like "reconcile known message" vs. "reconcile unknown message" (or "new" or "previously unknown"?)
lib/model/message.dart
Outdated
| : current; | ||
| } | ||
|
|
||
| Message _reconcileWhenAbsent(Message incoming) { |
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: maybe put the absent/unknown case first — it happens first chronologically, after all (except in the case of a message we learn about from an event), and it's also simpler, so I think conceptually prior
(can do the same at the call site too, thanks to Dart accepting named arguments before positional)
Thanks to Greg for pointing this out: zulip#1912 (comment)
a9b4906 to
6f75b2b
Compare
|
Thanks for the review! Revision pushed. |
|
Thanks! I think that revision is helpful. Over to @rajveermalviya for normal maintainer-review. |
rajveermalviya
left a 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.
Thanks @chrisbobbe! LGTM, and tests great.
Moving over to Greg's review.
|
Thanks for the review! |
gnprice
left a 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.
Thanks! Comments below after a first full review.
lib/model/message.dart
Outdated
|
|
||
| messages[i] = this.messages.update(message.id, |
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:
| messages[i] = this.messages.update(message.id, | |
| messages[i] = this.messages.update(message.id, |
lib/model/message.dart
Outdated
| // This message is one we already know about. This is common and normal: | ||
| // in particular it happens when one message list overlaps another, | ||
| // e.g. a stream and a topic within it. |
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: keep the semantic line-breaks the existing comment has:
| // This message is one we already know about. This is common and normal: | |
| // in particular it happens when one message list overlaps another, | |
| // e.g. a stream and a topic within it. | |
| // This message is one we already know about. | |
| // This is common and normal: in particular it happens when one message list | |
| // overlaps another, e.g. a stream and a topic within it. |
Separately, I think saying "just-fetched" here is helpful for connecting to the later paragraphs, and setting context of what "this" message is:
| // This message is one we already know about. This is common and normal: | |
| // in particular it happens when one message list overlaps another, | |
| // e.g. a stream and a topic within it. | |
| // This just-fetched message is one we already know about. | |
| // This is common and normal: in particular it happens when one message list | |
| // overlaps another, e.g. a stream and a topic within it. |
lib/model/message.dart
Outdated
| if (channelIds.length > 1) { | ||
| assert(channelIds is! Set); | ||
| channelIds = Set.from(channelIds); // optimization | ||
| } |
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.
At length 2, for a set of ints, I think List.contains is going to be considerably faster than Set.contains.
The former just has to take the "needle" int in question and compare it against two other ints in turn. The latter has to take a hash of the needle, look to see if there's an entry at the resulting spot in the hash table, then compare the needle against what it finds there.
Similarly for a handful of ints. At a guess, the breakpoint I'd use for this optimization would be 8 rather than 1.
(If this were a likely hot path so the optimization was important then naturally we'd want to measure. But it's definitely not worth that level of effort.)
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.
Oh I see, at one call site channelIds is a List.map iterable rather than a List itself.
I think that doesn't change much. The map callback is just looking up a field on an object; so basically it just adds one load from memory through a pointer. Maybe that tips my guess toward 4 as the breakpoint rather than 8 (I was already waffling a bit between those).
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.
Oh I see, at one call site
channelIdsis a List.map iterable rather than a List itself.
This is about to change to be a List too; we're about to add a List<int> channelIds to ChannelDeleteEvent; see 8b88703 🙂
So I'll use 8, and link to your comment here? 🙂
lib/model/message.dart
Outdated
| // See [fetchedMessages] for reasoning. | ||
| messages[event.message.id] = event.message; | ||
| // See [reconcileMessages] for reasoning. | ||
| messages[event.message.id] = message; |
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:
| messages[event.message.id] = message; | |
| messages[message.id] = message; |
| if (isChannelSubscribed) { | ||
| subscription = eg.subscription(stream); |
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.
Should set subscription to null in the alternative case — otherwise the previous test's value for it leaks into this test
test/model/message_test.dart
Outdated
| check(messages).single.identicalTo(message); | ||
| check(store.messages).deepEquals({1: message}); | ||
| group('fetched message with ID already in store.messages', () { | ||
| late Message messageCopy; |
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 can avoid a shared variable here — instead make it local within each test body, by just inserting Message before the first assignment.
test/model/message_test.dart
Outdated
| Message copyStoredMessage({String? displayRecipient}) { | ||
| final message = store.messages.values.single; | ||
|
|
||
| Map<String, dynamic> json = message.toJson(); |
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:
| Map<String, dynamic> json = message.toJson(); | |
| final json = message.toJson(); |
test/model/message_test.dart
Outdated
| messageCopy = copyStoredMessage(); | ||
| store.reconcileMessages([messageCopy]); | ||
| // The channel became subscribed, | ||
| // but the message's data hasn't been refreshed, so clobber… | ||
| checkStoredMessageIdenticalTo(messageCopy); | ||
|
|
||
| store.reconcileMessages([copyStoredMessage()]); | ||
| // …Now it's been refreshed, by reconcileMessages, so don't clobber. | ||
| checkStoredMessageIdenticalTo(messageCopy); |
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 test case is a bit long and fiddly to read. I think it could be simplified with a pair of helpers along these lines:
| messageCopy = copyStoredMessage(); | |
| store.reconcileMessages([messageCopy]); | |
| // The channel became subscribed, | |
| // but the message's data hasn't been refreshed, so clobber… | |
| checkStoredMessageIdenticalTo(messageCopy); | |
| store.reconcileMessages([copyStoredMessage()]); | |
| // …Now it's been refreshed, by reconcileMessages, so don't clobber. | |
| checkStoredMessageIdenticalTo(messageCopy); | |
| // The channel became subscribed, | |
| // but the message's data hasn't been refreshed, so clobber… | |
| checkClobber(); | |
| // …Now it's been refreshed, by reconcileMessages, so don't clobber. | |
| checkNoClobber(); |
which should make it easier to scan and see all the different scenarios it's going through.
Thanks to Greg for pointing this out: zulip#1912 (comment)
6f75b2b to
bb7b66a
Compare
|
Thanks for the review! Revision pushed. |
test/model/message_test.dart
Outdated
| store.reconcileMessages([copyStoredMessage()]); | ||
| checkStoredMessageIdenticalTo(messageBefore); |
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.
| store.reconcileMessages([copyStoredMessage()]); | |
| checkStoredMessageIdenticalTo(messageBefore); | |
| final messageBefore = store.messages.values.single; | |
| store.reconcileMessages([copyStoredMessage()]); | |
| checkStoredMessageIdenticalTo(messageBefore); |
This would be equivalent, right? And it's getting the existing message from the same place copyStoredMessage is, so the logic remains coherent.
Then I think the caller doesn't need checkClobber to return anything, either.
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.
Oh nice, yep! Thanks!
gnprice
left a 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.
Thanks for the revision! This makes the tests much easier to read. Comments below (and one above).
test/model/message_test.dart
Outdated
| await store.addStream(otherChannel); | ||
|
|
||
| store.reconcileMessages([message]); | ||
| checkStoredMessageIdenticalTo(message); |
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.
Then I think this and the similar call below no longer help tell the story — "check no clobber" says it already, and this line doesn't particularly connect to that anyway.
| checkStoredMessageIdenticalTo(message); |
test/model/message_test.dart
Outdated
| final otherChannel = eg.stream(); | ||
| await store.addStream(otherChannel); |
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 these are clearest if moved to just before where they start getting used — i.e. to the start of the section where the message gets moved.
test/model/message_test.dart
Outdated
| await store.handleEvent( | ||
| eg.updateMessageEventMoveFrom(origMessages: [message], | ||
| newStreamId: otherChannel.streamId)); |
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:
| await store.handleEvent( | |
| eg.updateMessageEventMoveFrom(origMessages: [message], | |
| newStreamId: otherChannel.streamId)); | |
| await store.handleEvent( | |
| eg.updateMessageEventMoveFrom(origMessages: [message], | |
| newStreamId: otherChannel.streamId)); |
test/model/message_test.dart
Outdated
| test('various conditions', () async { | ||
| final channel = eg.stream(); | ||
| await prepare(stream: channel, isChannelSubscribed: true); | ||
| final message = eg.streamMessage(id: 1, stream: channel); |
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.
| final message = eg.streamMessage(id: 1, stream: channel); | |
| final message = eg.streamMessage(stream: channel); |
The message ID isn't relevant, right? (Similarly below.)
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.
bump on this one 🙂 (though you handled the others below)
| checkClobber(); | ||
| }); | ||
|
|
||
| test('new-message event when in unsubscribed channel', () async { |
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.
Another case to test: message event in subscribed channel
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 test is now quite easy to write! 😄
test('new-message event when in a subscribed channel', () async {
await prepare(isChannelSubscribed: true);
final message = eg.streamMessage();
await store.handleEvent(eg.messageEvent(message));
checkNoClobber();
checkNoClobber();
});Thanks to Greg for pointing this out: zulip#1912 (comment)
bb7b66a to
12ba5b8
Compare
|
Thanks for the review! Revision pushed. |
gnprice
left a 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.
Thanks! Two nits.
test/model/message_test.dart
Outdated
| .deepEquals({message.id: conditionIdentical(message)}); | ||
| } | ||
|
|
||
| Message checkClobber({Message? withMessageCopy}) { |
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: no need to return a value now, right?
(And the caller could always get the same value from store.messages.values.single.)
test/model/message_test.dart
Outdated
| test('various conditions', () async { | ||
| final channel = eg.stream(); | ||
| await prepare(stream: channel, isChannelSubscribed: true); | ||
| final message = eg.streamMessage(id: 1, stream: channel); |
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.
bump on this one 🙂 (though you handled the others below)
Thanks to Greg for pointing this out: zulip#1912 (comment)
12ba5b8 to
956fe13
Compare
|
Ah indeed, thanks!! Revision pushed. |
Thanks to Greg for pointing this out: zulip#1912 (comment)
It's way more common to be using the app with subscribed channels than unsubscribed channels, so we might as well test with subscribed channels except where we specifically want to check behavior for unsubscribed channels.
Like we did in the compose-box tests in the previous commit.
This fixes the "fourth buggy behavior" in zulip#1798: zulip#1798 (comment) Fixes-partly: zulip#1798
956fe13 to
2ab661c
Compare
|
Thanks! Looks good; merging. |
This fixes the "fourth buggy behavior" in #1798:
#1798 (comment)
Fixes-partly: #1798