use std::io::{Cursor, Read, Seek, Write};
use std::path::PathBuf;
use super::{GuardedSeek, SeekRead, Source, SourceCaps};
use crate::error::{Error, Result};
pub const DEFAULT_MEM_CAP: u64 = 64 * 1024 * 1024;
pub const DEFAULT_MAX_SPILL: u64 = 2 * 1024 * 1024 * 1024;
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum SpillPolicy {
Off,
Memory { cap: u64 },
Temp {
dir: Option<PathBuf>,
mem_cap: u64,
max: u64,
},
}
impl Default for SpillPolicy {
fn default() -> Self {
SpillPolicy::Temp {
dir: None,
mem_cap: DEFAULT_MEM_CAP,
max: DEFAULT_MAX_SPILL,
}
}
}
impl SpillPolicy {
pub fn is_enabled(&self) -> bool {
!matches!(self, SpillPolicy::Off)
}
pub fn memory_cap(&self) -> u64 {
match self {
SpillPolicy::Off => 0,
SpillPolicy::Memory { cap } => *cap,
SpillPolicy::Temp { mem_cap, .. } => *mem_cap,
}
}
fn hard_limit(&self) -> u64 {
match self {
SpillPolicy::Off => 0,
SpillPolicy::Memory { cap } => *cap,
SpillPolicy::Temp { max, .. } => *max,
}
}
}
#[derive(Debug)]
enum Backing {
Mem(GuardedSeek<Cursor<Vec<u8>>>),
File(GuardedSeek<std::fs::File>),
}
#[derive(Debug)]
pub struct SpillSource {
backing: Backing,
len: u64,
on_disk: bool,
}
impl SpillSource {
pub fn materialize(mut src: Box<dyn Source>, policy: &SpillPolicy) -> Result<Self> {
Self::materialize_from(&mut *src, policy)
}
pub fn materialize_from<R: Read + ?Sized>(src: &mut R, policy: &SpillPolicy) -> Result<Self> {
let mut spool = SpillWriter::new(policy)?;
let mut buf = vec![0u8; 64 * 1024];
loop {
let n = src.read(&mut buf)?;
if n == 0 {
return spool.finish();
}
spool.push(&buf[..n])?;
}
}
pub fn spilled_to_disk(&self) -> bool {
self.on_disk
}
}
#[derive(Debug)]
pub struct SpillWriter {
limit: u64,
mem_cap: u64,
dir: Option<Option<PathBuf>>,
mem: Vec<u8>,
file: Option<std::fs::File>,
total: u64,
}
impl SpillWriter {
pub fn new(policy: &SpillPolicy) -> Result<Self> {
if !policy.is_enabled() {
return Err(Error::SpillLimitExceeded { limit: 0 });
}
Ok(Self {
limit: policy.hard_limit(),
mem_cap: policy.memory_cap(),
dir: match policy {
SpillPolicy::Temp { dir, .. } => Some(dir.clone()),
SpillPolicy::Off | SpillPolicy::Memory { .. } => None,
},
mem: Vec::new(),
file: None,
total: 0,
})
}
pub fn len(&self) -> u64 {
self.total
}
pub fn is_empty(&self) -> bool {
self.total == 0
}
pub fn reserve(&mut self, additional: u64) {
if self.file.is_some() {
return;
}
let room = self.mem_cap.saturating_sub(self.total);
let n = usize::try_from(additional.min(room)).unwrap_or(0);
self.mem.reserve_exact(n);
}
pub fn push(&mut self, bytes: &[u8]) -> Result<()> {
self.total += bytes.len() as u64;
if self.total > self.limit {
return Err(Error::SpillLimitExceeded { limit: self.limit });
}
if let Some(file) = &mut self.file {
file.write_all(bytes)?;
return Ok(());
}
if self.total <= self.mem_cap {
self.mem.extend_from_slice(bytes);
return Ok(());
}
let Some(dir) = &self.dir else {
return Err(Error::SpillLimitExceeded { limit: self.limit });
};
let mut file = match dir {
Some(d) => tempfile::tempfile_in(d)?,
None => tempfile::tempfile()?,
};
file.write_all(&self.mem)?;
file.write_all(bytes)?;
self.mem = Vec::new();
self.file = Some(file);
Ok(())
}
pub fn finish(mut self) -> Result<SpillSource> {
match self.file.take() {
None => {
self.mem.shrink_to_fit();
Ok(SpillSource {
backing: Backing::Mem(GuardedSeek::new(Cursor::new(self.mem))),
len: self.total,
on_disk: false,
})
}
Some(mut file) => {
file.flush()?;
file.seek(std::io::SeekFrom::Start(0))?;
Ok(SpillSource {
backing: Backing::File(GuardedSeek::new(file)),
len: self.total,
on_disk: true,
})
}
}
}
}
impl Read for SpillSource {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
match &mut self.backing {
Backing::Mem(c) => c.read(buf),
Backing::File(f) => f.read(buf),
}
}
}
impl Source for SpillSource {
fn caps(&self) -> SourceCaps {
SourceCaps {
seekable: true,
len: Some(self.len),
}
}
fn as_seek(&mut self) -> Option<&mut dyn SeekRead> {
match &mut self.backing {
Backing::Mem(c) => Some(c),
Backing::File(f) => Some(f),
}
}
}
#[cfg(test)]
mod tests {
use crate::source::ReaderSource;
use std::io::{Read, SeekFrom};
use super::*;
fn pipe(bytes: Vec<u8>) -> Box<dyn crate::source::Source> {
Box::new(ReaderSource::new(std::io::Cursor::new(bytes)))
}
#[test]
fn small_input_stays_in_memory_and_becomes_seekable() {
let mut s = SpillSource::materialize(
pipe(b"0123456789".to_vec()),
&SpillPolicy::Temp {
dir: None,
mem_cap: 1024,
max: 1 << 20,
},
)
.unwrap();
assert!(!s.spilled_to_disk());
assert!(s.caps().seekable);
assert_eq!(s.caps().len, Some(10));
let seek = s.as_seek().unwrap();
seek.seek(SeekFrom::Start(7)).unwrap();
let mut buf = [0u8; 3];
seek.read_exact(&mut buf).unwrap();
assert_eq!(&buf, b"789");
}
#[test]
fn input_over_the_memory_cap_escalates_to_a_temp_file() {
let data: Vec<u8> = (0..4096u32).map(|i| (i % 251) as u8).collect();
let mut s = SpillSource::materialize(
pipe(data.clone()),
&SpillPolicy::Temp {
dir: None,
mem_cap: 64,
max: 1 << 20,
},
)
.unwrap();
assert!(s.spilled_to_disk());
assert_eq!(s.caps().len, Some(4096));
let mut out = Vec::new();
s.read_to_end(&mut out).unwrap();
assert_eq!(out, data);
}
#[test]
fn memory_only_policy_refuses_to_exceed_its_cap() {
let err = SpillSource::materialize(pipe(vec![7u8; 500]), &SpillPolicy::Memory { cap: 100 })
.unwrap_err();
assert!(matches!(
err,
crate::Error::SpillLimitExceeded { limit: 100 }
));
assert_eq!(err.exit_code(), 6);
}
#[test]
fn max_spill_is_enforced_for_the_temp_policy_too() {
let err = SpillSource::materialize(
pipe(vec![7u8; 5000]),
&SpillPolicy::Temp {
dir: None,
mem_cap: 16,
max: 1000,
},
)
.unwrap_err();
assert!(matches!(
err,
crate::Error::SpillLimitExceeded { limit: 1000 }
));
}
#[test]
fn off_policy_is_not_enabled_and_cannot_materialize() {
assert!(!SpillPolicy::Off.is_enabled());
assert!(SpillPolicy::default().is_enabled());
let err = SpillSource::materialize(pipe(b"x".to_vec()), &SpillPolicy::Off).unwrap_err();
assert!(matches!(err, crate::Error::SpillLimitExceeded { limit: 0 }));
}
#[test]
fn a_spill_writer_escalates_and_refuses_like_materialize() {
let policy = SpillPolicy::Temp {
dir: None,
mem_cap: 10,
max: 30,
};
let mut w = SpillWriter::new(&policy).unwrap();
w.push(b"0123456789").unwrap();
w.push(b"abcdef").unwrap();
assert_eq!(w.len(), 16);
let mut s = w.finish().unwrap();
assert!(s.spilled_to_disk());
let mut out = Vec::new();
s.read_to_end(&mut out).unwrap();
assert_eq!(out, b"0123456789abcdef");
let mut w = SpillWriter::new(&policy).unwrap();
let err = w.push(&[0u8; 31]).unwrap_err();
assert!(matches!(
err,
crate::Error::SpillLimitExceeded { limit: 30 }
));
assert!(matches!(
SpillWriter::new(&SpillPolicy::Off).unwrap_err(),
crate::Error::SpillLimitExceeded { limit: 0 }
));
assert_eq!(policy.memory_cap(), 10);
}
#[test]
fn defaults_match_the_spec() {
match SpillPolicy::default() {
SpillPolicy::Temp { mem_cap, max, dir } => {
assert_eq!(mem_cap, 64 * 1024 * 1024);
assert_eq!(max, 2 * 1024 * 1024 * 1024);
assert!(dir.is_none());
}
other => panic!("unexpected default: {other:?}"),
}
}
#[test]
fn empty_input_materializes_to_an_empty_seekable_source() {
let mut s = SpillSource::materialize(pipe(Vec::new()), &SpillPolicy::default()).unwrap();
assert_eq!(s.caps().len, Some(0));
let mut out = Vec::new();
s.read_to_end(&mut out).unwrap();
assert!(out.is_empty());
}
#[test]
fn escalation_preserves_order_across_many_sub_cap_chunks() {
let data: Vec<u8> = (0..4096u32).map(|i| (i % 251) as u8).collect();
let src: Box<dyn Source> = Box::new(crate::source::ReaderSource::new(
crate::testing::ChunkedReader::new(data.clone(), 100),
));
let mut s = SpillSource::materialize(
src,
&SpillPolicy::Temp {
dir: None,
mem_cap: 1000,
max: 1 << 20,
},
)
.unwrap();
assert!(s.spilled_to_disk());
let mut out = Vec::new();
s.read_to_end(&mut out).unwrap();
assert_eq!(out, data, "bytes must survive escalation in order");
}
}