Skip to content

Commit

Permalink
msglist: Throttle fetchOlder retries
Browse files Browse the repository at this point in the history
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: zulip#945

Signed-off-by: Zixuan James Li <zixuan@zulip.com>
  • Loading branch information
PIG208 committed Nov 7, 2024
1 parent cdce570 commit 8b14db4
Show file tree
Hide file tree
Showing 2 changed files with 70 additions and 8 deletions.
46 changes: 38 additions & 8 deletions lib/model/message_list.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';

import '../api/backoff.dart';
import '../api/model/events.dart';
import '../api/model/model.dart';
import '../api/route/messages.dart';
Expand Down Expand Up @@ -92,6 +93,9 @@ mixin _MessageSequence {
bool get fetchingOlder => _fetchingOlder;
bool _fetchingOlder = false;

/// Manages a backoff machine for retries to fetch older messages.
final _fetchOlderBackoff = _RetryBackoff();

/// The parsed message contents, as a list parallel to [messages].
///
/// The i'th element is the result of parsing the i'th element of [messages].
Expand Down Expand Up @@ -469,7 +473,7 @@ class MessageListView with ChangeNotifier, _MessageSequence {
Future<void> fetchInitial() async {
// TODO(#80): fetch from anchor firstUnread, instead of newest
// TODO(#82): fetch from a given message ID as anchor
assert(!fetched && !haveOldest && !fetchingOlder);
assert(!fetched && !haveOldest && !fetchingOlder && !_fetchOlderBackoff.isWaiting);
assert(messages.isEmpty && contents.isEmpty);
// TODO schedule all this in another isolate
final generation = this.generation;
Expand Down Expand Up @@ -497,20 +501,27 @@ class MessageListView with ChangeNotifier, _MessageSequence {
Future<void> fetchOlder() async {
if (haveOldest) return;
if (fetchingOlder) return;
if (_fetchOlderBackoff.isWaiting) return;
assert(fetched);
assert(messages.isNotEmpty);
_fetchingOlder = true;
_updateEndMarkers();
notifyListeners();
final generation = this.generation;
try {
final result = await getMessages(store.connection,
narrow: narrow.apiEncode(),
anchor: NumericAnchor(messages[0].id),
includeAnchor: false,
numBefore: kMessageListFetchBatchSize,
numAfter: 0,
);
final GetMessagesResult result;
try {
result = await getMessages(store.connection,
narrow: narrow.apiEncode(),
anchor: NumericAnchor(messages[0].id),
includeAnchor: false,
numBefore: kMessageListFetchBatchSize,
numAfter: 0,
);
} catch (e) {
_fetchOlderBackoff.start();
rethrow;
}
if (this.generation > generation) return;

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

_insertAllMessages(0, fetchedMessages);
_haveOldest = result.foundOldest;
_fetchOlderBackoff.reset();
} finally {
if (this.generation == generation) {
_fetchingOlder = false;
Expand Down Expand Up @@ -708,3 +720,21 @@ class MessageListView with ChangeNotifier, _MessageSequence {
notifyListeners();
}
}

class _RetryBackoff {
bool get isWaiting => _isWaiting;
bool _isWaiting = false;

BackoffMachine? _backoffMachine;

void start() async {
assert(!_isWaiting);
_isWaiting = true;
await (_backoffMachine ??= BackoffMachine()).wait();
_isWaiting = false;
}

void reset() {
_backoffMachine = null;
}
}
32 changes: 32 additions & 0 deletions test/model/message_list_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:checks/checks.dart';
import 'package:http/http.dart' as http;
import 'package:test/scaffolding.dart';
import 'package:zulip/api/exception.dart';
import 'package:zulip/api/model/events.dart';
import 'package:zulip/api/model/model.dart';
import 'package:zulip/api/model/narrow.dart';
Expand Down Expand Up @@ -236,6 +237,37 @@ void main() {
..messages.length.equals(30);
});

test('fetchOlder nop during backoff', () => awaitFakeAsync((async) async {
final olderMessages = List.generate(5, (i) => eg.streamMessage());
final initialMessages = List.generate(5, (i) => eg.streamMessage());
await prepare(narrow: const CombinedFeedNarrow());
await prepareMessages(foundOldest: false, messages: initialMessages);
check(model).messages.length.equals(5);

connection.prepare(httpStatus: 400, json: {
'result': 'error', 'code': 'BAD_REQUEST', 'msg': 'Bad request'});
check(async.pendingTimers).isEmpty();
await check(model.fetchOlder()).throws<ZulipApiException>();
// The backoff timer has started.
check(async.pendingTimers).single;
checkNotified(count: 2);
check(model).messages.length.equals(5);

connection.takeRequests();
await model.fetchOlder();
check(connection.lastRequest).isNull();
check(async.pendingTimers).single;
checkNotNotified();
check(model).messages.length.equals(5);

async.flushTimers();
connection.prepare(json: olderResult(
anchor: 1000, foundOldest: false, messages: olderMessages).toJson());
await model.fetchOlder();
checkNotified(count: 2);
check(model).messages.length.equals(10);
}));

test('fetchOlder handles servers not understanding includeAnchor', () async {
const narrow = CombinedFeedNarrow();
await prepare(narrow: narrow);
Expand Down

0 comments on commit 8b14db4

Please sign in to comment.