perf(downloads): index progress notification candidates

This commit is contained in:
zarzet committed 2026-10-08 17:05:13 +07:00
1 parent c6332e77d8
commit 1df593eb4f
6 files changed
+397 -24

No files matched your search

@@ -266,6 +266,8 @@ registerExtension({
);
final files = <String, String>{};
final states = <String, List<String>>{};
var candidateChecks = 0;
var candidatesMatch = true;
final tracks = [
for (var i = 0; i < 2; i++)
Track(
@@ -308,6 +310,21 @@ registerExtension({
var retried = false;
String? failedId;
final subscription = container.listen(downloadQueueProvider, (_, next) {
candidateChecks++;
candidatesMatch =
candidatesMatch &&
identical(
next.lookup.firstDownloading,
next.items
.where((item) => item.status == DownloadStatus.downloading)
.firstOrNull,
) &&
identical(
next.lookup.firstFinalizing,
next.items
.where((item) => item.status == DownloadStatus.finalizing)
.firstOrNull,
);
for (final item in next.items) {
final transitions = states.putIfAbsent(item.track.id, () => []);
if (transitions.lastOrNull != item.status.name) {
@@ -362,6 +379,13 @@ registerExtension({
await done.future.timeout(const Duration(seconds: 60));
}
await queue.flushQueuePersistence();
expect(candidateChecks, greaterThan(0));
expect(
candidatesMatch,
isTrue,
reason:
'Cached notification candidates must match each real queue snapshot',
);
} finally {
subscription.close();
}
@@ -0,0 +1,158 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:spotiflac_android/models/download_item.dart';
import 'package:spotiflac_android/providers/download_queue_state.dart';
import 'package:spotiflac_android/utils/chunked_list.dart';
import '../test/support/queue_removal_benchmark.dart';
({DownloadItem? downloading, DownloadItem? finalizing}) _scan(
DownloadQueueState state,
) {
DownloadItem? downloading;
DownloadItem? finalizing;
final downloadingCount = state.lookup.activeDownloadsCount;
final hasFinalizing = state.lookup.finalizingCount > 0;
if (downloadingCount > 0 || hasFinalizing) {
for (final item in state.items) {
if (downloading == null && item.status == DownloadStatus.downloading) {
downloading = item;
}
if (finalizing == null && item.status == DownloadStatus.finalizing) {
finalizing = item;
}
if ((downloadingCount == 0 || downloading != null) &&
(!hasFinalizing || finalizing != null)) {
break;
}
}
}
return (downloading: downloading, finalizing: finalizing);
}
int _measure(
DownloadQueueState initial,
int activeIndex,
bool finalizing, {
required bool indexed,
required int iterations,
}) {
var state = initial;
final expectedDownload = initial.items[activeIndex].id;
final expectedFinalizing = finalizing
? initial.items[activeIndex + 1].id
: null;
final watch = Stopwatch()..start();
for (var tick = 0; tick < iterations; tick++) {
final previous = state.items;
final next = ChunkedList<DownloadItem>.from(previous).updated({
activeIndex: previous[activeIndex].copyWith(
progress: (tick + 1) / (iterations + 1),
),
});
state = state.copyWith(
items: next,
lookup: state.lookup.updatedForIndices(
previousItems: previous,
nextItems: next,
changedIndices: [activeIndex],
),
);
final selected = indexed
? (
downloading: state.lookup.firstDownloading,
finalizing: state.lookup.firstFinalizing,
)
: _scan(state);
if (selected.downloading?.id != expectedDownload ||
selected.finalizing?.id != expectedFinalizing ||
!identical(selected.downloading, next[activeIndex])) {
fail('Notification selected a different track or stale progress');
}
}
watch.stop();
expect(initial.items[activeIndex].progress, 0);
return watch.elapsedMicroseconds;
}
void main() {
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
final cases = <Map<String, dynamic>>[];
// Sub-millisecond front samples were noisy during plugin/driver startup.
// Let startup settle, and use longer front samples instead of filtering them.
setUpAll(() => Future<void>.delayed(const Duration(seconds: 2)));
tearDownAll(() {
binding.reportData ??= {};
binding.reportData!['queue_progress_selection'] = {
'platform': Platform.operatingSystem,
'completed_cases': cases.length,
'note':
'Five alternating pairs after warming both methods: 10000 sparse progress snapshots for front candidates, 500 for tail candidates. Both use the current index update; legacy selection scans items, current selection reads cached first positions. Setup excluded. CPU only, not rendered frames or download speed. iOS debug correctness only.',
'cases': cases,
};
});
for (final count in [1000, 10000]) {
for (final atEnd in [false, true]) {
for (final finalizing in [false, true]) {
testWidgets(
'select active items in $count, tail=$atEnd, finalizing=$finalizing',
(tester) async {
final fixture = await queueRemovalFixture(count);
final activeIndex = atEnd ? count - 3 : 0;
final iterations = atEnd ? 500 : 10000;
final initial = fixture.copyWith(
items: [
for (var i = 0; i < count; i++)
fixture.items[i].copyWith(
status: i == activeIndex
? DownloadStatus.downloading
: finalizing && i == activeIndex + 1
? DownloadStatus.finalizing
: DownloadStatus.completed,
),
],
);
_measure(
initial,
activeIndex,
finalizing,
indexed: false,
iterations: iterations,
);
_measure(
initial,
activeIndex,
finalizing,
indexed: true,
iterations: iterations,
);
final samples = <String, List<int>>{'scan': [], 'indexed': []};
for (var pair = 0; pair < 5; pair++) {
for (final indexed
in pair.isEven ? [false, true] : [true, false]) {
samples[indexed ? 'indexed' : 'scan']!.add(
_measure(
initial,
activeIndex,
finalizing,
indexed: indexed,
iterations: iterations,
),
);
}
}
cases.add({
'queue_size': count,
'active_at_tail': atEnd,
'with_finalizing': finalizing,
'updates_per_sample': iterations,
'samples_us': samples,
});
},
);
}
}
}
}
@@ -17,28 +17,11 @@ extension _DownloadQueueProgress on DownloadQueueNotifier {
_progressTracker.pruneLogs(lookup);
final queuedCount = lookup.queuedCount;
final downloadingCount = lookup.activeDownloadsCount;
DownloadItem? firstDownloading;
final firstDownloading = lookup.firstDownloading;
final firstFinalizing = lookup.firstFinalizing;
bool hasFinalizingItem = lookup.finalizingCount > 0;
String? finalizingTrackName;
String? finalizingArtistName;
if (downloadingCount > 0 || hasFinalizingItem) {
for (final item in currentItems) {
if (firstDownloading == null &&
item.status == DownloadStatus.downloading) {
firstDownloading = item;
}
if (finalizingTrackName == null &&
item.status == DownloadStatus.finalizing) {
hasFinalizingItem = true;
finalizingTrackName = item.track.name;
finalizingArtistName = item.track.artistName;
}
if ((downloadingCount == 0 || firstDownloading != null) &&
(!hasFinalizingItem || finalizingTrackName != null)) {
break;
}
}
}
String? finalizingTrackName = firstFinalizing?.track.name;
String? finalizingArtistName = firstFinalizing?.track.artistName;
final replacements = <int, DownloadItem>{};
for (final entry in items.entries) {
+102 -3
View File
@@ -248,8 +248,16 @@ class DownloadQueueLookup {
final int failedCount;
final int activeDownloadsCount;
final int finalizingCount;
final List<DownloadItem> _items;
final int? _firstDownloadingIndex;
final int? _firstFinalizingIndex;
final Map<String, List<int>>? _duplicateTrackPositions;
DownloadItem? get firstDownloading =>
_firstDownloadingIndex == null ? null : _items[_firstDownloadingIndex];
DownloadItem? get firstFinalizing =>
_firstFinalizingIndex == null ? null : _items[_firstFinalizingIndex];
const DownloadQueueLookup.empty()
: byTrackId = const {},
byItemId = const {},
@@ -261,6 +269,9 @@ class DownloadQueueLookup {
failedCount = 0,
activeDownloadsCount = 0,
finalizingCount = 0,
_items = const [],
_firstDownloadingIndex = null,
_firstFinalizingIndex = null,
_duplicateTrackPositions = null;
DownloadQueueLookup._({
@@ -274,8 +285,55 @@ class DownloadQueueLookup {
required this.failedCount,
required this.activeDownloadsCount,
required this.finalizingCount,
required List<DownloadItem> items,
required int? firstDownloadingIndex,
required int? firstFinalizingIndex,
Map<String, List<int>>? duplicateTrackPositions,
}) : _duplicateTrackPositions = duplicateTrackPositions;
}) : _items = items,
_firstDownloadingIndex = firstDownloadingIndex,
_firstFinalizingIndex = firstFinalizingIndex,
_duplicateTrackPositions = duplicateTrackPositions;
static int? _firstAfterRemoval(
int? first,
int removedIndex,
DownloadStatus status,
int count,
List<DownloadItem> nextItems,
) {
if (first == null) return null;
if (first < removedIndex) return first;
if (first > removedIndex) return first - 1;
if (count > 1) {
for (var index = removedIndex; index < nextItems.length; index++) {
if (nextItems[index].status == status) return index;
}
}
return null;
}
// Progress-only snapshots retain the first positions. Search beyond an old
// first item only when it leaves the status, not on every native sample.
static int? _firstAfterUpdates(
int? first,
DownloadStatus status,
int count,
List<DownloadItem> nextItems,
Set<int> changedIndices,
) {
if (count == 0) return null;
for (final index in changedIndices) {
if ((first == null || index < first) &&
nextItems[index].status == status) {
first = index;
}
}
if (first == null || nextItems[first].status == status) return first;
for (var index = first + 1; index < nextItems.length; index++) {
if (nextItems[index].status == status) return index;
}
return null;
}
DownloadQueueLookup withoutIndex(
List<DownloadItem> previousItems,
@@ -356,6 +414,21 @@ class DownloadQueueLookup {
finalizingCount:
finalizingCount -
(removed.status == DownloadStatus.finalizing ? 1 : 0),
items: nextItems,
firstDownloadingIndex: _firstAfterRemoval(
_firstDownloadingIndex,
index,
DownloadStatus.downloading,
activeDownloadsCount,
nextItems,
),
firstFinalizingIndex: _firstAfterRemoval(
_firstFinalizingIndex,
index,
DownloadStatus.finalizing,
finalizingCount,
nextItems,
),
duplicateTrackPositions: duplicates,
);
}
@@ -370,6 +443,8 @@ class DownloadQueueLookup {
var failedCount = 0;
var activeDownloadsCount = 0;
var finalizingCount = 0;
int? firstDownloadingIndex;
int? firstFinalizingIndex;
for (var index = 0; index < items.length; index++) {
final item = items[index];
byTrackIndex.putIfAbsent(item.track.id, () => index);
@@ -381,8 +456,14 @@ class DownloadQueueLookup {
if (_countsAsQueued(item.status)) queuedCount++;
if (item.status == DownloadStatus.completed) completedCount++;
if (item.status == DownloadStatus.failed) failedCount++;
if (item.status == DownloadStatus.downloading) activeDownloadsCount++;
if (item.status == DownloadStatus.finalizing) finalizingCount++;
if (item.status == DownloadStatus.downloading) {
activeDownloadsCount++;
firstDownloadingIndex ??= index;
}
if (item.status == DownloadStatus.finalizing) {
finalizingCount++;
firstFinalizingIndex ??= index;
}
}
final snapshot = ChunkedList<DownloadItem>.from(items);
final itemIndex = Map<String, int>.unmodifiable(indexByItemId);
@@ -397,6 +478,9 @@ class DownloadQueueLookup {
failedCount: failedCount,
activeDownloadsCount: activeDownloadsCount,
finalizingCount: finalizingCount,
items: snapshot,
firstDownloadingIndex: firstDownloadingIndex,
firstFinalizingIndex: firstFinalizingIndex,
);
}
@@ -505,6 +589,21 @@ class DownloadQueueLookup {
failedCount: nextFailedCount,
activeDownloadsCount: nextActiveDownloadsCount,
finalizingCount: nextFinalizingCount,
items: snapshot,
firstDownloadingIndex: _firstAfterUpdates(
_firstDownloadingIndex,
DownloadStatus.downloading,
nextActiveDownloadsCount,
snapshot,
normalizedChanged,
),
firstFinalizingIndex: _firstAfterUpdates(
_firstFinalizingIndex,
DownloadStatus.finalizing,
nextFinalizingCount,
snapshot,
normalizedChanged,
),
duplicateTrackPositions: _duplicateTrackPositions,
);
}
+9
View File
@@ -39,6 +39,15 @@ void _check(DownloadQueueState state) {
expect(actual.failedCount, expected.failedCount);
expect(actual.activeDownloadsCount, expected.activeDownloadsCount);
expect(actual.finalizingCount, expected.finalizingCount);
for (final entry in {
DownloadStatus.downloading: actual.firstDownloading,
DownloadStatus.finalizing: actual.firstFinalizing,
}.entries) {
expect(
entry.value,
same(state.items.where((item) => item.status == entry.key).firstOrNull),
);
}
expect(actual.byItemId['absent'], isNull);
expect(actual.byTrackId['absent'], isNull);
expect(actual.indexByItemId['absent'], isNull);
+100
View File
@@ -98,6 +98,8 @@ void main() {
expect(state.lookup.failedCount, rebuilt.failedCount);
expect(state.lookup.activeDownloadsCount, rebuilt.activeDownloadsCount);
expect(state.lookup.finalizingCount, rebuilt.finalizingCount);
expect(state.lookup.firstDownloading, same(rebuilt.firstDownloading));
expect(state.lookup.firstFinalizing, same(rebuilt.firstFinalizing));
expect(state.lookup.notCompletedItemIds, rebuilt.notCompletedItemIds);
expect(
old.lookup.byItemId[old.items[index].id],
@@ -112,4 +114,102 @@ void main() {
expect(() => state.lookup.byItemId.clear(), throwsUnsupportedError);
},
);
test(
'notification candidates follow batch transitions and keep old values',
() {
void check(DownloadQueueState state) {
expect(
state.lookup.firstDownloading,
same(
state.items
.where((item) => item.status == DownloadStatus.downloading)
.firstOrNull,
),
);
expect(
state.lookup.firstFinalizing,
same(
state.items
.where((item) => item.status == DownloadStatus.finalizing)
.firstOrNull,
),
);
}
var state = const DownloadQueueState().copyWith(
items: List.generate(200, _item),
);
void update(Map<int, DownloadItem> changes) {
final old = state;
final next = ChunkedList<DownloadItem>.from(old.items).updated(changes);
state = old.copyWith(
items: next,
lookup: old.lookup.updatedForIndices(
previousItems: old.items,
nextItems: next,
changedIndices: changes.keys.toList().reversed,
),
);
check(state);
check(old);
}
final random = Random(506);
for (var tick = 0; tick < 300; tick++) {
update({
for (var i = 0; i < 7; i++)
random.nextInt(200): state.items[random.nextInt(200)],
});
// Above exercises identity/reordering fallback. Keep IDs stable here to
// exercise the incremental path with multiple simultaneous transitions.
final changes = <int, DownloadItem>{};
for (var i = 0; i < 7; i++) {
final index = random.nextInt(200);
changes[index] = state.items[index].copyWith(
status: DownloadStatus
.values[random.nextInt(DownloadStatus.values.length)],
progress: tick / 300,
track: state.items[index].track.copyWith(name: 'Updated $tick'),
);
}
// Restore unique request IDs after the intentionally malformed batch.
state = state.copyWith(
items: [
for (var i = 0; i < state.items.length; i++)
state.items[i].copyWith(id: 'item-$i'),
],
);
update({
for (final entry in changes.entries)
entry.key: entry.value.copyWith(id: 'item-${entry.key}'),
});
}
update({
for (var i = 0; i < state.items.length; i++)
i: state.items[i].copyWith(status: DownloadStatus.completed),
});
expect(state.lookup.firstDownloading, isNull);
expect(state.lookup.firstFinalizing, isNull);
update({
190: state.items[190].copyWith(status: DownloadStatus.downloading),
195: state.items[195].copyWith(status: DownloadStatus.finalizing),
});
update({
195: state.items[195].copyWith(status: DownloadStatus.downloading),
190: state.items[190].copyWith(status: DownloadStatus.finalizing),
1: state.items[1].copyWith(status: DownloadStatus.downloading),
0: state.items[0].copyWith(status: DownloadStatus.finalizing),
});
expect(state.lookup.firstDownloading, same(state.items[1]));
expect(state.lookup.firstFinalizing, same(state.items[0]));
update({
0: state.items[0].copyWith(status: DownloadStatus.completed),
1: state.items[1].copyWith(status: DownloadStatus.completed),
});
expect(state.lookup.firstDownloading, same(state.items[195]));
expect(state.lookup.firstFinalizing, same(state.items[190]));
check(const DownloadQueueState());
},
);
}