use std::collections::HashMap;
use std::hash::Hash;
use std::marker::PhantomData;
use std::path::{Path, PathBuf};
use crate::data::io::uring_reader::UringReader;
use crate::data::io::uring_writer::UringWriter;
const FINALIZE_CAP_FACTOR: usize = 10;
pub(super) struct SpillCore<K, V> {
spill_dir: PathBuf,
runs: Vec<PathBuf>,
pub(super) spilled_runs: u64,
_marker: PhantomData<(K, V)>,
}
impl<K, V> SpillCore<K, V>
where
K: serde::Serialize + serde::de::DeserializeOwned + Eq + Hash,
V: serde::Serialize + serde::de::DeserializeOwned,
{
pub(super) fn new(spill_dir: PathBuf) -> crate::Result<Self> {
std::fs::create_dir_all(&spill_dir).map_err(|e| crate::Error::Storage {
engine: "groupby_spill".into(),
detail: format!("failed to create spill dir {}: {e}", spill_dir.display()),
})?;
Ok(Self {
spill_dir,
runs: Vec::new(),
spilled_runs: 0,
_marker: PhantomData,
})
}
pub(super) fn flush_run(&mut self, entries: impl Iterator<Item = (K, V)>) -> crate::Result<()> {
let entries: Vec<(K, V)> = entries.collect();
if entries.is_empty() {
return Ok(());
}
let encoded = sonic_rs::to_vec(&entries).map_err(|e| crate::Error::Storage {
engine: "groupby_spill".into(),
detail: format!("spill serialize error: {e}"),
})?;
let run_path = self
.spill_dir
.join(format!("run-{}.spill", self.spilled_runs));
write_run_file(&run_path, &encoded)?;
self.runs.push(run_path);
self.spilled_runs += 1;
Ok(())
}
pub(super) fn merge<F>(
self,
in_mem: &mut HashMap<K, V>,
cap: usize,
merge_fn: F,
) -> crate::Result<HashMap<K, V>>
where
F: Fn(&mut V, V),
{
let output_cap = cap.saturating_mul(FINALIZE_CAP_FACTOR);
let mut output: HashMap<K, V> = HashMap::new();
let mut reader = if self.runs.is_empty() {
None
} else {
UringReader::with_config(8, 2, 4 * 1024 * 1024)
};
for run_path in &self.runs {
let buf = read_run_file(&mut reader, run_path)?;
let entries: Vec<(K, V)> =
sonic_rs::from_slice(&buf).map_err(|e| crate::Error::Storage {
engine: "groupby_spill".into(),
detail: format!("spill run deserialize error: {e}"),
})?;
merge_entries(&mut output, entries, output_cap, &merge_fn)?;
}
let in_mem_entries: Vec<(K, V)> = in_mem.drain().collect();
merge_entries(&mut output, in_mem_entries, output_cap, &merge_fn)?;
Ok(output)
}
}
fn write_run_file(path: &Path, bytes: &[u8]) -> crate::Result<()> {
match UringWriter::new(path) {
Some(mut writer) => {
writer.append(bytes)?;
writer.finish()?;
Ok(())
}
None => std::fs::write(path, bytes).map_err(|e| crate::Error::Storage {
engine: "groupby_spill".into(),
detail: format!("spill run write error: {e}"),
}),
}
}
fn read_run_file(reader: &mut Option<UringReader>, path: &Path) -> crate::Result<Vec<u8>> {
let buf = match reader.as_mut() {
Some(r) => {
let mut bufs = r.read_files(&[path]);
bufs.pop().unwrap_or_default()
}
None => std::fs::read(path).map_err(|e| crate::Error::Storage {
engine: "groupby_spill".into(),
detail: format!("spill run read error: {e}"),
})?,
};
if buf.is_empty() {
return Err(crate::Error::Storage {
engine: "groupby_spill".into(),
detail: format!(
"spill run {} read back empty (read failure)",
path.display()
),
});
}
Ok(buf)
}
fn merge_entries<K, V, F>(
output: &mut HashMap<K, V>,
entries: Vec<(K, V)>,
output_cap: usize,
merge_fn: &F,
) -> crate::Result<()>
where
K: Eq + Hash,
F: Fn(&mut V, V),
{
for (key, value) in entries {
if output.len() >= output_cap && !output.contains_key(&key) {
return Err(crate::Error::Storage {
engine: "groupby_spill".into(),
detail: format!(
"finalized group cardinality exceeds {FINALIZE_CAP_FACTOR}x cap \
({output_cap}), query result cardinality limit reached"
),
});
}
match output.entry(key) {
std::collections::hash_map::Entry::Occupied(mut e) => {
merge_fn(e.get_mut(), value);
}
std::collections::hash_map::Entry::Vacant(e) => {
e.insert(value);
}
}
}
Ok(())
}
impl<K, V> Drop for SpillCore<K, V> {
fn drop(&mut self) {
for path in self.runs.drain(..) {
let _ = std::fs::remove_file(&path);
}
if let Err(e) = std::fs::remove_dir(&self.spill_dir)
&& self.spill_dir.exists()
{
tracing::warn!(
dir = %self.spill_dir.display(),
error = %e,
"groupby_spill: could not remove spill directory"
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn merge_consolidates_runs_and_in_mem() {
let dir = tempfile::tempdir().unwrap();
let mut core: SpillCore<String, u64> = SpillCore::new(dir.path().join("sc")).unwrap();
core.flush_run(vec![("a".to_string(), 1u64), ("b".to_string(), 2)].into_iter())
.unwrap();
core.flush_run(vec![("a".to_string(), 10u64), ("c".to_string(), 3)].into_iter())
.unwrap();
assert_eq!(core.spilled_runs, 2);
let mut in_mem: HashMap<String, u64> = HashMap::new();
in_mem.insert("b".to_string(), 20);
in_mem.insert("d".to_string(), 4);
let out = core
.merge(&mut in_mem, 100, |dst, src| *dst += src)
.unwrap();
assert_eq!(out.get("a"), Some(&11));
assert_eq!(out.get("b"), Some(&22));
assert_eq!(out.get("c"), Some(&3));
assert_eq!(out.get("d"), Some(&4));
assert_eq!(out.len(), 4);
}
#[test]
fn empty_flush_is_noop() {
let dir = tempfile::tempdir().unwrap();
let mut core: SpillCore<String, u64> = SpillCore::new(dir.path().join("sc")).unwrap();
core.flush_run(std::iter::empty()).unwrap();
assert_eq!(core.spilled_runs, 0);
let mut in_mem: HashMap<String, u64> = HashMap::new();
in_mem.insert("x".to_string(), 1);
let out = core
.merge(&mut in_mem, 100, |dst, src| *dst += src)
.unwrap();
assert_eq!(out.get("x"), Some(&1));
assert_eq!(out.len(), 1);
}
#[test]
fn merge_with_empty_in_mem_returns_runs() {
let dir = tempfile::tempdir().unwrap();
let mut core: SpillCore<String, u64> = SpillCore::new(dir.path().join("sc")).unwrap();
core.flush_run(vec![("k1".to_string(), 5u64)].into_iter())
.unwrap();
core.flush_run(vec![("k2".to_string(), 7u64)].into_iter())
.unwrap();
let mut in_mem: HashMap<String, u64> = HashMap::new();
let out = core
.merge(&mut in_mem, 100, |dst, src| *dst += src)
.unwrap();
assert_eq!(out.get("k1"), Some(&5));
assert_eq!(out.get("k2"), Some(&7));
assert_eq!(out.len(), 2);
}
#[test]
fn cardinality_cap_errors() {
let dir = tempfile::tempdir().unwrap();
let mut core: SpillCore<String, u64> = SpillCore::new(dir.path().join("sc")).unwrap();
let entries: Vec<(String, u64)> = (0..11).map(|i| (format!("k{i}"), i as u64)).collect();
core.flush_run(entries.into_iter()).unwrap();
let mut in_mem: HashMap<String, u64> = HashMap::new();
let res = core.merge(&mut in_mem, 1, |dst, src| *dst += src);
assert!(res.is_err(), "expected cardinality-cap error, got {res:?}");
}
}