Skip to main content

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}