use std::collections::HashMap;
use std::fs::{self, File, OpenOptions};
use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use parking_lot::Mutex;
use thiserror::Error;
#[derive(Debug, Error, Eq, PartialEq)]
pub enum HintStoreError {
#[error("hint store over capacity ({max_bytes} bytes)")]
OverCapacity {
max_bytes: u64,
},
#[error("hint TTL must be greater than zero")]
ZeroTtl,
#[error("hint payload is empty")]
EmptyPayload,
#[error("hint segment write failed: {message}")]
Io {
message: String,
},
}
#[derive(Debug, Error)]
pub enum HintStoreOpenError {
#[error("hint segment directory {path}: {source}")]
Dir {
path: PathBuf,
source: io::Error,
},
#[error("hint segment {path}: {source}")]
Segment {
path: PathBuf,
source: io::Error,
},
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Hint {
pub peer_idx: u32,
pub payload: Vec<u8>,
pub deadline: Instant,
}
impl Hint {
#[must_use]
fn weight(&self) -> u64 {
u64::try_from(self.payload.len()).unwrap_or(u64::MAX)
}
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct HintStoreStats {
pub hint_count: usize,
pub bytes: u64,
pub max_bytes: u64,
pub expired_total: u64,
pub rejected_over_capacity_total: u64,
}
#[derive(Debug)]
pub struct HintStore {
inner: Mutex<Inner>,
}
#[derive(Debug)]
struct Inner {
by_peer: HashMap<u32, Vec<Hint>>,
bytes: u64,
max_bytes: u64,
expired_total: u64,
rejected_over_capacity_total: u64,
disk: Option<DiskBackend>,
}
impl HintStore {
#[must_use]
pub fn new(max_bytes: u64) -> Self {
Self {
inner: Mutex::new(Inner {
by_peer: HashMap::new(),
bytes: 0,
max_bytes,
expired_total: 0,
rejected_over_capacity_total: 0,
disk: None,
}),
}
}
pub fn open<P: AsRef<Path>>(dir: P, max_bytes: u64) -> Result<Self, HintStoreOpenError> {
let dir = dir.as_ref().to_path_buf();
fs::create_dir_all(&dir).map_err(|source| HintStoreOpenError::Dir {
path: dir.clone(),
source,
})?;
let backend = DiskBackend { dir };
let by_peer = backend.replay()?;
let mut bytes: u64 = 0;
for queue in by_peer.values() {
for h in queue {
bytes = bytes.saturating_add(h.weight());
}
}
Ok(Self {
inner: Mutex::new(Inner {
by_peer,
bytes,
max_bytes,
expired_total: 0,
rejected_over_capacity_total: 0,
disk: Some(backend),
}),
})
}
pub fn enqueue(
&self,
peer_idx: u32,
payload: Vec<u8>,
ttl: Duration,
) -> Result<(), HintStoreError> {
if ttl.is_zero() {
return Err(HintStoreError::ZeroTtl);
}
if payload.is_empty() {
return Err(HintStoreError::EmptyPayload);
}
let weight = u64::try_from(payload.len()).unwrap_or(u64::MAX);
let mut inner = self.inner.lock();
if inner.max_bytes > 0 && inner.bytes.saturating_add(weight) > inner.max_bytes {
inner.rejected_over_capacity_total =
inner.rejected_over_capacity_total.saturating_add(1);
return Err(HintStoreError::OverCapacity {
max_bytes: inner.max_bytes,
});
}
let deadline = Instant::now() + ttl;
let wall_deadline = SystemTime::now() + ttl;
if let Some(disk) = inner.disk.as_ref() {
disk.append(peer_idx, wall_deadline, &payload)
.map_err(|e| HintStoreError::Io {
message: e.to_string(),
})?;
}
inner.by_peer.entry(peer_idx).or_default().push(Hint {
peer_idx,
payload,
deadline,
});
inner.bytes = inner.bytes.saturating_add(weight);
Ok(())
}
pub fn take_for(&self, peer_idx: u32) -> Vec<Hint> {
let now = Instant::now();
let mut inner = self.inner.lock();
let Some(queue) = inner.by_peer.remove(&peer_idx) else {
return Vec::new();
};
let mut out = Vec::with_capacity(queue.len());
for h in queue {
if h.deadline <= now {
let w = h.weight();
inner.bytes = inner.bytes.saturating_sub(w);
inner.expired_total = inner.expired_total.saturating_add(1);
continue;
}
inner.bytes = inner.bytes.saturating_sub(h.weight());
out.push(h);
}
if let Some(disk) = inner.disk.as_ref() {
if let Err(e) = disk.remove_peer(peer_idx) {
tracing::warn!(
target: "dynomite::cluster::hints",
peer_idx,
error = %e,
"failed to remove drained hint segment"
);
}
}
out
}
pub fn expire_now(&self, now: Instant) -> usize {
let mut inner = self.inner.lock();
let mut dropped = 0usize;
let mut empty_keys: Vec<u32> = Vec::new();
let mut touched: Vec<u32> = Vec::new();
for (k, queue) in &mut inner.by_peer {
let before = queue.len();
queue.retain(|h| h.deadline > now);
let after = queue.len();
let removed = before - after;
if removed > 0 {
dropped += removed;
touched.push(*k);
if after == 0 {
empty_keys.push(*k);
}
}
}
let mut new_bytes: u64 = 0;
for queue in inner.by_peer.values() {
for h in queue {
new_bytes = new_bytes.saturating_add(h.weight());
}
}
inner.bytes = new_bytes;
inner.expired_total = inner.expired_total.saturating_add(dropped as u64);
if let Some(disk) = inner.disk.as_ref() {
let wall_now = SystemTime::now();
let inst_now = Instant::now();
for k in &touched {
let records: Vec<(SystemTime, Vec<u8>)> = inner
.by_peer
.get(k)
.map(|q| {
q.iter()
.map(|h| {
(
deadline_to_wall(h.deadline, inst_now, wall_now),
h.payload.clone(),
)
})
.collect()
})
.unwrap_or_default();
let result = if records.is_empty() {
disk.remove_peer(*k)
} else {
disk.rewrite_peer(*k, &records)
};
if let Err(e) = result {
tracing::warn!(
target: "dynomite::cluster::hints",
peer_idx = *k,
error = %e,
"failed to compact hint segment during expiry"
);
}
}
}
for k in empty_keys {
inner.by_peer.remove(&k);
}
dropped
}
#[must_use]
pub fn total_len(&self) -> usize {
let inner = self.inner.lock();
inner.by_peer.values().map(Vec::len).sum()
}
#[must_use]
pub fn len_for(&self, peer_idx: u32) -> usize {
let inner = self.inner.lock();
inner.by_peer.get(&peer_idx).map_or(0, Vec::len)
}
#[must_use]
pub fn stats(&self) -> HintStoreStats {
let inner = self.inner.lock();
HintStoreStats {
hint_count: inner.by_peer.values().map(Vec::len).sum(),
bytes: inner.bytes,
max_bytes: inner.max_bytes,
expired_total: inner.expired_total,
rejected_over_capacity_total: inner.rejected_over_capacity_total,
}
}
#[must_use]
pub fn peers_with_hints(&self) -> Vec<u32> {
let inner = self.inner.lock();
inner
.by_peer
.iter()
.filter_map(|(k, v)| if v.is_empty() { None } else { Some(*k) })
.collect()
}
}
fn deadline_to_wall(deadline: Instant, inst_now: Instant, wall_now: SystemTime) -> SystemTime {
let remaining = deadline.saturating_duration_since(inst_now);
wall_now + remaining
}
#[derive(Debug)]
struct DiskBackend {
dir: PathBuf,
}
impl DiskBackend {
fn segment_path(&self, peer_idx: u32) -> PathBuf {
self.dir.join(format!("peer-{peer_idx}.hints"))
}
fn append(&self, peer_idx: u32, deadline: SystemTime, payload: &[u8]) -> io::Result<()> {
let mut f = OpenOptions::new()
.create(true)
.append(true)
.open(self.segment_path(peer_idx))?;
let frame = encode_record(deadline, payload);
f.write_all(&frame)?;
f.flush()?;
Ok(())
}
fn rewrite_peer(&self, peer_idx: u32, records: &[(SystemTime, Vec<u8>)]) -> io::Result<()> {
let final_path = self.segment_path(peer_idx);
let tmp_path = self.dir.join(format!("peer-{peer_idx}.hints.tmp"));
{
let mut f = File::create(&tmp_path)?;
for (deadline, payload) in records {
f.write_all(&encode_record(*deadline, payload))?;
}
f.flush()?;
}
fs::rename(&tmp_path, &final_path)
}
fn remove_peer(&self, peer_idx: u32) -> io::Result<()> {
match fs::remove_file(self.segment_path(peer_idx)) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
}
}
fn replay(&self) -> Result<HashMap<u32, Vec<Hint>>, HintStoreOpenError> {
let mut out: HashMap<u32, Vec<Hint>> = HashMap::new();
let entries = fs::read_dir(&self.dir).map_err(|source| HintStoreOpenError::Dir {
path: self.dir.clone(),
source,
})?;
let wall_now = SystemTime::now();
let inst_now = Instant::now();
for entry in entries {
let entry = entry.map_err(|source| HintStoreOpenError::Dir {
path: self.dir.clone(),
source,
})?;
let path = entry.path();
let Some(peer_idx) = parse_segment_name(&path) else {
continue;
};
let mut bytes = Vec::new();
File::open(&path)
.and_then(|mut f| f.read_to_end(&mut bytes))
.map_err(|source| HintStoreOpenError::Segment {
path: path.clone(),
source,
})?;
let queue = out.entry(peer_idx).or_default();
for (deadline, payload) in decode_records(&bytes) {
let Ok(remaining) = deadline.duration_since(wall_now) else {
continue;
};
queue.push(Hint {
peer_idx,
payload,
deadline: inst_now + remaining,
});
}
if queue.is_empty() {
out.remove(&peer_idx);
}
}
Ok(out)
}
}
fn parse_segment_name(path: &Path) -> Option<u32> {
let name = path.file_name()?.to_str()?;
let idx = name.strip_prefix("peer-")?.strip_suffix(".hints")?;
idx.parse::<u32>().ok()
}
fn encode_record(deadline: SystemTime, payload: &[u8]) -> Vec<u8> {
let millis = deadline
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_millis());
let millis = u64::try_from(millis).unwrap_or(u64::MAX);
let body_len = 8 + payload.len();
let mut body = Vec::with_capacity(body_len);
body.extend_from_slice(&millis.to_le_bytes());
body.extend_from_slice(payload);
let crc = crc32(&body);
let mut frame = Vec::with_capacity(8 + body.len());
frame.extend_from_slice(&(u32::try_from(body.len()).unwrap_or(u32::MAX)).to_le_bytes());
frame.extend_from_slice(&crc.to_le_bytes());
frame.extend_from_slice(&body);
frame
}
fn decode_records(bytes: &[u8]) -> Vec<(SystemTime, Vec<u8>)> {
let mut out = Vec::new();
let mut off = 0usize;
while off + 8 <= bytes.len() {
let len = u32::from_le_bytes([bytes[off], bytes[off + 1], bytes[off + 2], bytes[off + 3]]);
let len = len as usize;
let crc = u32::from_le_bytes([
bytes[off + 4],
bytes[off + 5],
bytes[off + 6],
bytes[off + 7],
]);
let body_start = off + 8;
let Some(body_end) = body_start.checked_add(len) else {
break;
};
if body_end > bytes.len() {
break;
}
let body = &bytes[body_start..body_end];
if len < 8 || crc32(body) != crc {
break;
}
let millis = u64::from_le_bytes([
body[0], body[1], body[2], body[3], body[4], body[5], body[6], body[7],
]);
let deadline = UNIX_EPOCH + Duration::from_millis(millis);
out.push((deadline, body[8..].to_vec()));
off = body_end;
}
out
}
fn crc32(data: &[u8]) -> u32 {
let mut crc: u32 = 0xFFFF_FFFF;
for &b in data {
crc ^= u32::from(b);
for _ in 0..8 {
let mask = (crc & 1).wrapping_neg();
crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
}
}
!crc
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt as _;
fn payload(b: u8, n: usize) -> Vec<u8> {
vec![b; n]
}
#[test]
fn enqueue_and_take_round_trip() {
let store = HintStore::new(1024);
store
.enqueue(3, payload(b'a', 4), Duration::from_mins(1))
.unwrap();
store
.enqueue(3, payload(b'b', 4), Duration::from_mins(1))
.unwrap();
store
.enqueue(7, payload(b'c', 4), Duration::from_mins(1))
.unwrap();
assert_eq!(store.total_len(), 3);
let drained = store.take_for(3);
assert_eq!(drained.len(), 2);
assert_eq!(drained[0].payload, payload(b'a', 4));
assert_eq!(drained[1].payload, payload(b'b', 4));
assert_eq!(store.len_for(3), 0);
assert_eq!(store.len_for(7), 1);
assert_eq!(store.total_len(), 1);
}
#[test]
fn enqueue_rejects_over_capacity() {
let store = HintStore::new(8);
store
.enqueue(0, payload(b'x', 6), Duration::from_mins(1))
.unwrap();
let err = store
.enqueue(0, payload(b'y', 4), Duration::from_mins(1))
.unwrap_err();
assert_eq!(err, HintStoreError::OverCapacity { max_bytes: 8 });
assert_eq!(store.stats().bytes, 6);
assert_eq!(store.stats().rejected_over_capacity_total, 1);
let drained = store.take_for(0);
assert_eq!(drained.len(), 1);
store
.enqueue(0, payload(b'y', 4), Duration::from_mins(1))
.unwrap();
}
#[test]
fn expire_now_drops_old_hints() {
let store = HintStore::new(64);
store
.enqueue(1, payload(b'a', 3), Duration::from_millis(1))
.unwrap();
store
.enqueue(1, payload(b'b', 3), Duration::from_mins(1))
.unwrap();
std::thread::sleep(Duration::from_millis(5));
let now = Instant::now();
let dropped = store.expire_now(now);
assert_eq!(dropped, 1);
assert_eq!(store.len_for(1), 1);
let stats = store.stats();
assert_eq!(stats.expired_total, 1);
assert_eq!(stats.bytes, 3);
let drained = store.take_for(1);
assert_eq!(drained[0].payload, payload(b'b', 3));
}
#[test]
fn take_for_skips_already_expired() {
let store = HintStore::new(64);
store
.enqueue(2, payload(b'a', 3), Duration::from_millis(1))
.unwrap();
store
.enqueue(2, payload(b'b', 3), Duration::from_mins(1))
.unwrap();
std::thread::sleep(Duration::from_millis(5));
let drained = store.take_for(2);
assert_eq!(drained.len(), 1);
assert_eq!(drained[0].payload, payload(b'b', 3));
assert_eq!(store.stats().expired_total, 1);
}
#[test]
fn enqueue_rejects_zero_ttl_and_empty_payload() {
let store = HintStore::new(64);
let err = store
.enqueue(0, payload(b'x', 1), Duration::from_secs(0))
.unwrap_err();
assert_eq!(err, HintStoreError::ZeroTtl);
let err = store
.enqueue(0, Vec::new(), Duration::from_mins(1))
.unwrap_err();
assert_eq!(err, HintStoreError::EmptyPayload);
assert_eq!(store.total_len(), 0);
}
#[test]
fn mixed_peer_queues_are_independent() {
let store = HintStore::new(0); store
.enqueue(0, payload(b'a', 1), Duration::from_mins(1))
.unwrap();
store
.enqueue(1, payload(b'b', 1), Duration::from_mins(1))
.unwrap();
store
.enqueue(2, payload(b'c', 1), Duration::from_mins(1))
.unwrap();
assert_eq!(store.total_len(), 3);
let mut peers = store.peers_with_hints();
peers.sort_unstable();
assert_eq!(peers, vec![0, 1, 2]);
let drained = store.take_for(1);
assert_eq!(drained.len(), 1);
assert_eq!(drained[0].payload, payload(b'b', 1));
assert_eq!(store.len_for(0), 1);
assert_eq!(store.len_for(1), 0);
assert_eq!(store.len_for(2), 1);
}
#[test]
fn empty_max_bytes_means_unbounded() {
let store = HintStore::new(0);
for _ in 0..1024 {
store
.enqueue(0, payload(b'x', 1024), Duration::from_mins(1))
.unwrap();
}
assert_eq!(store.total_len(), 1024);
}
#[test]
fn expire_now_no_op_when_nothing_old() {
let store = HintStore::new(64);
store
.enqueue(0, payload(b'x', 3), Duration::from_mins(1))
.unwrap();
let dropped = store.expire_now(Instant::now());
assert_eq!(dropped, 0);
assert_eq!(store.total_len(), 1);
}
#[test]
fn stats_track_capacity_and_bytes() {
let store = HintStore::new(1024);
store
.enqueue(0, payload(b'x', 100), Duration::from_mins(1))
.unwrap();
let s = store.stats();
assert_eq!(s.hint_count, 1);
assert_eq!(s.bytes, 100);
assert_eq!(s.max_bytes, 1024);
}
fn scratch_dir() -> tempfile::TempDir {
tempfile::Builder::new()
.prefix("hints-")
.tempdir_in("/scratch")
.expect("create scratch tempdir")
}
#[test]
fn ram_only_store_never_touches_disk() {
let dir = scratch_dir();
let store = HintStore::new(1024);
store
.enqueue(0, payload(b'a', 4), Duration::from_mins(1))
.unwrap();
store.take_for(0);
store.expire_now(Instant::now());
let count = std::fs::read_dir(dir.path()).unwrap().count();
assert_eq!(count, 0, "new() must not create segment files");
}
#[test]
fn durable_round_trip_survives_reopen() {
let dir = scratch_dir();
{
let store = HintStore::open(dir.path(), 1024).unwrap();
store
.enqueue(3, payload(b'a', 4), Duration::from_mins(10))
.unwrap();
store
.enqueue(3, payload(b'b', 5), Duration::from_mins(10))
.unwrap();
store
.enqueue(7, payload(b'c', 6), Duration::from_mins(10))
.unwrap();
}
let reopened = HintStore::open(dir.path(), 1024).unwrap();
assert_eq!(reopened.total_len(), 3);
let p3 = reopened.take_for(3);
assert_eq!(p3.len(), 2);
assert_eq!(p3[0].payload, payload(b'a', 4));
assert_eq!(p3[1].payload, payload(b'b', 5));
let p7 = reopened.take_for(7);
assert_eq!(p7.len(), 1);
assert_eq!(p7[0].payload, payload(b'c', 6));
assert_eq!(reopened.stats().bytes, 0);
}
#[test]
fn torn_tail_recovers_intact_records() {
use std::io::Write;
let dir = scratch_dir();
{
let store = HintStore::open(dir.path(), 1024).unwrap();
store
.enqueue(2, payload(b'a', 8), Duration::from_mins(10))
.unwrap();
}
let seg = dir.path().join("peer-2.hints");
let mut f = std::fs::OpenOptions::new().append(true).open(&seg).unwrap();
f.write_all(&100u32.to_le_bytes()).unwrap();
f.write_all(&0u32.to_le_bytes()).unwrap();
f.write_all(&[0xAB, 0xCD, 0xEF]).unwrap();
f.flush().unwrap();
drop(f);
let reopened = HintStore::open(dir.path(), 1024).unwrap();
let p2 = reopened.take_for(2);
assert_eq!(p2.len(), 1);
assert_eq!(p2[0].payload, payload(b'a', 8));
}
#[test]
fn torn_body_with_bad_crc_is_discarded() {
use std::io::Write;
let dir = scratch_dir();
{
let store = HintStore::open(dir.path(), 1024).unwrap();
store
.enqueue(5, payload(b'z', 4), Duration::from_mins(10))
.unwrap();
}
let seg = dir.path().join("peer-5.hints");
let mut f = std::fs::OpenOptions::new().append(true).open(&seg).unwrap();
let body = [0u8; 12]; let body_len = u32::try_from(body.len()).unwrap();
f.write_all(&body_len.to_le_bytes()).unwrap();
f.write_all(&0xDEAD_BEEFu32.to_le_bytes()).unwrap(); f.write_all(&body).unwrap();
f.flush().unwrap();
drop(f);
let reopened = HintStore::open(dir.path(), 1024).unwrap();
let p5 = reopened.take_for(5);
assert_eq!(p5.len(), 1, "only the intact record survives");
assert_eq!(p5[0].payload, payload(b'z', 4));
}
#[test]
fn take_for_clears_disk_segment() {
let dir = scratch_dir();
let store = HintStore::open(dir.path(), 1024).unwrap();
store
.enqueue(4, payload(b'a', 4), Duration::from_mins(10))
.unwrap();
assert!(dir.path().join("peer-4.hints").exists());
let drained = store.take_for(4);
assert_eq!(drained.len(), 1);
assert!(
!dir.path().join("peer-4.hints").exists(),
"take_for must remove the drained segment"
);
drop(store);
let reopened = HintStore::open(dir.path(), 1024).unwrap();
assert_eq!(reopened.len_for(4), 0);
}
#[test]
fn expire_now_compacts_disk_segment() {
let dir = scratch_dir();
let store = HintStore::open(dir.path(), 1024).unwrap();
store
.enqueue(1, payload(b'a', 3), Duration::from_millis(1))
.unwrap();
store
.enqueue(1, payload(b'b', 3), Duration::from_mins(10))
.unwrap();
let seg = dir.path().join("peer-1.hints");
let big = std::fs::metadata(&seg).unwrap().len();
std::thread::sleep(Duration::from_millis(5));
let dropped = store.expire_now(Instant::now());
assert_eq!(dropped, 1);
let small = std::fs::metadata(&seg).unwrap().len();
assert!(small < big, "expiry must compact the segment");
drop(store);
let reopened = HintStore::open(dir.path(), 1024).unwrap();
let p1 = reopened.take_for(1);
assert_eq!(p1.len(), 1);
assert_eq!(p1[0].payload, payload(b'b', 3));
}
#[test]
fn expire_now_to_empty_removes_segment() {
let dir = scratch_dir();
let store = HintStore::open(dir.path(), 1024).unwrap();
store
.enqueue(9, payload(b'a', 3), Duration::from_millis(1))
.unwrap();
assert!(dir.path().join("peer-9.hints").exists());
std::thread::sleep(Duration::from_millis(5));
assert_eq!(store.expire_now(Instant::now()), 1);
assert!(
!dir.path().join("peer-9.hints").exists(),
"a fully-expired peer's segment must be removed"
);
}
#[test]
fn capacity_enforced_across_replayed_bytes() {
let dir = scratch_dir();
{
let store = HintStore::open(dir.path(), 8).unwrap();
store
.enqueue(0, payload(b'x', 6), Duration::from_mins(10))
.unwrap();
}
let reopened = HintStore::open(dir.path(), 8).unwrap();
assert_eq!(reopened.stats().bytes, 6);
let err = reopened
.enqueue(0, payload(b'y', 4), Duration::from_mins(10))
.unwrap_err();
assert_eq!(err, HintStoreError::OverCapacity { max_bytes: 8 });
}
#[test]
fn replay_drops_expired_records() {
let dir = scratch_dir();
{
let store = HintStore::open(dir.path(), 1024).unwrap();
store
.enqueue(0, payload(b'a', 4), Duration::from_millis(1))
.unwrap();
store
.enqueue(0, payload(b'b', 4), Duration::from_mins(10))
.unwrap();
}
std::thread::sleep(Duration::from_millis(5));
let reopened = HintStore::open(dir.path(), 1024).unwrap();
let p0 = reopened.take_for(0);
assert_eq!(p0.len(), 1, "the expired record is not replayed");
assert_eq!(p0[0].payload, payload(b'b', 4));
}
#[test]
fn record_codec_round_trip() {
let now = SystemTime::now() + Duration::from_secs(100);
let frame = encode_record(now, b"hello");
let recs = decode_records(&frame);
assert_eq!(recs.len(), 1);
assert_eq!(recs[0].1, b"hello");
let mut two = encode_record(now, b"one");
two.extend_from_slice(&encode_record(now, b"two"));
let recs = decode_records(&two);
assert_eq!(recs.len(), 2);
assert_eq!(recs[0].1, b"one");
assert_eq!(recs[1].1, b"two");
}
#[test]
fn crc32_known_vector() {
assert_eq!(crc32(b"123456789"), 0xCBF4_3926);
assert_eq!(crc32(b""), 0);
}
#[test]
fn open_fails_when_path_is_a_file() {
let dir = scratch_dir();
let file_path = dir.path().join("not-a-dir");
std::fs::write(&file_path, b"x").unwrap();
let err = HintStore::open(&file_path, 1024).expect_err("open on a file");
assert!(matches!(err, HintStoreOpenError::Dir { .. }), "got {err:?}");
}
fn running_as_root() -> bool {
nix::unistd::Uid::effective().is_root()
}
#[test]
fn enqueue_surfaces_disk_append_error_on_readonly_dir() {
if running_as_root() {
return; }
let dir = scratch_dir();
let store = HintStore::open(dir.path(), 1024).unwrap();
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o500)).unwrap();
let err = store
.enqueue(5, payload(b'a', 4), Duration::from_mins(10))
.expect_err("append into read-only dir must fail");
assert!(matches!(err, HintStoreError::Io { .. }), "got {err:?}");
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
}
#[test]
fn take_for_tolerates_segment_remove_failure() {
if running_as_root() {
return;
}
let dir = scratch_dir();
let store = HintStore::open(dir.path(), 1024).unwrap();
store
.enqueue(8, payload(b'a', 4), Duration::from_mins(10))
.unwrap();
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o500)).unwrap();
let drained = store.take_for(8);
assert_eq!(drained.len(), 1);
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
}
#[test]
fn expire_now_tolerates_compaction_failure() {
if running_as_root() {
return;
}
let dir = scratch_dir();
let store = HintStore::open(dir.path(), 1024).unwrap();
store
.enqueue(6, payload(b'a', 4), Duration::from_millis(1))
.unwrap();
store
.enqueue(6, payload(b'b', 4), Duration::from_mins(10))
.unwrap();
std::thread::sleep(Duration::from_millis(5));
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o500)).unwrap();
let dropped = store.expire_now(Instant::now());
assert_eq!(dropped, 1);
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
}
#[test]
fn replay_surfaces_unreadable_segment_error() {
if running_as_root() {
return;
}
let dir = scratch_dir();
let seg = dir.path().join("peer-1.hints");
std::fs::write(&seg, b"").unwrap();
std::fs::set_permissions(&seg, std::fs::Permissions::from_mode(0o000)).unwrap();
let err = HintStore::open(dir.path(), 1024).expect_err("unreadable segment");
assert!(
matches!(err, HintStoreOpenError::Segment { .. }),
"got {err:?}"
);
std::fs::set_permissions(&seg, std::fs::Permissions::from_mode(0o600)).unwrap();
}
#[test]
fn take_for_unknown_peer_is_empty() {
let store = HintStore::new(1024);
assert!(store.take_for(123).is_empty());
}
#[test]
fn take_for_twice_is_idempotent_on_disk() {
let dir = scratch_dir();
let store = HintStore::open(dir.path(), 1024).unwrap();
store
.enqueue(7, payload(b'a', 4), Duration::from_mins(10))
.unwrap();
assert_eq!(store.take_for(7).len(), 1);
assert!(store.take_for(7).is_empty());
assert!(!dir.path().join("peer-7.hints").exists());
}
#[test]
fn replay_ignores_stray_non_segment_files() {
let dir = scratch_dir();
{
let store = HintStore::open(dir.path(), 1024).unwrap();
store
.enqueue(2, payload(b'a', 4), Duration::from_mins(10))
.unwrap();
}
std::fs::write(dir.path().join("peer-2.hints.tmp"), b"junk").unwrap();
std::fs::write(dir.path().join("README"), b"not a segment").unwrap();
let reopened = HintStore::open(dir.path(), 1024).unwrap();
assert_eq!(reopened.peers_with_hints(), vec![2]);
assert_eq!(reopened.len_for(2), 1);
}
#[test]
fn replay_drops_peer_whose_records_all_expired() {
let dir = scratch_dir();
{
let store = HintStore::open(dir.path(), 1024).unwrap();
store
.enqueue(3, payload(b'a', 4), Duration::from_millis(1))
.unwrap();
store
.enqueue(3, payload(b'b', 4), Duration::from_millis(1))
.unwrap();
}
std::thread::sleep(Duration::from_millis(5));
let reopened = HintStore::open(dir.path(), 1024).unwrap();
assert!(
reopened.peers_with_hints().is_empty(),
"a peer with only expired records must not be replayed"
);
assert_eq!(reopened.total_len(), 0);
}
}