Skip to main content

dynomite/cluster/
hints.rs

1//! Node-local hinted-handoff store.
2//!
3//! When a write request fans out to a peer in
4//! [`crate::cluster::peer::PeerState::Down`] or to a peer whose
5//! outbound channel is closed, the dispatcher records a hint:
6//! the on-the-wire request bytes, the index of the intended
7//! peer, and an absolute expiry deadline. A background task
8//! periodically:
9//!
10//! * drains hints destined for any peer that has returned to
11//!   [`crate::cluster::peer::PeerState::Normal`] and ships them
12//!   over the same per-peer outbound channel the dispatcher
13//!   would have used;
14//! * drops hints that have aged past their `hint_ttl_seconds`
15//!   so the in-memory store stays bounded.
16//!
17//! The store has two constructors. [`HintStore::new`] is the
18//! RAM-only variant: hints live only in the per-peer queues and
19//! are lost if the coordinator restarts. [`HintStore::open`]
20//! adds a durable backend so queued hints survive a restart.
21//!
22//! # Durable backend
23//!
24//! The durable backend keeps one append-only segment file per
25//! peer under `<dir>/peer-<idx>.hints`. Each record frames a
26//! single hint as a little-endian `u32` body length, a
27//! little-endian `u32` CRC-32 (IEEE) of the body, and the body
28//! itself: a little-endian `u64` wall-clock deadline (Unix
29//! milliseconds) followed by the raw payload bytes. The peer
30//! index is encoded in the file name, not the record.
31//!
32//! * [`HintStore::enqueue`] write-through appends one record to
33//!   the target peer's segment.
34//! * [`HintStore::take_for`] returns the live hints and then
35//!   removes that peer's segment (the hints have been handed
36//!   off; a failed delivery is re-enqueued by the caller, which
37//!   re-appends a fresh segment).
38//! * [`HintStore::expire_now`] rewrites each affected peer's
39//!   segment from the surviving in-memory hints so the on-disk
40//!   log is compacted and never grows past `max_bytes`.
41//!
42//! [`HintStore::open`] replays every segment in `dir` back into
43//! the in-memory queues at startup. The deadline is stored as a
44//! wall-clock instant so it survives the process boundary; on
45//! replay each deadline is re-anchored to the current monotonic
46//! clock and any hint whose wall-clock deadline has already
47//! passed is dropped.
48//!
49//! ## Torn-tail safety
50//!
51//! A crash mid-append can leave a torn trailing record. Replay
52//! detects this two ways: a record whose framed length cannot
53//! be read in full (a short read before the body completes) is
54//! discarded as a clean EOF, and a record whose body CRC does
55//! not match the stored CRC is treated as torn. In both cases
56//! replay stops at the first damaged record and keeps every
57//! intact record before it. A torn tail never panics and never
58//! surfaces an error from [`HintStore::open`].
59//!
60//! # Examples
61//!
62//! ```
63//! use std::time::{Duration, Instant};
64//! use dynomite::cluster::hints::HintStore;
65//!
66//! let store = HintStore::new(1024);
67//! store.enqueue(7, b"*3\r\n$3\r\nSET\r\n$1\r\nk\r\n$1\r\nv\r\n".to_vec(), Duration::from_secs(60))
68//!     .expect("under capacity");
69//! let drained = store.take_for(7);
70//! assert_eq!(drained.len(), 1);
71//! assert_eq!(store.expire_now(Instant::now()), 0);
72//! ```
73
74use 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/// Errors produced by [`HintStore::enqueue`].
84#[derive(Debug, Error, Eq, PartialEq)]
85pub enum HintStoreError {
86    /// The store has reached `max_bytes`. The caller is
87    /// expected to fall back to its non-handoff error path
88    /// (typically, return `DynomiteNoQuorumAchieved` to the
89    /// client) and the next drainer sweep will reclaim space
90    /// when peers come back online.
91    #[error("hint store over capacity ({max_bytes} bytes)")]
92    OverCapacity {
93        /// Configured upper bound, in bytes.
94        max_bytes: u64,
95    },
96    /// The supplied TTL is zero. A zero TTL would be expired
97    /// immediately by the next sweep, so the store rejects it
98    /// up front to surface a configuration error.
99    #[error("hint TTL must be greater than zero")]
100    ZeroTtl,
101    /// The hint payload is empty. The wire-replay path requires
102    /// at least one byte; an empty payload is rejected so the
103    /// drainer never produces a no-op outbound write.
104    #[error("hint payload is empty")]
105    EmptyPayload,
106    /// A durable segment write failed. The hint was not
107    /// persisted; the caller must treat the enqueue as failed
108    /// and fall back to its non-handoff error path rather than
109    /// risk silently losing the write across a restart.
110    #[error("hint segment write failed: {message}")]
111    Io {
112        /// Rendered underlying I/O error.
113        message: String,
114    },
115}
116
117/// Errors produced by [`HintStore::open`].
118#[derive(Debug, Error)]
119pub enum HintStoreOpenError {
120    /// The segment directory could not be created or read.
121    #[error("hint segment directory {path}: {source}")]
122    Dir {
123        /// The directory path that failed.
124        path: PathBuf,
125        /// Underlying I/O error.
126        source: io::Error,
127    },
128    /// A segment file could not be read during replay.
129    #[error("hint segment {path}: {source}")]
130    Segment {
131        /// The segment file path that failed.
132        path: PathBuf,
133        /// Underlying I/O error.
134        source: io::Error,
135    },
136}
137
138/// One pending hint.
139#[derive(Clone, Debug, Eq, PartialEq)]
140pub struct Hint {
141    /// Index of the intended peer in
142    /// [`crate::cluster::pool::ServerPool::peers`].
143    pub peer_idx: u32,
144    /// On-the-wire request bytes, ready to forward.
145    pub payload: Vec<u8>,
146    /// Absolute deadline after which the hint is dropped.
147    pub deadline: Instant,
148}
149
150impl Hint {
151    /// Heap footprint in bytes used for the store's accounting.
152    /// Counts the payload only; the surrounding metadata
153    /// (`u32` + `Instant`) is small and constant per entry.
154    #[must_use]
155    fn weight(&self) -> u64 {
156        u64::try_from(self.payload.len()).unwrap_or(u64::MAX)
157    }
158}
159
160/// Snapshot of the store's current size.
161#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
162pub struct HintStoreStats {
163    /// Number of hints currently retained.
164    pub hint_count: usize,
165    /// Sum of payload bytes currently retained.
166    pub bytes: u64,
167    /// Configured upper bound on `bytes`.
168    pub max_bytes: u64,
169    /// Total hints dropped due to TTL expiry since the store
170    /// was created.
171    pub expired_total: u64,
172    /// Total hints rejected for over-capacity since the store
173    /// was created.
174    pub rejected_over_capacity_total: u64,
175}
176
177/// Node-local hint store.
178///
179/// The store is internally synchronised so [`std::sync::Arc`]
180/// clones share the same per-peer queues. Operations are O(1)
181/// with respect to the number of pending hints for the queried
182/// peer and O(N) for [`HintStore::expire_now`].
183#[derive(Debug)]
184pub struct HintStore {
185    inner: Mutex<Inner>,
186}
187
188#[derive(Debug)]
189struct Inner {
190    /// Per-peer FIFO queue. Insertion appends; drain pops the
191    /// whole queue at once (the dispatcher never wants to
192    /// trickle-deliver because hints are buffered against a
193    /// down peer that has already returned).
194    by_peer: HashMap<u32, Vec<Hint>>,
195    bytes: u64,
196    max_bytes: u64,
197    expired_total: u64,
198    rejected_over_capacity_total: u64,
199    /// Durable backend. `None` for the RAM-only store built by
200    /// [`HintStore::new`].
201    disk: Option<DiskBackend>,
202}
203
204impl HintStore {
205    /// Build a new store with the supplied byte cap.
206    ///
207    /// `max_bytes` of zero means "no cap"; this is intended for
208    /// tests that drive enqueue/take patterns and never want to
209    /// exercise the back-pressure branch.
210    #[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    /// Build a durable store backed by per-peer segment files
225    /// under `dir`, replaying any segments already present.
226    ///
227    /// On success the in-memory queues are pre-populated with
228    /// every intact hint found on disk whose wall-clock deadline
229    /// has not yet passed (re-anchored to the monotonic clock).
230    /// A torn trailing record in any segment is discarded
231    /// silently; see the module-level torn-tail documentation.
232    /// The replayed bytes are counted toward `max_bytes`, so a
233    /// store recovered from disk enforces the same combined cap
234    /// the RAM-only store does.
235    ///
236    /// # Errors
237    ///
238    /// * [`HintStoreOpenError::Dir`] when `dir` cannot be
239    ///   created or its entries cannot be listed.
240    /// * [`HintStoreOpenError::Segment`] when a segment file
241    ///   exists but cannot be opened or read (a torn tail is not
242    ///   an error; an unreadable file is).
243    ///
244    /// # Examples
245    ///
246    /// ```
247    /// use std::time::Duration;
248    /// use dynomite::cluster::hints::HintStore;
249    ///
250    /// let dir = tempfile::tempdir().unwrap();
251    /// let store = HintStore::open(dir.path(), 1024).unwrap();
252    /// store.enqueue(1, b"hi".to_vec(), Duration::from_secs(60)).unwrap();
253    /// drop(store);
254    /// let reopened = HintStore::open(dir.path(), 1024).unwrap();
255    /// assert_eq!(reopened.len_for(1), 1);
256    /// ```
257    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    /// Append a hint for `peer_idx`. The hint expires at
284    /// `Instant::now() + ttl`.
285    ///
286    /// # Errors
287    ///
288    /// * [`HintStoreError::ZeroTtl`] when `ttl` is zero.
289    /// * [`HintStoreError::EmptyPayload`] when `payload` is
290    ///   empty.
291    /// * [`HintStoreError::OverCapacity`] when accepting the
292    ///   hint would push the cumulative payload bytes over
293    ///   `max_bytes`.
294    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        // Wall-clock deadline for the durable record so the
317        // hint's lifetime survives a restart (the monotonic
318        // `Instant` above is meaningless across a reboot).
319        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    /// Drain every pending hint for `peer_idx`. Hints that have
336    /// expired are dropped on the floor (and counted toward
337    /// [`HintStoreStats::expired_total`]).
338    ///
339    /// Returned hints are ordered by enqueue time, oldest first.
340    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        // The whole queue has been handed off; drop the durable
358        // segment so the bytes are reclaimed on disk too. A
359        // failed removal is non-fatal: a stale segment would at
360        // worst be replayed (and re-delivered) on the next
361        // restart, which the at-least-once handoff contract
362        // already tolerates.
363        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    /// Drop every hint whose deadline has passed at `now`.
377    /// Returns the number of hints dropped. Walks the entire
378    /// store; intended for the periodic drainer task.
379    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        // Peers whose queue lost entries and whose durable
384        // segment therefore needs compacting.
385        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        // Recompute total bytes from scratch: the per-peer
400        // retained weights are now consistent with `bytes` only
401        // after we subtract the dropped weights. We trade a
402        // second pass for a clean invariant rather than tracking
403        // dropped weights inline above.
404        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        // Compact the durable segments for every touched peer so
413        // the on-disk log mirrors the surviving in-memory hints
414        // and never grows past `max_bytes`. A full per-peer
415        // rewrite is acceptable because each segment is bounded
416        // by the same cap. Borrow `disk` out before mutating the
417        // map's keys to keep the borrow checker happy.
418        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    /// Number of hints across every peer.
458    #[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    /// Pending hint count for `peer_idx`. Useful for tests.
465    #[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    /// Snapshot the store's accounting fields.
472    #[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    /// Iterate the peer indices that currently have pending
485    /// hints. Used by the drainer to decide which peers to ship
486    /// to without holding the inner lock across the network
487    /// send.
488    #[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
499/// Re-anchor a monotonic `Instant` deadline onto a wall clock.
500///
501/// `inst_now` and `wall_now` are a matched pair sampled close
502/// together. A deadline already in the past maps to `wall_now`
503/// (zero remaining), which replay then treats as expired.
504fn 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/// Durable per-peer segment backend.
510///
511/// One append-only file per peer named `peer-<idx>.hints` lives
512/// under [`DiskBackend::dir`]. See the module docs for the
513/// record framing and the torn-tail recovery contract.
514#[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    /// Append one framed record to the peer's segment, flushing
525    /// to the OS before returning so a clean process exit does
526    /// not lose the hint.
527    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    /// Replace the peer's segment with exactly `records`, written
539    /// to a sibling temporary file and renamed into place so a
540    /// crash mid-rewrite cannot leave a half-compacted segment.
541    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    /// Remove the peer's segment. A missing file is not an error.
555    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    /// Replay every `peer-<idx>.hints` segment in the directory
564    /// back into per-peer queues, re-anchoring deadlines onto
565    /// the monotonic clock and dropping records whose wall-clock
566    /// deadline has already passed. Torn trailing records are
567    /// discarded silently.
568    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                    // Wall-clock deadline already passed; drop.
596                    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
612/// Parse `peer-<idx>.hints` into the peer index. Returns `None`
613/// for any other file (including the `.tmp` rewrite scratch
614/// file), so stray files in the directory are ignored.
615fn 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
621/// Frame one record: `len: u32 LE`, `crc: u32 LE` of the body,
622/// then the body (`deadline_millis: u64 LE` followed by the
623/// payload). The CRC covers the body only.
624fn 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
641/// Decode every intact record in `bytes`, stopping at the first
642/// torn or corrupt record (clean EOF, short read, or CRC
643/// mismatch). Never panics on arbitrary input.
644fn 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            // Torn tail: the body was not fully written.
662            break;
663        }
664        let body = &bytes[body_start..body_end];
665        if len < 8 || crc32(body) != crc {
666            // Corrupt or torn record; discard it and everything
667            // after it.
668            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
680/// CRC-32 (IEEE 802.3, reflected, polynomial `0xEDB88820`).
681/// Hand-rolled to avoid pulling a checksum crate into the
682/// engine's direct dependency set.
683fn 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        // Bytes accounting unaffected by the rejected enqueue.
738        assert_eq!(store.stats().bytes, 6);
739        assert_eq!(store.stats().rejected_over_capacity_total, 1);
740        // Drain reclaims space.
741        let drained = store.take_for(0);
742        assert_eq!(drained.len(), 1);
743        // Now the previously-rejected payload fits.
744        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        // Sleep a moment so the first hint expires.
759        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        // Surviving hint is the one with the long TTL.
768        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); // unbounded
805        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        // A RAM-only store must not write to the directory even
871        // when one is handy.
872        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        // Bytes were counted toward the cap on replay.
907        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        // Append a truncated partial record after the intact one.
921        let seg = dir.path().join("peer-2.hints");
922        let mut f = std::fs::OpenOptions::new().append(true).open(&seg).unwrap();
923        // A plausible 100-byte length prefix plus a few stray
924        // bytes that never complete the body.
925        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        // Replay must recover the intact record and discard the
931        // partial without panicking or erroring.
932        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        // A full-length record whose body bytes are present but
949        // whose CRC does not match (a torn-within-body write that
950        // still flushed the length prefix).
951        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]; // 8-byte deadline + 4-byte payload
954        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(); // wrong crc
957        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        // Reopening yields nothing for that peer.
981        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        // Segment shrank after compaction.
1002        let small = std::fs::metadata(&seg).unwrap().len();
1003        assert!(small < big, "expiry must compact the segment");
1004        // The surviving hint is the only thing replayed.
1005        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        // Reopen with the same cap: the replayed 6 bytes count,
1038        // so a 4-byte enqueue must still be rejected.
1039        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        // Concatenated frames decode in order.
1074        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        // CRC-32 of "123456789" is 0xCBF43926.
1085        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        // `create_dir_all` cannot turn an existing regular file
1092        // into a directory, so `open` surfaces a Dir error rather
1093        // than panicking.
1094        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    /// True when the current process is the superuser, in which
1102    /// case file-permission denials do not apply and the
1103    /// permission-error tests are skipped.
1104    fn running_as_root() -> bool {
1105        // SAFETY-free: `geteuid` is a pure syscall wrapper. We
1106        // avoid pulling extra deps by reading it through `nix`,
1107        // which is already a workspace dependency.
1108        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; // root bypasses the permission bits.
1115        }
1116        let dir = scratch_dir();
1117        let store = HintStore::open(dir.path(), 1024).unwrap();
1118        // Make the directory read-only so creating a new segment
1119        // file fails inside the disk append.
1120        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        // Restore so the tempdir can be cleaned up.
1126        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        // Read-only dir: take_for still returns the drained hint,
1140        // but the durable segment removal fails. The failure is
1141        // logged and swallowed (at-least-once handoff), so the
1142        // call still yields the hint.
1143        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        // One short-lived + one long-lived hint for the same peer,
1157        // so expiry compacts (rewrites) the segment rather than
1158        // removing it.
1159        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        // Read-only dir: the compaction rewrite fails, but expiry
1167        // still drops the in-memory hint and reports it. The disk
1168        // failure is logged and swallowed.
1169        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        // A correctly-named segment file that cannot be read makes
1182        // replay surface a Segment error instead of silently
1183        // skipping it.
1184        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        // A peer with no queued hints returns an empty vec without
1198        // touching disk.
1199        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        // The first take removes the segment; the second take has
1206        // no in-RAM queue and no segment, so the durable
1207        // remove_peer hits its NotFound -> Ok arm.
1208        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        // Second drain: nothing to return, segment already gone.
1215        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        // A directory holding files that are not `peer-<idx>.hints`
1222        // (e.g. a leftover `.tmp` rewrite scratch file) must be
1223        // skipped during replay rather than treated as a segment.
1224        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        // Only the real segment for peer 2 is replayed.
1235        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        // When every record in a segment has expired, replay must
1242        // drop the peer entry entirely rather than surface an
1243        // empty queue.
1244        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}