use std::collections::HashMap;
use std::fs::{File, OpenOptions};
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::Path;
#[cfg(test)]
use std::path::PathBuf;
use crate::error::{ChiselError, Result};
use crate::page::PAGE_SIZE;
pub const SLOT_HEADER_SIZE: usize = 16;
#[allow(dead_code)]
pub const SLOT_SIZE: usize = SLOT_HEADER_SIZE + PAGE_SIZE;
enum Backing {
File { file: File },
Memory { bytes: Vec<u8> },
}
pub struct Spillway {
backing: Backing,
slots: HashMap<u64, u64>,
next_slot_index: u64,
max_bytes: u64,
payload_size: usize,
}
impl Spillway {
pub fn open_file(db_path: &Path, max_bytes: u64, payload_size: usize) -> Result<Spillway> {
let mut path = db_path.as_os_str().to_owned();
path.push(".spillway");
let file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(true)
.open(&path)
.map_err(ChiselError::IoError)?;
Ok(Spillway {
backing: Backing::File { file },
slots: HashMap::new(),
next_slot_index: 0,
max_bytes,
payload_size,
})
}
pub fn open_memory(max_bytes: u64, payload_size: usize) -> Spillway {
Spillway {
backing: Backing::Memory { bytes: Vec::new() },
slots: HashMap::new(),
next_slot_index: 0,
max_bytes,
payload_size,
}
}
pub fn is_resident(&self, page_id: u64) -> bool {
self.slots.contains_key(&page_id)
}
pub fn slot_count(&self) -> u64 {
self.slots.len() as u64
}
pub fn logical_bytes(&self) -> u64 {
self.slots.len() as u64 * self.payload_size as u64
}
pub fn max_bytes(&self) -> u64 {
self.max_bytes
}
pub fn set_max_bytes(&mut self, bytes: u64) {
self.max_bytes = bytes;
}
#[allow(dead_code)]
pub fn slot_size(&self) -> usize {
SLOT_HEADER_SIZE + self.payload_size
}
pub fn spill(&mut self, page_id: u64, blob: &[u8]) -> Result<()> {
debug_assert_eq!(blob.len(), self.payload_size, "spill blob != payload_size");
let slot_index = if let Some(&existing) = self.slots.get(&page_id) {
existing
} else {
let post_write_bytes = (self.slots.len() as u64 + 1) * self.payload_size as u64;
if post_write_bytes > self.max_bytes {
return Err(ChiselError::SpillwayFull {
limit_bytes: self.max_bytes,
});
}
let new_index = self.next_slot_index;
self.next_slot_index += 1;
self.slots.insert(page_id, new_index);
new_index
};
write_slot(
&mut self.backing,
slot_index,
page_id,
blob,
self.payload_size,
)?;
Ok(())
}
pub fn truncate(&mut self) -> Result<()> {
self.slots.clear();
self.next_slot_index = 0;
match &mut self.backing {
Backing::File { file } => {
file.set_len(0)?;
file.seek(SeekFrom::Start(0))?;
}
Backing::Memory { bytes } => {
bytes.clear();
}
}
Ok(())
}
pub fn drain_batch(&self, batch_size: usize) -> Vec<u64> {
let mut ids = Vec::with_capacity(batch_size.min(self.slots.len()));
for &id in self.slots.keys().take(batch_size) {
ids.push(id);
}
ids
}
pub fn forget(&mut self, page_id: u64) {
self.slots.remove(&page_id);
}
pub fn forget_above(&mut self, n: u64) {
self.slots.retain(|&page_id, _| page_id < n);
}
pub fn rehydrate(&mut self, page_id: u64) -> Result<Vec<u8>> {
let slot_index = match self.slots.get(&page_id) {
Some(&i) => i,
None => return Err(ChiselError::InvalidPageId { page_id }),
};
let (stored_page_id, stored_checksum, blob) =
read_slot(&mut self.backing, slot_index, self.payload_size)?;
if stored_page_id != page_id {
return Err(ChiselError::ChecksumMismatch { page_id });
}
let computed = slot_checksum(page_id, &blob);
if computed != stored_checksum {
return Err(ChiselError::ChecksumMismatch { page_id });
}
Ok(blob)
}
}
fn slot_checksum(page_id: u64, blob: &[u8]) -> u64 {
let mut hasher = xxhash_rust::xxh3::Xxh3::new();
hasher.update(&page_id.to_le_bytes());
hasher.update(blob);
hasher.digest()
}
fn write_slot(
backing: &mut Backing,
slot_index: u64,
page_id: u64,
blob: &[u8],
payload_size: usize,
) -> Result<()> {
let slot_size = SLOT_HEADER_SIZE + payload_size;
let checksum = slot_checksum(page_id, blob);
let offset = slot_index * slot_size as u64;
let mut header = [0u8; SLOT_HEADER_SIZE];
header[..8].copy_from_slice(&page_id.to_le_bytes());
header[8..16].copy_from_slice(&checksum.to_le_bytes());
match backing {
Backing::File { file } => {
file.seek(SeekFrom::Start(offset))?;
file.write_all(&header)?;
file.write_all(blob)?;
}
Backing::Memory { bytes } => {
let needed = (offset + slot_size as u64) as usize;
if bytes.len() < needed {
bytes.resize(needed, 0);
}
let off = offset as usize;
bytes[off..off + SLOT_HEADER_SIZE].copy_from_slice(&header);
bytes[off + SLOT_HEADER_SIZE..off + slot_size].copy_from_slice(blob);
}
}
Ok(())
}
fn read_slot(
backing: &mut Backing,
slot_index: u64,
payload_size: usize,
) -> Result<(u64, u64, Vec<u8>)> {
let slot_size = SLOT_HEADER_SIZE + payload_size;
let offset = slot_index * slot_size as u64;
let mut header = [0u8; SLOT_HEADER_SIZE];
let mut blob = vec![0u8; payload_size];
match backing {
Backing::File { file } => {
file.seek(SeekFrom::Start(offset))?;
file.read_exact(&mut header)?;
file.read_exact(&mut blob)?;
}
Backing::Memory { bytes } => {
let off = offset as usize;
if bytes.len() < off + slot_size {
return Err(ChiselError::IoError(std::io::Error::new(
std::io::ErrorKind::UnexpectedEof,
format!("spillway memory backing too short for slot {slot_index}"),
)));
}
header.copy_from_slice(&bytes[off..off + SLOT_HEADER_SIZE]);
blob.copy_from_slice(&bytes[off + SLOT_HEADER_SIZE..off + slot_size]);
}
}
let stored_page_id = u64::from_le_bytes(header[..8].try_into().unwrap());
let stored_checksum = u64::from_le_bytes(header[8..16].try_into().unwrap());
Ok((stored_page_id, stored_checksum, blob))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn open_file_truncates_existing_content() {
let dir = tempfile::TempDir::new().unwrap();
let db_path = dir.path().join("test.chisel");
let spillway_path = {
let mut p = db_path.as_os_str().to_owned();
p.push(".spillway");
PathBuf::from(p)
};
std::fs::write(&spillway_path, b"garbage").unwrap();
let spw = Spillway::open_file(&db_path, 1024 * 1024, PAGE_SIZE).unwrap();
assert!(!spw.is_resident(42));
assert_eq!(spw.slot_count(), 0);
assert_eq!(spw.logical_bytes(), 0);
assert_eq!(spw.max_bytes(), 1024 * 1024);
let on_disk = std::fs::read(&spillway_path).unwrap();
assert_eq!(on_disk.len(), 0);
}
#[test]
fn open_memory_starts_empty() {
let spw = Spillway::open_memory(1024 * 1024, PAGE_SIZE);
assert!(!spw.is_resident(0));
assert_eq!(spw.slot_count(), 0);
assert_eq!(spw.logical_bytes(), 0);
assert_eq!(spw.max_bytes(), 1024 * 1024);
}
#[test]
fn set_max_bytes_updates_cap() {
let mut spw = Spillway::open_memory(1024, PAGE_SIZE);
spw.set_max_bytes(2048);
assert_eq!(spw.max_bytes(), 2048);
}
fn page(byte: u8) -> Vec<u8> {
vec![byte; PAGE_SIZE]
}
#[test]
fn spill_inserts_new_slot() {
let mut spw = Spillway::open_memory(SLOT_SIZE as u64 * 4, PAGE_SIZE);
spw.spill(100, &page(0xAA)).unwrap();
assert!(spw.is_resident(100));
assert_eq!(spw.slot_count(), 1);
assert_eq!(spw.logical_bytes(), PAGE_SIZE as u64);
}
#[test]
fn re_spill_of_resident_page_reuses_slot() {
let mut spw = Spillway::open_memory(SLOT_SIZE as u64 * 4, PAGE_SIZE);
spw.spill(100, &page(0xAA)).unwrap();
spw.spill(100, &page(0xBB)).unwrap(); assert_eq!(spw.slot_count(), 1, "slot count must not grow on re-spill");
}
#[test]
fn spill_full_returns_spillway_full_error() {
let max_bytes = (PAGE_SIZE * 2) as u64;
let mut spw = Spillway::open_memory(max_bytes, PAGE_SIZE);
spw.spill(100, &page(0xAA)).unwrap();
spw.spill(101, &page(0xBB)).unwrap();
let err = spw.spill(102, &page(0xCC)).unwrap_err();
match err {
ChiselError::SpillwayFull { limit_bytes } => {
assert_eq!(limit_bytes, max_bytes);
}
other => panic!("expected SpillwayFull, got {other:?}"),
}
}
#[test]
fn spill_cap_and_logical_bytes_track_live_residency_not_cumulative_volume() {
let max_bytes = (PAGE_SIZE * 2) as u64; let mut spw = Spillway::open_memory(max_bytes, PAGE_SIZE);
for id in 0..100u64 {
spw.spill(id, &page(id as u8))
.expect("a single live page is far below the cap; must not trip SpillwayFull");
assert_eq!(
spw.logical_bytes(),
PAGE_SIZE as u64,
"one live page must report exactly one page of logical bytes"
);
spw.forget(id);
assert_eq!(
spw.logical_bytes(),
0,
"forgetting the only live page must drop logical bytes to zero"
);
}
}
#[test]
fn rehydrate_round_trips_bytes() {
let mut spw = Spillway::open_memory(SLOT_SIZE as u64 * 4, PAGE_SIZE);
let original = page(0xAB);
spw.spill(100, &original).unwrap();
let restored = spw.rehydrate(100).unwrap();
assert_eq!(restored, original);
}
#[test]
fn rehydrate_after_overwrite_returns_latest_bytes() {
let mut spw = Spillway::open_memory(SLOT_SIZE as u64 * 4, PAGE_SIZE);
spw.spill(100, &page(0xAA)).unwrap();
spw.spill(100, &page(0xBB)).unwrap();
let restored = spw.rehydrate(100).unwrap();
assert_eq!(restored, page(0xBB));
}
#[test]
fn rehydrate_missing_page_returns_invalid_page_id() {
let mut spw = Spillway::open_memory(SLOT_SIZE as u64 * 4, PAGE_SIZE);
let err = spw.rehydrate(999).unwrap_err();
assert!(matches!(err, ChiselError::InvalidPageId { page_id: 999 }));
}
#[test]
fn rehydrate_with_corrupted_byte_returns_checksum_mismatch() {
let mut spw = Spillway::open_memory(SLOT_SIZE as u64 * 4, PAGE_SIZE);
spw.spill(100, &page(0xAA)).unwrap();
if let Backing::Memory { ref mut bytes } = spw.backing {
bytes[SLOT_HEADER_SIZE] ^= 0x01;
}
let err = spw.rehydrate(100).unwrap_err();
assert!(matches!(
err,
ChiselError::ChecksumMismatch { page_id: 100 }
));
}
#[test]
fn truncate_clears_residents_and_resets_index() {
let mut spw = Spillway::open_memory(SLOT_SIZE as u64 * 4, PAGE_SIZE);
spw.spill(100, &page(0xAA)).unwrap();
spw.spill(101, &page(0xBB)).unwrap();
assert_eq!(spw.slot_count(), 2);
spw.truncate().unwrap();
assert_eq!(spw.slot_count(), 0);
assert_eq!(spw.logical_bytes(), 0);
assert!(!spw.is_resident(100));
assert!(!spw.is_resident(101));
spw.spill(200, &page(0xCC)).unwrap();
assert_eq!(spw.slot_count(), 1);
}
#[test]
fn drain_batch_returns_resident_ids_up_to_batch_size() {
let mut spw = Spillway::open_memory(SLOT_SIZE as u64 * 8, PAGE_SIZE);
for id in 100..105 {
spw.spill(id, &page(id as u8)).unwrap();
}
let batch = spw.drain_batch(3);
assert_eq!(batch.len(), 3);
for id in &batch {
assert!((100..105).contains(id), "unexpected id {id} in batch");
}
}
#[test]
fn forget_above_drops_high_ids_only() {
let mut spw = Spillway::open_memory(SLOT_SIZE as u64 * 8, PAGE_SIZE);
for id in 0..6 {
spw.spill(id, &page(id as u8)).unwrap();
}
spw.forget_above(3);
for id in 0..3 {
assert!(spw.is_resident(id), "low id {id} should still be resident");
}
for id in 3..6 {
assert!(!spw.is_resident(id), "high id {id} should be gone");
}
}
#[test]
fn forget_drops_from_resident_set() {
let mut spw = Spillway::open_memory(SLOT_SIZE as u64 * 4, PAGE_SIZE);
spw.spill(100, &page(0xAA)).unwrap();
assert!(spw.is_resident(100));
spw.forget(100);
assert!(!spw.is_resident(100));
}
#[test]
fn wide_slot_round_trips_sealed_blob() {
use crate::crypto::ENC_PAGE_SIZE;
let slot = (SLOT_HEADER_SIZE + ENC_PAGE_SIZE) as u64;
let mut spw = Spillway::open_memory(slot * 4, ENC_PAGE_SIZE);
let mut blob = vec![0u8; ENC_PAGE_SIZE];
blob[0] = 0xEE;
blob[ENC_PAGE_SIZE - 1] = 0x11;
spw.spill(7, &blob).unwrap();
assert!(spw.is_resident(7));
assert_eq!(spw.rehydrate(7).unwrap(), blob);
}
#[test]
fn wide_slot_checksum_catches_tampered_payload() {
use crate::crypto::ENC_PAGE_SIZE;
let slot = (SLOT_HEADER_SIZE + ENC_PAGE_SIZE) as u64;
let mut spw = Spillway::open_memory(slot * 4, ENC_PAGE_SIZE);
spw.spill(7, &vec![0xAB; ENC_PAGE_SIZE]).unwrap();
if let Backing::Memory { ref mut bytes } = spw.backing {
bytes[SLOT_HEADER_SIZE + 5] ^= 0x01; }
assert!(matches!(
spw.rehydrate(7).unwrap_err(),
ChiselError::ChecksumMismatch { page_id: 7 }
));
}
}