Skip to content

Commit 8dc3fe6

Browse files
committed
unread: Use state.messages to optimize updating stream unreads.
Before this commit, when a message was marked as read we'd have to search through this entire data structure to find where it was so we could remove it. In fact, even if the message was actually a PM we'd end up searching through this whole data structure which is entirely about stream messages, because we didn't use that information. The same thing was true with the old data structure, before this series. Much better would be, when a message in a particular conversation gets marked as read, to go straight to that particular conversation's part of the data structure and update that without having to search through anything else. Do that. Knowing what conversation the message is in requires looking that information up in our data structures. Happily we can do that now (and without an intrusive hack like we've sometimes done in the past): that was #4437. This reduces the time spent in this reducer to 7ms in the slowest sample I've seen, or as little as <1ms (until recently the threshold of measurement), and the total time spent in dispatch to 110-120ms. Those compare with 30-50ms reducer / 150-200ms total before this commit, and with 70ms reducer / 300ms total before the whole series, using the old data structure. (Based on measuring the same way as described a few commits ago.) So that's an improvement of about 2.5x, or 180ms! The 110-120ms we're still spending, almost all of it now outside the reducer, still isn't *great*. But it's enough better that I think further optimization is no longer a top-priority thing for me to work on; and because the remaining problem isn't inside the reducer where I've been working and have built up the perf-logging tools to iterate on, it's beyond the scope of where it's just easy to keep going. So with this I'm declaring victory on #4438, the task of making this "unread" model efficient at marking a message as read. Fixes: #4438
1 parent 4849292 commit 8dc3fe6

File tree

2 files changed

+167
-59
lines changed

2 files changed

+167
-59
lines changed

src/unread/__tests__/unreadModel-test.js

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -146,19 +146,28 @@ describe('stream substate', () => {
146146
};
147147
};
148148

149-
const streamAction = args => mkMessageAction(eg.streamMessage(args));
149+
const messages = [
150+
eg.streamMessage({ stream_id: 123, subject: 'foo', id: 1 }),
151+
eg.streamMessage({ stream_id: 123, subject: 'foo', id: 2 }),
152+
eg.streamMessage({ stream_id: 123, subject: 'foo', id: 3 }),
153+
eg.streamMessage({ stream_id: 234, subject: 'bar', id: 4 }),
154+
eg.streamMessage({ stream_id: 234, subject: 'bar', id: 5 }),
155+
];
150156

151157
const baseState = (() => {
152158
const r = (state, action) => reducer(state, action, eg.plusReduxState);
153159
let state = initialState;
154-
state = r(state, streamAction({ stream_id: 123, subject: 'foo', id: 1 }));
155-
state = r(state, streamAction({ stream_id: 123, subject: 'foo', id: 2 }));
156-
state = r(state, streamAction({ stream_id: 123, subject: 'foo', id: 3 }));
157-
state = r(state, streamAction({ stream_id: 234, subject: 'bar', id: 4 }));
158-
state = r(state, streamAction({ stream_id: 234, subject: 'bar', id: 5 }));
160+
for (const message of messages) {
161+
state = r(state, mkMessageAction(message));
162+
}
159163
return state;
160164
})();
161165

166+
const baseGlobalState = eg.reduxStatePlus({
167+
messages: eg.makeMessagesState(messages),
168+
unread: baseState,
169+
});
170+
162171
test('(base state, for comparison)', () => {
163172
// prettier-ignore
164173
expect(summary(baseState)).toEqual(Immutable.Map([
@@ -169,36 +178,38 @@ describe('stream substate', () => {
169178

170179
test('when operation is "add" but flag is not "read" do not mutate state', () => {
171180
const action = mkAction({ messages: [1, 2, 3], flag: 'star' });
172-
expect(reducer(initialState, action, eg.plusReduxState)).toBe(initialState);
181+
expect(reducer(initialState, action, baseGlobalState)).toBe(initialState);
173182
});
174183

175184
test('if id does not exist do not mutate state', () => {
176185
const action = mkAction({ messages: [6, 7] });
177-
expect(reducer(baseState, action, eg.plusReduxState)).toBe(baseState);
186+
expect(reducer(baseState, action, baseGlobalState)).toBe(baseState);
178187
});
179188

180189
test('if ids are in state remove them', () => {
181190
const action = mkAction({ messages: [3, 4, 5, 6] });
182191
// prettier-ignore
183-
expect(summary(reducer(baseState, action, eg.plusReduxState))).toEqual(Immutable.Map([
192+
expect(summary(reducer(baseState, action, baseGlobalState))).toEqual(Immutable.Map([
184193
[123, Immutable.Map([['foo', [1, 2]]])],
185194
]));
186195
});
187196

188197
test("when removing, don't touch unaffected topics or streams", () => {
189-
const state = reducer(
190-
baseState,
191-
streamAction({ stream_id: 123, subject: 'qux', id: 7 }),
192-
eg.plusReduxState,
193-
);
198+
const message = eg.streamMessage({ stream_id: 123, subject: 'qux', id: 7 });
199+
const state = reducer(baseState, mkMessageAction(message), baseGlobalState);
200+
const globalState = eg.reduxStatePlus({
201+
messages: eg.makeMessagesState([...messages, message]),
202+
unread: state,
203+
});
204+
194205
// prettier-ignore
195206
expect(summary(state)).toEqual(Immutable.Map([
196207
[123, Immutable.Map([['foo', [1, 2, 3]], ['qux', [7]]])],
197208
[234, Immutable.Map([['bar', [4, 5]]])],
198209
]));
199210

200211
const action = mkAction({ messages: [1, 2] });
201-
const newState = reducer(state, action, eg.plusReduxState);
212+
const newState = reducer(state, action, globalState);
202213
// prettier-ignore
203214
expect(summary(newState)).toEqual(Immutable.Map([
204215
[123, Immutable.Map([['foo', [3]], ['qux', [7]]])],
@@ -210,12 +221,12 @@ describe('stream substate', () => {
210221

211222
test('when operation is "remove" do nothing', () => {
212223
const action = mkAction({ messages: [1, 2], operation: 'remove' });
213-
expect(reducer(baseState, action, eg.plusReduxState)).toBe(baseState);
224+
expect(reducer(baseState, action, baseGlobalState)).toBe(baseState);
214225
});
215226

216227
test('when "all" is true reset state', () => {
217228
const action = mkAction({ messages: [], all: true });
218-
expect(reducer(baseState, action, eg.plusReduxState).streams).toBe(initialState.streams);
229+
expect(reducer(baseState, action, baseGlobalState).streams).toBe(initialState.streams);
219230
});
220231
});
221232
});

src/unread/unreadModel.js

Lines changed: 139 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -44,52 +44,153 @@ export const getUnreadMentions = (state: GlobalState): UnreadMentionsState => st
4444

4545
const initialStreamsState: UnreadStreamsState = Immutable.Map();
4646

47-
// Like `Immutable.Map#map`, but with the update-only-if-different semantics
48-
// of `Immutable.Map#update`. Kept for comparison to `updateAllAndPrune`.
49-
/* eslint-disable-next-line no-unused-vars */
50-
function updateAll<K, V>(map: Immutable.Map<K, V>, updater: V => V): Immutable.Map<K, V> {
51-
return map.withMutations(mapMut => {
52-
map.forEach((value, key) => {
53-
const newValue = updater(value);
54-
if (newValue !== value) {
55-
mapMut.set(key, newValue);
56-
}
57-
});
58-
});
59-
}
60-
61-
// Like `updateAll`, but prune values equal to `zero` given by `updater`.
62-
function updateAllAndPrune<K, V>(
47+
// Like `Immutable.Map#update`, but prune returned values equal to `zero`.
48+
function updateAndPrune<K, V>(
6349
map: Immutable.Map<K, V>,
6450
zero: V,
65-
updater: V => V,
51+
key: K,
52+
updater: (V | void) => V,
6653
): Immutable.Map<K, V> {
67-
return map.withMutations(mapMut => {
68-
map.forEach((value, key) => {
69-
const newValue = updater(value);
70-
if (newValue === zero) {
71-
mapMut.delete(key);
72-
}
73-
if (newValue === value) {
74-
return; // i.e., continue
75-
}
76-
mapMut.set(key, newValue);
77-
});
78-
});
54+
const value = map.get(key);
55+
const newValue = updater(value);
56+
if (newValue === zero) {
57+
return map.delete(key);
58+
}
59+
if (newValue === value) {
60+
return map;
61+
}
62+
return map.set(key, newValue);
63+
}
64+
65+
/**
66+
* Remove the given values from the list.
67+
*
68+
* This is equivalent to
69+
* list_.filter(x => toDelete.indexOf(x) < 0)
70+
* but more efficient.
71+
*
72+
* Specifically, for n items in the list and k to delete, this takes time
73+
* O(n log n) in the worst case.
74+
*
75+
* In the case where the items to delete all appear at the beginning of the
76+
* list, and in the same order, it takes time O(k log n). (This is the
77+
* common case when marking messages as read, which motivates this
78+
* optimization.)
79+
*/
80+
// In principle this should be doable in time O(k + log n) in the
81+
// all-at-start case. We'd need the iterator on Immutable.List to support
82+
// iterating through the first k elements in O(k + log n) time. It seems
83+
// like it should be able to do that, but the current implementation (as of
84+
// Immutable 4.0.0-rc.12) takes time O(k log n): each step of the iterator
85+
// passes through a stack of log(n) helper functions. Ah well.
86+
//
87+
// The logs are base 32, so in practice our log(n) is never more than 3
88+
// (which would be enough for 32**3 = 32768 items), usually at most 2
89+
// (enough for 1024 items); and for the messages in one conversation, very
90+
// commonly 1, i.e. there are commonly just ≤32 messages. So the difference
91+
// between O(k log n) and O(k + log n) might be noticeable but is unlikely
92+
// to be catastrophic.
93+
function deleteFromList<V>(
94+
list_: Immutable.List<V>,
95+
toDelete_: Immutable.List<V>,
96+
): Immutable.List<V> {
97+
// Alias the parameters because Flow doesn't accept mutating them.
98+
let list = list_;
99+
let toDelete = toDelete_;
100+
101+
// First, see if some items to delete happen to be at the start, and
102+
// remove those. This is the common case for marking messages as read,
103+
// so it's worth some effort to optimize. And we can do it efficiently:
104+
// for deleting the first k out of n messages, we take time O(k log n)
105+
// rather than O(n).
106+
107+
const minSize = Math.min(list.size, toDelete.size);
108+
let i = 0;
109+
for (; i < minSize; i++) {
110+
// This loop takes time O(log n) per iteration, O(k log n) total.
111+
if (list.get(i) !== toDelete.get(i)) {
112+
break;
113+
}
114+
}
115+
116+
if (i > 0) {
117+
// This takes time O(log n).
118+
list = list.slice(i);
119+
// This takes time O(log k) ≤ O(log n).
120+
toDelete = toDelete.slice(i);
121+
}
122+
123+
// That might have been all the items we wanted to delete.
124+
// In fact that's the most common case when marking items as read.
125+
if (toDelete.isEmpty()) {
126+
return list;
127+
}
128+
129+
// It wasn't; we have more to delete. We'll have to find them in the
130+
// middle of the list and delete them wherever they are.
131+
//
132+
// This takes time O(n log n), probably (though an ideal implementation of
133+
// Immutable should be able to make it O(n).)
134+
const toDeleteSet = new Set(toDelete);
135+
return list.filterNot(id => toDeleteSet.has(id));
79136
}
80137

138+
/**
139+
* Delete the given messages from the unreads state.
140+
*
141+
* Relies on `globalMessages` to look up exactly where in the unreads data
142+
* structure the messages are expected to appear.
143+
*
144+
* This is efficient at deleting some messages even when the total number of
145+
* existing messages is much larger. Specifically the time spent should be
146+
* O(N' log n + c log C), where the messages to delete appear in c out of a
147+
* total of C conversations, and the affected conversations have a total of
148+
* N' messages and at most n in any one conversation. If the messages to be
149+
* deleted are all at the start of the list for their respective
150+
* conversations the time should be O(k log n + c log C), where there are
151+
* k messages to delete.
152+
*
153+
* For the common case of marking some messages as read, we expect that all
154+
* the affected messages will indeed be at the start of their respective
155+
* conversations, and the number c of affected conversations will be small,
156+
* typically 1. (It could be more than 1 if reading a stream narrow, or
157+
* other interleaved narrow.)
158+
*/
81159
function deleteMessages(
82160
state: UnreadStreamsState,
83161
ids: $ReadOnlyArray<number>,
162+
globalMessages,
84163
): UnreadStreamsState {
85-
const idSet = new Set(ids);
86-
const toDelete = id => idSet.has(id);
164+
const byConversation =
165+
// prettier-ignore
166+
(Immutable.Map(): Immutable.Map<number, Immutable.Map<string, Immutable.List<number>>>)
167+
.withMutations(mut => {
168+
for (const id of ids) {
169+
const message = globalMessages.get(id);
170+
if (!message || message.type !== 'stream') {
171+
continue;
172+
}
173+
const { stream_id, subject: topic } = message;
174+
mut.updateIn([stream_id, topic], (l = Immutable.List()) => l.push(id));
175+
}
176+
});
177+
178+
const emptyMap = Immutable.Map();
87179
const emptyList = Immutable.List();
88-
return updateAllAndPrune(state, Immutable.Map(), perStream =>
89-
updateAllAndPrune(perStream, emptyList, perTopic =>
90-
perTopic.find(toDelete) ? perTopic.filterNot(toDelete) : perTopic,
91-
),
92-
);
180+
// prettier-ignore
181+
return state.withMutations(stateMut => {
182+
byConversation.forEach((byTopic, streamId) => {
183+
updateAndPrune(stateMut, emptyMap, streamId, perStream =>
184+
perStream && perStream.withMutations(perStreamMut => {
185+
byTopic.forEach((msgIds, topic) => {
186+
updateAndPrune(perStreamMut, emptyList, topic, perTopic =>
187+
perTopic && deleteFromList(perTopic, msgIds),
188+
);
189+
});
190+
}),
191+
);
192+
});
193+
});
93194
}
94195

95196
function streamsReducer(
@@ -142,8 +243,7 @@ function streamsReducer(
142243
}
143244

144245
case EVENT_MESSAGE_DELETE:
145-
// TODO optimize by using `state.messages` to look up directly
146-
return deleteMessages(state, action.messageIds);
246+
return deleteMessages(state, action.messageIds, globalState.messages);
147247

148248
case EVENT_UPDATE_MESSAGE_FLAGS: {
149249
if (action.flag !== 'read') {
@@ -159,10 +259,7 @@ function streamsReducer(
159259
return state;
160260
}
161261

162-
// TODO optimize by using `state.messages` to look up directly.
163-
// Then when do, also optimize so deleting the oldest items is fast,
164-
// as that should be the common case here.
165-
return deleteMessages(state, action.messages);
262+
return deleteMessages(state, action.messages, globalState.messages);
166263
}
167264

168265
default:

0 commit comments

Comments
 (0)