fix(queue): dispatch admitted downloads concurrently

This commit is contained in:
NimBold
2026-07-25 16:50:00 +03:30
parent d62e0429a4
commit dd7c2196fe
2 changed files with 100 additions and 1 deletions
+9 -1
View File
@@ -1199,7 +1199,15 @@ impl<R: tauri::Runtime> QueueManager<R> {
loop {
let notified = self.notify.notified();
if let Some((permit, task)) = self.try_admit_next_task().await {
Arc::clone(&self).dispatch_one(permit, task).await;
// Admission owns the global and per-queue reservation before
// this task is spawned. Keep the dispatcher free to admit
// other work while an Aria2 addUri call is retrying or a
// control RPC is slow; dispatch_one retains the reservation
// until that lifecycle reaches its own terminal path.
let manager = Arc::clone(&self);
tauri::async_runtime::spawn(async move {
manager.dispatch_one(permit, task).await;
});
} else {
// This covers both an empty pending list and the case where
// all queue/global capacity is occupied. The notification
+91
View File
@@ -27,6 +27,13 @@ struct DelayedAria2Spawner {
remove_uri_calls: AtomicUsize,
}
struct BlockingAria2Spawner {
first_started: tokio::sync::Notify,
second_started: tokio::sync::Notify,
release_first: tokio::sync::Notify,
add_uri_calls: AtomicUsize,
}
struct FailFirstAria2Spawner {
add_uri_calls: AtomicUsize,
fail_first: std::sync::atomic::AtomicBool,
@@ -56,6 +63,49 @@ impl DelayedAria2Spawner {
}
}
impl BlockingAria2Spawner {
fn new() -> Self {
Self {
first_started: tokio::sync::Notify::new(),
second_started: tokio::sync::Notify::new(),
release_first: tokio::sync::Notify::new(),
add_uri_calls: AtomicUsize::new(0),
}
}
}
#[async_trait::async_trait]
impl SidecarSpawner for BlockingAria2Spawner {
async fn add_uri(&self, _id: &str, _payload: &SpawnPayload) -> Result<String, String> {
let call = self.add_uri_calls.fetch_add(1, Ordering::SeqCst) + 1;
match call {
1 => {
self.first_started.notify_one();
self.release_first.notified().await;
Ok("gid-first".to_string())
}
2 => {
self.second_started.notify_one();
Ok("gid-second".to_string())
}
_ => Ok(format!("gid-{call}")),
}
}
async fn remove_uri(&self, _gid: &str) -> Result<(), String> {
Ok(())
}
async fn run_media(
&self,
_id: &str,
_payload: &SpawnPayload,
_generation: u64,
) -> Result<(), String> {
unreachable!("media is not used by blocking aria2 tests")
}
}
#[async_trait::async_trait]
impl SidecarSpawner for DelayedAria2Spawner {
async fn add_uri(&self, _id: &str, _payload: &SpawnPayload) -> Result<String, String> {
@@ -820,6 +870,47 @@ async fn dispatcher_skips_a_full_front_queue_for_later_eligible_work() {
dispatcher.abort();
}
#[tokio::test]
async fn dispatcher_admits_next_task_while_first_add_uri_is_blocked() {
let app = mock_builder()
.build(mock_context(noop_assets()))
.expect("mock app");
let spawner = Arc::new(BlockingAria2Spawner::new());
let manager = Arc::new(QueueManager::test_new(
app.handle().clone(),
2,
spawner.clone(),
));
manager.push(aria2_task("blocked-first")).await.unwrap();
manager.push(aria2_task("admitted-second")).await.unwrap();
let first_started = spawner.first_started.notified();
let dispatcher = {
let manager = Arc::clone(&manager);
tokio::spawn(async move { manager.run_dispatcher().await })
};
timeout(Duration::from_secs(1), first_started)
.await
.expect("the first addUri should start");
timeout(Duration::from_secs(1), spawner.second_started.notified())
.await
.expect("a blocked addUri must not serialize admission of the next task");
spawner.release_first.notify_one();
timeout(Duration::from_secs(1), async {
while manager.aria2_gid_for_download("blocked-first").is_none()
|| manager.aria2_gid_for_download("admitted-second").is_none()
{
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("both admitted tasks should finish dispatching");
dispatcher.abort();
}
#[tokio::test]
async fn dispatcher_rotates_eligible_queues_without_starving_later_work() {
let (manager, spawner) = make_manager(1);