Skip to main content

spatialrust_io/
spool.rs

1//! Explicit, size-limited temporary disk spools for streaming formats.
2
3use std::fs::{File, OpenOptions};
4use std::io::{Read, Seek, SeekFrom, Write};
5use std::path::{Path, PathBuf};
6use std::sync::atomic::{AtomicU64, Ordering};
7
8use crate::IoError;
9
10static NEXT_SPOOL_ID: AtomicU64 = AtomicU64::new(0);
11
12/// Configuration for a recoverably temporary, size-limited spool.
13#[derive(Clone, Debug, PartialEq, Eq)]
14pub struct SpoolOptions {
15    directory: PathBuf,
16    limit_bytes: u64,
17}
18
19impl SpoolOptions {
20    /// Creates a positive spool limit in an existing directory.
21    pub fn new(directory: impl Into<PathBuf>, limit_bytes: u64) -> Result<Self, IoError> {
22        let directory = directory.into();
23        if limit_bytes == 0 {
24            return Err(IoError::Streaming("spool limit must be positive".into()));
25        }
26        if !directory.is_dir() {
27            return Err(IoError::Streaming(format!(
28                "spool directory does not exist: {}",
29                directory.display()
30            )));
31        }
32        Ok(Self { directory, limit_bytes })
33    }
34
35    /// Returns the temporary directory.
36    #[must_use]
37    pub fn directory(&self) -> &Path {
38        &self.directory
39    }
40
41    /// Returns the hard maximum file extent.
42    #[must_use]
43    pub const fn limit_bytes(&self) -> u64 {
44        self.limit_bytes
45    }
46}
47
48/// Seekable temporary file that rejects writes before its extent exceeds the
49/// configured disk budget and removes its `.part` file unless committed.
50pub struct BoundedSpool {
51    file: Option<File>,
52    path: PathBuf,
53    limit_bytes: u64,
54    extent_bytes: u64,
55    committed: bool,
56}
57
58impl BoundedSpool {
59    /// Creates a uniquely named `.part` file with exclusive creation.
60    pub fn create(options: &SpoolOptions, stem: &str) -> Result<Self, IoError> {
61        let safe_stem: String = stem
62            .chars()
63            .map(|character| {
64                if character.is_ascii_alphanumeric() || matches!(character, '-' | '_') {
65                    character
66                } else {
67                    '_'
68                }
69            })
70            .collect();
71        for _ in 0..100 {
72            let id = NEXT_SPOOL_ID.fetch_add(1, Ordering::Relaxed);
73            let path =
74                options.directory.join(format!(".{safe_stem}.{}.{}.part", std::process::id(), id));
75            match OpenOptions::new().read(true).write(true).create_new(true).open(&path) {
76                Ok(file) => {
77                    return Ok(Self {
78                        file: Some(file),
79                        path,
80                        limit_bytes: options.limit_bytes,
81                        extent_bytes: 0,
82                        committed: false,
83                    });
84                }
85                Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
86                Err(error) => return Err(error.into()),
87            }
88        }
89        Err(IoError::Streaming("could not allocate a unique temporary spool".into()))
90    }
91
92    /// Returns the temporary path, suitable for format libraries requiring a path.
93    #[must_use]
94    pub fn path(&self) -> &Path {
95        &self.path
96    }
97
98    /// Returns the largest written file extent.
99    #[must_use]
100    pub const fn extent_bytes(&self) -> u64 {
101        self.extent_bytes
102    }
103
104    /// Flushes, closes, and atomically renames the spool to a new destination.
105    ///
106    /// Existing destinations are rejected rather than overwritten.
107    pub fn commit(mut self, destination: impl AsRef<Path>) -> Result<PathBuf, IoError> {
108        let destination = destination.as_ref();
109        if destination.exists() {
110            return Err(IoError::Streaming(format!(
111                "spool destination already exists: {}",
112                destination.display()
113            )));
114        }
115        if let Some(mut file) = self.file.take() {
116            file.flush()?;
117            file.sync_all()?;
118        }
119        std::fs::rename(&self.path, destination)?;
120        self.committed = true;
121        Ok(destination.to_path_buf())
122    }
123
124    fn file_mut(&mut self) -> std::io::Result<&mut File> {
125        self.file.as_mut().ok_or_else(|| std::io::Error::other("spool is closed"))
126    }
127}
128
129impl Write for BoundedSpool {
130    fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> {
131        let position = self.file_mut()?.stream_position()?;
132        let requested_end = position
133            .checked_add(buffer.len() as u64)
134            .ok_or_else(|| std::io::Error::other("spool extent overflow"))?;
135        if requested_end > self.limit_bytes {
136            return Err(std::io::Error::other(format!(
137                "spool limit exceeded: requested extent {requested_end}, limit {}",
138                self.limit_bytes
139            )));
140        }
141        let written = self.file_mut()?.write(buffer)?;
142        self.extent_bytes = self.extent_bytes.max(position + written as u64);
143        Ok(written)
144    }
145
146    fn flush(&mut self) -> std::io::Result<()> {
147        self.file_mut()?.flush()
148    }
149}
150
151impl Read for BoundedSpool {
152    fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
153        self.file_mut()?.read(buffer)
154    }
155}
156
157impl Seek for BoundedSpool {
158    fn seek(&mut self, position: SeekFrom) -> std::io::Result<u64> {
159        self.file_mut()?.seek(position)
160    }
161}
162
163impl Drop for BoundedSpool {
164    fn drop(&mut self) {
165        self.file.take();
166        if !self.committed {
167            let _ = std::fs::remove_file(&self.path);
168        }
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::{BoundedSpool, SpoolOptions};
175    use std::io::{Seek, SeekFrom, Write};
176
177    #[test]
178    fn rejects_growth_before_limit_is_crossed_and_cleans_up() {
179        let options = SpoolOptions::new(std::env::temp_dir(), 4).unwrap();
180        let path;
181        {
182            let mut spool = BoundedSpool::create(&options, "bounded-test").unwrap();
183            path = spool.path().to_path_buf();
184            spool.write_all(b"1234").unwrap();
185            assert!(spool.write_all(b"5").is_err());
186            spool.seek(SeekFrom::Start(1)).unwrap();
187            spool.write_all(b"x").unwrap();
188            assert_eq!(spool.extent_bytes(), 4);
189        }
190        assert!(!path.exists());
191    }
192}