Skip to content

Commit 30693a8

Browse files
gnpricechrisbobbe
authored andcommitted
store: Replace event queue on expiry
Fixes: #185
1 parent 7465951 commit 30693a8

File tree

4 files changed

+97
-24
lines changed

4 files changed

+97
-24
lines changed

lib/model/store.dart

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import 'package:path/path.dart' as p;
88
import 'package:path_provider/path_provider.dart';
99

1010
import '../api/core.dart';
11+
import '../api/exception.dart';
1112
import '../api/model/events.dart';
1213
import '../api/model/initial_snapshot.dart';
1314
import '../api/model/model.dart';
@@ -96,12 +97,26 @@ abstract class GlobalStore extends ChangeNotifier {
9697
future = loadPerAccount(account!);
9798
_perAccountStoresLoading[accountId] = future;
9899
store = await future;
99-
_perAccountStores[accountId] = store;
100+
_setPerAccount(accountId, store);
100101
_perAccountStoresLoading.remove(accountId);
101-
notifyListeners();
102102
return store;
103103
}
104104

105+
Future<void> _reloadPerAccount(Account account) async {
106+
assert(identical(_accounts[account.id], account));
107+
assert(_perAccountStores.containsKey(account.id));
108+
assert(!_perAccountStoresLoading.containsKey(account.id));
109+
final store = await loadPerAccount(account);
110+
_setPerAccount(account.id, store);
111+
}
112+
113+
void _setPerAccount(int accountId, PerAccountStore store) {
114+
final oldStore = _perAccountStores[accountId];
115+
_perAccountStores[accountId] = store;
116+
notifyListeners();
117+
oldStore?.dispose();
118+
}
119+
105120
/// Load per-account data for the given account, unconditionally.
106121
///
107122
/// This method should be called only by the implementation of [perAccount].
@@ -199,8 +214,6 @@ class PerAccountStore extends ChangeNotifier with StreamStore {
199214
}) : _globalStore = globalStore,
200215
_streams = streams;
201216

202-
// We'll use this in an upcoming commit.
203-
// ignore: unused_field
204217
final GlobalStore _globalStore;
205218

206219
final Account account;
@@ -569,9 +582,26 @@ class UpdateMachine {
569582
}());
570583
}
571584

572-
final result = await getEvents(store.connection,
573-
queueId: queueId, lastEventId: lastEventId);
574-
// TODO handle errors on get-events; retry with backoff
585+
final GetEventsResult result;
586+
try {
587+
result = await getEvents(store.connection,
588+
queueId: queueId, lastEventId: lastEventId);
589+
} catch (e) {
590+
switch (e) {
591+
case ZulipApiException(code: 'BAD_EVENT_QUEUE_ID'):
592+
assert(debugLog('Lost event queue for $store. Replacing…'));
593+
await store._globalStore._reloadPerAccount(store.account);
594+
dispose();
595+
debugLog('… Event queue replaced.');
596+
return;
597+
598+
default:
599+
assert(debugLog('Error polling event queue for $store: $e'));
600+
// TODO(#184) handle errors on get-events; retry with backoff
601+
rethrow;
602+
}
603+
}
604+
575605
final events = result.events;
576606
for (final event in events) {
577607
store.handleEvent(event);

lib/widgets/message_list.dart

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,8 +186,7 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
186186
}
187187

