use std::collections::{HashMap, HashSet};
use std::fs::File;
use std::io::{BufReader, BufWriter, Read, Seek, SeekFrom, Write};
use std::path::Path;
const HEADER_BYTES: usize = 6;
pub struct LabelSpillWriter {
writer: BufWriter<File>,
truncated: u64,
}
impl LabelSpillWriter {
pub fn new(path: &Path) -> std::io::Result<Self> {
let file = File::create(path)?;
Ok(Self {
writer: BufWriter::with_capacity(64 * 1024, file),
truncated: 0,
})
}
pub fn append(&mut self, qnum: u32, label: &str) -> std::io::Result<()> {
let bytes = label.as_bytes();
let len = if bytes.len() > u16::MAX as usize {
let mut cut = u16::MAX as usize;
while cut > 0 && !label.is_char_boundary(cut) {
cut -= 1;
}
self.truncated += 1;
cut
} else {
bytes.len()
};
self.writer.write_all(&qnum.to_le_bytes())?;
self.writer.write_all(&(len as u16).to_le_bytes())?;
self.writer.write_all(&bytes[..len])?;
Ok(())
}
pub fn finish(mut self) -> std::io::Result<u64> {
self.writer.flush()?;
if self.truncated > 0 {
eprintln!(
"[{}] label journal: truncated {} label(s) longer than {} bytes \
(kept the leading bytes, cut at a char boundary)",
chrono::Local::now().format("%H:%M:%S"),
self.truncated,
u16::MAX
);
}
let file = self
.writer
.into_inner()
.map_err(|e| std::io::Error::other(e.to_string()))?;
let size = file.metadata()?.len();
file.sync_all()?;
Ok(size)
}
}
pub fn read_labels_for(
path: &Path,
wanted: &HashSet<u32>,
) -> std::io::Result<HashMap<u32, String>> {
let file = File::open(path)?;
let file_len = file.metadata()?.len();
let mut reader = BufReader::with_capacity(64 * 1024, file);
let mut result: HashMap<u32, String> = HashMap::with_capacity(wanted.len());
let mut qbuf = [0u8; 4];
let mut lenbuf = [0u8; 2];
let mut offset: u64 = 0;
let torn = |offset: u64, what: &str| {
eprintln!(
"[{}] label journal: torn record at byte offset {offset} ({what}); \
keeping the {} bytes before it and stopping",
chrono::Local::now().format("%H:%M:%S"),
offset
);
};
loop {
let n = read_full(&mut reader, &mut qbuf)?;
if n == 0 {
break; }
if n < qbuf.len() {
torn(offset, "partial qnum header");
break;
}
let n = read_full(&mut reader, &mut lenbuf)?;
if n < lenbuf.len() {
torn(offset, "partial length header");
break;
}
let qnum = u32::from_le_bytes(qbuf);
let len = u16::from_le_bytes(lenbuf) as usize;
if offset + HEADER_BYTES as u64 + len as u64 > file_len {
torn(offset, "payload extends past end of file");
break;
}
if wanted.contains(&qnum) && len > 0 {
let mut bytes = vec![0u8; len];
reader.read_exact(&mut bytes)?;
result.insert(qnum, String::from_utf8_lossy(&bytes).into_owned());
} else {
reader.seek(SeekFrom::Current(len as i64))?;
}
offset += HEADER_BYTES as u64 + len as u64;
}
Ok(result)
}
fn read_full(r: &mut impl Read, buf: &mut [u8]) -> std::io::Result<usize> {
let mut filled = 0;
while filled < buf.len() {
match r.read(&mut buf[filled..]) {
Ok(0) => break,
Ok(n) => filled += n,
Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Err(e) => return Err(e),
}
}
Ok(filled)
}
#[allow(dead_code)]
pub const fn record_overhead_bytes() -> usize {
HEADER_BYTES
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp_path() -> std::path::PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!("kglite_label_spill_{}_{}.bin", nanos, seq))
}
#[test]
fn write_then_read_wanted_subset() {
let path = tmp_path();
let mut w = LabelSpillWriter::new(&path).unwrap();
w.append(5, "human").unwrap();
w.append(76, "Barack Obama").unwrap();
w.append(20, "Norway").unwrap();
w.append(42, "Douglas Adams").unwrap();
let size = w.finish().unwrap();
assert!(size > 0);
let wanted: HashSet<u32> = [5, 20].into_iter().collect();
let got = read_labels_for(&path, &wanted).unwrap();
assert_eq!(got.len(), 2);
assert_eq!(got.get(&5).unwrap(), "human");
assert_eq!(got.get(&20).unwrap(), "Norway");
assert!(!got.contains_key(&76));
assert!(!got.contains_key(&42));
let _ = std::fs::remove_file(path);
}
#[test]
fn last_write_wins_per_qnum() {
let path = tmp_path();
let mut w = LabelSpillWriter::new(&path).unwrap();
w.append(5, "first").unwrap();
w.append(5, "second").unwrap();
w.finish().unwrap();
let wanted: HashSet<u32> = [5].into_iter().collect();
let got = read_labels_for(&path, &wanted).unwrap();
assert_eq!(got.get(&5).unwrap(), "second");
let _ = std::fs::remove_file(path);
}
#[test]
fn empty_wanted_set_skips_all() {
let path = tmp_path();
let mut w = LabelSpillWriter::new(&path).unwrap();
for i in 0..1000 {
w.append(i, "label").unwrap();
}
w.finish().unwrap();
let wanted = HashSet::new();
let got = read_labels_for(&path, &wanted).unwrap();
assert!(got.is_empty());
let _ = std::fs::remove_file(path);
}
#[test]
fn empty_journal_reads_empty() {
let path = tmp_path();
LabelSpillWriter::new(&path).unwrap().finish().unwrap();
let wanted: HashSet<u32> = [1, 2, 3].into_iter().collect();
let got = read_labels_for(&path, &wanted).unwrap();
assert!(got.is_empty());
let _ = std::fs::remove_file(path);
}
#[test]
fn oversized_label_truncates_at_char_boundary() {
let path = tmp_path();
let big: String = "\u{10348}".repeat(20_000); let mut w = LabelSpillWriter::new(&path).unwrap();
w.append(1, &big).unwrap();
w.append(2, "after").unwrap();
w.finish().unwrap();
let wanted: HashSet<u32> = [1, 2].into_iter().collect();
let got = read_labels_for(&path, &wanted).unwrap();
let label = got.get(&1).unwrap();
assert!(label.len() <= u16::MAX as usize);
assert_eq!(label.len() % 4, 0, "cut must land on a 4-byte boundary");
assert!(
!label.contains('\u{FFFD}'),
"char-boundary truncation must survive the lossy decode intact"
);
assert!(big.starts_with(label.as_str()));
assert_eq!(got.get(&2).unwrap(), "after");
let _ = std::fs::remove_file(path);
}
#[test]
fn torn_trailing_header_stops_cleanly() {
let path = tmp_path();
let mut w = LabelSpillWriter::new(&path).unwrap();
w.append(5, "human").unwrap();
w.append(20, "Norway").unwrap();
w.finish().unwrap();
{
use std::io::Write;
let mut f = std::fs::OpenOptions::new()
.append(true)
.open(&path)
.unwrap();
f.write_all(&[0xAA, 0xBB, 0xCC]).unwrap();
}
let wanted: HashSet<u32> = [5, 20].into_iter().collect();
let got = read_labels_for(&path, &wanted).unwrap();
assert_eq!(got.len(), 2);
assert_eq!(got.get(&5).unwrap(), "human");
assert_eq!(got.get(&20).unwrap(), "Norway");
let _ = std::fs::remove_file(path);
}
#[test]
fn torn_payload_stops_cleanly_even_when_skipped() {
let path = tmp_path();
let mut w = LabelSpillWriter::new(&path).unwrap();
w.append(5, "human").unwrap();
w.finish().unwrap();
{
use std::io::Write;
let mut f = std::fs::OpenOptions::new()
.append(true)
.open(&path)
.unwrap();
f.write_all(&9u32.to_le_bytes()).unwrap();
f.write_all(&100u16.to_le_bytes()).unwrap();
f.write_all(b"oops").unwrap();
}
for wanted_set in [vec![5u32, 9], vec![5u32]] {
let wanted: HashSet<u32> = wanted_set.into_iter().collect();
let got = read_labels_for(&path, &wanted).unwrap();
assert_eq!(got.len(), 1, "only the intact record survives");
assert_eq!(got.get(&5).unwrap(), "human");
}
let _ = std::fs::remove_file(path);
}
#[test]
fn zero_length_labels_handled() {
let path = tmp_path();
let mut w = LabelSpillWriter::new(&path).unwrap();
w.append(1, "").unwrap();
w.append(2, "real").unwrap();
w.finish().unwrap();
let wanted: HashSet<u32> = [1, 2].into_iter().collect();
let got = read_labels_for(&path, &wanted).unwrap();
assert!(!got.contains_key(&1));
assert_eq!(got.get(&2).unwrap(), "real");
let _ = std::fs::remove_file(path);
}
}