Files
plezy/lib/utils/coalesced_load_coordinator.dart
T
edde746 74d3af3ae1 perf(home): load the home screen once instead of twice per cold start
The Discover tab fanned out its whole request set twice on every cold
start and replayed slow rows on a shrinking timeout ladder, so a healthy
remote server produced anywhere from 4s to 15s of loading.

Measured against a remote Jellyfin server with four libraries, 24
interleaved cold-start samples per side:

  requests  19 -> 9      payload  219 KB -> 94 KB
  settled   5231ms -> 2502ms median, 13222ms -> 5927ms p95

Four independent causes:

- Retry policy. `Client.send` resolves on response headers, so the
  connect budget covers the server's think time and a slow-but-alive
  query raises `connectionTimeout`. Replaying it made the server re-run
  the query with a shorter budget than the one it just missed; the
  `[10s, 8s, 5s]` ladder turned an 11s answer into an empty row after
  23s. Hub surfaces now get one whole-request deadline, retry only
  immediate connection errors, and the deadline bounds the whole call
  including the request still in flight.

- Request shape. `/Items/Latest` groups a TV library by series, so its
  rows are Series folder dtos and `RecursiveItemCount`/`ChildCount` cost
  a DB count each, per row. Hub rows now ask for `Overview` only; watch
  state survives because Jellyfin derives `UserData.Played` from
  `UnplayedItemCount` when the count fields are absent. `/Shows/NextUp`
  sends `NextUpDateCutoff` to bound the server's series-key scan, and
  `Thumb` leaves `EnableImageTypes` since nothing reads it. `UserData`
  and `PremiereDate` leave the browse set: neither is an `ItemFields`
  member, so the server dropped them anyway.

- Fan-out. Per-library hubs ran in batches of three separated by a
  barrier, so one slow library stalled every library behind it. A
  sliding window keeps the same peak concurrency without head-of-line
  blocking. Concurrent `fetchLibraries` calls now share one `/Views`
  instead of racing two identical round trips, Plex's global and music
  hub legs start together, and Jellyfin gets Plex's pool tuning.

- Duplicate pass. `DiscoverScreen.initState` starts a load and the
  online-entry hook asked for a full refresh on top of it, which
  `CoalescedLoadCoordinator` correctly queued as a trailing pass. The
  hook now calls `primeRefresh`, which rides along with a load already
  in flight; profile switches still go through `fullRefresh`.

Refs #1784
2026-08-04 04:35:06 +02:00

92 lines
2.8 KiB
Dart

import 'dart:async';
/// Coalesces full and targeted delta loads behind one in-flight drain.
///
/// A full load takes priority and supersedes every queued delta. Requests made
/// while a pass is running share the drain future and are replayed as trailing
/// work. The callbacks own fetch, commit, and failure policy.
final class CoalescedLoadCoordinator<T> {
factory CoalescedLoadCoordinator({
required Future<void> Function() onFull,
required Future<void> Function(Set<T>) onDelta,
}) => CoalescedLoadCoordinator._(onFull, onDelta);
CoalescedLoadCoordinator._(this._onFull, this._onDelta);
final Future<void> Function() _onFull;
final Future<void> Function(Set<T>) _onDelta;
final Set<T> _pendingDelta = {};
Future<void>? _inFlight;
bool _pendingFull = false;
bool _disposed = false;
/// Whether a pass is running (or queued behind the running one). Lets a
/// caller tell "this surface is already loading" from "nothing has started",
/// without changing the drain's trailing-replay contract.
bool get isBusy => _inFlight != null;
Future<void> requestFull() {
if (_disposed) return Future<void>.value();
_pendingFull = true;
return _ensureDrain();
}
Future<void> requestDelta(Iterable<T> values) {
if (_disposed) return Future<void>.value();
_pendingDelta.addAll(values);
if (_pendingDelta.isEmpty) return _inFlight ?? Future<void>.value();
return _ensureDrain();
}
/// Discards trailing work without interrupting the active callback.
void clearPending() {
if (_disposed) return;
_pendingFull = false;
_pendingDelta.clear();
}
/// Prevents new work and discards work queued behind the active callback.
void dispose() {
_disposed = true;
_pendingFull = false;
_pendingDelta.clear();
}
Future<void> _ensureDrain() {
final active = _inFlight;
if (active != null) return active;
// Install the shared future before invoking a callback so a synchronous,
// reentrant request is queued behind this drain rather than starting one.
final completer = Completer<void>();
final future = completer.future;
_inFlight = future;
_drain().then(
(_) {
if (!_disposed && identical(_inFlight, future)) _inFlight = null;
completer.complete();
},
onError: (Object error, StackTrace stackTrace) {
if (!_disposed && identical(_inFlight, future)) _inFlight = null;
completer.completeError(error, stackTrace);
},
);
return future;
}
Future<void> _drain() async {
while ((_pendingFull || _pendingDelta.isNotEmpty) && !_disposed) {
if (_pendingFull) {
_pendingFull = false;
_pendingDelta.clear();
await _onFull();
} else {
final values = Set<T>.of(_pendingDelta);
_pendingDelta.clear();
await _onDelta(values);
}
}
}
}