188188
@override
189-
void onNewStore() {
190-
model?.dispose();
189+
void onNewStore() { // TODO(#464) try to keep using old model until new one gets messages
191190
_initModel(PerAccountStoreWidget.of(context));
192191
}
193192

lib/widgets/store.dart

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -135,14 +135,17 @@ class PerAccountStoreWidget extends StatefulWidget {
135135
/// use [State.didChangeDependencies] instead. For discussion, see
136136
/// [BuildContext.dependOnInheritedWidgetOfExactType].
137137
///
138+
/// A [State] that calls this method in [State.didChangeDependencies]
139+
/// should typically also mix in [PerAccountStoreAwareStateMixin],
140+
/// in order to handle the store itself being replaced with a new store.
141+
/// This happens in particular if the old store's event queue expires
142+
/// on the server.
143+
///
138144
/// See also:
139145
/// * [accountIdOf], for the account ID corresponding to the same data.
140146
/// * [GlobalStoreWidget.of], for the app's data beyond that of a
141147
/// particular account.
142148
/// * [InheritedNotifier], which provides the "dependency" mechanism.
143-
// TODO(#185): Explain in dartdoc that the returned [PerAccountStore] might
144-
// differ from one call to the next, and to handle that with
145-
// [PerAccountStoreAwareStateMixin].
146149
static PerAccountStore of(BuildContext context) {
147150
final widget = context.dependOnInheritedWidgetOfExactType<_PerAccountStoreInheritedWidget>();
148151
assert(widget != null, 'No PerAccountStoreWidget ancestor');
@@ -253,13 +256,11 @@ class LoadingPage extends StatelessWidget {
253256
/// The ambient [PerAccountStore] can be replaced in some circumstances,
254257
/// such as when an event queue expires. See [PerAccountStoreWidget.of].
255258
/// When that happens, stateful widgets should
256-
/// - remove listeners on the old [PerAccountStore], and
259+
/// - stop using the old [PerAccountStore], which will already have
260+
/// been disposed;
257261
/// - add listeners on the new one.
258262
///
259263
/// Use this mixin, overriding [onNewStore], to do that concisely.
260-
// TODO(#185): Until #185, I think [PerAccountStoreWidget.of] never actually
261-
// returns a different [PerAccountStore] from one call to the next.
262-
// But it will, and when it does, we want our [StatefulWidgets] to handle it.
263264
mixin PerAccountStoreAwareStateMixin<T extends StatefulWidget> on State<T> {
264265
PerAccountStore? _store;
265266

@@ -270,8 +271,9 @@ mixin PerAccountStoreAwareStateMixin<T extends StatefulWidget> on State<T> {
270271
/// and again whenever dependencies change so that [PerAccountStoreWidget.of]
271272
/// would return a different store from previously.
272273
///
273-
/// In this, remove any listeners on the old store
274-
/// and add them on the new store.
274+
/// In this, add any needed listeners on the new store
275+
/// and drop any references to the old store, which will already
276+
/// have been disposed.
275277
void onNewStore();
276278

277279
@override

test/model/store_test.dart

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -140,18 +140,26 @@ void main() {
140140
});
141141

142142
group('UpdateMachine.poll', () {
143+
late TestGlobalStore globalStore;
143144
late UpdateMachine updateMachine;
144145
late PerAccountStore store;
145146
late FakeApiConnection connection;
146147

147-
void prepareStore({int? lastEventId}) {
148-
updateMachine = eg.updateMachine(initialSnapshot: eg.initialSnapshot(
149-
lastEventId: lastEventId,
150-
));
148+
void updateFromGlobalStore() {
149+
updateMachine = globalStore.updateMachines[eg.selfAccount.id]!;
151150
store = updateMachine.store;
151+
assert(identical(store, globalStore.perAccountSync(eg.selfAccount.id)));
152152
connection = store.connection as FakeApiConnection;
153153
}
154154

155+
Future<void> prepareStore({int? lastEventId}) async {
156+
globalStore = TestGlobalStore(accounts: []);
157+
await globalStore.add(eg.selfAccount, eg.initialSnapshot(
158+
lastEventId: lastEventId));
159+
await globalStore.perAccount(eg.selfAccount.id);
160+
updateFromGlobalStore();
161+
}
162+
155163
void checkLastRequest({required int lastEventId}) {
156164
check(connection.lastRequest).isA<http.Request>()
157165
..method.equals('GET')
@@ -163,7 +171,7 @@ void main() {
163171
}
164172

165173
test('loops on success', () async {
166-
prepareStore(lastEventId: 1);
174+
await prepareStore(lastEventId: 1);
167175
check(updateMachine.lastEventId).equals(1);
168176

169177
updateMachine.debugPauseLoop();
@@ -191,7 +199,7 @@ void main() {
191199
});
192200

193201
test('handles events', () async {
194-
prepareStore();
202+
await prepareStore();
195203
updateMachine.debugPauseLoop();
196204
updateMachine.poll();
197205

@@ -206,6 +214,40 @@ void main() {
206214
await Future.delayed(Duration.zero);
207215
check(store.userSettings!.twentyFourHourTime).isTrue();
208216
});
217+
218+
test('handles expired queue', () async {
219+
await prepareStore();
220+
updateMachine.debugPauseLoop();
221+
updateMachine.poll();
222+
check(globalStore.perAccountSync(store.account.id)).identicalTo(store);
223+
224+
// Let the server expire the event queue.
225+
connection.prepare(httpStatus: 400, json: {
226+
'result': 'error', 'code': 'BAD_EVENT_QUEUE_ID',
227+
'queue_id': updateMachine.queueId,
228+
'msg': 'Bad event queue ID: ${updateMachine.queueId}',
229+
});
230+
updateMachine.debugAdvanceLoop();
231+
await null;
232+
await Future.delayed(Duration.zero);
233+
234+
// The global store has a new store.
235+
check(globalStore.perAccountSync(store.account.id)).not(it()..identicalTo(store));
236+
updateFromGlobalStore();
237+
238+
// The new UpdateMachine updates the new store.
239+
updateMachine.debugPauseLoop();
240+
updateMachine.poll();
241+
check(store.userSettings!.twentyFourHourTime).isFalse();
242+
connection.prepare(json: GetEventsResult(events: [
243+
UserSettingsUpdateEvent(id: 2,
244+
property: UserSettingName.twentyFourHourTime, value: true),
245+
], queueId: null).toJson());
246+
updateMachine.debugAdvanceLoop();
247+
await null;
248+
await Future.delayed(Duration.zero);
249+
check(store.userSettings!.twentyFourHourTime).isTrue();
250+
});
209251
});
210252

211253
group('UpdateMachine.registerNotificationToken', () {

0 commit comments

Comments
 (0)