Skip to content

Commit 91ccf50

Browse files
committed
unread: Convert unread.streams to Immutable!
This makes us quite a bit faster at handling a message being marked as read! That's a frequent and latency-sensitive path -- whenever the user is reading a conversation and sees some messages that were unread, this is part of the path to updating the "N unreads" banner at the top of their view. Measurements described below. As we convert the other parts of the unread state, we'll want to fold their reducers into this file too, and in fact combine the logic. No need to have four copies of all this, most of which is the same. While making this conversion, I notice that this reducer doesn't reset state on LOGIN_SUCCESS like it does for LOGOUT and ACCOUNT_SWITCH. In general we're pretty consistent about resetting state on those latter two, but many of our reducers do so on LOGIN_SUCCESS while many others don't. I've filed zulip#4446 for fixing them all up to be consistent. Performance measurements: I made some manual performance measurements to evaluate this change and the others in this series. I used a test user with lots of unreads on chat.zulip.org, on a few-years-old flagship phone: a Pixel 2 XL running Android 10. The test user has 50k unreads in this data structure (that's the max the server will send in the initial fetch), across about 3400 topics in 27 different streams. Before this series, on visiting a topic with 1 unread, we'd spend about 70ms in this reducer, which is a long time. We'd spend 300ms in total on dispatching the EVENT_UPDATE_MESSAGE_FLAGS action, including the time spent in the reducer. (The other time is probably some combination of React re-rendering the components that use this data; before that, our selectors that sit between those components and this data recomputing their own results; and after that, React Native applying the resulting updates to the underlying native components. We don't yet have clear measurements to tell how much time those each contribute.) After this change, we spend about 30-50ms in the reducer, and a total of 150-200ms in dispatch. Still slow, but much improved! We'll speed this up further in upcoming commits. For EVENT_NEW_MESSAGE, which is the other frequent update to this data structure, not much changes: it was already "only" 4-9ms spent in this reducer, which is too slow but far from our worst performance problem. After this change, it's usually <=1ms (too small to measure with our existing tools), and the longest I've seen is 3ms. The total dispatch time varies widely, like 70-200ms, and it's not clear if it changed. There is one performance regression: we now spend about 100ms here on REALM_INIT, i.e. on handling the data from the initial fetch. Previously that time was <=1ms; we just took the data straight off the wire (well, the data we'd already deserialized from the JSON that came off the wire), and now we have to copy it into our more efficiently-usable data structure. As is, that 100ms is already well worth it: we save more than 100ms, of visible latency, every time the user reads some unread messages. But it's enough to be worth optimizing too, and we'll do so later in this series. Fixes-partly: zulip#4438
1 parent 567b863 commit 91ccf50

File tree

6 files changed

+170
-138
lines changed

6 files changed

+170
-138
lines changed

src/boot/store.js

+3
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,9 @@ const migrations: { [string]: (GlobalState) => GlobalState } = {
253253
// Convert `messages` from object-as-map to `Immutable.Map`.
254254
'23': dropCache,
255255

256+
// Convert `unread.streams` from over-the-wire array to `Immutable.Map`.
257+
'24': dropCache,
258+
256259
// TIP: When adding a migration, consider just using `dropCache`.
257260
};
258261

src/unread/__tests__/unreadModel-test.js

+5-13
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import Immutable from 'immutable';
33

44
import { ACCOUNT_SWITCH, EVENT_UPDATE_MESSAGE_FLAGS } from '../../actionConstants';
55
import { reducer } from '../unreadModel';
6+
import { type UnreadState } from '../unreadModelTypes';
67
import * as eg from '../../__tests__/lib/exampleData';
78
import { initialState, mkMessageAction } from './unread-testlib';
89

