dig_download/queue.rs
1//! [`DownloadQueue`] — a bounded, first-come-first-serve queue over a [`Downloader`] (#1435 req. 1):
2//! capsule downloads are QUEUED and scheduled a few at a time, not all fired at once.
3//!
4//! # Why queue at all
5//!
6//! The #1423 cache-fill flywheel can enqueue many capsule downloads at once. Launching them all
7//! concurrently would saturate this node's own bandwidth and open unbounded peer connections. The
8//! queue caps the number of **active** downloads (`max_active`, default 3); the rest wait in arrival
9//! order and start as slots free up.
10//!
11//! # The FCFS guarantee
12//!
13//! Submissions run through one FIFO channel drained by exactly `max_active` worker tasks. A job is
14//! received only when a worker is idle, and the channel yields jobs in submission order — so at most
15//! `max_active` downloads run at once and they START in the order they were submitted (no reordering,
16//! no starvation).
17//!
18//! Each [`submit`](DownloadQueue::submit) returns a [`QueuedHandle`] carrying the same live progress
19//! event stream and terminal result as a direct [`Downloader::download`], so a caller cannot tell
20//! whether its download ran immediately or waited for a slot.
21
22use std::sync::Arc;
23
24use dig_dht::ContentId;
25use tokio::sync::{mpsc, oneshot, Mutex};
26
27use crate::error::DownloadError;
28use crate::orchestrator::{DownloadOptions, Downloader};
29use crate::progress::DownloadEvent;
30use crate::sink::Sink;
31
32/// The default number of capsule downloads allowed to run at once (the rest queue).
33pub const DEFAULT_MAX_ACTIVE_DOWNLOADS: usize = 3;
34
35/// One queued download job handed to a worker: what to download plus the back-channels the worker
36/// uses to stream progress and deliver the terminal result to the [`QueuedHandle`].
37struct QueuedJob {
38 content: ContentId,
39 sink: Arc<dyn Sink>,
40 opts: DownloadOptions,
41 events: mpsc::Sender<DownloadEvent>,
42 result: oneshot::Sender<Result<u64, DownloadError>>,
43}
44
45/// A bounded FCFS scheduler in front of a [`Downloader`]: [`submit`](Self::submit) as many downloads
46/// as you like; at most `max_active` run concurrently and the rest wait their turn in arrival order.
47pub struct DownloadQueue {
48 submit_tx: mpsc::UnboundedSender<QueuedJob>,
49 max_active: usize,
50}
51
52impl DownloadQueue {
53 /// Build a queue over `downloader` that runs at most `max_active` downloads at once (clamped to at
54 /// least 1). Spawns `max_active` worker tasks that drain submissions FCFS for the queue's lifetime.
55 pub fn new(downloader: Arc<Downloader>, max_active: usize) -> Arc<Self> {
56 let max_active = max_active.max(1);
57 let (submit_tx, submit_rx) = mpsc::unbounded_channel::<QueuedJob>();
58 // One shared FIFO receiver behind a mutex: a worker locks only long enough to pull the next
59 // job, so jobs are handed out in submission order and a job leaves the queue only when a
60 // worker is free — the bounded-FCFS invariant.
61 let submit_rx = Arc::new(Mutex::new(submit_rx));
62 for _ in 0..max_active {
63 let downloader = downloader.clone();
64 let submit_rx = submit_rx.clone();
65 tokio::spawn(async move {
66 loop {
67 let job = {
68 let mut rx = submit_rx.lock().await;
69 rx.recv().await
70 };
71 let Some(job) = job else {
72 return; // queue dropped — no more submissions
73 };
74 run_job(&downloader, job).await;
75 }
76 });
77 }
78 Arc::new(DownloadQueue {
79 submit_tx,
80 max_active,
81 })
82 }
83
84 /// Build a queue with the default active cap ([`DEFAULT_MAX_ACTIVE_DOWNLOADS`]).
85 pub fn with_defaults(downloader: Arc<Downloader>) -> Arc<Self> {
86 Self::new(downloader, DEFAULT_MAX_ACTIVE_DOWNLOADS)
87 }
88
89 /// The configured maximum number of concurrently-active downloads.
90 pub fn max_active(&self) -> usize {
91 self.max_active
92 }
93
94 /// Enqueue a download. Returns immediately with a [`QueuedHandle`]; the transfer starts as soon as
95 /// a worker slot is free (immediately if under the active cap), in submission order.
96 pub fn submit(
97 &self,
98 content: ContentId,
99 sink: Arc<dyn Sink>,
100 opts: DownloadOptions,
101 ) -> QueuedHandle {
102 let (events_tx, events_rx) = mpsc::channel(256);
103 let (result_tx, result_rx) = oneshot::channel();
104 let job = QueuedJob {
105 content,
106 sink,
107 opts,
108 events: events_tx,
109 result: result_tx,
110 };
111 // Unbounded send never blocks; if all workers are busy the job simply waits in the channel.
112 if self.submit_tx.send(job).is_err() {
113 // Workers gone (queue dropped): the oneshot is already dropped, so join() yields TaskEnded.
114 }
115 QueuedHandle {
116 events: events_rx,
117 result: Some(result_rx),
118 }
119 }
120}
121
122/// Drive one queued download to completion on a worker: start it on the downloader, forward its
123/// progress events to the queued handle, then deliver the terminal result.
124async fn run_job(downloader: &Downloader, job: QueuedJob) {
125 let mut handle = downloader.download(job.content, job.sink, job.opts);
126 // Forward progress until the download task closes its event stream (i.e. it reached a terminal
127 // state); a receiver that has been dropped just means the caller stopped listening.
128 while let Some(event) = handle.next_event().await {
129 if job.events.send(event).await.is_err() {
130 break;
131 }
132 }
133 let _ = job.result.send(handle.join().await);
134}
135
136/// A handle to a queued download: the live progress [`DownloadEvent`] stream plus the terminal result
137/// via [`join`](Self::join) — the same surface as a direct download, whether it ran now or waited.
138pub struct QueuedHandle {
139 events: mpsc::Receiver<DownloadEvent>,
140 result: Option<oneshot::Receiver<Result<u64, DownloadError>>>,
141}
142
143impl QueuedHandle {
144 /// Await the next progress [`DownloadEvent`], or `None` once the stream closes (download ended or
145 /// the queue was dropped).
146 pub async fn next_event(&mut self) -> Option<DownloadEvent> {
147 self.events.recv().await
148 }
149
150 /// Await the terminal result: `Ok(total_length)` on success, else the terminal [`DownloadError`]
151 /// ([`DownloadError::TaskEnded`] if the queue was dropped before the download ran).
152 pub async fn join(mut self) -> Result<u64, DownloadError> {
153 match self.result.take() {
154 Some(rx) => rx.await.unwrap_or(Err(DownloadError::TaskEnded)),
155 None => Err(DownloadError::TaskEnded),
156 }
157 }
158}