Skip to main content

http_handle/
batch.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2// Copyright (c) 2026 Sebastien Rousseau
3
4//! Batch processing utilities for concurrent file reads.
5
6use crate::error::ServerError;
7use std::fs;
8use std::path::{Path, PathBuf};
9use std::sync::{Arc, Mutex};
10use std::thread;
11
12/// Batch operation request.
13///
14/// # Examples
15///
16/// ```rust
17/// use http_handle::batch::BatchRequest;
18/// use std::path::PathBuf;
19/// let req = BatchRequest { relative_path: PathBuf::from("index.html") };
20/// assert_eq!(req.relative_path, PathBuf::from("index.html"));
21/// ```
22///
23/// # Panics
24///
25/// This type does not panic.
26#[derive(Clone, Debug)]
27pub struct BatchRequest {
28    /// Relative path to read.
29    pub relative_path: PathBuf,
30}
31
32/// Batch operation result.
33///
34/// # Examples
35///
36/// ```rust
37/// use http_handle::batch::BatchResult;
38/// use http_handle::ServerError;
39/// use std::path::PathBuf;
40/// let result = BatchResult { relative_path: PathBuf::from("a.txt"), body: Ok(Vec::new()) };
41/// assert!(result.body.is_ok());
42/// let _unused: Result<Vec<u8>, ServerError> = Ok(Vec::new());
43/// ```
44///
45/// # Panics
46///
47/// This type does not panic.
48#[derive(Debug)]
49pub struct BatchResult {
50    /// Requested relative path.
51    pub relative_path: PathBuf,
52    /// Read bytes if successful.
53    pub body: Result<Vec<u8>, ServerError>,
54}
55
56/// Concurrently reads multiple files under a shared root.
57///
58/// # Examples
59///
60/// ```rust,no_run
61/// use http_handle::batch::{BatchRequest, process_batch};
62/// use std::path::{Path, PathBuf};
63/// let requests = vec![BatchRequest { relative_path: PathBuf::from("index.html") }];
64/// let _results = process_batch(Path::new("."), &requests, 2);
65/// ```
66///
67/// # Errors
68///
69/// This function does not return a `Result`. Per-file errors are captured in each
70/// [`BatchResult::body`] entry.
71///
72/// # Panics
73///
74/// This function does not panic.
75pub fn process_batch(
76    document_root: &Path,
77    requests: &[BatchRequest],
78    workers: usize,
79) -> Vec<BatchResult> {
80    if requests.is_empty() {
81        return Vec::new();
82    }
83
84    let workers = workers.max(1).min(requests.len());
85    let shared =
86        Arc::new(Mutex::new(Vec::with_capacity(requests.len())));
87
88    thread::scope(|scope| {
89        let chunk_size = requests.len().div_ceil(workers);
90        for chunk in requests.chunks(chunk_size) {
91            let root = document_root.to_path_buf();
92            let out = Arc::clone(&shared);
93            let _ = scope.spawn(move || {
94                for req in chunk {
95                    let full_path = root.join(&req.relative_path);
96                    let result =
97                        fs::read(&full_path).map_err(ServerError::from);
98                    let entry = BatchResult {
99                        relative_path: req.relative_path.clone(),
100                        body: result,
101                    };
102                    if let Ok(mut guard) = out.lock() {
103                        guard.push(entry);
104                    }
105                }
106            });
107        }
108    });
109
110    let mut guard = shared
111        .lock()
112        .unwrap_or_else(|poisoned| poisoned.into_inner());
113    let mut out = std::mem::take(&mut *guard);
114    out.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
115    out
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use tempfile::TempDir;
122
123    #[test]
124    fn batch_reads_multiple_files() {
125        let tmp = TempDir::new().expect("tmp");
126        fs::write(tmp.path().join("a.txt"), b"a").expect("write a");
127        fs::write(tmp.path().join("b.txt"), b"b").expect("write b");
128
129        let requests = vec![
130            BatchRequest {
131                relative_path: PathBuf::from("a.txt"),
132            },
133            BatchRequest {
134                relative_path: PathBuf::from("b.txt"),
135            },
136        ];
137
138        let results = process_batch(tmp.path(), &requests, 2);
139        assert_eq!(results.len(), 2);
140        assert!(results.iter().all(|r| r.body.is_ok()));
141    }
142
143    #[test]
144    fn batch_returns_empty_for_empty_requests() {
145        let tmp = TempDir::new().expect("tmp");
146        let requests: Vec<BatchRequest> = Vec::new();
147        let results = process_batch(tmp.path(), &requests, 4);
148        assert!(results.is_empty());
149    }
150
151    #[test]
152    fn batch_reports_missing_file_error() {
153        let tmp = TempDir::new().expect("tmp");
154        let requests = vec![BatchRequest {
155            relative_path: PathBuf::from("missing.txt"),
156        }];
157
158        let results = process_batch(tmp.path(), &requests, 1);
159        assert_eq!(results.len(), 1);
160        assert!(matches!(results[0].body, Err(ServerError::Io(_))));
161    }
162}