Skip to main content

nzb_dispatch/
bandwidth.rs

1use std::num::NonZeroU32;
2use std::sync::atomic::{AtomicU32, Ordering};
3
4use arc_swap::ArcSwapOption;
5use governor::DefaultDirectRateLimiter as RateLimiter;
6use governor::Quota;
7use serde::{Deserialize, Serialize};
8use std::sync::Arc;
9use tokio::sync::Notify;
10
11#[derive(Default, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
12pub struct BandwidthConfig {
13    /// Download speed limit in bytes per second (None = unlimited)
14    pub download_bps: Option<NonZeroU32>,
15}
16
17/// A rate limiter together with its burst size, swapped as one unit so a
18/// concurrent reconfiguration can never pair a limiter with the wrong burst.
19struct Bucket {
20    limiter: RateLimiter,
21    burst: NonZeroU32,
22}
23
24struct Limit {
25    limiter: ArcSwapOption<Bucket>,
26    current_bps: AtomicU32,
27    /// Signalled on every reconfiguration so parked acquires abandon the
28    /// limiter they loaded and restart against the current one.
29    changed: Notify,
30}
31
32impl Limit {
33    fn new_inner(bps: Option<NonZeroU32>) -> Option<Arc<Bucket>> {
34        let bps = bps?;
35        Some(Arc::new(Bucket {
36            limiter: RateLimiter::direct(Quota::per_second(bps)),
37            burst: bps,
38        }))
39    }
40
41    fn new(bps: Option<NonZeroU32>) -> Self {
42        Self {
43            limiter: ArcSwapOption::new(Self::new_inner(bps)),
44            current_bps: AtomicU32::new(bps.map(|v| v.get()).unwrap_or(0)),
45            changed: Notify::new(),
46        }
47    }
48
49    async fn acquire(&self, size: NonZeroU32) -> anyhow::Result<()> {
50        let mut remaining = size.get();
51        while remaining > 0 {
52            // Register for change notifications *before* loading the limiter,
53            // so a reconfiguration between the load and the wait is not lost.
54            let changed = self.changed.notified();
55            tokio::pin!(changed);
56            changed.as_mut().enable();
57
58            let lim = self.limiter.load_full();
59            let Some(bucket) = lim else {
60                // Unlimited (including a switch to unlimited mid-acquire).
61                return Ok(());
62            };
63            // `Quota::per_second(bps)` gives a burst of `bps` cells, and
64            // governor rejects any single request larger than the burst with
65            // `InsufficientCapacity`. A decoded article (~750 KB) exceeds the
66            // burst for every limit below that, so acquire in burst-sized
67            // chunks rather than in one call.
68            let chunk = remaining.min(bucket.burst.get());
69            // `chunk` is non-zero: `remaining > 0` and `burst >= 1`.
70            let n = NonZeroU32::new(chunk).expect("chunk > 0");
71            tokio::select! {
72                res = bucket.limiter.until_n_ready(n) => {
73                    res?;
74                    remaining -= chunk;
75                }
76                // The limit changed while parked: drop the wait on the old
77                // limiter and re-evaluate `remaining` against the new one.
78                () = &mut changed => {}
79            }
80        }
81        Ok(())
82    }
83
84    fn set(&self, limit: Option<NonZeroU32>) {
85        let new = Self::new_inner(limit);
86        self.limiter.swap(new);
87        self.current_bps
88            .store(limit.map(|v| v.get()).unwrap_or(0), Ordering::Relaxed);
89        self.changed.notify_waiters();
90    }
91
92    fn get(&self) -> Option<NonZeroU32> {
93        NonZeroU32::new(self.current_bps.load(Ordering::Relaxed))
94    }
95}
96
97pub struct BandwidthLimiter {
98    download: Limit,
99}
100
101impl BandwidthLimiter {
102    pub fn new(config: BandwidthConfig) -> Self {
103        Self {
104            download: Limit::new(config.download_bps),
105        }
106    }
107
108    pub async fn acquire_download(&self, len: NonZeroU32) -> anyhow::Result<()> {
109        self.download.acquire(len).await
110    }
111
112    pub fn set_download_bps(&self, bps: Option<NonZeroU32>) {
113        self.download.set(bps);
114    }
115
116    pub fn get_download_bps(&self) -> Option<NonZeroU32> {
117        self.download.get()
118    }
119
120    pub fn get_config(&self) -> BandwidthConfig {
121        BandwidthConfig {
122            download_bps: self.get_download_bps(),
123        }
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    /// Regression: with `Quota::per_second(bps)` the burst equals `bps`, so a
132    /// single request larger than the per-second limit (one decoded article
133    /// is ~750 KB) used to fail with `InsufficientCapacity` and the callers
134    /// discarded the error — the limit was silently ignored.
135    #[tokio::test]
136    async fn request_larger_than_burst_is_throttled_not_rejected() {
137        let limiter = BandwidthLimiter::new(BandwidthConfig {
138            download_bps: NonZeroU32::new(200_000),
139        });
140        let start = std::time::Instant::now();
141        limiter
142            .acquire_download(NonZeroU32::new(500_000).unwrap())
143            .await
144            .expect("oversized request must be throttled, not rejected");
145        // 200 KB of burst is free; the remaining 300 KB must wait ~1.5 s.
146        assert!(
147            start.elapsed() >= std::time::Duration::from_millis(1_000),
148            "500 KB at 200 KB/s returned after {:?}",
149            start.elapsed()
150        );
151    }
152
153    /// Start an acquire that, at `bps`, would take hours, then reconfigure
154    /// the limit and return how long the parked acquire took to complete.
155    async fn acquire_then_reconfigure(
156        initial: u32,
157        changed: Option<NonZeroU32>,
158    ) -> std::time::Duration {
159        let limiter = Arc::new(BandwidthLimiter::new(BandwidthConfig {
160            download_bps: NonZeroU32::new(initial),
161        }));
162        let waiter = {
163            let limiter = Arc::clone(&limiter);
164            tokio::spawn(async move {
165                // One decoded article: ~750 KB, i.e. ~10 h at 20 B/s.
166                limiter
167                    .acquire_download(NonZeroU32::new(750_000).unwrap())
168                    .await
169            })
170        };
171        // Let the acquire park inside the old limiter.
172        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
173        assert!(!waiter.is_finished(), "acquire should be throttled");
174
175        let changed_at = std::time::Instant::now();
176        limiter.set_download_bps(changed);
177        tokio::time::timeout(std::time::Duration::from_secs(5), waiter)
178            .await
179            .expect("parked acquire never observed the limit change")
180            .unwrap()
181            .unwrap();
182        changed_at.elapsed()
183    }
184
185    /// Regression (BUG-57): a parked acquire kept awaiting the limiter it
186    /// loaded on entry, so lowering the limit to 20 B/s wedged every worker
187    /// for hours and setting it back to unlimited did not release them.
188    #[tokio::test]
189    async fn switching_to_unlimited_releases_parked_acquire() {
190        let took = acquire_then_reconfigure(20, None).await;
191        assert!(
192            took < std::time::Duration::from_secs(2),
193            "unlimited took {took:?} to apply"
194        );
195    }
196
197    #[tokio::test]
198    async fn raising_limit_releases_parked_acquire() {
199        let took = acquire_then_reconfigure(20, NonZeroU32::new(10_000_000)).await;
200        assert!(
201            took < std::time::Duration::from_secs(2),
202            "raised limit took {took:?} to apply"
203        );
204    }
205
206    #[tokio::test]
207    async fn unlimited_acquire_returns_immediately() {
208        let limiter = BandwidthLimiter::new(BandwidthConfig::default());
209        tokio::time::timeout(
210            std::time::Duration::from_millis(100),
211            limiter.acquire_download(NonZeroU32::new(u32::MAX).unwrap()),
212        )
213        .await
214        .expect("unlimited acquire must not wait")
215        .unwrap();
216    }
217
218    #[tokio::test]
219    async fn limiter_can_be_reconfigured_without_recreation() {
220        let limiter = BandwidthLimiter::new(BandwidthConfig::default());
221        assert_eq!(limiter.get_download_bps(), None);
222        limiter
223            .acquire_download(NonZeroU32::new(1).unwrap())
224            .await
225            .unwrap();
226
227        limiter.set_download_bps(NonZeroU32::new(1_000));
228        assert_eq!(limiter.get_config().download_bps.unwrap().get(), 1_000);
229        limiter.set_download_bps(None);
230        assert_eq!(limiter.get_download_bps(), None);
231    }
232}