Skip to main content

stuffr_core/source/
spill.rs

1//! Rung 3 of the ladder: spool a non-seekable input so it becomes seekable.
2//!
3//! Spilling costs disk and latency, never accuracy — which is why
4//! [`crate::Rung::Spilled`] counts as authoritative.
5
6use std::io::{Cursor, Read, Seek, Write};
7use std::path::PathBuf;
8
9use super::{GuardedSeek, SeekRead, Source, SourceCaps};
10use crate::error::{Error, Result};
11
12pub const DEFAULT_MEM_CAP: u64 = 64 * 1024 * 1024;
13pub const DEFAULT_MAX_SPILL: u64 = 2 * 1024 * 1024 * 1024;
14
15#[derive(Clone, PartialEq, Eq, Debug)]
16pub enum SpillPolicy {
17    /// Never spool. Collapses the ladder from four rungs to three.
18    Off,
19    /// Spool in memory only, failing past `cap`.
20    Memory { cap: u64 },
21    /// Spool in memory up to `mem_cap`, then escalate to a temp file, failing
22    /// past `max`.
23    Temp {
24        dir: Option<PathBuf>,
25        mem_cap: u64,
26        max: u64,
27    },
28}
29
30impl Default for SpillPolicy {
31    fn default() -> Self {
32        SpillPolicy::Temp {
33            dir: None,
34            mem_cap: DEFAULT_MEM_CAP,
35            max: DEFAULT_MAX_SPILL,
36        }
37    }
38}
39
40impl SpillPolicy {
41    pub fn is_enabled(&self) -> bool {
42        !matches!(self, SpillPolicy::Off)
43    }
44
45    /// How many bytes this policy holds in memory before it escalates (or,
46    /// for `Memory`, fails): `0` for `Off`.
47    pub fn memory_cap(&self) -> u64 {
48        match self {
49            SpillPolicy::Off => 0,
50            SpillPolicy::Memory { cap } => *cap,
51            SpillPolicy::Temp { mem_cap, .. } => *mem_cap,
52        }
53    }
54
55    fn hard_limit(&self) -> u64 {
56        match self {
57            SpillPolicy::Off => 0,
58            SpillPolicy::Memory { cap } => *cap,
59            SpillPolicy::Temp { max, .. } => *max,
60        }
61    }
62}
63
64#[derive(Debug)]
65enum Backing {
66    Mem(GuardedSeek<Cursor<Vec<u8>>>),
67    File(GuardedSeek<std::fs::File>),
68}
69
70/// A seekable source produced by draining a forward-only one.
71#[derive(Debug)]
72pub struct SpillSource {
73    backing: Backing,
74    len: u64,
75    on_disk: bool,
76}
77
78impl SpillSource {
79    /// Drains `src` into a seekable backing store, eagerly.
80    ///
81    /// Eager rather than lazy: a container that asked for seek is about to read
82    /// the trailing index, so it will need the whole stream regardless, and the
83    /// eager path has no partially-materialized states to get wrong.
84    pub fn materialize(mut src: Box<dyn Source>, policy: &SpillPolicy) -> Result<Self> {
85        Self::materialize_from(&mut *src, policy)
86    }
87
88    /// [`Self::materialize`] from any reader, borrowed: for a caller whose
89    /// bytes are not a [`Source`] — an archive entry's payload borrows its
90    /// archive, so it can be neither boxed as `'static` nor sent. `stuffr
91    /// convert` buffers an entry of unknown size through this, under the same
92    /// policy. The limits are [`SpillWriter`]'s, which this drives.
93    pub fn materialize_from<R: Read + ?Sized>(src: &mut R, policy: &SpillPolicy) -> Result<Self> {
94        let mut spool = SpillWriter::new(policy)?;
95        let mut buf = vec![0u8; 64 * 1024];
96        loop {
97            let n = src.read(&mut buf)?;
98            if n == 0 {
99                return spool.finish();
100            }
101            spool.push(&buf[..n])?;
102        }
103    }
104
105    /// Whether the spool escalated past the memory cap. Surfaced by `stuffr info`.
106    pub fn spilled_to_disk(&self) -> bool {
107        self.on_disk
108    }
109}
110
111/// The push side of the spill loop: bytes handed over one slice at a time,
112/// held in memory up to the policy's memory cap, then in a temp file, and
113/// refused past its hard limit. The one owner of those limits —
114/// [`SpillSource::materialize_from`] drives it from a reader, and a caller
115/// that sees the bytes go past on their way somewhere else (a tee) pushes
116/// them itself.
117///
118/// A refusal ([`Error::SpillLimitExceeded`]) or a temp-file failure leaves
119/// the writer unusable; nothing it held is ever handed out partially.
120#[derive(Debug)]
121pub struct SpillWriter {
122    limit: u64,
123    mem_cap: u64,
124    dir: Option<Option<PathBuf>>,
125    mem: Vec<u8>,
126    file: Option<std::fs::File>,
127    total: u64,
128}
129
130impl SpillWriter {
131    /// An empty spool under `policy`. `SpillPolicy::Off` is refused at once,
132    /// as [`SpillSource::materialize`] refuses it.
133    pub fn new(policy: &SpillPolicy) -> Result<Self> {
134        if !policy.is_enabled() {
135            return Err(Error::SpillLimitExceeded { limit: 0 });
136        }
137        Ok(Self {
138            limit: policy.hard_limit(),
139            mem_cap: policy.memory_cap(),
140            dir: match policy {
141                SpillPolicy::Temp { dir, .. } => Some(dir.clone()),
142                SpillPolicy::Off | SpillPolicy::Memory { .. } => None,
143            },
144            mem: Vec::new(),
145            file: None,
146            total: 0,
147        })
148    }
149
150    /// Bytes accepted so far.
151    pub fn len(&self) -> u64 {
152        self.total
153    }
154
155    /// Whether nothing has been accepted yet.
156    pub fn is_empty(&self) -> bool {
157        self.total == 0
158    }
159
160    /// Reserves memory for `additional` more bytes, up to what the memory
161    /// tier can still hold, so a payload of known size grows its buffer once
162    /// rather than by doubling (which could leave it holding about twice
163    /// what it keeps). A hint only: nothing is refused here.
164    pub fn reserve(&mut self, additional: u64) {
165        if self.file.is_some() {
166            return;
167        }
168        let room = self.mem_cap.saturating_sub(self.total);
169        let n = usize::try_from(additional.min(room)).unwrap_or(0);
170        self.mem.reserve_exact(n);
171    }
172
173    /// Appends `bytes`, escalating to a temp file once the memory cap is
174    /// passed, and refusing past the hard limit.
175    pub fn push(&mut self, bytes: &[u8]) -> Result<()> {
176        self.total += bytes.len() as u64;
177        if self.total > self.limit {
178            return Err(Error::SpillLimitExceeded { limit: self.limit });
179        }
180        if let Some(file) = &mut self.file {
181            file.write_all(bytes)?;
182            return Ok(());
183        }
184        if self.total <= self.mem_cap {
185            self.mem.extend_from_slice(bytes);
186            return Ok(());
187        }
188        // Past the memory cap. Defensive for `Memory`, unreachable while
189        // `mem_cap == hard_limit()` holds for it: the limit check above
190        // always fires first.
191        let Some(dir) = &self.dir else {
192            return Err(Error::SpillLimitExceeded { limit: self.limit });
193        };
194        let mut file = match dir {
195            Some(d) => tempfile::tempfile_in(d)?,
196            None => tempfile::tempfile()?,
197        };
198        file.write_all(&self.mem)?;
199        file.write_all(bytes)?;
200        self.mem = Vec::new();
201        self.file = Some(file);
202        Ok(())
203    }
204
205    /// The bytes pushed, as a seekable source positioned at the start.
206    pub fn finish(mut self) -> Result<SpillSource> {
207        match self.file.take() {
208            None => {
209                // A kept buffer holds what it holds, not what doubling left.
210                self.mem.shrink_to_fit();
211                Ok(SpillSource {
212                    backing: Backing::Mem(GuardedSeek::new(Cursor::new(self.mem))),
213                    len: self.total,
214                    on_disk: false,
215                })
216            }
217            Some(mut file) => {
218                file.flush()?;
219                file.seek(std::io::SeekFrom::Start(0))?;
220                Ok(SpillSource {
221                    backing: Backing::File(GuardedSeek::new(file)),
222                    len: self.total,
223                    on_disk: true,
224                })
225            }
226        }
227    }
228}
229
230impl Read for SpillSource {
231    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
232        match &mut self.backing {
233            Backing::Mem(c) => c.read(buf),
234            Backing::File(f) => f.read(buf),
235        }
236    }
237}
238
239impl Source for SpillSource {
240    fn caps(&self) -> SourceCaps {
241        SourceCaps {
242            seekable: true,
243            len: Some(self.len),
244        }
245    }
246
247    fn as_seek(&mut self) -> Option<&mut dyn SeekRead> {
248        match &mut self.backing {
249            Backing::Mem(c) => Some(c),
250            Backing::File(f) => Some(f),
251        }
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use crate::source::ReaderSource;
258    use std::io::{Read, SeekFrom};
259
260    use super::*;
261
262    fn pipe(bytes: Vec<u8>) -> Box<dyn crate::source::Source> {
263        Box::new(ReaderSource::new(std::io::Cursor::new(bytes)))
264    }
265
266    #[test]
267    fn small_input_stays_in_memory_and_becomes_seekable() {
268        let mut s = SpillSource::materialize(
269            pipe(b"0123456789".to_vec()),
270            &SpillPolicy::Temp {
271                dir: None,
272                mem_cap: 1024,
273                max: 1 << 20,
274            },
275        )
276        .unwrap();
277
278        assert!(!s.spilled_to_disk());
279        assert!(s.caps().seekable);
280        assert_eq!(s.caps().len, Some(10));
281
282        let seek = s.as_seek().unwrap();
283        seek.seek(SeekFrom::Start(7)).unwrap();
284        let mut buf = [0u8; 3];
285        seek.read_exact(&mut buf).unwrap();
286        assert_eq!(&buf, b"789");
287    }
288
289    #[test]
290    fn input_over_the_memory_cap_escalates_to_a_temp_file() {
291        let data: Vec<u8> = (0..4096u32).map(|i| (i % 251) as u8).collect();
292        let mut s = SpillSource::materialize(
293            pipe(data.clone()),
294            &SpillPolicy::Temp {
295                dir: None,
296                mem_cap: 64,
297                max: 1 << 20,
298            },
299        )
300        .unwrap();
301
302        assert!(s.spilled_to_disk());
303        assert_eq!(s.caps().len, Some(4096));
304
305        // Escalation must not lose or reorder the bytes already buffered.
306        let mut out = Vec::new();
307        s.read_to_end(&mut out).unwrap();
308        assert_eq!(out, data);
309    }
310
311    #[test]
312    fn memory_only_policy_refuses_to_exceed_its_cap() {
313        let err = SpillSource::materialize(pipe(vec![7u8; 500]), &SpillPolicy::Memory { cap: 100 })
314            .unwrap_err();
315
316        // Never a silent truncation: a stream too big to spool must say so.
317        assert!(matches!(
318            err,
319            crate::Error::SpillLimitExceeded { limit: 100 }
320        ));
321        assert_eq!(err.exit_code(), 6);
322    }
323
324    #[test]
325    fn max_spill_is_enforced_for_the_temp_policy_too() {
326        let err = SpillSource::materialize(
327            pipe(vec![7u8; 5000]),
328            &SpillPolicy::Temp {
329                dir: None,
330                mem_cap: 16,
331                max: 1000,
332            },
333        )
334        .unwrap_err();
335        assert!(matches!(
336            err,
337            crate::Error::SpillLimitExceeded { limit: 1000 }
338        ));
339    }
340
341    #[test]
342    fn off_policy_is_not_enabled_and_cannot_materialize() {
343        assert!(!SpillPolicy::Off.is_enabled());
344        assert!(SpillPolicy::default().is_enabled());
345        let err = SpillSource::materialize(pipe(b"x".to_vec()), &SpillPolicy::Off).unwrap_err();
346        assert!(matches!(err, crate::Error::SpillLimitExceeded { limit: 0 }));
347    }
348
349    /// The push side keeps the pull side's limits: memory up to the cap,
350    /// then a temp file holding every byte in order, refused past the max.
351    #[test]
352    fn a_spill_writer_escalates_and_refuses_like_materialize() {
353        let policy = SpillPolicy::Temp {
354            dir: None,
355            mem_cap: 10,
356            max: 30,
357        };
358        let mut w = SpillWriter::new(&policy).unwrap();
359        w.push(b"0123456789").unwrap();
360        w.push(b"abcdef").unwrap();
361        assert_eq!(w.len(), 16);
362        let mut s = w.finish().unwrap();
363        assert!(s.spilled_to_disk());
364        let mut out = Vec::new();
365        s.read_to_end(&mut out).unwrap();
366        assert_eq!(out, b"0123456789abcdef");
367
368        let mut w = SpillWriter::new(&policy).unwrap();
369        let err = w.push(&[0u8; 31]).unwrap_err();
370        assert!(matches!(
371            err,
372            crate::Error::SpillLimitExceeded { limit: 30 }
373        ));
374        assert!(matches!(
375            SpillWriter::new(&SpillPolicy::Off).unwrap_err(),
376            crate::Error::SpillLimitExceeded { limit: 0 }
377        ));
378        assert_eq!(policy.memory_cap(), 10);
379    }
380
381    #[test]
382    fn defaults_match_the_spec() {
383        match SpillPolicy::default() {
384            SpillPolicy::Temp { mem_cap, max, dir } => {
385                assert_eq!(mem_cap, 64 * 1024 * 1024);
386                assert_eq!(max, 2 * 1024 * 1024 * 1024);
387                assert!(dir.is_none());
388            }
389            other => panic!("unexpected default: {other:?}"),
390        }
391    }
392
393    #[test]
394    fn empty_input_materializes_to_an_empty_seekable_source() {
395        let mut s = SpillSource::materialize(pipe(Vec::new()), &SpillPolicy::default()).unwrap();
396        assert_eq!(s.caps().len, Some(0));
397        let mut out = Vec::new();
398        s.read_to_end(&mut out).unwrap();
399        assert!(out.is_empty());
400    }
401
402    #[test]
403    fn escalation_preserves_order_across_many_sub_cap_chunks() {
404        // A Cursor hands over the whole input in one read, so the multi-chunk
405        // accumulate-then-escalate path never runs. Force 100-byte reads so the
406        // memory buffer genuinely grows across several iterations before the
407        // temp file takes over.
408        let data: Vec<u8> = (0..4096u32).map(|i| (i % 251) as u8).collect();
409        let src: Box<dyn Source> = Box::new(crate::source::ReaderSource::new(
410            crate::testing::ChunkedReader::new(data.clone(), 100),
411        ));
412        let mut s = SpillSource::materialize(
413            src,
414            &SpillPolicy::Temp {
415                dir: None,
416                mem_cap: 1000,
417                max: 1 << 20,
418            },
419        )
420        .unwrap();
421
422        assert!(s.spilled_to_disk());
423        let mut out = Vec::new();
424        s.read_to_end(&mut out).unwrap();
425        assert_eq!(out, data, "bytes must survive escalation in order");
426    }
427}