use std::path::PathBuf;
use linkrs_core::columnar::MaterializedBatch;
use linkrs_core::error::QueryError;
use linkrs_core::Value;
pub(super) const RUN_MAGIC: [u8; 4] = [0x4C, 0x52, 0x53, 0x43];
pub(super) const RUN_VERSION: u32 = 3;
pub(super) const RUN_HEADER_SIZE: u32 = 48;
pub(super) const SECTION_ROWS: usize = 1024;
pub(super) const COMPRESSION_MIN_SIZE: usize = 256;
pub(super) const FNV1A_64_INIT: u64 = 0xcbf29ce484222325;
const FNV1A_64_PRIME: u64 = 0x100000001b3;
fn fnv1a_64(data: &[u8]) -> u64 {
fnv1a_64_update(FNV1A_64_INIT, data)
}
pub(super) fn fnv1a_64_update(mut hash: u64, data: &[u8]) -> u64 {
for &byte in data {
hash ^= byte as u64;
hash = hash.wrapping_mul(FNV1A_64_PRIME);
}
hash
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum RunCompression {
None = 0,
Zstd = 1,
}
#[derive(Debug, Clone, Copy)]
pub struct RunHeader {
pub version: u32,
pub schema_fingerprint: u64,
pub row_count: u64,
pub num_columns: u32,
pub section_count: u32,
pub flags: u32,
}
impl RunHeader {
pub(super) fn encode(&self) -> [u8; RUN_HEADER_SIZE as usize] {
let mut buf = [0u8; RUN_HEADER_SIZE as usize];
buf[0..4].copy_from_slice(&RUN_MAGIC);
buf[4..8].copy_from_slice(&self.version.to_le_bytes());
buf[8..16].copy_from_slice(&self.schema_fingerprint.to_le_bytes());
buf[16..24].copy_from_slice(&self.row_count.to_le_bytes());
buf[24..28].copy_from_slice(&self.num_columns.to_le_bytes());
buf[28..32].copy_from_slice(&self.section_count.to_le_bytes());
buf[32..36].copy_from_slice(&self.flags.to_le_bytes());
let checksum = fnv1a_64(&buf[0..40]);
buf[40..48].copy_from_slice(&checksum.to_le_bytes());
buf
}
pub(super) fn decode(buf: &[u8]) -> Result<Self, QueryError> {
if buf.len() < RUN_HEADER_SIZE as usize {
return Err(QueryError::execution(
"spill run: truncated header".to_string(),
));
}
if buf[0..4] != RUN_MAGIC {
return Err(QueryError::execution(
"spill run: invalid magic bytes".to_string(),
));
}
let version = u32::from_le_bytes(buf[4..8].try_into().unwrap());
if version != RUN_VERSION {
return Err(QueryError::execution(format!(
"spill run: unsupported version {}, expected {}",
version, RUN_VERSION
)));
}
let expected = fnv1a_64(&buf[0..40]);
let actual = u64::from_le_bytes(buf[40..48].try_into().unwrap());
if expected != actual {
return Err(QueryError::execution(
"spill run: header checksum mismatch".to_string(),
));
}
Ok(Self {
version,
schema_fingerprint: u64::from_le_bytes(buf[8..16].try_into().unwrap()),
row_count: u64::from_le_bytes(buf[16..24].try_into().unwrap()),
num_columns: u32::from_le_bytes(buf[24..28].try_into().unwrap()),
section_count: u32::from_le_bytes(buf[28..32].try_into().unwrap()),
flags: u32::from_le_bytes(buf[32..36].try_into().unwrap()),
})
}
}
pub(super) fn encode_section(batch: &MaterializedBatch) -> Result<Vec<u8>, QueryError> {
let num_rows = batch.num_rows();
let num_cols = batch.num_columns();
let mut payload = Vec::new();
payload.extend_from_slice(&(num_rows as u32).to_le_bytes());
payload.extend_from_slice(&(num_cols as u32).to_le_bytes());
for i in 0..num_cols {
let encoded = postcard::to_allocvec(batch.column_slice(i))
.map_err(|e| QueryError::execution(format!("run serialize column: {}", e)))?;
payload.extend_from_slice(&(encoded.len() as u64).to_le_bytes());
payload.extend_from_slice(&encoded);
}
Ok(payload)
}
pub(super) fn decode_section(
payload: &[u8],
expected_cols: Option<u32>,
) -> Result<MaterializedBatch, QueryError> {
let fail = |msg: String| QueryError::execution(format!("spill run: {msg}"));
if payload.len() < 8 {
return Err(fail("truncated section payload".to_string()));
}
let num_rows = u32::from_le_bytes(payload[0..4].try_into().unwrap()) as usize;
let num_cols = u32::from_le_bytes(payload[4..8].try_into().unwrap());
if num_rows > 10_000_000 || num_cols > 100_000 {
return Err(fail("section dimensions out of range".to_string()));
}
if let Some(expected) = expected_cols {
if num_cols != expected {
return Err(fail(format!(
"section column count {} != run column count {}",
num_cols, expected
)));
}
}
let mut offset = 8usize;
let mut batch = MaterializedBatch::new(0, 0);
for _ in 0..num_cols {
if payload.len() < offset + 8 {
return Err(fail("truncated section column".to_string()));
}
let col_len = u64::from_le_bytes(payload[offset..offset + 8].try_into().unwrap()) as usize;
offset += 8;
if payload.len() < offset + col_len {
return Err(fail("truncated section column data".to_string()));
}
let values: Vec<Value> = postcard::from_bytes(&payload[offset..offset + col_len])
.map_err(|e| QueryError::execution(format!("spill run: deserialize column: {}", e)))?;
offset += col_len;
if values.len() != num_rows {
return Err(fail(format!(
"section column length {} != section row count {}",
values.len(),
num_rows
)));
}
batch.append_column(values);
}
Ok(batch)
}
#[derive(Debug, Clone)]
pub struct SpilledRun {
pub path: PathBuf,
pub row_count: u64,
pub byte_size: u64,
pub schema_fingerprint: u64,
}
pub fn schema_fingerprint(col_names: &[String]) -> u64 {
use std::hash::Hasher;
let mut hasher = std::collections::hash_map::DefaultHasher::new();
for name in col_names {
hasher.write(name.as_bytes());
hasher.write_u8(0);
}
hasher.finish()
}