perf(downloads): bound native queue state

This commit is contained in:
zarzet
2026-08-27 00:17:25 +07:00
parent 5e8608cef9
commit 04c42bd511
10 changed files with 483 additions and 108 deletions
+118 -36
View File
@@ -53,6 +53,52 @@ part 'download_queue_provider_single_item.dart';
final _log = AppLogger('DownloadQueue');
typedef _PersistedQueueItemCache = Map<String, DownloadItem?>;
/// Prevents asynchronous queue startup checks from overlapping before
/// [DownloadQueueState.isProcessing] can be published.
class QueueProcessingGate {
bool _active = false;
bool _pending = false;
bool tryEnter() {
if (_active) {
_pending = true;
return false;
}
_active = true;
return true;
}
bool leave() {
_active = false;
final shouldRunAgain = _pending;
_pending = false;
return shouldRunAgain;
}
}
/// Encodes only restart-relevant queue state. Transfer progress is delivered
/// by the native progress stream and must not rewrite SQLite every few seconds.
String encodeDownloadQueueItemForPersistence(DownloadItem item) {
final persistedStatus = downloadQueuePersistenceStatus(item.status);
final json = item.toJson()
..['status'] = persistedStatus.name
..['progress'] = 0.0
..['speedMBps'] = 0.0
..['bytesReceived'] = 0
..['bytesTotal'] = 0
..['preparationStage'] = '';
return jsonEncode(json);
}
DownloadStatus downloadQueuePersistenceStatus(DownloadStatus status) =>
switch (status) {
DownloadStatus.downloading ||
DownloadStatus.finalizing => DownloadStatus.queued,
_ => status,
};
/// Set on queued items when the persisted Android SAF grant fails validation.
/// The queue UI matches on this to offer re-selecting the download folder.
const String safPermissionLostErrorMessage =
@@ -264,7 +310,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
Timer? _queuePersistDebounce;
Future<void> _queuePersistenceWrite = Future<void>.value();
Future<void> _queuePausePersistenceWrite = Future<void>.value();
final Map<String, String> _persistedQueueJsonById = {};
final _PersistedQueueItemCache _persistedQueueItemById = {};
final Set<String> _nonCanonicalPersistedQueueIds = {};
final QueueProcessingGate _queueProcessingGate = QueueProcessingGate();
StreamSubscription<List<ConnectivityResult>>? _connectivitySub;
int _downloadCount = 0;
static const _cleanupInterval = 50;
@@ -273,6 +321,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
static const _progressStreamBootstrapTimeout = Duration(seconds: 3);
static const _queueSchedulingInterval = Duration(milliseconds: 250);
static const _queuePersistDebounceDuration = Duration(milliseconds: 350);
static const _nativePreparationBatchSize = 32;
static const _nativePreparationWindowSize = 128;
static const _nativeWorkerRunIdPrefsKey =
'download_queue_native_worker_run_id';
static const _userPausedQueuePrefsKey = 'download_queue_user_paused_v1';
@@ -461,18 +511,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
await _appStateDb.migrateQueueFromSharedPreferences();
final restorePaused = await _loadUserPausedQueue();
final rows = await _appStateDb.getPendingDownloadQueueRows();
_persistedQueueJsonById
..clear()
..addEntries(
rows
.map(
(row) => MapEntry(
row['id']?.toString() ?? '',
row['item_json']?.toString() ?? '',
),
)
.where((entry) => entry.key.isNotEmpty),
);
_persistedQueueItemById.clear();
_nonCanonicalPersistedQueueIds.clear();
if (rows.isEmpty) {
if (restorePaused) _persistUserPausedQueue(false);
_log.d('No queue found in storage');
@@ -481,13 +521,31 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final pendingItems = <DownloadItem>[];
for (final row in rows) {
final rowId = row['id']?.toString() ?? '';
if (rowId.isEmpty) continue;
// Keep a null sentinel until the payload has been decoded. If the row
// is corrupt, the next flush still knows that its database ID must be
// deleted instead of silently leaving it behind forever.
_persistedQueueItemById[rowId] = null;
final itemJson = row['item_json'] as String?;
if (itemJson == null || itemJson.isEmpty) continue;
try {
final decoded = jsonDecode(itemJson);
if (decoded is! Map) continue;
var item = DownloadItem.fromJson(Map<String, dynamic>.from(decoded));
final persistedItem = DownloadItem.fromJson(
Map<String, dynamic>.from(decoded),
);
_persistedQueueItemById[rowId] = persistedItem;
final canonicalStatus = downloadQueuePersistenceStatus(
persistedItem.status,
).name;
if (itemJson !=
encodeDownloadQueueItemForPersistence(persistedItem) ||
row['status']?.toString() != canonicalStatus) {
_nonCanonicalPersistedQueueIds.add(rowId);
}
var item = persistedItem;
final normalizedService = _normalizeQueuedService(item.service);
if (normalizedService != item.service) {
item = item.copyWith(service: normalizedService);
@@ -509,7 +567,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (restorePaused) _persistUserPausedQueue(false);
_log.d('No pending items to restore');
await _appStateDb.replacePendingDownloadQueueRows(const []);
_persistedQueueJsonById.clear();
_persistedQueueItemById.clear();
_nonCanonicalPersistedQueueIds.clear();
return;
}
@@ -552,42 +611,54 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
try {
// skipped (user-cancelled) rows persist too, so a cancelled download can
// still be retried after an app restart instead of being re-searched.
final pendingItems = state.items
.where(
(item) =>
item.status == DownloadStatus.queued ||
item.status == DownloadStatus.downloading ||
item.status == DownloadStatus.finalizing ||
item.status == DownloadStatus.skipped,
)
.toList(growable: false);
final nowIso = DateTime.now().toIso8601String();
final currentJsonById = <String, String>{};
final currentItemsById = <String, DownloadItem?>{};
final upserts = <Map<String, dynamic>>[];
for (final item in pendingItems) {
final itemJson = jsonEncode(item.toJson());
currentJsonById[item.id] = itemJson;
if (_persistedQueueJsonById[item.id] == itemJson) continue;
for (final item in state.items) {
if (item.status != DownloadStatus.queued &&
item.status != DownloadStatus.downloading &&
item.status != DownloadStatus.finalizing &&
item.status != DownloadStatus.skipped) {
continue;
}
currentItemsById[item.id] = item;
final previous = _persistedQueueItemById[item.id];
final mustRewrite = _nonCanonicalPersistedQueueIds.contains(item.id);
if (!mustRewrite && identical(previous, item)) continue;
final itemJson = encodeDownloadQueueItemForPersistence(item);
if (!mustRewrite &&
previous != null &&
encodeDownloadQueueItemForPersistence(previous) == itemJson) {
continue;
}
upserts.add({
'id': item.id,
'item_json': itemJson,
'status': item.status.name,
'status': downloadQueuePersistenceStatus(item.status).name,
'created_at': item.createdAt.toIso8601String(),
'updated_at': nowIso,
});
}
final deletedIds = _persistedQueueJsonById.keys
.where((id) => !currentJsonById.containsKey(id))
final deletedIds = _persistedQueueItemById.keys
.where((id) => !currentItemsById.containsKey(id))
.toList(growable: false);
if (upserts.isEmpty && deletedIds.isEmpty) return;
if (upserts.isEmpty && deletedIds.isEmpty) {
_persistedQueueItemById
..clear()
..addAll(currentItemsById);
_nonCanonicalPersistedQueueIds.clear();
return;
}
await _appStateDb.applyPendingDownloadQueueChanges(
upserts: upserts,
deletedIds: deletedIds,
);
_persistedQueueJsonById
_persistedQueueItemById
..clear()
..addAll(currentJsonById);
..addAll(currentItemsById);
_nonCanonicalPersistedQueueIds.clear();
_log.d(
'Persisted ${upserts.length} changed and removed '
'${deletedIds.length} queue items',
@@ -1452,8 +1523,19 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
Future<void> _processQueue() async {
if (state.isProcessing) return;
if (!_queueProcessingGate.tryEnter()) return;
try {
if (state.isProcessing) return;
await _processQueueSingleFlight();
} finally {
if (_queueProcessingGate.leave()) {
Future.microtask(_processQueue);
}
}
}
Future<void> _processQueueSingleFlight() async {
if (Platform.isAndroid &&
state.items.any((item) => item.status == DownloadStatus.queued) &&
!canStartForegroundDownloadForLifecycle(
@@ -606,6 +606,7 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
await _persistNativeWorkerRunId(runId);
final reconciledIds = <String>{};
Future<void>? preparationFuture;
var preparationStopRequested = false;
try {
await PlatformBridge.startNativeDownloadWorker(
requests: [encodeRequest(firstItem, firstContext)],
@@ -627,39 +628,90 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
preparationFuture = () async {
var nextIndex = 1;
final preparationConcurrency = min(2, queuedItems.length - 1);
try {
await Future.wait(
List.generate(preparationConcurrency, (_) async {
while (nextIndex < queuedItems.length) {
final index = nextIndex++;
final item = queuedItems[index];
try {
final context = await _buildAndroidNativeWorkerRequest(
item,
settings,
);
if (context == null) {
_log.w(
'Native worker gate rejected ${item.track.name}; leaving it queued for the Dart worker',
while (nextIndex < queuedItems.length && !preparationStopRequested) {
while (contexts.length >=
DownloadQueueNotifier._nativePreparationWindowSize &&
!preparationStopRequested) {
await Future<void>.delayed(const Duration(milliseconds: 250));
}
if (preparationStopRequested) break;
final capacity =
DownloadQueueNotifier._nativePreparationWindowSize -
contexts.length;
final batchLength = min(
DownloadQueueNotifier._nativePreparationBatchSize,
min(capacity, queuedItems.length - nextIndex),
);
if (batchLength <= 0) continue;
final batchItems = queuedItems.sublist(
nextIndex,
nextIndex + batchLength,
);
nextIndex += batchLength;
final prepared = List<_NativeWorkerRequestContext?>.filled(
batchItems.length,
null,
);
var preparationIndex = 0;
final preparationConcurrency = min(2, batchItems.length);
await Future.wait(
List.generate(preparationConcurrency, (_) async {
while (true) {
final index = preparationIndex++;
if (index >= batchItems.length) return;
final item = batchItems[index];
try {
prepared[index] = await _buildAndroidNativeWorkerRequest(
item,
settings,
);
} catch (e, stack) {
_log.e(
'Could not prepare native request for ${item.track.name}: $e',
e,
stack,
);
continue;
}
contexts[item.id] = context;
await PlatformBridge.appendNativeDownloadWorkerRequests(
runId: runId,
requests: [encodeRequest(item, context)],
);
} catch (e, stack) {
_log.e(
'Could not prepare native request for ${item.track.name}: $e',
e,
stack,
);
}
}),
);
final appendRequests = <Map<String, dynamic>>[];
final appendedIds = <String>[];
for (var index = 0; index < batchItems.length; index++) {
final item = batchItems[index];
final context = prepared[index];
if (context == null) {
_log.w(
'Native worker gate rejected ${item.track.name}; leaving it queued for the Dart worker',
);
continue;
}
}),
);
contexts[item.id] = context;
appendedIds.add(item.id);
appendRequests.add(encodeRequest(item, context));
}
if (appendRequests.isEmpty) continue;
try {
await PlatformBridge.appendNativeDownloadWorkerRequests(
runId: runId,
requests: appendRequests,
);
} catch (e, stack) {
for (final id in appendedIds) {
contexts.remove(id);
}
_log.e(
'Could not append ${appendRequests.length} prepared native requests: $e',
e,
stack,
);
}
}
} finally {
try {
await PlatformBridge.finishNativeDownloadWorkerPreparation(
@@ -696,12 +748,14 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
);
lastStateSerial = _snapshotStateSerial(snapshot, lastStateSerial);
if (snapshot['is_running'] != true) {
preparationStopRequested = true;
await _clearNativeWorkerRunId(runId);
break;
}
await Future<void>.delayed(const Duration(seconds: 1));
}
} catch (e, stack) {
preparationStopRequested = true;
if (isForegroundServiceStartNotAllowed(e)) {
_log.w(
'Android rejected the native worker start while backgrounded; keeping the queue pending',
@@ -752,6 +806,7 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
_failedInSession++;
}
} finally {
preparationStopRequested = true;
if (preparationFuture != null) {
try {
await preparationFuture;
@@ -1106,6 +1161,38 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier {
}
}
}
final releasableIds = contexts.keys
.where(reconciledIds.contains)
.toList(growable: false);
if (releasableIds.isEmpty) return;
await flushQueuePersistence();
if (!workerRunning) {
for (final itemId in releasableIds) {
contexts.remove(itemId);
reconciledIds.remove(itemId);
}
return;
}
final runId = _snapshotRunId(snapshot);
if (runId.isEmpty) return;
try {
await PlatformBridge.acknowledgeNativeDownloadWorkerItems(
runId: runId,
itemIds: releasableIds,
);
for (final itemId in releasableIds) {
contexts.remove(itemId);
reconciledIds.remove(itemId);
}
} catch (e) {
// Keep contexts so the acknowledgement is retried on the next poll.
// This also keeps the preparation window bounded while native results
// are still retained by the service.
_log.w('Could not release reconciled native queue items: $e');
}
}
Future<void> _completeAndroidNativeWorkerItem(
+11
View File
@@ -1220,6 +1220,17 @@ class PlatformBridge {
});
}
static Future<void> acknowledgeNativeDownloadWorkerItems({
required String runId,
required List<String> itemIds,
}) async {
if (runId.isEmpty || itemIds.isEmpty) return;
await _channel.invokeMethod('acknowledgeNativeDownloadWorkerItems', {
'run_id': runId,
'item_ids_json': jsonEncode(itemIds),
});
}
static Future<void> _deleteFileIfExists(String path) async {
try {
final file = File(path);