1use std::collections::HashMap;
75use std::fs::{self, File, OpenOptions};
76use std::io::{self, Read, Write};
77use std::path::{Path, PathBuf};
78use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
79
80use parking_lot::Mutex;
81use thiserror::Error;
82
83#[derive(Debug, Error, Eq, PartialEq)]
85pub enum HintStoreError {
86 #[error("hint store over capacity ({max_bytes} bytes)")]
92 OverCapacity {
93 max_bytes: u64,
95 },
96 #[error("hint TTL must be greater than zero")]
100 ZeroTtl,
101 #[error("hint payload is empty")]
105 EmptyPayload,
106 #[error("hint segment write failed: {message}")]
111 Io {
112 message: String,
114 },
115}
116
117#[derive(Debug, Error)]
119pub enum HintStoreOpenError {
120 #[error("hint segment directory {path}: {source}")]
122 Dir {
123 path: PathBuf,
125 source: io::Error,
127 },
128 #[error("hint segment {path}: {source}")]
130 Segment {
131 path: PathBuf,
133 source: io::Error,
135 },
136}
137
138#[derive(Clone, Debug, Eq, PartialEq)]
140pub struct Hint {
141 pub peer_idx: u32,
144 pub payload: Vec<u8>,
146 pub deadline: Instant,
148}
149
150impl Hint {
151 #[must_use]
155 fn weight(&self) -> u64 {
156 u64::try_from(self.payload.len()).unwrap_or(u64::MAX)
157 }
158}
159
160#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
162pub struct HintStoreStats {
163 pub hint_count: usize,
165 pub bytes: u64,
167 pub max_bytes: u64,
169 pub expired_total: u64,
172 pub rejected_over_capacity_total: u64,
175}
176
177#[derive(Debug)]
184pub struct HintStore {
185 inner: Mutex<Inner>,
186}
187
188#[derive(Debug)]
189struct Inner {
190 by_peer: HashMap<u32, Vec<Hint>>,
195 bytes: u64,
196 max_bytes: u64,
197 expired_total: u64,
198 rejected_over_capacity_total: u64,
199 disk: Option<DiskBackend>,
202}
203
204impl HintStore {
205 #[must_use]
211 pub fn new(max_bytes: u64) -> Self {
212 Self {
213 inner: Mutex::new(Inner {
214 by_peer: HashMap::new(),
215 bytes: 0,
216 max_bytes,
217 expired_total: 0,
218 rejected_over_capacity_total: 0,
219 disk: None,
220 }),
221 }
222 }
223
224 pub fn open<P: AsRef<Path>>(dir: P, max_bytes: u64) -> Result<Self, HintStoreOpenError> {
258 let dir = dir.as_ref().to_path_buf();
259 fs::create_dir_all(&dir).map_err(|source| HintStoreOpenError::Dir {
260 path: dir.clone(),
261 source,
262 })?;
263 let backend = DiskBackend { dir };
264 let by_peer = backend.replay()?;
265 let mut bytes: u64 = 0;
266 for queue in by_peer.values() {
267 for h in queue {
268 bytes = bytes.saturating_add(h.weight());
269 }
270 }
271 Ok(Self {
272 inner: Mutex::new(Inner {
273 by_peer,
274 bytes,
275 max_bytes,
276 expired_total: 0,
277 rejected_over_capacity_total: 0,
278 disk: Some(backend),
279 }),
280 })
281 }
282
283 pub fn enqueue(
295 &self,
296 peer_idx: u32,
297 payload: Vec<u8>,
298 ttl: Duration,
299 ) -> Result<(), HintStoreError> {
300 if ttl.is_zero() {
301 return Err(HintStoreError::ZeroTtl);
302 }
303 if payload.is_empty() {
304 return Err(HintStoreError::EmptyPayload);
305 }
306 let weight = u64::try_from(payload.len()).unwrap_or(u64::MAX);
307 let mut inner = self.inner.lock();
308 if inner.max_bytes > 0 && inner.bytes.saturating_add(weight) > inner.max_bytes {
309 inner.rejected_over_capacity_total =
310 inner.rejected_over_capacity_total.saturating_add(1);
311 return Err(HintStoreError::OverCapacity {
312 max_bytes: inner.max_bytes,
313 });
314 }
315 let deadline = Instant::now() + ttl;
316 let wall_deadline = SystemTime::now() + ttl;
320 if let Some(disk) = inner.disk.as_ref() {
321 disk.append(peer_idx, wall_deadline, &payload)
322 .map_err(|e| HintStoreError::Io {
323 message: e.to_string(),
324 })?;
325 }
326 inner.by_peer.entry(peer_idx).or_default().push(Hint {
327 peer_idx,
328 payload,
329 deadline,
330 });
331 inner.bytes = inner.bytes.saturating_add(weight);
332 Ok(())
333 }
334
335 pub fn take_for(&self, peer_idx: u32) -> Vec<Hint> {
341 let now = Instant::now();
342 let mut inner = self.inner.lock();
343 let Some(queue) = inner.by_peer.remove(&peer_idx) else {
344 return Vec::new();
345 };
346 let mut out = Vec::with_capacity(queue.len());
347 for h in queue {
348 if h.deadline <= now {
349 let w = h.weight();
350 inner.bytes = inner.bytes.saturating_sub(w);
351 inner.expired_total = inner.expired_total.saturating_add(1);
352 continue;
353 }
354 inner.bytes = inner.bytes.saturating_sub(h.weight());
355 out.push(h);
356 }
357 if let Some(disk) = inner.disk.as_ref() {
364 if let Err(e) = disk.remove_peer(peer_idx) {
365 tracing::warn!(
366 target: "dynomite::cluster::hints",
367 peer_idx,
368 error = %e,
369 "failed to remove drained hint segment"
370 );
371 }
372 }
373 out
374 }
375
376 pub fn expire_now(&self, now: Instant) -> usize {
380 let mut inner = self.inner.lock();
381 let mut dropped = 0usize;
382 let mut empty_keys: Vec<u32> = Vec::new();
383 let mut touched: Vec<u32> = Vec::new();
386 for (k, queue) in &mut inner.by_peer {
387 let before = queue.len();
388 queue.retain(|h| h.deadline > now);
389 let after = queue.len();
390 let removed = before - after;
391 if removed > 0 {
392 dropped += removed;
393 touched.push(*k);
394 if after == 0 {
395 empty_keys.push(*k);
396 }
397 }
398 }
399 let mut new_bytes: u64 = 0;
405 for queue in inner.by_peer.values() {
406 for h in queue {
407 new_bytes = new_bytes.saturating_add(h.weight());
408 }
409 }
410 inner.bytes = new_bytes;
411 inner.expired_total = inner.expired_total.saturating_add(dropped as u64);
412 if let Some(disk) = inner.disk.as_ref() {
419 let wall_now = SystemTime::now();
420 let inst_now = Instant::now();
421 for k in &touched {
422 let records: Vec<(SystemTime, Vec<u8>)> = inner
423 .by_peer
424 .get(k)
425 .map(|q| {
426 q.iter()
427 .map(|h| {
428 (
429 deadline_to_wall(h.deadline, inst_now, wall_now),
430 h.payload.clone(),
431 )
432 })
433 .collect()
434 })
435 .unwrap_or_default();
436 let result = if records.is_empty() {
437 disk.remove_peer(*k)
438 } else {
439 disk.rewrite_peer(*k, &records)
440 };
441 if let Err(e) = result {
442 tracing::warn!(
443 target: "dynomite::cluster::hints",
444 peer_idx = *k,
445 error = %e,
446 "failed to compact hint segment during expiry"
447 );
448 }
449 }
450 }
451 for k in empty_keys {
452 inner.by_peer.remove(&k);
453 }
454 dropped
455 }
456
457 #[must_use]
459 pub fn total_len(&self) -> usize {
460 let inner = self.inner.lock();
461 inner.by_peer.values().map(Vec::len).sum()
462 }
463
464 #[must_use]
466 pub fn len_for(&self, peer_idx: u32) -> usize {
467 let inner = self.inner.lock();
468 inner.by_peer.get(&peer_idx).map_or(0, Vec::len)
469 }
470
471 #[must_use]
473 pub fn stats(&self) -> HintStoreStats {
474 let inner = self.inner.lock();
475 HintStoreStats {
476 hint_count: inner.by_peer.values().map(Vec::len).sum(),
477 bytes: inner.bytes,
478 max_bytes: inner.max_bytes,
479 expired_total: inner.expired_total,
480 rejected_over_capacity_total: inner.rejected_over_capacity_total,
481 }
482 }
483
484 #[must_use]
489 pub fn peers_with_hints(&self) -> Vec<u32> {
490 let inner = self.inner.lock();
491 inner
492 .by_peer
493 .iter()
494 .filter_map(|(k, v)| if v.is_empty() { None } else { Some(*k) })
495 .collect()
496 }
497}
498
499fn deadline_to_wall(deadline: Instant, inst_now: Instant, wall_now: SystemTime) -> SystemTime {
505 let remaining = deadline.saturating_duration_since(inst_now);
506 wall_now + remaining
507}
508
509#[derive(Debug)]
515struct DiskBackend {
516 dir: PathBuf,
517}
518
519impl DiskBackend {
520 fn segment_path(&self, peer_idx: u32) -> PathBuf {
521 self.dir.join(format!("peer-{peer_idx}.hints"))
522 }
523
524 fn append(&self, peer_idx: u32, deadline: SystemTime, payload: &[u8]) -> io::Result<()> {
528 let mut f = OpenOptions::new()
529 .create(true)
530 .append(true)
531 .open(self.segment_path(peer_idx))?;
532 let frame = encode_record(deadline, payload);
533 f.write_all(&frame)?;
534 f.flush()?;
535 Ok(())
536 }
537
538 fn rewrite_peer(&self, peer_idx: u32, records: &[(SystemTime, Vec<u8>)]) -> io::Result<()> {
542 let final_path = self.segment_path(peer_idx);
543 let tmp_path = self.dir.join(format!("peer-{peer_idx}.hints.tmp"));
544 {
545 let mut f = File::create(&tmp_path)?;
546 for (deadline, payload) in records {
547 f.write_all(&encode_record(*deadline, payload))?;
548 }
549 f.flush()?;
550 }
551 fs::rename(&tmp_path, &final_path)
552 }
553
554 fn remove_peer(&self, peer_idx: u32) -> io::Result<()> {
556 match fs::remove_file(self.segment_path(peer_idx)) {
557 Ok(()) => Ok(()),
558 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
559 Err(e) => Err(e),
560 }
561 }
562
563 fn replay(&self) -> Result<HashMap<u32, Vec<Hint>>, HintStoreOpenError> {
569 let mut out: HashMap<u32, Vec<Hint>> = HashMap::new();
570 let entries = fs::read_dir(&self.dir).map_err(|source| HintStoreOpenError::Dir {
571 path: self.dir.clone(),
572 source,
573 })?;
574 let wall_now = SystemTime::now();
575 let inst_now = Instant::now();
576 for entry in entries {
577 let entry = entry.map_err(|source| HintStoreOpenError::Dir {
578 path: self.dir.clone(),
579 source,
580 })?;
581 let path = entry.path();
582 let Some(peer_idx) = parse_segment_name(&path) else {
583 continue;
584 };
585 let mut bytes = Vec::new();
586 File::open(&path)
587 .and_then(|mut f| f.read_to_end(&mut bytes))
588 .map_err(|source| HintStoreOpenError::Segment {
589 path: path.clone(),
590 source,
591 })?;
592 let queue = out.entry(peer_idx).or_default();
593 for (deadline, payload) in decode_records(&bytes) {
594 let Ok(remaining) = deadline.duration_since(wall_now) else {
595 continue;
597 };
598 queue.push(Hint {
599 peer_idx,
600 payload,
601 deadline: inst_now + remaining,
602 });
603 }
604 if queue.is_empty() {
605 out.remove(&peer_idx);
606 }
607 }
608 Ok(out)
609 }
610}
611
612fn parse_segment_name(path: &Path) -> Option<u32> {
616 let name = path.file_name()?.to_str()?;
617 let idx = name.strip_prefix("peer-")?.strip_suffix(".hints")?;
618 idx.parse::<u32>().ok()
619}
620
621fn encode_record(deadline: SystemTime, payload: &[u8]) -> Vec<u8> {
625 let millis = deadline
626 .duration_since(UNIX_EPOCH)
627 .map_or(0, |d| d.as_millis());
628 let millis = u64::try_from(millis).unwrap_or(u64::MAX);
629 let body_len = 8 + payload.len();
630 let mut body = Vec::with_capacity(body_len);
631 body.extend_from_slice(&millis.to_le_bytes());
632 body.extend_from_slice(payload);
633 let crc = crc32(&body);
634 let mut frame = Vec::with_capacity(8 + body.len());
635 frame.extend_from_slice(&(u32::try_from(body.len()).unwrap_or(u32::MAX)).to_le_bytes());
636 frame.extend_from_slice(&crc.to_le_bytes());
637 frame.extend_from_slice(&body);
638 frame
639}
640
641fn decode_records(bytes: &[u8]) -> Vec<(SystemTime, Vec<u8>)> {
645 let mut out = Vec::new();
646 let mut off = 0usize;
647 while off + 8 <= bytes.len() {
648 let len = u32::from_le_bytes([bytes[off], bytes[off + 1], bytes[off + 2], bytes[off + 3]]);
649 let len = len as usize;
650 let crc = u32::from_le_bytes([
651 bytes[off + 4],
652 bytes[off + 5],
653 bytes[off + 6],
654 bytes[off + 7],
655 ]);
656 let body_start = off + 8;
657 let Some(body_end) = body_start.checked_add(len) else {
658 break;
659 };
660 if body_end > bytes.len() {
661 break;
663 }
664 let body = &bytes[body_start..body_end];
665 if len < 8 || crc32(body) != crc {
666 break;
669 }
670 let millis = u64::from_le_bytes([
671 body[0], body[1], body[2], body[3], body[4], body[5], body[6], body[7],
672 ]);
673 let deadline = UNIX_EPOCH + Duration::from_millis(millis);
674 out.push((deadline, body[8..].to_vec()));
675 off = body_end;
676 }
677 out
678}
679
680fn crc32(data: &[u8]) -> u32 {
684 let mut crc: u32 = 0xFFFF_FFFF;
685 for &b in data {
686 crc ^= u32::from(b);
687 for _ in 0..8 {
688 let mask = (crc & 1).wrapping_neg();
689 crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
690 }
691 }
692 !crc
693}
694
695#[cfg(test)]
696mod tests {
697 use super::*;
698 #[cfg(unix)]
699 use std::os::unix::fs::PermissionsExt as _;
700
701 fn payload(b: u8, n: usize) -> Vec<u8> {
702 vec![b; n]
703 }
704
705 #[test]
706 fn enqueue_and_take_round_trip() {
707 let store = HintStore::new(1024);
708 store
709 .enqueue(3, payload(b'a', 4), Duration::from_mins(1))
710 .unwrap();
711 store
712 .enqueue(3, payload(b'b', 4), Duration::from_mins(1))
713 .unwrap();
714 store
715 .enqueue(7, payload(b'c', 4), Duration::from_mins(1))
716 .unwrap();
717 assert_eq!(store.total_len(), 3);
718 let drained = store.take_for(3);
719 assert_eq!(drained.len(), 2);
720 assert_eq!(drained[0].payload, payload(b'a', 4));
721 assert_eq!(drained[1].payload, payload(b'b', 4));
722 assert_eq!(store.len_for(3), 0);
723 assert_eq!(store.len_for(7), 1);
724 assert_eq!(store.total_len(), 1);
725 }
726
727 #[test]
728 fn enqueue_rejects_over_capacity() {
729 let store = HintStore::new(8);
730 store
731 .enqueue(0, payload(b'x', 6), Duration::from_mins(1))
732 .unwrap();
733 let err = store
734 .enqueue(0, payload(b'y', 4), Duration::from_mins(1))
735 .unwrap_err();
736 assert_eq!(err, HintStoreError::OverCapacity { max_bytes: 8 });
737 assert_eq!(store.stats().bytes, 6);
739 assert_eq!(store.stats().rejected_over_capacity_total, 1);
740 let drained = store.take_for(0);
742 assert_eq!(drained.len(), 1);
743 store
745 .enqueue(0, payload(b'y', 4), Duration::from_mins(1))
746 .unwrap();
747 }
748
749 #[test]
750 fn expire_now_drops_old_hints() {
751 let store = HintStore::new(64);
752 store
753 .enqueue(1, payload(b'a', 3), Duration::from_millis(1))
754 .unwrap();
755 store
756 .enqueue(1, payload(b'b', 3), Duration::from_mins(1))
757 .unwrap();
758 std::thread::sleep(Duration::from_millis(5));
760 let now = Instant::now();
761 let dropped = store.expire_now(now);
762 assert_eq!(dropped, 1);
763 assert_eq!(store.len_for(1), 1);
764 let stats = store.stats();
765 assert_eq!(stats.expired_total, 1);
766 assert_eq!(stats.bytes, 3);
767 let drained = store.take_for(1);
769 assert_eq!(drained[0].payload, payload(b'b', 3));
770 }
771
772 #[test]
773 fn take_for_skips_already_expired() {
774 let store = HintStore::new(64);
775 store
776 .enqueue(2, payload(b'a', 3), Duration::from_millis(1))
777 .unwrap();
778 store
779 .enqueue(2, payload(b'b', 3), Duration::from_mins(1))
780 .unwrap();
781 std::thread::sleep(Duration::from_millis(5));
782 let drained = store.take_for(2);
783 assert_eq!(drained.len(), 1);
784 assert_eq!(drained[0].payload, payload(b'b', 3));
785 assert_eq!(store.stats().expired_total, 1);
786 }
787
788 #[test]
789 fn enqueue_rejects_zero_ttl_and_empty_payload() {
790 let store = HintStore::new(64);
791 let err = store
792 .enqueue(0, payload(b'x', 1), Duration::from_secs(0))
793 .unwrap_err();
794 assert_eq!(err, HintStoreError::ZeroTtl);
795 let err = store
796 .enqueue(0, Vec::new(), Duration::from_mins(1))
797 .unwrap_err();
798 assert_eq!(err, HintStoreError::EmptyPayload);
799 assert_eq!(store.total_len(), 0);
800 }
801
802 #[test]
803 fn mixed_peer_queues_are_independent() {
804 let store = HintStore::new(0); store
806 .enqueue(0, payload(b'a', 1), Duration::from_mins(1))
807 .unwrap();
808 store
809 .enqueue(1, payload(b'b', 1), Duration::from_mins(1))
810 .unwrap();
811 store
812 .enqueue(2, payload(b'c', 1), Duration::from_mins(1))
813 .unwrap();
814 assert_eq!(store.total_len(), 3);
815 let mut peers = store.peers_with_hints();
816 peers.sort_unstable();
817 assert_eq!(peers, vec![0, 1, 2]);
818 let drained = store.take_for(1);
819 assert_eq!(drained.len(), 1);
820 assert_eq!(drained[0].payload, payload(b'b', 1));
821 assert_eq!(store.len_for(0), 1);
822 assert_eq!(store.len_for(1), 0);
823 assert_eq!(store.len_for(2), 1);
824 }
825
826 #[test]
827 fn empty_max_bytes_means_unbounded() {
828 let store = HintStore::new(0);
829 for _ in 0..1024 {
830 store
831 .enqueue(0, payload(b'x', 1024), Duration::from_mins(1))
832 .unwrap();
833 }
834 assert_eq!(store.total_len(), 1024);
835 }
836
837 #[test]
838 fn expire_now_no_op_when_nothing_old() {
839 let store = HintStore::new(64);
840 store
841 .enqueue(0, payload(b'x', 3), Duration::from_mins(1))
842 .unwrap();
843 let dropped = store.expire_now(Instant::now());
844 assert_eq!(dropped, 0);
845 assert_eq!(store.total_len(), 1);
846 }
847
848 #[test]
849 fn stats_track_capacity_and_bytes() {
850 let store = HintStore::new(1024);
851 store
852 .enqueue(0, payload(b'x', 100), Duration::from_mins(1))
853 .unwrap();
854 let s = store.stats();
855 assert_eq!(s.hint_count, 1);
856 assert_eq!(s.bytes, 100);
857 assert_eq!(s.max_bytes, 1024);
858 }
859
860 fn scratch_dir() -> tempfile::TempDir {
861 tempfile::Builder::new()
862 .prefix("hints-")
863 .tempdir_in("/scratch")
864 .expect("create scratch tempdir")
865 }
866
867 #[test]
868 fn ram_only_store_never_touches_disk() {
869 let dir = scratch_dir();
870 let store = HintStore::new(1024);
873 store
874 .enqueue(0, payload(b'a', 4), Duration::from_mins(1))
875 .unwrap();
876 store.take_for(0);
877 store.expire_now(Instant::now());
878 let count = std::fs::read_dir(dir.path()).unwrap().count();
879 assert_eq!(count, 0, "new() must not create segment files");
880 }
881
882 #[test]
883 fn durable_round_trip_survives_reopen() {
884 let dir = scratch_dir();
885 {
886 let store = HintStore::open(dir.path(), 1024).unwrap();
887 store
888 .enqueue(3, payload(b'a', 4), Duration::from_mins(10))
889 .unwrap();
890 store
891 .enqueue(3, payload(b'b', 5), Duration::from_mins(10))
892 .unwrap();
893 store
894 .enqueue(7, payload(b'c', 6), Duration::from_mins(10))
895 .unwrap();
896 }
897 let reopened = HintStore::open(dir.path(), 1024).unwrap();
898 assert_eq!(reopened.total_len(), 3);
899 let p3 = reopened.take_for(3);
900 assert_eq!(p3.len(), 2);
901 assert_eq!(p3[0].payload, payload(b'a', 4));
902 assert_eq!(p3[1].payload, payload(b'b', 5));
903 let p7 = reopened.take_for(7);
904 assert_eq!(p7.len(), 1);
905 assert_eq!(p7[0].payload, payload(b'c', 6));
906 assert_eq!(reopened.stats().bytes, 0);
908 }
909
910 #[test]
911 fn torn_tail_recovers_intact_records() {
912 use std::io::Write;
913 let dir = scratch_dir();
914 {
915 let store = HintStore::open(dir.path(), 1024).unwrap();
916 store
917 .enqueue(2, payload(b'a', 8), Duration::from_mins(10))
918 .unwrap();
919 }
920 let seg = dir.path().join("peer-2.hints");
922 let mut f = std::fs::OpenOptions::new().append(true).open(&seg).unwrap();
923 f.write_all(&100u32.to_le_bytes()).unwrap();
926 f.write_all(&0u32.to_le_bytes()).unwrap();
927 f.write_all(&[0xAB, 0xCD, 0xEF]).unwrap();
928 f.flush().unwrap();
929 drop(f);
930 let reopened = HintStore::open(dir.path(), 1024).unwrap();
933 let p2 = reopened.take_for(2);
934 assert_eq!(p2.len(), 1);
935 assert_eq!(p2[0].payload, payload(b'a', 8));
936 }
937
938 #[test]
939 fn torn_body_with_bad_crc_is_discarded() {
940 use std::io::Write;
941 let dir = scratch_dir();
942 {
943 let store = HintStore::open(dir.path(), 1024).unwrap();
944 store
945 .enqueue(5, payload(b'z', 4), Duration::from_mins(10))
946 .unwrap();
947 }
948 let seg = dir.path().join("peer-5.hints");
952 let mut f = std::fs::OpenOptions::new().append(true).open(&seg).unwrap();
953 let body = [0u8; 12]; let body_len = u32::try_from(body.len()).unwrap();
955 f.write_all(&body_len.to_le_bytes()).unwrap();
956 f.write_all(&0xDEAD_BEEFu32.to_le_bytes()).unwrap(); f.write_all(&body).unwrap();
958 f.flush().unwrap();
959 drop(f);
960 let reopened = HintStore::open(dir.path(), 1024).unwrap();
961 let p5 = reopened.take_for(5);
962 assert_eq!(p5.len(), 1, "only the intact record survives");
963 assert_eq!(p5[0].payload, payload(b'z', 4));
964 }
965
966 #[test]
967 fn take_for_clears_disk_segment() {
968 let dir = scratch_dir();
969 let store = HintStore::open(dir.path(), 1024).unwrap();
970 store
971 .enqueue(4, payload(b'a', 4), Duration::from_mins(10))
972 .unwrap();
973 assert!(dir.path().join("peer-4.hints").exists());
974 let drained = store.take_for(4);
975 assert_eq!(drained.len(), 1);
976 assert!(
977 !dir.path().join("peer-4.hints").exists(),
978 "take_for must remove the drained segment"
979 );
980 drop(store);
982 let reopened = HintStore::open(dir.path(), 1024).unwrap();
983 assert_eq!(reopened.len_for(4), 0);
984 }
985
986 #[test]
987 fn expire_now_compacts_disk_segment() {
988 let dir = scratch_dir();
989 let store = HintStore::open(dir.path(), 1024).unwrap();
990 store
991 .enqueue(1, payload(b'a', 3), Duration::from_millis(1))
992 .unwrap();
993 store
994 .enqueue(1, payload(b'b', 3), Duration::from_mins(10))
995 .unwrap();
996 let seg = dir.path().join("peer-1.hints");
997 let big = std::fs::metadata(&seg).unwrap().len();
998 std::thread::sleep(Duration::from_millis(5));
999 let dropped = store.expire_now(Instant::now());
1000 assert_eq!(dropped, 1);
1001 let small = std::fs::metadata(&seg).unwrap().len();
1003 assert!(small < big, "expiry must compact the segment");
1004 drop(store);
1006 let reopened = HintStore::open(dir.path(), 1024).unwrap();
1007 let p1 = reopened.take_for(1);
1008 assert_eq!(p1.len(), 1);
1009 assert_eq!(p1[0].payload, payload(b'b', 3));
1010 }
1011
1012 #[test]
1013 fn expire_now_to_empty_removes_segment() {
1014 let dir = scratch_dir();
1015 let store = HintStore::open(dir.path(), 1024).unwrap();
1016 store
1017 .enqueue(9, payload(b'a', 3), Duration::from_millis(1))
1018 .unwrap();
1019 assert!(dir.path().join("peer-9.hints").exists());
1020 std::thread::sleep(Duration::from_millis(5));
1021 assert_eq!(store.expire_now(Instant::now()), 1);
1022 assert!(
1023 !dir.path().join("peer-9.hints").exists(),
1024 "a fully-expired peer's segment must be removed"
1025 );
1026 }
1027
1028 #[test]
1029 fn capacity_enforced_across_replayed_bytes() {
1030 let dir = scratch_dir();
1031 {
1032 let store = HintStore::open(dir.path(), 8).unwrap();
1033 store
1034 .enqueue(0, payload(b'x', 6), Duration::from_mins(10))
1035 .unwrap();
1036 }
1037 let reopened = HintStore::open(dir.path(), 8).unwrap();
1040 assert_eq!(reopened.stats().bytes, 6);
1041 let err = reopened
1042 .enqueue(0, payload(b'y', 4), Duration::from_mins(10))
1043 .unwrap_err();
1044 assert_eq!(err, HintStoreError::OverCapacity { max_bytes: 8 });
1045 }
1046
1047 #[test]
1048 fn replay_drops_expired_records() {
1049 let dir = scratch_dir();
1050 {
1051 let store = HintStore::open(dir.path(), 1024).unwrap();
1052 store
1053 .enqueue(0, payload(b'a', 4), Duration::from_millis(1))
1054 .unwrap();
1055 store
1056 .enqueue(0, payload(b'b', 4), Duration::from_mins(10))
1057 .unwrap();
1058 }
1059 std::thread::sleep(Duration::from_millis(5));
1060 let reopened = HintStore::open(dir.path(), 1024).unwrap();
1061 let p0 = reopened.take_for(0);
1062 assert_eq!(p0.len(), 1, "the expired record is not replayed");
1063 assert_eq!(p0[0].payload, payload(b'b', 4));
1064 }
1065
1066 #[test]
1067 fn record_codec_round_trip() {
1068 let now = SystemTime::now() + Duration::from_secs(100);
1069 let frame = encode_record(now, b"hello");
1070 let recs = decode_records(&frame);
1071 assert_eq!(recs.len(), 1);
1072 assert_eq!(recs[0].1, b"hello");
1073 let mut two = encode_record(now, b"one");
1075 two.extend_from_slice(&encode_record(now, b"two"));
1076 let recs = decode_records(&two);
1077 assert_eq!(recs.len(), 2);
1078 assert_eq!(recs[0].1, b"one");
1079 assert_eq!(recs[1].1, b"two");
1080 }
1081
1082 #[test]
1083 fn crc32_known_vector() {
1084 assert_eq!(crc32(b"123456789"), 0xCBF4_3926);
1086 assert_eq!(crc32(b""), 0);
1087 }
1088
1089 #[test]
1090 fn open_fails_when_path_is_a_file() {
1091 let dir = scratch_dir();
1095 let file_path = dir.path().join("not-a-dir");
1096 std::fs::write(&file_path, b"x").unwrap();
1097 let err = HintStore::open(&file_path, 1024).expect_err("open on a file");
1098 assert!(matches!(err, HintStoreOpenError::Dir { .. }), "got {err:?}");
1099 }
1100
1101 fn running_as_root() -> bool {
1105 nix::unistd::Uid::effective().is_root()
1109 }
1110
1111 #[test]
1112 fn enqueue_surfaces_disk_append_error_on_readonly_dir() {
1113 if running_as_root() {
1114 return; }
1116 let dir = scratch_dir();
1117 let store = HintStore::open(dir.path(), 1024).unwrap();
1118 std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o500)).unwrap();
1121 let err = store
1122 .enqueue(5, payload(b'a', 4), Duration::from_mins(10))
1123 .expect_err("append into read-only dir must fail");
1124 assert!(matches!(err, HintStoreError::Io { .. }), "got {err:?}");
1125 std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
1127 }
1128
1129 #[test]
1130 fn take_for_tolerates_segment_remove_failure() {
1131 if running_as_root() {
1132 return;
1133 }
1134 let dir = scratch_dir();
1135 let store = HintStore::open(dir.path(), 1024).unwrap();
1136 store
1137 .enqueue(8, payload(b'a', 4), Duration::from_mins(10))
1138 .unwrap();
1139 std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o500)).unwrap();
1144 let drained = store.take_for(8);
1145 assert_eq!(drained.len(), 1);
1146 std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
1147 }
1148
1149 #[test]
1150 fn expire_now_tolerates_compaction_failure() {
1151 if running_as_root() {
1152 return;
1153 }
1154 let dir = scratch_dir();
1155 let store = HintStore::open(dir.path(), 1024).unwrap();
1156 store
1160 .enqueue(6, payload(b'a', 4), Duration::from_millis(1))
1161 .unwrap();
1162 store
1163 .enqueue(6, payload(b'b', 4), Duration::from_mins(10))
1164 .unwrap();
1165 std::thread::sleep(Duration::from_millis(5));
1166 std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o500)).unwrap();
1170 let dropped = store.expire_now(Instant::now());
1171 assert_eq!(dropped, 1);
1172 std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
1173 }
1174
1175 #[test]
1176 fn replay_surfaces_unreadable_segment_error() {
1177 if running_as_root() {
1178 return;
1179 }
1180 let dir = scratch_dir();
1181 let seg = dir.path().join("peer-1.hints");
1185 std::fs::write(&seg, b"").unwrap();
1186 std::fs::set_permissions(&seg, std::fs::Permissions::from_mode(0o000)).unwrap();
1187 let err = HintStore::open(dir.path(), 1024).expect_err("unreadable segment");
1188 assert!(
1189 matches!(err, HintStoreOpenError::Segment { .. }),
1190 "got {err:?}"
1191 );
1192 std::fs::set_permissions(&seg, std::fs::Permissions::from_mode(0o600)).unwrap();
1193 }
1194
1195 #[test]
1196 fn take_for_unknown_peer_is_empty() {
1197 let store = HintStore::new(1024);
1200 assert!(store.take_for(123).is_empty());
1201 }
1202
1203 #[test]
1204 fn take_for_twice_is_idempotent_on_disk() {
1205 let dir = scratch_dir();
1209 let store = HintStore::open(dir.path(), 1024).unwrap();
1210 store
1211 .enqueue(7, payload(b'a', 4), Duration::from_mins(10))
1212 .unwrap();
1213 assert_eq!(store.take_for(7).len(), 1);
1214 assert!(store.take_for(7).is_empty());
1216 assert!(!dir.path().join("peer-7.hints").exists());
1217 }
1218
1219 #[test]
1220 fn replay_ignores_stray_non_segment_files() {
1221 let dir = scratch_dir();
1225 {
1226 let store = HintStore::open(dir.path(), 1024).unwrap();
1227 store
1228 .enqueue(2, payload(b'a', 4), Duration::from_mins(10))
1229 .unwrap();
1230 }
1231 std::fs::write(dir.path().join("peer-2.hints.tmp"), b"junk").unwrap();
1232 std::fs::write(dir.path().join("README"), b"not a segment").unwrap();
1233 let reopened = HintStore::open(dir.path(), 1024).unwrap();
1234 assert_eq!(reopened.peers_with_hints(), vec![2]);
1236 assert_eq!(reopened.len_for(2), 1);
1237 }
1238
1239 #[test]
1240 fn replay_drops_peer_whose_records_all_expired() {
1241 let dir = scratch_dir();
1245 {
1246 let store = HintStore::open(dir.path(), 1024).unwrap();
1247 store
1248 .enqueue(3, payload(b'a', 4), Duration::from_millis(1))
1249 .unwrap();
1250 store
1251 .enqueue(3, payload(b'b', 4), Duration::from_millis(1))
1252 .unwrap();
1253 }
1254 std::thread::sleep(Duration::from_millis(5));
1255 let reopened = HintStore::open(dir.path(), 1024).unwrap();
1256 assert!(
1257 reopened.peers_with_hints().is_empty(),
1258 "a peer with only expired records must not be replayed"
1259 );
1260 assert_eq!(reopened.total_len(), 0);
1261 }
1262}