Skip to content

Commit 4bf2482

Browse files
committed
msglist: Throttle fetchOlder retries
The main purpose of having a separate class is to wait for the BackoffMachine without blocking fetchOlder so that we can reach the finally block and return. 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. The test drops irrelevant requests with `connection.takeRequests` without checking, as we are only interested in verifying that no request was sent. Fixes: #945 Signed-off-by: Zixuan James Li <[email protected]>
1 parent 921eacd commit 4bf2482

File tree

2 files changed

+70
-8
lines changed

2 files changed

+70
-8
lines changed

lib/model/message_list.dart

+38-8
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import 'package:collection/collection.dart';
22
import 'package:flutter/foundation.dart';
33

4+
import '../api/backoff.dart';
45
import '../api/model/events.dart';
56
import '../api/model/model.dart';
67
import '../api/route/messages.dart';
@@ -92,6 +93,9 @@ mixin _MessageSequence {
9293
bool get fetchingOlder => _fetchingOlder;
9394
bool _fetchingOlder = false;
9495

96+
/// Manages a backoff machine for retries to fetch older messages.
97+
final _fetchOlderBackoff = _RetryBackoff();
98+
9599
/// The parsed message contents, as a list parallel to [messages].
96100
///
97101
/// The i'th element is the result of parsing the i'th element of [messages].
@@ -469,7 +473,7 @@ class MessageListView with ChangeNotifier, _MessageSequence {
469473
Future<void> fetchInitial() async {
470474
// TODO(#80): fetch from anchor firstUnread, instead of newest
471475
// TODO(#82): fetch from a given message ID as anchor
472-
assert(!fetched && !haveOldest && !fetchingOlder);
476+
assert(!fetched && !haveOldest && !fetchingOlder && !_fetchOlderBackoff.isWaiting);
473477
assert(messages.isEmpty && contents.isEmpty);
474478
// TODO schedule all this in another isolate
475479
final generation = this.generation;
@@ -497,20 +501,27 @@ class MessageListView with ChangeNotifier, _MessageSequence {
497501
Future<void> fetchOlder() async {
498502
if (haveOldest) return;
499503
if (fetchingOlder) return;
504+
if (_fetchOlderBackoff.isWaiting) return;
500505
assert(fetched);
501506
assert(messages.isNotEmpty);
502507
_fetchingOlder = true;
503508
_updateEndMarkers();
504509
notifyListeners();
505510
final generation = this.generation;
506511
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-
);
512+
final GetMessagesResult result;
513+
try {
514+
result = await getMessages(store.connection,
515+
narrow: narrow.apiEncode(),
516+
anchor: NumericAnchor(messages[0].id),
517+
includeAnchor: false,
518+
numBefore: kMessageListFetchBatchSize,
519+
numAfter: 0,
520+
);
521+
} catch (e) {
522+
_fetchOlderBackoff.start();
523+
rethrow;
524+
}
514525
if (this.generation > generation) return;
515526

516527
if (result.messages.isNotEmpty
@@ -528,6 +539,7 @@ class MessageListView with ChangeNotifier, _MessageSequence {
528539

529540
_insertAllMessages(0, fetchedMessages);
530541
_haveOldest = result.foundOldest;
542+
_fetchOlderBackoff.reset();
531543
} finally {
532544
if (this.generation == generation) {
533545
_fetchingOlder = false;
@@ -708,3 +720,21 @@ class MessageListView with ChangeNotifier, _MessageSequence {
708720
notifyListeners();
709721
}
710722
}
723+
724+
class _RetryBackoff {
725+
bool get isWaiting => _isWaiting;
726+
bool _isWaiting = false;
727+
728+
BackoffMachine? _backoffMachine;
729+
730+
void start() async {
731+
assert(!_isWaiting);
732+
_isWaiting = true;
733+
await (_backoffMachine ??= BackoffMachine()).wait();
734+
_isWaiting = false;
735+
}
736+
737+
void reset() {
738+
_backoffMachine = null;
739+
}
740+
}

test/model/message_list_test.dart

+32
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';
@@ -236,6 +237,37 @@ void main() {
236237
..messages.length.equals(30);
237238
});
238239

240+
test('fetchOlder nop during backoff', () => awaitFakeAsync((async) async {
241+
final olderMessages = List.generate(5, (i) => eg.streamMessage());
242+
final initialMessages = List.generate(5, (i) => eg.streamMessage());
243+
await prepare(narrow: const CombinedFeedNarrow());
244+
await prepareMessages(foundOldest: false, messages: initialMessages);
245+
check(model).messages.length.equals(5);
246+
247+
connection.prepare(httpStatus: 400, json: {
248+
'result': 'error', 'code': 'BAD_REQUEST', 'msg': 'Bad request'});
249+
check(async.pendingTimers).isEmpty();
250+
await check(model.fetchOlder()).throws<ZulipApiException>();
251+
// The backoff timer has started.
252+
check(async.pendingTimers).single;
253+
checkNotified(count: 2);
254+
check(model).messages.length.equals(5);
255+
256+
connection.takeRequests();
257+
await model.fetchOlder();
258+
check(connection.lastRequest).isNull();
259+
check(async.pendingTimers).single;
260+
checkNotNotified();
261+
check(model).messages.length.equals(5);
262+
263+
async.flushTimers();
264+
connection.prepare(json: olderResult(
265+
anchor: 1000, foundOldest: false, messages: olderMessages).toJson());
266+
await model.fetchOlder();
267+
checkNotified(count: 2);
268+
check(model).messages.length.equals(10);
269+
}));
270+
239271
test('fetchOlder handles servers not understanding includeAnchor', () async {
240272
const narrow = CombinedFeedNarrow();
241273
await prepare(narrow: narrow);

0 commit comments

Comments
 (0)