@@ -12,19 +13,10 @@ import { initialState, mkMessageAction } from './unread-testlib';
1213
// but this way simplifies the conversion from the old tests.
1314
describe('stream substate', () => {
1415
// Summarize the state, for convenient comparison to expectations.
15-
// In particular, abstract away irrelevant details of the ordering of
16-
// streams and topics in the data structure -- those should never matter
17-
// to selectors, and in a better data structure they wouldn't exist in the
18-
// first place.
19-
const summary = state => {
20-
// prettier-ignore
21-
const result: Immutable.Map<number, Immutable.Map<string, number[]>> =
22-
Immutable.Map().asMutable();
23-
for (const { stream_id, topic, unread_message_ids } of state.streams) {
24-
result.setIn([stream_id, topic], unread_message_ids);
25-
}
26-
return result.asImmutable();
27-
};
16+
// Specifically just turn the inner `Immutable.List`s into arrays,
17+
// to shorten writing the expected data.
18+
const summary = (state: UnreadState) =>
19+
state.streams.map(perStream => perStream.map(perTopic => perTopic.toArray()));
2820

2921
describe('ACCOUNT_SWITCH', () => {
3022
test('resets state to initial state', () => {

src/unread/unreadHelpers.js

+1-22
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/* @flow strict-local */
2-
import type { HuddlesUnreadItem, PmsUnreadItem, StreamUnreadItem, UserId } from '../types';
2+
import type { HuddlesUnreadItem, PmsUnreadItem, UserId } from '../types';
33
import { addItemsToArray, removeItemsFromArray, filterArray } from '../utils/immutability';
44

55
type SomeUnreadItem = { unread_message_ids: number[] };
@@ -86,24 +86,3 @@ export const addItemsToHuddleArray = (
8686
},
8787
];
8888
};
89-
90-
export const addItemsToStreamArray = (
91-
input: StreamUnreadItem[],
92-
itemsToAdd: number[],
93-
streamId: number,
94-
topic: string,
95-
): StreamUnreadItem[] => {
96-
const index = input.findIndex(s => s.stream_id === streamId && s.topic === topic);
97-
98-
if (index !== -1) {
99-
return addItemsDeeply(input, itemsToAdd, index);
100-
}
101-
return [
102-
...input,
103-
{
104-
stream_id: streamId,
105-
topic,
106-
unread_message_ids: itemsToAdd,
107-
},
108-
];
109-
};

src/unread/unreadModel.js

+156-23
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,187 @@
11
/* @flow strict-local */
22
import Immutable from 'immutable';
3-
import { createSelector } from 'reselect';
43

54
import type { Action } from '../actionTypes';
65
import type {
76
UnreadState,
7+
UnreadStreamsState,
88
UnreadPmsState,
99
UnreadHuddlesState,
1010
UnreadMentionsState,
1111
} from './unreadModelTypes';
1212
import type { GlobalState } from '../reduxTypes';
13-
import type { Selector } from '../types';
14-
import unreadStreamsReducer from './unreadStreamsReducer';
1513
import unreadPmsReducer from './unreadPmsReducer';
1614
import unreadHuddlesReducer from './unreadHuddlesReducer';
1715
import unreadMentionsReducer from './unreadMentionsReducer';
16+
import {
17+
ACCOUNT_SWITCH,
18+
EVENT_MESSAGE_DELETE,
19+
EVENT_NEW_MESSAGE,
20+
EVENT_UPDATE_MESSAGE_FLAGS,
21+
LOGOUT,
22+
MESSAGE_FETCH_COMPLETE,
23+
REALM_INIT,
24+
} from '../actionConstants';
25+
import { getOwnUserId } from '../users/userSelectors';
1826

19-
export const getUnreadStreams: Selector<
20-
Immutable.Map<number, Immutable.Map<string, Immutable.List<number>>>,
21-
> = createSelector(
22-
state => state.unread.streams,
23-
data => {
24-
// prettier-ignore
25-
// This is an example of the style-guide rule "Always provide a type
26-
// when writing an empty `Immutable` value". Without the explicit type,
27-
// `result` gets inferred as `Immutable.Map<number, empty>`, and then
28-
// e.g. the `setIn` call could be completely wrong and the type-checker
29-
// wouldn't notice.
30-
const result: Immutable.Map<number, Immutable.Map<string, Immutable.List<number>>> =
31-
Immutable.Map().asMutable();
32-
for (const { stream_id, topic, unread_message_ids } of data) {
33-
result.setIn([stream_id, topic], Immutable.List(unread_message_ids));
34-
}
35-
return result.asImmutable();
36-
},
37-
);
27+
//
28+
//
29+
// Selectors.
30+
//
31+
32+
export const getUnreadStreams = (state: GlobalState): UnreadStreamsState => state.unread.streams;
3833

3934
export const getUnreadPms = (state: GlobalState): UnreadPmsState => state.unread.pms;
4035

4136
export const getUnreadHuddles = (state: GlobalState): UnreadHuddlesState => state.unread.huddles;
4237

4338
export const getUnreadMentions = (state: GlobalState): UnreadMentionsState => state.unread.mentions;
4439

40+
//
41+
//
42+
// Reducer.
43+
//
44+
45+
const initialStreamsState: UnreadStreamsState = Immutable.Map();
46+
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>(
63+
map: Immutable.Map<K, V>,
64+
zero: V,
65+
updater: V => V,
66+
): Immutable.Map<K, V> {
67+
return map.withMutations(mapMut => {
68+
map.forEach((value, key) => {
69+
const newValue = updater(value);
70+
if (newValue === value) {
71+
return; // i.e., continue
72+
}
73+
if (newValue === zero) {
74+
mapMut.delete(key);
75+
} else {
76+
mapMut.set(key, newValue);
77+
}
78+
});
79+
});
80+
}
81+
82+
function deleteMessages(
83+
state: UnreadStreamsState,
84+
ids: $ReadOnlyArray<number>,
85+
): UnreadStreamsState {
86+
const idSet = new Set(ids);
87+
const toDelete = id => idSet.has(id);
88+
const emptyList = Immutable.List();
89+
return updateAllAndPrune(state, Immutable.Map(), perStream =>
90+
updateAllAndPrune(perStream, emptyList, perTopic =>
91+
perTopic.find(toDelete) ? perTopic.filterNot(toDelete) : perTopic,
92+
),
93+
);
94+
}
95+
96+
function streamsReducer(
97+
state: UnreadStreamsState = initialStreamsState,
98+
action: Action,
99+
globalState: GlobalState,
100+
): UnreadStreamsState {
101+
switch (action.type) {
102+
case LOGOUT:
103+
case ACCOUNT_SWITCH:
104+
// TODO(#4446) also LOGIN_SUCCESS, presumably
105+
return initialStreamsState;
106+
107+
case REALM_INIT: {
108+
// This may indeed be unnecessary, but it's legacy; have not investigated
109+
// if it's this bit of our API types that is too optimistic.
110+
// flowlint-next-line unnecessary-optional-chain:off
111+
const data = action.data.unread_msgs?.streams ?? [];
112+
113+
const st = initialStreamsState.asMutable();
114+
for (const { stream_id, topic, unread_message_ids } of data) {
115+
// unread_message_ids is already sorted; see comment at its
116+
// definition in src/api/initialDataTypes.js.
117+
st.setIn([stream_id, topic], Immutable.List(unread_message_ids));
118+
}
119+
return st.asImmutable();
120+
}
121+
122+
case MESSAGE_FETCH_COMPLETE:
123+
// TODO handle MESSAGE_FETCH_COMPLETE here. This rarely matters, but
124+
// could in principle: we could be fetching some messages from
125+
// before the (long) window included in the initial unreads data.
126+
// For comparison, the webapp does handle this case; see the call to
127+
// message_util.do_unread_count_updates in message_fetch.js.
128+
return state;
129+
130+
case EVENT_NEW_MESSAGE: {
131+
const { message } = action;
132+
if (message.type !== 'stream') {
133+
return state;
134+
}
135+
136+
if (message.sender_id === getOwnUserId(globalState)) {
137+
return state;
138+
}
139+
140+
// prettier-ignore
141+
return state.updateIn([message.stream_id, message.subject],
142+
(perTopic = Immutable.List()) => perTopic.push(message.id));
143+
}
144+
145+
case EVENT_MESSAGE_DELETE:
146+
// TODO optimize by using `state.messages` to look up directly
147+
return deleteMessages(state, action.messageIds);
148+
149+
case EVENT_UPDATE_MESSAGE_FLAGS: {
150+
if (action.flag !== 'read') {
151+
return state;
152+
}
153+
154+
if (action.all) {
155+
return initialStreamsState;
156+
}
157+
158+
if (action.operation === 'remove') {
159+
// Zulip doesn't support un-reading a message. Ignore it.
160+
return state;
161+
}
162+
163+
// TODO optimize by using `state.messages` to look up directly.
164+
// Then when do, also optimize so deleting the oldest items is fast,
165+
// as that should be the common case here.
166+
return deleteMessages(state, action.messages);
167+
}
168+
169+
default:
170+
return state;
171+
}
172+
}
173+
45174
export const reducer = (
46175
state: void | UnreadState,
47176
action: Action,
48177
globalState: GlobalState,
49178
): UnreadState => {
50179
const nextState = {
51-
streams: unreadStreamsReducer(state?.streams, action, globalState),
180+
streams: streamsReducer(state?.streams, action, globalState),
181+
182+
// Note for converting these other sub-reducers to the new design:
183+
// Probably first push this four-part data structure down through the
184+
// `switch` statement, and the other logic that's duplicated between them.
52185
pms: unreadPmsReducer(state?.pms, action),
53186
huddles: unreadHuddlesReducer(state?.huddles, action),
54187
mentions: unreadMentionsReducer(state?.mentions, action),

src/unread/unreadModelTypes.js

+5-2
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
/* @flow strict-local */
2+
import Immutable from 'immutable';
23

3-
import type { HuddlesUnreadItem, PmsUnreadItem, StreamUnreadItem } from '../api/apiTypes';
4+
import type { HuddlesUnreadItem, PmsUnreadItem } from '../api/apiTypes';
45

56
// These four are fragments of UnreadState; see below.
6-
export type UnreadStreamsState = StreamUnreadItem[];
7+
// prettier-ignore
8+
export type UnreadStreamsState =
9+
Immutable.Map<number, Immutable.Map<string, Immutable.List<number>>>;
710
export type UnreadHuddlesState = HuddlesUnreadItem[];
811
export type UnreadPmsState = PmsUnreadItem[];
912
export type UnreadMentionsState = number[];

src/unread/unreadStreamsReducer.js

-78
This file was deleted.

0 commit comments

Comments
 (0)