Skip to content

Commit eff21e5

Browse files
committed
msglist: Throttle fetchOlder retries
This approach is different from how a BackoffMachine is typically used, because the message list doesn't send and retry requests in a loop; its caller retries rapidly on scroll changes, and we want to ignore the excessive requests. Fixes: #945 Signed-off-by: Zixuan James Li <[email protected]>
1 parent 1fd8830 commit eff21e5

File tree

2 files changed

+171
-13
lines changed

2 files changed

+171
-13
lines changed

lib/model/message_list.dart

+62-11
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
import 'dart:async';
2+
13
import 'package:collection/collection.dart';
24
import 'package:flutter/foundation.dart';
35

6+
import '../api/backoff.dart';
47
import '../api/model/events.dart';
58
import '../api/model/model.dart';
69
import '../api/route/messages.dart';
@@ -89,9 +92,32 @@ mixin _MessageSequence {
8992
bool _haveOldest = false;
9093

9194
/// Whether we are currently fetching the next batch of older messages.
95+
///
96+
/// When this is true, [fetchOlder] is a no-op.
97+
/// That method is called frequently by Flutter's scrolling logic,
98+
/// and this field helps us avoid spamming the same request just to get
99+
/// the same response each time.
100+
///
101+
/// See also [fetchOlderCoolingDown].
92102
bool get fetchingOlder => _fetchingOlder;
93103
bool _fetchingOlder = false;
94104

105+
/// Whether [fetchOlder] had a request error recently.
106+
///
107+
/// When this is true, [fetchOlder] is a no-op.
108+
/// That method is called frequently by Flutter's scrolling logic,
109+
/// and this field mitigates spamming the same request and getting
110+
/// the same error each time.
111+
///
112+
/// "Recently" is decided by a [BackoffMachine] that resets
113+
/// when a [fetchOlder] request succeeds.
114+
///
115+
/// See also [fetchingOlder].
116+
bool get fetchOlderCoolingDown => _fetchOlderCoolingDown;
117+
bool _fetchOlderCoolingDown = false;
118+
119+
BackoffMachine? _fetchOlderCooldownBackoffMachine;
120+
95121
/// The parsed message contents, as a list parallel to [messages].
96122
///
97123
/// The i'th element is the result of parsing the i'th element of [messages].
@@ -107,7 +133,7 @@ mixin _MessageSequence {
107133
/// before, between, or after the messages.
108134
///
109135
/// This information is completely derived from [messages] and
110-
/// the flags [haveOldest] and [fetchingOlder].
136+
/// the flags [haveOldest], [fetchingOlder] and [fetchOlderCoolingDown].
111137
/// It exists as an optimization, to memoize that computation.
112138
final QueueList<MessageListItem> items = QueueList();
113139

@@ -241,6 +267,8 @@ mixin _MessageSequence {
241267
_fetched = false;
242268
_haveOldest = false;
243269
_fetchingOlder = false;
270+
_fetchOlderCoolingDown = false;
271+
_fetchOlderCooldownBackoffMachine = null;
244272
contents.clear();
245273
items.clear();
246274
}
@@ -289,8 +317,10 @@ mixin _MessageSequence {
289317
/// Update [items] to include markers at start and end as appropriate.
290318
void _updateEndMarkers() {
291319
assert(fetched);
292-
assert(!(haveOldest && fetchingOlder));
293-
final startMarker = switch ((fetchingOlder, haveOldest)) {
320+
assert(!(fetchingOlder && fetchOlderCoolingDown));
321+
final effectiveFetchingOlder = fetchingOlder || fetchOlderCoolingDown;
322+
assert(!(effectiveFetchingOlder && haveOldest));
323+
final startMarker = switch ((effectiveFetchingOlder, haveOldest)) {
294324
(true, _) => const MessageListLoadingItem(MessageListDirection.older),
295325
(_, true) => const MessageListHistoryStartItem(),
296326
(_, _) => null,
@@ -470,7 +500,7 @@ class MessageListView with ChangeNotifier, _MessageSequence {
470500
Future<void> fetchInitial() async {
471501
// TODO(#80): fetch from anchor firstUnread, instead of newest
472502
// TODO(#82): fetch from a given message ID as anchor
473-
assert(!fetched && !haveOldest && !fetchingOlder);
503+
assert(!fetched && !haveOldest && !fetchingOlder && !fetchOlderCoolingDown);
474504
assert(messages.isEmpty && contents.isEmpty);
475505
// TODO schedule all this in another isolate
476506
final generation = this.generation;
@@ -498,20 +528,28 @@ class MessageListView with ChangeNotifier, _MessageSequence {
498528
Future<void> fetchOlder() async {
499529
if (haveOldest) return;
500530
if (fetchingOlder) return;
531+
if (fetchOlderCoolingDown) return;
501532
assert(fetched);
502533
assert(messages.isNotEmpty);
503534
_fetchingOlder = true;
504535
_updateEndMarkers();
505536
notifyListeners();
506537
final generation = this.generation;
538+
bool hasFetchError = false;
507539
try {
508-
final result = await getMessages(store.connection,
509-
narrow: narrow.apiEncode(),
510-
anchor: NumericAnchor(messages[0].id),
511-
includeAnchor: false,
512-
numBefore: kMessageListFetchBatchSize,
513-
numAfter: 0,
514-
);
540+
final GetMessagesResult result;
541+
try {
542+
result = await getMessages(store.connection,
543+
narrow: narrow.apiEncode(),
544+
anchor: NumericAnchor(messages[0].id),
545+
includeAnchor: false,
546+
numBefore: kMessageListFetchBatchSize,
547+
numAfter: 0,
548+
);
549+
} catch (e) {
550+
hasFetchError = true;
551+
rethrow;
552+
}
515553
if (this.generation > generation) return;
516554

517555
if (result.messages.isNotEmpty
@@ -532,6 +570,19 @@ class MessageListView with ChangeNotifier, _MessageSequence {
532570
} finally {
533571
if (this.generation == generation) {
534572
_fetchingOlder = false;
573+
if (hasFetchError) {
574+
assert(!fetchOlderCoolingDown);
575+
_fetchOlderCoolingDown = true;
576+
unawaited((_fetchOlderCooldownBackoffMachine ??= BackoffMachine())
577+
.wait().then((_) {
578+
if (this.generation > generation) return;
579+
_fetchOlderCoolingDown = false;
580+
_updateEndMarkers();
581+
notifyListeners();
582+
}));
583+
} else {
584+
_fetchOlderCooldownBackoffMachine = null;
585+
}
535586
_updateEndMarkers();
536587
notifyListeners();
537588
}

test/model/message_list_test.dart

+109-2
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import 'dart:convert';
33
import 'package:checks/checks.dart';
44
import 'package:http/http.dart' as http;
55
import 'package:test/scaffolding.dart';
6+
import 'package:zulip/api/backoff.dart';
7+
import 'package:zulip/api/exception.dart';
68
import 'package:zulip/api/model/events.dart';
79
import 'package:zulip/api/model/model.dart';
810
import 'package:zulip/api/model/narrow.dart';
@@ -238,6 +240,41 @@ void main() {
238240
..messages.length.equals(30);
239241
});
240242

243+
test('fetchOlder nop during backoff', () => awaitFakeAsync((async) async {
244+
final olderMessages = List.generate(5, (i) => eg.streamMessage());
245+
final initialMessages = List.generate(5, (i) => eg.streamMessage());
246+
await prepare(narrow: const CombinedFeedNarrow());
247+
await prepareMessages(foundOldest: false, messages: initialMessages);
248+
check(connection.takeRequests()).single;
249+
250+
connection.prepare(httpStatus: 400, json: {
251+
'result': 'error', 'code': 'BAD_REQUEST', 'msg': 'Bad request'});
252+
check(async.pendingTimers).isEmpty();
253+
await check(model.fetchOlder()).throws<ZulipApiException>();
254+
checkNotified(count: 2);
255+
check(model).fetchOlderCoolingDown.isTrue();
256+
check(connection.takeRequests()).single;
257+
258+
await model.fetchOlder();
259+
checkNotNotified();
260+
check(model).fetchOlderCoolingDown.isTrue();
261+
check(model).fetchingOlder.isFalse();
262+
check(connection.lastRequest).isNull();
263+
264+
// Wait long enough that a first backoff is sure to finish.
265+
async.elapse(const Duration(seconds: 1));
266+
check(model).fetchOlderCoolingDown.isFalse();
267+
checkNotifiedOnce();
268+
check(connection.lastRequest).isNull();
269+
270+
connection.prepare(json: olderResult(
271+
anchor: 1000, foundOldest: false, messages: olderMessages).toJson());
272+
await model.fetchOlder();
273+
checkNotified(count: 2);
274+
check(connection.takeRequests()).single;
275+
check(model).messages.length.equals(10);
276+
}));
277+
241278
test('fetchOlder handles servers not understanding includeAnchor', () async {
242279
const narrow = CombinedFeedNarrow();
243280
await prepare(narrow: narrow);
@@ -1020,6 +1057,70 @@ void main() {
10201057
checkNotNotified();
10211058
}));
10221059

1060+
test('fetchOlder backoff A starts, _reset, move fetch finishes,'
1061+
' fetchOlder backoff B starts, fetchOlder backoff A ends', () => awaitFakeAsync((async) async {
1062+
addTearDown(() => BackoffMachine.debugDuration = null);
1063+
await prepareNarrow(narrow, initialMessages);
1064+
1065+
connection.prepare(httpStatus: 400, json: {
1066+
'result': 'error', 'code': 'BAD_REQUEST', 'msg': 'Bad request'});
1067+
BackoffMachine.debugDuration = const Duration(seconds: 1);
1068+
await check(model.fetchOlder()).throws<ZulipApiException>();
1069+
final backoffTimerA = async.pendingTimers.single;
1070+
check(model).fetchOlderCoolingDown.isTrue();
1071+
check(model).fetched.isTrue();
1072+
checkHasMessages(initialMessages);
1073+
checkNotified(count: 2);
1074+
1075+
connection.prepare(json: newestResult(
1076+
foundOldest: false,
1077+
messages: initialMessages + movedMessages,
1078+
).toJson());
1079+
await store.handleEvent(eg.updateMessageEventMoveTo(
1080+
origTopic: movedMessages[0].topic,
1081+
origStreamId: otherStream.streamId,
1082+
newMessages: movedMessages,
1083+
));
1084+
// Check that _reset was called.
1085+
check(model).fetched.isFalse();
1086+
checkHasMessages([]);
1087+
checkNotifiedOnce();
1088+
check(model).fetchOlderCoolingDown.isFalse();
1089+
check(backoffTimerA.isActive).isTrue();
1090+
1091+
async.elapse(Duration.zero);
1092+
check(model).fetched.isTrue();
1093+
checkHasMessages(initialMessages + movedMessages);
1094+
checkNotifiedOnce();
1095+
check(model).fetchOlderCoolingDown.isFalse();
1096+
check(backoffTimerA.isActive).isTrue();
1097+
1098+
connection.prepare(httpStatus: 400, json: {
1099+
'result': 'error', 'code': 'BAD_REQUEST', 'msg': 'Bad request'});
1100+
BackoffMachine.debugDuration = const Duration(seconds: 2);
1101+
await check(model.fetchOlder()).throws<ZulipApiException>();
1102+
final backoffTimerB = async.pendingTimers.last;
1103+
check(model).fetchOlderCoolingDown.isTrue();
1104+
check(backoffTimerA.isActive).isTrue();
1105+
check(backoffTimerB.isActive).isTrue();
1106+
checkNotified(count: 2);
1107+
1108+
// When `backoffTimerA` ends, `fetchOlderCoolingDown` remains `true`
1109+
// because the backoff was from a previous generation.
1110+
async.elapse(const Duration(seconds: 1));
1111+
check(model).fetchOlderCoolingDown.isTrue();
1112+
check(backoffTimerA.isActive).isFalse();
1113+
check(backoffTimerB.isActive).isTrue();
1114+
checkNotNotified();
1115+
1116+
// When `backoffTimerB` ends, `fetchOlderCoolingDown` gets reset.
1117+
async.elapse(const Duration(seconds: 1));
1118+
check(model).fetchOlderCoolingDown.isFalse();
1119+
check(backoffTimerA.isActive).isFalse();
1120+
check(backoffTimerB.isActive).isFalse();
1121+
checkNotifiedOnce();
1122+
}));
1123+
10231124
test('fetchInitial, _reset, initial fetch finishes, move fetch finishes', () => awaitFakeAsync((async) async {
10241125
await prepareNarrow(narrow, null);
10251126

@@ -1750,10 +1851,15 @@ void checkInvariants(MessageListView model) {
17501851
check(model)
17511852
..messages.isEmpty()
17521853
..haveOldest.isFalse()
1753-
..fetchingOlder.isFalse();
1854+
..fetchingOlder.isFalse()
1855+
..fetchOlderCoolingDown.isFalse();
17541856
}
17551857
if (model.haveOldest) {
17561858
check(model).fetchingOlder.isFalse();
1859+
check(model).fetchOlderCoolingDown.isFalse();
1860+
}
1861+
if (model.fetchingOlder) {
1862+
check(model).fetchOlderCoolingDown.isFalse();
17571863
}
17581864

17591865
for (final message in model.messages) {
@@ -1793,7 +1899,7 @@ void checkInvariants(MessageListView model) {
17931899
if (model.haveOldest) {
17941900
check(model.items[i++]).isA<MessageListHistoryStartItem>();
17951901
}
1796-
if (model.fetchingOlder) {
1902+
if (model.fetchingOlder || model.fetchOlderCoolingDown) {
17971903
check(model.items[i++]).isA<MessageListLoadingItem>();
17981904
}
17991905
for (int j = 0; j < model.messages.length; j++) {
@@ -1849,4 +1955,5 @@ extension MessageListViewChecks on Subject<MessageListView> {
18491955
Subject<bool> get fetched => has((x) => x.fetched, 'fetched');
18501956
Subject<bool> get haveOldest => has((x) => x.haveOldest, 'haveOldest');
18511957
Subject<bool> get fetchingOlder => has((x) => x.fetchingOlder, 'fetchingOlder');
1958+
Subject<bool> get fetchOlderCoolingDown => has((x) => x.fetchOlderCoolingDown, 'fetchOlderCoolingDown');
18521959
}

0 commit comments

Comments
 (0)