Skip to content

Commit b9f0c07

Browse files
committed
Merge remote-tracking branch 'pr/1050'
2 parents 3bbd921 + 2bc878d commit b9f0c07

File tree

2 files changed

+151
-18
lines changed

2 files changed

+151
-18
lines changed

lib/model/message_list.dart

Lines changed: 73 additions & 17 deletions
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
}
@@ -288,11 +316,14 @@ mixin _MessageSequence {
288316

289317
/// Update [items] to include markers at start and end as appropriate.
290318
void _updateEndMarkers() {
319+
assert(fetched);
291320
assert(!(haveOldest && fetchingOlder));
292-
final startMarker = switch ((fetchingOlder, haveOldest)) {
293-
(true, _) => const MessageListLoadingItem(MessageListDirection.older),
294-
(_, true) => const MessageListHistoryStartItem(),
295-
(_, _) => null,
321+
assert(!(fetchingOlder && fetchOlderCoolingDown));
322+
final startMarker = switch ((fetchingOlder, haveOldest, fetchOlderCoolingDown)) {
323+
(true, _, _) => const MessageListLoadingItem(MessageListDirection.older),
324+
(_, true, _) => const MessageListHistoryStartItem(),
325+
(_, _, true) => const MessageListLoadingItem(MessageListDirection.older),
326+
(_, _, _) => null,
296327
};
297328
final hasStartMarker = switch (items.firstOrNull) {
298329
MessageListLoadingItem() => true,
@@ -469,7 +500,7 @@ class MessageListView with ChangeNotifier, _MessageSequence {
469500
Future<void> fetchInitial() async {
470501
// TODO(#80): fetch from anchor firstUnread, instead of newest
471502
// TODO(#82): fetch from a given message ID as anchor
472-
assert(!fetched && !haveOldest && !fetchingOlder);
503+
assert(!fetched && !haveOldest && !fetchingOlder && !fetchOlderCoolingDown);
473504
assert(messages.isEmpty && contents.isEmpty);
474505
// TODO schedule all this in another isolate
475506
final generation = this.generation;
@@ -497,20 +528,28 @@ class MessageListView with ChangeNotifier, _MessageSequence {
497528
Future<void> fetchOlder() async {
498529
if (haveOldest) return;
499530
if (fetchingOlder) return;
531+
if (fetchOlderCoolingDown) return;
500532
assert(fetched);
501533
assert(messages.isNotEmpty);
502534
_fetchingOlder = true;
503535
_updateEndMarkers();
504536
notifyListeners();
505537
final generation = this.generation;
538+
bool hasFetchError = false;
506539
try {
507-
final result = await getMessages(store.connection,
508-
narrow: narrow.apiEncode(),
509-
anchor: NumericAnchor(messages[0].id),
510-
includeAnchor: false,
511-
numBefore: kMessageListFetchBatchSize,
512-
numAfter: 0,
513-
);
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+
}
514553
if (this.generation > generation) return;
515554

516555
if (result.messages.isNotEmpty
@@ -528,12 +567,29 @@ class MessageListView with ChangeNotifier, _MessageSequence {
528567

529568
_insertAllMessages(0, fetchedMessages);
530569
_haveOldest = result.foundOldest;
570+
_fetchOlderCooldownBackoffMachine = null;
531571
} finally {
532-
if (this.generation == generation) {
533-
_fetchingOlder = false;
534-
_updateEndMarkers();
535-
notifyListeners();
572+
if (this.generation != generation) {
573+
// We need the finally block always clean up regardless of errors
574+
// occured in the try block, and returning early here is necessary
575+
// if such cleanup must be skipped, as the fetch is considered stale.
576+
// ignore: control_flow_in_finally
577+
return;
578+
}
579+
_fetchingOlder = false;
580+
if (hasFetchError) {
581+
assert(!fetchOlderCoolingDown);
582+
_fetchOlderCoolingDown = true;
583+
unawaited((_fetchOlderCooldownBackoffMachine ??= BackoffMachine())
584+
.wait().then((_) {
585+
if (this.generation != generation) return;
586+
_fetchOlderCoolingDown = false;
587+
_updateEndMarkers();
588+
notifyListeners();
589+
}));
536590
}
591+
_updateEndMarkers();
592+
notifyListeners();
537593
}
538594
}
539595

test/model/message_list_test.dart

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ 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/exception.dart';
67
import 'package:zulip/api/model/events.dart';
78
import 'package:zulip/api/model/model.dart';
89
import 'package:zulip/api/model/narrow.dart';
@@ -238,6 +239,42 @@ void main() {
238239
..messages.length.equals(30);
239240
});
240241

242+
test('fetchOlder nop during backoff', () => awaitFakeAsync((async) async {
243+
final olderMessages = List.generate(5, (i) => eg.streamMessage());
244+
final initialMessages = List.generate(5, (i) => eg.streamMessage());
245+
await prepare(narrow: const CombinedFeedNarrow());
246+
await prepareMessages(foundOldest: false, messages: initialMessages);
247+
248+
connection.prepare(httpStatus: 400, json: {
249+
'result': 'error', 'code': 'BAD_REQUEST', 'msg': 'Bad request'});
250+
check(async.pendingTimers).isEmpty();
251+
await check(model.fetchOlder()).throws<ZulipApiException>();
252+
checkNotified(count: 2);
253+
check(model).fetchOlderCoolingDown.isTrue();
254+
255+
// Drop irrelevant requests with without checking,
256+
// as we are only interested in verifying if any request was sent.
257+
connection.takeRequests();
258+
259+
await model.fetchOlder();
260+
checkNotNotified();
261+
check(model).fetchOlderCoolingDown.isTrue();
262+
check(model).fetchingOlder.isFalse();
263+
check(connection.lastRequest).isNull();
264+
265+
// The first backoff is expected to be short enough to complete.
266+
async.elapse(const Duration(seconds: 1));
267+
check(model).fetchOlderCoolingDown.isFalse();
268+
checkNotifiedOnce();
269+
check(connection.lastRequest).isNull();
270+
271+
connection.prepare(json: olderResult(
272+
anchor: 1000, foundOldest: false, messages: olderMessages).toJson());
273+
await model.fetchOlder();
274+
checkNotified(count: 2);
275+
check(connection.lastRequest).isNotNull();
276+
}));
277+
241278
test('fetchOlder handles servers not understanding includeAnchor', () async {
242279
const narrow = CombinedFeedNarrow();
243280
await prepare(narrow: narrow);
@@ -985,6 +1022,45 @@ void main() {
9851022
checkNotifiedOnce();
9861023
}));
9871024

1025+
test('fetchOlder backoff start, _reset, fetchOlder backoff ends, move fetch finishes', () => awaitFakeAsync((async) async {
1026+
await prepareNarrow(narrow, initialMessages);
1027+
1028+
connection.prepare(httpStatus: 400, json: {
1029+
'result': 'error', 'code': 'BAD_REQUEST', 'msg': 'Bad request'});
1030+
await check(model.fetchOlder()).throws<ZulipApiException>();
1031+
final backoffTimer = async.pendingTimers.single;
1032+
check(model).fetchOlderCoolingDown.isTrue();
1033+
checkHasMessages(initialMessages);
1034+
checkNotified(count: 2);
1035+
1036+
connection.prepare(delay: const Duration(seconds: 2), json: newestResult(
1037+
foundOldest: false,
1038+
messages: initialMessages + movedMessages,
1039+
).toJson());
1040+
check(model).fetched.isTrue();
1041+
await store.handleEvent(eg.updateMessageEventMoveTo(
1042+
origTopic: movedMessages[0].topic,
1043+
origStreamId: otherStream.streamId,
1044+
newMessages: movedMessages,
1045+
));
1046+
// check that _reset was caleed
1047+
check(model).fetched.isFalse();
1048+
check(model).fetchOlderCoolingDown.isFalse();
1049+
check(async.pendingTimers).contains(backoffTimer);
1050+
checkHasMessages([]);
1051+
checkNotifiedOnce();
1052+
1053+
// The first backoff is expected to be short enough to complete.
1054+
async.elapse(const Duration(seconds: 1));
1055+
check(model).fetchOlderCoolingDown.isFalse();
1056+
check(async.pendingTimers).not((x) => x.contains(backoffTimer));
1057+
checkNotNotified();
1058+
1059+
async.elapse(const Duration(seconds: 1));
1060+
checkHasMessages(initialMessages + movedMessages);
1061+
checkNotifiedOnce();
1062+
}));
1063+
9881064
test('fetchOlder, _reset, move fetch finishes, fetchOlder returns', () => awaitFakeAsync((async) async {
9891065
await prepareNarrow(narrow, initialMessages);
9901066

@@ -1793,7 +1869,7 @@ void checkInvariants(MessageListView model) {
17931869
if (model.haveOldest) {
17941870
check(model.items[i++]).isA<MessageListHistoryStartItem>();
17951871
}
1796-
if (model.fetchingOlder) {
1872+
if (model.fetchingOlder || model.fetchOlderCoolingDown) {
17971873
check(model.items[i++]).isA<MessageListLoadingItem>();
17981874
}
17991875
for (int j = 0; j < model.messages.length; j++) {
@@ -1849,4 +1925,5 @@ extension MessageListViewChecks on Subject<MessageListView> {
18491925
Subject<bool> get fetched => has((x) => x.fetched, 'fetched');
18501926
Subject<bool> get haveOldest => has((x) => x.haveOldest, 'haveOldest');
18511927
Subject<bool> get fetchingOlder => has((x) => x.fetchingOlder, 'fetchingOlder');
1928+
Subject<bool> get fetchOlderCoolingDown => has((x) => x.fetchOlderCoolingDown, 'fetchOlderCoolingDown');
18521929
}

0 commit comments

Comments
 (0)