1use 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 Off,
19 Memory { cap: u64 },
21 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#[derive(Debug)]
62pub struct SpillSource {
63 backing: Backing,
64 len: u64,
65 on_disk: bool,
66}
67
68impl SpillSource {
69 pub fn materialize(mut src: Box<dyn Source>, policy: &SpillPolicy) -> Result<Self> {
75 Self::materialize_from(&mut *src, policy)
76 }
77
78 pub fn materialize_from<R: Read + ?Sized>(src: &mut R, policy: &SpillPolicy) -> Result<Self> {
84 let limit = policy.hard_limit();
85 if !policy.is_enabled() {
86 return Err(Error::SpillLimitExceeded { limit: 0 });
87 }
88
89 let mem_cap = match policy {
90 SpillPolicy::Off => 0,
91 SpillPolicy::Memory { cap } => *cap,
92 SpillPolicy::Temp { mem_cap, .. } => *mem_cap,
93 };
94
95 let mut mem: Vec<u8> = Vec::new();
96 let mut buf = vec![0u8; 64 * 1024];
97 let mut total: u64 = 0;
98
99 loop {
101 let n = src.read(&mut buf)?;
102 if n == 0 {
103 return Ok(Self {
104 len: total,
105 backing: Backing::Mem(GuardedSeek::new(Cursor::new(mem))),
106 on_disk: false,
107 });
108 }
109 total += n as u64;
110 if total > limit {
111 return Err(Error::SpillLimitExceeded { limit });
112 }
113 if total > mem_cap {
114 mem.extend_from_slice(&buf[..n]);
115 break;
116 }
117 mem.extend_from_slice(&buf[..n]);
118 }
119
120 let SpillPolicy::Temp { dir, .. } = policy else {
122 return Err(Error::SpillLimitExceeded { limit });
126 };
127
128 let mut file = match dir {
129 Some(d) => tempfile::tempfile_in(d)?,
130 None => tempfile::tempfile()?,
131 };
132 file.write_all(&mem)?;
133 drop(mem);
134
135 loop {
136 let n = src.read(&mut buf)?;
137 if n == 0 {
138 break;
139 }
140 total += n as u64;
141 if total > limit {
142 return Err(Error::SpillLimitExceeded { limit });
143 }
144 file.write_all(&buf[..n])?;
145 }
146
147 file.flush()?;
148 file.seek(std::io::SeekFrom::Start(0))?;
149 Ok(Self {
150 backing: Backing::File(GuardedSeek::new(file)),
151 len: total,
152 on_disk: true,
153 })
154 }
155
156 pub fn spilled_to_disk(&self) -> bool {
158 self.on_disk
159 }
160}
161
162impl Read for SpillSource {
163 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
164 match &mut self.backing {
165 Backing::Mem(c) => c.read(buf),
166 Backing::File(f) => f.read(buf),
167 }
168 }
169}
170
171impl Source for SpillSource {
172 fn caps(&self) -> SourceCaps {
173 SourceCaps {
174 seekable: true,
175 len: Some(self.len),
176 }
177 }
178
179 fn as_seek(&mut self) -> Option<&mut dyn SeekRead> {
180 match &mut self.backing {
181 Backing::Mem(c) => Some(c),
182 Backing::File(f) => Some(f),
183 }
184 }
185}
186
187#[cfg(test)]
188mod tests {
189 use crate::source::ReaderSource;
190 use std::io::{Read, SeekFrom};
191
192 use super::*;
193
194 fn pipe(bytes: Vec<u8>) -> Box<dyn crate::source::Source> {
195 Box::new(ReaderSource::new(std::io::Cursor::new(bytes)))
196 }
197
198 #[test]
199 fn small_input_stays_in_memory_and_becomes_seekable() {
200 let mut s = SpillSource::materialize(
201 pipe(b"0123456789".to_vec()),
202 &SpillPolicy::Temp {
203 dir: None,
204 mem_cap: 1024,
205 max: 1 << 20,
206 },
207 )
208 .unwrap();
209
210 assert!(!s.spilled_to_disk());
211 assert!(s.caps().seekable);
212 assert_eq!(s.caps().len, Some(10));
213
214 let seek = s.as_seek().unwrap();
215 seek.seek(SeekFrom::Start(7)).unwrap();
216 let mut buf = [0u8; 3];
217 seek.read_exact(&mut buf).unwrap();
218 assert_eq!(&buf, b"789");
219 }
220
221 #[test]
222 fn input_over_the_memory_cap_escalates_to_a_temp_file() {
223 let data: Vec<u8> = (0..4096u32).map(|i| (i % 251) as u8).collect();
224 let mut s = SpillSource::materialize(
225 pipe(data.clone()),
226 &SpillPolicy::Temp {
227 dir: None,
228 mem_cap: 64,
229 max: 1 << 20,
230 },
231 )
232 .unwrap();
233
234 assert!(s.spilled_to_disk());
235 assert_eq!(s.caps().len, Some(4096));
236
237 let mut out = Vec::new();
239 s.read_to_end(&mut out).unwrap();
240 assert_eq!(out, data);
241 }
242
243 #[test]
244 fn memory_only_policy_refuses_to_exceed_its_cap() {
245 let err = SpillSource::materialize(pipe(vec![7u8; 500]), &SpillPolicy::Memory { cap: 100 })
246 .unwrap_err();
247
248 assert!(matches!(
250 err,
251 crate::Error::SpillLimitExceeded { limit: 100 }
252 ));
253 assert_eq!(err.exit_code(), 6);
254 }
255
256 #[test]
257 fn max_spill_is_enforced_for_the_temp_policy_too() {
258 let err = SpillSource::materialize(
259 pipe(vec![7u8; 5000]),
260 &SpillPolicy::Temp {
261 dir: None,
262 mem_cap: 16,
263 max: 1000,
264 },
265 )
266 .unwrap_err();
267 assert!(matches!(
268 err,
269 crate::Error::SpillLimitExceeded { limit: 1000 }
270 ));
271 }
272
273 #[test]
274 fn off_policy_is_not_enabled_and_cannot_materialize() {
275 assert!(!SpillPolicy::Off.is_enabled());
276 assert!(SpillPolicy::default().is_enabled());
277 let err = SpillSource::materialize(pipe(b"x".to_vec()), &SpillPolicy::Off).unwrap_err();
278 assert!(matches!(err, crate::Error::SpillLimitExceeded { limit: 0 }));
279 }
280
281 #[test]
282 fn defaults_match_the_spec() {
283 match SpillPolicy::default() {
284 SpillPolicy::Temp { mem_cap, max, dir } => {
285 assert_eq!(mem_cap, 64 * 1024 * 1024);
286 assert_eq!(max, 2 * 1024 * 1024 * 1024);
287 assert!(dir.is_none());
288 }
289 other => panic!("unexpected default: {other:?}"),
290 }
291 }
292
293 #[test]
294 fn empty_input_materializes_to_an_empty_seekable_source() {
295 let mut s = SpillSource::materialize(pipe(Vec::new()), &SpillPolicy::default()).unwrap();
296 assert_eq!(s.caps().len, Some(0));
297 let mut out = Vec::new();
298 s.read_to_end(&mut out).unwrap();
299 assert!(out.is_empty());
300 }
301
302 #[test]
303 fn escalation_preserves_order_across_many_sub_cap_chunks() {
304 let data: Vec<u8> = (0..4096u32).map(|i| (i % 251) as u8).collect();
309 let src: Box<dyn Source> = Box::new(crate::source::ReaderSource::new(
310 crate::testing::ChunkedReader::new(data.clone(), 100),
311 ));
312 let mut s = SpillSource::materialize(
313 src,
314 &SpillPolicy::Temp {
315 dir: None,
316 mem_cap: 1000,
317 max: 1 << 20,
318 },
319 )
320 .unwrap();
321
322 assert!(s.spilled_to_disk());
323 let mut out = Vec::new();
324 s.read_to_end(&mut out).unwrap();
325 assert_eq!(out, data, "bytes must survive escalation in order");
326 }
327}