Skip to main content

nzb_postproc/
resources.rs

1//! Resource gates shared by independent post-processing jobs.
2//!
3//! The limits are deliberately expressed as worker counts. PAR2 work is the
4//! CPU- and memory-heavy stage while extraction is primarily constrained by
5//! disk throughput. Bounding each stage separately lets one job extract while
6//! another repairs without allowing either workload to grow without bound.
7
8use std::sync::Arc;
9use std::sync::atomic::{AtomicUsize, Ordering};
10
11use tokio::sync::{OwnedSemaphorePermit, Semaphore};
12
13/// Concurrent post-processing worker limits.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub struct PostProcLimits {
16    /// Jobs admitted to the post-processing pipeline at once.
17    pub pipelines: usize,
18    /// Concurrent PAR2 verify/repair workers.
19    pub repair: usize,
20    /// Concurrent archive extraction workers.
21    pub extract: usize,
22}
23
24impl Default for PostProcLimits {
25    fn default() -> Self {
26        Self {
27            pipelines: 2,
28            repair: 1,
29            extract: 1,
30        }
31    }
32}
33
34impl PostProcLimits {
35    fn normalized(self) -> Self {
36        Self {
37            pipelines: self.pipelines.max(1),
38            repair: self.repair.max(1),
39            extract: self.extract.max(1),
40        }
41    }
42}
43
44/// Current and lifetime-high concurrent stage usage.
45#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
46pub struct PostProcResourceSnapshot {
47    pub active_pipelines: usize,
48    pub active_repairs: usize,
49    pub active_extractions: usize,
50    pub peak_pipelines: usize,
51    pub peak_repairs: usize,
52    pub peak_extractions: usize,
53}
54
55#[derive(Debug, Default)]
56struct Usage {
57    active: AtomicUsize,
58    peak: AtomicUsize,
59}
60
61impl Usage {
62    fn acquired(&self) {
63        let active = self.active.fetch_add(1, Ordering::AcqRel) + 1;
64        self.peak.fetch_max(active, Ordering::Relaxed);
65    }
66
67    fn released(&self) {
68        self.active.fetch_sub(1, Ordering::AcqRel);
69    }
70}
71
72/// Shared stage-specific semaphore set for the post-processing pipeline.
73#[derive(Debug)]
74pub struct PostProcResourcePool {
75    limits: PostProcLimits,
76    pipelines: Arc<Semaphore>,
77    repairs: Arc<Semaphore>,
78    extractions: Arc<Semaphore>,
79    pipeline_usage: Arc<Usage>,
80    repair_usage: Arc<Usage>,
81    extraction_usage: Arc<Usage>,
82}
83
84impl PostProcResourcePool {
85    pub fn new(limits: PostProcLimits) -> Arc<Self> {
86        let limits = limits.normalized();
87        Arc::new(Self {
88            limits,
89            pipelines: Arc::new(Semaphore::new(limits.pipelines)),
90            repairs: Arc::new(Semaphore::new(limits.repair)),
91            extractions: Arc::new(Semaphore::new(limits.extract)),
92            pipeline_usage: Arc::new(Usage::default()),
93            repair_usage: Arc::new(Usage::default()),
94            extraction_usage: Arc::new(Usage::default()),
95        })
96    }
97
98    pub fn limits(&self) -> PostProcLimits {
99        self.limits
100    }
101
102    pub async fn acquire_pipeline(self: &Arc<Self>) -> PostProcPermit {
103        Self::acquire(&self.pipelines, &self.pipeline_usage).await
104    }
105
106    pub(crate) async fn acquire_repair(self: &Arc<Self>) -> PostProcPermit {
107        Self::acquire(&self.repairs, &self.repair_usage).await
108    }
109
110    pub(crate) async fn acquire_extract(self: &Arc<Self>) -> PostProcPermit {
111        Self::acquire(&self.extractions, &self.extraction_usage).await
112    }
113
114    async fn acquire(semaphore: &Arc<Semaphore>, usage: &Arc<Usage>) -> PostProcPermit {
115        let permit = Arc::clone(semaphore)
116            .acquire_owned()
117            .await
118            .expect("post-processing resource semaphore must remain open");
119        usage.acquired();
120        PostProcPermit {
121            _permit: permit,
122            usage: Arc::clone(usage),
123        }
124    }
125
126    pub fn snapshot(&self) -> PostProcResourceSnapshot {
127        PostProcResourceSnapshot {
128            active_pipelines: self.pipeline_usage.active.load(Ordering::Relaxed),
129            active_repairs: self.repair_usage.active.load(Ordering::Relaxed),
130            active_extractions: self.extraction_usage.active.load(Ordering::Relaxed),
131            peak_pipelines: self.pipeline_usage.peak.load(Ordering::Relaxed),
132            peak_repairs: self.repair_usage.peak.load(Ordering::Relaxed),
133            peak_extractions: self.extraction_usage.peak.load(Ordering::Relaxed),
134        }
135    }
136}
137
138/// RAII permit which releases both the semaphore slot and usage counter.
139#[derive(Debug)]
140pub struct PostProcPermit {
141    _permit: OwnedSemaphorePermit,
142    usage: Arc<Usage>,
143}
144
145impl Drop for PostProcPermit {
146    fn drop(&mut self) {
147        self.usage.released();
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use std::time::Duration;
154
155    use super::*;
156
157    #[tokio::test]
158    async fn different_stages_can_overlap_but_same_stage_respects_its_limit() {
159        let pool = PostProcResourcePool::new(PostProcLimits {
160            pipelines: 2,
161            repair: 1,
162            extract: 1,
163        });
164        let _pipeline_a = pool.acquire_pipeline().await;
165        let _pipeline_b = pool.acquire_pipeline().await;
166        let repair_a = pool.acquire_repair().await;
167        let _extract_b = pool.acquire_extract().await;
168
169        let overlap = pool.snapshot();
170        assert_eq!(overlap.active_pipelines, 2);
171        assert_eq!(overlap.active_repairs, 1);
172        assert_eq!(overlap.active_extractions, 1);
173
174        let waiting_pool = Arc::clone(&pool);
175        let mut waiting_repair = tokio::spawn(async move { waiting_pool.acquire_repair().await });
176        assert!(
177            tokio::time::timeout(Duration::from_millis(25), &mut waiting_repair)
178                .await
179                .is_err(),
180            "a second repair must wait for the configured repair worker"
181        );
182
183        drop(repair_a);
184        let repair_b = tokio::time::timeout(Duration::from_secs(1), waiting_repair)
185            .await
186            .expect("waiting repair should be admitted")
187            .expect("waiting repair task should not panic");
188        drop(repair_b);
189
190        let snapshot = pool.snapshot();
191        assert_eq!(snapshot.peak_pipelines, 2);
192        assert_eq!(snapshot.peak_repairs, 1);
193        assert_eq!(snapshot.peak_extractions, 1);
194    }
195
196    #[tokio::test]
197    async fn zero_limits_are_safely_normalized_to_one_worker() {
198        let pool = PostProcResourcePool::new(PostProcLimits {
199            pipelines: 0,
200            repair: 0,
201            extract: 0,
202        });
203        assert_eq!(
204            pool.limits(),
205            PostProcLimits {
206                pipelines: 1,
207                repair: 1,
208                extract: 1,
209            }
210        );
211    }
212
213    #[tokio::test]
214    async fn stress_many_jobs_never_exceeds_stage_worker_limits() {
215        let pool = PostProcResourcePool::new(PostProcLimits {
216            pipelines: 3,
217            repair: 2,
218            extract: 1,
219        });
220        let mut jobs = Vec::new();
221        for index in 0..24 {
222            let resources = Arc::clone(&pool);
223            jobs.push(tokio::spawn(async move {
224                let _pipeline = resources.acquire_pipeline().await;
225                if index % 2 == 0 {
226                    let _repair = resources.acquire_repair().await;
227                    tokio::task::yield_now().await;
228                }
229                let _extract = resources.acquire_extract().await;
230                tokio::task::yield_now().await;
231            }));
232        }
233        for job in jobs {
234            job.await.expect("stress job should not panic");
235        }
236
237        let snapshot = pool.snapshot();
238        assert!(snapshot.peak_pipelines <= 3, "{snapshot:?}");
239        assert!(snapshot.peak_repairs <= 2, "{snapshot:?}");
240        assert!(snapshot.peak_extractions <= 1, "{snapshot:?}");
241        assert_eq!(snapshot.active_pipelines, 0);
242        assert_eq!(snapshot.active_repairs, 0);
243        assert_eq!(snapshot.active_extractions, 0);
244    }
245}