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    fn hard_limit(&self) -> u64 {
46        match self {
47            SpillPolicy::Off => 0,
48            SpillPolicy::Memory { cap } => *cap,
49            SpillPolicy::Temp { max, .. } => *max,
50        }
51    }
52}
53
54#[derive(Debug)]
55enum Backing {
56    Mem(GuardedSeek<Cursor<Vec<u8>>>),
57    File(GuardedSeek<std::fs::File>),
58}
59
60/// A seekable source produced by draining a forward-only one.
61#[derive(Debug)]
62pub struct SpillSource {
63    backing: Backing,
64    len: u64,
65    on_disk: bool,
66}
67
68impl SpillSource {
69    /// Drains `src` into a seekable backing store, eagerly.
70    ///
71    /// Eager rather than lazy: a container that asked for seek is about to read
72    /// the trailing index, so it will need the whole stream regardless, and the
73    /// eager path has no partially-materialized states to get wrong.
74    pub fn materialize(mut src: Box<dyn Source>, policy: &SpillPolicy) -> Result<Self> {
75        let limit = policy.hard_limit();
76        if !policy.is_enabled() {
77            return Err(Error::SpillLimitExceeded { limit: 0 });
78        }
79
80        let mem_cap = match policy {
81            SpillPolicy::Off => 0,
82            SpillPolicy::Memory { cap } => *cap,
83            SpillPolicy::Temp { mem_cap, .. } => *mem_cap,
84        };
85
86        let mut mem: Vec<u8> = Vec::new();
87        let mut buf = vec![0u8; 64 * 1024];
88        let mut total: u64 = 0;
89
90        // Phase 1: fill memory up to mem_cap.
91        loop {
92            let n = src.read(&mut buf)?;
93            if n == 0 {
94                return Ok(Self {
95                    len: total,
96                    backing: Backing::Mem(GuardedSeek::new(Cursor::new(mem))),
97                    on_disk: false,
98                });
99            }
100            total += n as u64;
101            if total > limit {
102                return Err(Error::SpillLimitExceeded { limit });
103            }
104            if total > mem_cap {
105                mem.extend_from_slice(&buf[..n]);
106                break;
107            }
108            mem.extend_from_slice(&buf[..n]);
109        }
110
111        // Phase 2: escalate to a temp file, carrying the memory buffer over.
112        let SpillPolicy::Temp { dir, .. } = policy else {
113            // Defensive, unreachable while `mem_cap == hard_limit()` holds for
114            // `Memory`: the `total > limit` check above always fires first, so
115            // the Phase-1 loop never breaks into this arm for that policy.
116            return Err(Error::SpillLimitExceeded { limit });
117        };
118
119        let mut file = match dir {
120            Some(d) => tempfile::tempfile_in(d)?,
121            None => tempfile::tempfile()?,
122        };
123        file.write_all(&mem)?;
124        drop(mem);
125
126        loop {
127            let n = src.read(&mut buf)?;
128            if n == 0 {
129                break;
130            }
131            total += n as u64;
132            if total > limit {
133                return Err(Error::SpillLimitExceeded { limit });
134            }
135            file.write_all(&buf[..n])?;
136        }
137
138        file.flush()?;
139        file.seek(std::io::SeekFrom::Start(0))?;
140        Ok(Self {
141            backing: Backing::File(GuardedSeek::new(file)),
142            len: total,
143            on_disk: true,
144        })
145    }
146
147    /// Whether the spool escalated past the memory cap. Surfaced by `stuffr info`.
148    pub fn spilled_to_disk(&self) -> bool {
149        self.on_disk
150    }
151}
152
153impl Read for SpillSource {
154    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
155        match &mut self.backing {
156            Backing::Mem(c) => c.read(buf),
157            Backing::File(f) => f.read(buf),
158        }
159    }
160}
161
162impl Source for SpillSource {
163    fn caps(&self) -> SourceCaps {
164        SourceCaps {
165            seekable: true,
166            len: Some(self.len),
167        }
168    }
169
170    fn as_seek(&mut self) -> Option<&mut dyn SeekRead> {
171        match &mut self.backing {
172            Backing::Mem(c) => Some(c),
173            Backing::File(f) => Some(f),
174        }
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use crate::source::ReaderSource;
181    use std::io::{Read, SeekFrom};
182
183    use super::*;
184
185    fn pipe(bytes: Vec<u8>) -> Box<dyn crate::source::Source> {
186        Box::new(ReaderSource::new(std::io::Cursor::new(bytes)))
187    }
188
189    #[test]
190    fn small_input_stays_in_memory_and_becomes_seekable() {
191        let mut s = SpillSource::materialize(
192            pipe(b"0123456789".to_vec()),
193            &SpillPolicy::Temp {
194                dir: None,
195                mem_cap: 1024,
196                max: 1 << 20,
197            },
198        )
199        .unwrap();
200
201        assert!(!s.spilled_to_disk());
202        assert!(s.caps().seekable);
203        assert_eq!(s.caps().len, Some(10));
204
205        let seek = s.as_seek().unwrap();
206        seek.seek(SeekFrom::Start(7)).unwrap();
207        let mut buf = [0u8; 3];
208        seek.read_exact(&mut buf).unwrap();
209        assert_eq!(&buf, b"789");
210    }
211
212    #[test]
213    fn input_over_the_memory_cap_escalates_to_a_temp_file() {
214        let data: Vec<u8> = (0..4096u32).map(|i| (i % 251) as u8).collect();
215        let mut s = SpillSource::materialize(
216            pipe(data.clone()),
217            &SpillPolicy::Temp {
218                dir: None,
219                mem_cap: 64,
220                max: 1 << 20,
221            },
222        )
223        .unwrap();
224
225        assert!(s.spilled_to_disk());
226        assert_eq!(s.caps().len, Some(4096));
227
228        // Escalation must not lose or reorder the bytes already buffered.
229        let mut out = Vec::new();
230        s.read_to_end(&mut out).unwrap();
231        assert_eq!(out, data);
232    }
233
234    #[test]
235    fn memory_only_policy_refuses_to_exceed_its_cap() {
236        let err = SpillSource::materialize(pipe(vec![7u8; 500]), &SpillPolicy::Memory { cap: 100 })
237            .unwrap_err();
238
239        // Never a silent truncation: a stream too big to spool must say so.
240        assert!(matches!(
241            err,
242            crate::Error::SpillLimitExceeded { limit: 100 }
243        ));
244        assert_eq!(err.exit_code(), 6);
245    }
246
247    #[test]
248    fn max_spill_is_enforced_for_the_temp_policy_too() {
249        let err = SpillSource::materialize(
250            pipe(vec![7u8; 5000]),
251            &SpillPolicy::Temp {
252                dir: None,
253                mem_cap: 16,
254                max: 1000,
255            },
256        )
257        .unwrap_err();
258        assert!(matches!(
259            err,
260            crate::Error::SpillLimitExceeded { limit: 1000 }
261        ));
262    }
263
264    #[test]
265    fn off_policy_is_not_enabled_and_cannot_materialize() {
266        assert!(!SpillPolicy::Off.is_enabled());
267        assert!(SpillPolicy::default().is_enabled());
268        let err = SpillSource::materialize(pipe(b"x".to_vec()), &SpillPolicy::Off).unwrap_err();
269        assert!(matches!(err, crate::Error::SpillLimitExceeded { limit: 0 }));
270    }
271
272    #[test]
273    fn defaults_match_the_spec() {
274        match SpillPolicy::default() {
275            SpillPolicy::Temp { mem_cap, max, dir } => {
276                assert_eq!(mem_cap, 64 * 1024 * 1024);
277                assert_eq!(max, 2 * 1024 * 1024 * 1024);
278                assert!(dir.is_none());
279            }
280            other => panic!("unexpected default: {other:?}"),
281        }
282    }
283
284    #[test]
285    fn empty_input_materializes_to_an_empty_seekable_source() {
286        let mut s = SpillSource::materialize(pipe(Vec::new()), &SpillPolicy::default()).unwrap();
287        assert_eq!(s.caps().len, Some(0));
288        let mut out = Vec::new();
289        s.read_to_end(&mut out).unwrap();
290        assert!(out.is_empty());
291    }
292
293    #[test]
294    fn escalation_preserves_order_across_many_sub_cap_chunks() {
295        // A Cursor hands over the whole input in one read, so the multi-chunk
296        // accumulate-then-escalate path never runs. Force 100-byte reads so the
297        // memory buffer genuinely grows across several iterations before the
298        // temp file takes over.
299        let data: Vec<u8> = (0..4096u32).map(|i| (i % 251) as u8).collect();
300        let src: Box<dyn Source> = Box::new(crate::source::ReaderSource::new(
301            crate::testing::ChunkedReader::new(data.clone(), 100),
302        ));
303        let mut s = SpillSource::materialize(
304            src,
305            &SpillPolicy::Temp {
306                dir: None,
307                mem_cap: 1000,
308                max: 1 << 20,
309            },
310        )
311        .unwrap();
312
313        assert!(s.spilled_to_disk());
314        let mut out = Vec::new();
315        s.read_to_end(&mut out).unwrap();
316        assert_eq!(out, data, "bytes must survive escalation in order");
317    }
318}