Skip to content

Commit 8e9d199

Browse files
author
HermesCoder
committed
feat(puzzle): non-blocking background fill for large offline queues
Show the first puzzle immediately after a single foreground sync, then top the queue up to the configured length in the background. The server caps each batch at 50 (nb.atMost(50)), so a large offline queue is filled over several requests without blocking the UI. Background fill re-reads storage each iteration and merges by puzzle id so a solve made mid-fill is never clobbered, guards against overlapping fills, and stops when the server is exhausted.
1 parent 6cb8c4c commit 8e9d199

2 files changed

Lines changed: 200 additions & 44 deletions

File tree

lib/src/model/puzzle/puzzle_service.dart

Lines changed: 117 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import 'dart:async' show unawaited;
12
import 'dart:math' show max;
23

34
import 'package:async/async.dart';
@@ -8,6 +9,7 @@ import 'package:lichess_mobile/src/model/common/id.dart';
89
import 'package:lichess_mobile/src/model/puzzle/puzzle.dart';
910
import 'package:lichess_mobile/src/model/puzzle/puzzle_angle.dart';
1011
import 'package:lichess_mobile/src/model/puzzle/puzzle_batch_storage.dart';
12+
import 'package:lichess_mobile/src/model/puzzle/puzzle_difficulty.dart';
1113
import 'package:lichess_mobile/src/model/puzzle/puzzle_preferences.dart';
1214
import 'package:lichess_mobile/src/model/puzzle/puzzle_repository.dart';
1315
import 'package:lichess_mobile/src/model/puzzle/puzzle_storage.dart';
@@ -82,6 +84,10 @@ class PuzzleService {
8284
final PuzzleStorage puzzleStorage;
8385
final Logger _log = Logger('PuzzleService');
8486

87+
/// Guards [_backgroundFill] against overlapping runs issuing duplicate
88+
/// requests.
89+
bool _isFilling = false;
90+
8591
/// Loads the next puzzle from database and the glicko rating if available.
8692
///
8793
/// Will sync with server if necessary.
@@ -145,9 +151,10 @@ class PuzzleService {
145151

146152
/// Synchronize offline puzzle queue with server and gets latest data.
147153
///
148-
/// This task will fetch missing puzzles so the queue length is always equal to
149-
/// `queueLength`.
150-
/// It will call [PuzzleRepository.solveBatch] if necessary.
154+
/// Does a single request so the next puzzle is available without blocking.
155+
/// A large offline queue can't be filled in one call (the server caps each
156+
/// batch at 50), so any remaining deficit is filled by [_backgroundFill]
157+
/// rather than making the user wait.
151158
///
152159
/// This method should never fail, as if the network is down it will fallback
153160
/// to the local database.
@@ -162,48 +169,114 @@ class PuzzleService {
162169

163170
final deficit = max(0, queueLength - unsolved.length);
164171

165-
if (deficit > 0 || solved.isNotEmpty) {
166-
_log.fine('Will sync puzzles with lichess (deficit: $deficit, solved: ${solved.length})');
167-
168-
final difficulty = _ref.read(puzzlePreferencesProvider).difficulty;
169-
170-
// anonymous users can't solve puzzles so we just download the deficit
171-
final batchResponse = _ref.withClient(
172-
(client) => Result.capture(
173-
solved.isNotEmpty && userId != null
174-
? PuzzleRepository(
175-
client,
176-
).solveBatch(nb: deficit, solved: solved, angle: angle, difficulty: difficulty)
177-
: PuzzleRepository(
178-
client,
179-
).selectBatch(nb: deficit, angle: angle, difficulty: difficulty),
180-
),
181-
);
182-
183-
return batchResponse
184-
.fold(
185-
(value) => Result.value((
186-
PuzzleBatch(
187-
solved: IList(const []),
188-
unsolved: IList([...unsolved, ...value.puzzles]),
189-
),
190-
value.glicko,
191-
value.rounds,
192-
true, // should save the batch
193-
)),
194-
195-
// we don't need to save the batch if the request failed
196-
(_, _) => Result.value((data, null, null, false)),
197-
)
198-
.flatMap((tuple) async {
199-
final (newBatch, glicko, rounds, shouldSave) = tuple;
200-
if (newBatch != null && shouldSave) {
201-
await batchStorage.save(userId: userId, angle: angle, data: newBatch);
202-
}
203-
return Result.value((newBatch, glicko, rounds));
204-
});
172+
if (deficit <= 0 && solved.isEmpty) {
173+
return Result.value((data, null, null));
174+
}
175+
176+
final difficulty = _ref.read(puzzlePreferencesProvider).difficulty;
177+
178+
// One request keeps the next puzzle responsive; the rest fills in the
179+
// background.
180+
final batchResult = await _fetchBatch(
181+
userId: userId,
182+
angle: angle,
183+
difficulty: difficulty,
184+
nb: deficit,
185+
solved: solved,
186+
);
187+
188+
if (batchResult.isError) {
189+
// Offline: fall back to the local database.
190+
return Result.value((data, null, null));
205191
}
206192

207-
return Result.value((data, null, null));
193+
final value = batchResult.asValue!.value;
194+
final newBatch = PuzzleBatch(
195+
solved: IList(const []),
196+
unsolved: IList([...unsolved, ...value.puzzles]),
197+
);
198+
await batchStorage.save(userId: userId, angle: angle, data: newBatch);
199+
200+
// Fill the rest without blocking so a large queue is cached before the
201+
// user goes offline.
202+
if (value.puzzles.isNotEmpty && newBatch.unsolved.length < queueLength) {
203+
unawaited(_backgroundFill(userId, angle, difficulty));
204+
}
205+
206+
return Result.value((newBatch, value.glicko, value.rounds));
207+
}
208+
209+
/// Downloads a single batch, submitting [solved] if there is anything to
210+
/// report (and the user is logged in), otherwise just fetching new puzzles.
211+
FutureResult<PuzzleBatchResponse> _fetchBatch({
212+
required UserId? userId,
213+
required PuzzleAngle angle,
214+
required PuzzleDifficulty difficulty,
215+
required int nb,
216+
IList<PuzzleSolution> solved = const IListConst([]),
217+
}) {
218+
_log.fine('Will sync puzzles with lichess (nb: $nb, solved: ${solved.length})');
219+
// anonymous users can't solve puzzles so we just download the deficit.
220+
return _ref.withClient(
221+
(client) => Result.capture(
222+
solved.isNotEmpty && userId != null
223+
? PuzzleRepository(
224+
client,
225+
).solveBatch(nb: nb, solved: solved, angle: angle, difficulty: difficulty)
226+
: PuzzleRepository(client).selectBatch(nb: nb, angle: angle, difficulty: difficulty),
227+
),
228+
);
229+
}
230+
231+
/// Tops up the offline queue to `queueLength` in the background.
232+
///
233+
/// Re-reads storage each pass and merges by puzzle id so a solve made while
234+
/// the fill runs is never clobbered. Guarded by [_isFilling] so overlapping
235+
/// calls can't double-fetch.
236+
Future<void> _backgroundFill(
237+
UserId? userId,
238+
PuzzleAngle angle,
239+
PuzzleDifficulty difficulty,
240+
) async {
241+
if (_isFilling) return;
242+
_isFilling = true;
243+
try {
244+
while (true) {
245+
final current = await batchStorage.fetch(userId: userId, angle: angle);
246+
final unsolved = current?.unsolved ?? IList(const []);
247+
final deficit = max(0, queueLength - unsolved.length);
248+
if (deficit <= 0) break;
249+
250+
final batchResult = await _fetchBatch(
251+
userId: userId,
252+
angle: angle,
253+
difficulty: difficulty,
254+
nb: deficit,
255+
);
256+
if (batchResult.isError) break;
257+
258+
final value = batchResult.asValue!.value;
259+
// Server has no more puzzles: stop rather than spin forever below
260+
// an unreachable `queueLength`.
261+
if (value.puzzles.isEmpty) break;
262+
263+
// Skip ids already stored so a re-read after a mid-fill solve can't
264+
// reintroduce a duplicate.
265+
final existingIds = unsolved.map((p) => p.puzzle.id).toISet();
266+
final additions = value.puzzles.where((p) => !existingIds.contains(p.puzzle.id));
267+
if (additions.isEmpty) break;
268+
269+
await batchStorage.save(
270+
userId: userId,
271+
angle: angle,
272+
data: PuzzleBatch(
273+
solved: current?.solved ?? IList(const []),
274+
unsolved: IList([...unsolved, ...additions]),
275+
),
276+
);
277+
}
278+
} finally {
279+
_isFilling = false;
280+
}
208281
}
209282
}

test/model/puzzle/puzzle_service_test.dart

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,75 @@ void main() {
356356
expect(data?.unsolved[0].puzzle.id, equals(const PuzzleId('20yWT')));
357357
expect(data?.unsolved[1].puzzle.id, equals(const PuzzleId('7H5EV')));
358358
});
359+
360+
test(
361+
'fills queue in the background over multiple requests when server caps each batch',
362+
() async {
363+
// The server caps each batch at 50 puzzles, so a large queueLength is
364+
// filled over several requests. The first request runs in the foreground
365+
// (so the next puzzle shows immediately) and the rest fill in the
366+
// background. Simulate the cap by returning one puzzle per request.
367+
int nbReq = 0;
368+
final mockClient = MockClient((request) {
369+
if (request.url.path == '/api/puzzle/batch/mix') {
370+
nbReq++;
371+
// each request yields a single puzzle with a distinct id, so the
372+
// background merge keeps appending until the queue reaches 3
373+
return mockResponse(batchOf1.replaceFirst('"20yWT"', '"pz$nbReq"'), 200);
374+
}
375+
return mockResponse('', 404);
376+
});
377+
378+
final container = await makeTestContainer(mockClient);
379+
final storage = await container.read(puzzleBatchStorageProvider.future);
380+
final service = await container.read(puzzleServiceFactoryProvider)(queueLength: 3);
381+
382+
final next = await service.nextPuzzle(userId: null);
383+
384+
// the first puzzle is available immediately after the foreground request
385+
expect(next?.puzzle.puzzle.id, equals(const PuzzleId('pz1')));
386+
387+
// the background fill tops the queue up to queueLength
388+
await _waitUntil(() async {
389+
final data = await storage.fetch(userId: null);
390+
return (data?.unsolved.length ?? 0) >= 3;
391+
});
392+
393+
expect(nbReq, equals(3));
394+
final data = await storage.fetch(userId: null);
395+
expect(data?.unsolved.length, equals(3));
396+
},
397+
);
398+
399+
test('background fill stops when server returns no more puzzles', () async {
400+
// If the server runs out of puzzles before the queue is full, the fill
401+
// must stop instead of looping forever.
402+
int nbReq = 0;
403+
final mockClient = MockClient((request) {
404+
if (request.url.path == '/api/puzzle/batch/mix') {
405+
nbReq++;
406+
// first request returns 1 puzzle, subsequent requests return none
407+
return mockResponse(nbReq == 1 ? batchOf1 : '{"puzzles":[]}', 200);
408+
}
409+
return mockResponse('', 404);
410+
});
411+
412+
final container = await makeTestContainer(mockClient);
413+
final storage = await container.read(puzzleBatchStorageProvider.future);
414+
final service = await container.read(puzzleServiceFactoryProvider)(queueLength: 10);
415+
416+
final next = await service.nextPuzzle(userId: null);
417+
expect(next?.puzzle.puzzle.id, equals(const PuzzleId('20yWT')));
418+
419+
// wait for the background fill to make its (empty) second request and stop
420+
await _waitUntil(() async => nbReq >= 2);
421+
// give the loop a tick to settle after the empty response
422+
await Future<void>.delayed(const Duration(milliseconds: 20));
423+
424+
expect(nbReq, equals(2));
425+
final data = await storage.fetch(userId: null);
426+
expect(data?.unsolved.length, equals(1));
427+
});
359428
});
360429
}
361430

@@ -423,3 +492,17 @@ PuzzleBatch _makePuzzleBatch({
423492
]),
424493
);
425494
}
495+
496+
/// Polls [condition] until it returns true or [timeout] elapses. Used to wait
497+
/// for the background queue fill, which runs after `nextPuzzle` returns.
498+
Future<void> _waitUntil(
499+
Future<bool> Function() condition, {
500+
Duration timeout = const Duration(seconds: 2),
501+
}) async {
502+
final deadline = DateTime.now().add(timeout);
503+
while (DateTime.now().isBefore(deadline)) {
504+
if (await condition()) return;
505+
await Future<void>.delayed(const Duration(milliseconds: 5));
506+
}
507+
throw StateError('Timed out waiting for condition');
508+
}

0 commit comments

Comments
 (0)