Packages
ferricstore
0.3.6
0.11.12
0.11.11
0.11.10
0.11.9
0.11.8
0.11.7
0.11.6
0.11.5
0.11.4
0.11.3
0.11.2
0.11.1
0.11.0
0.10.3
0.10.2
0.10.1
0.10.0
0.9.1
0.9.0
0.8.0
0.7.5
0.7.4
0.7.3
0.7.2
0.7.1
0.7.0
0.6.0
0.5.7
0.5.6
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.3
0.4.2
0.4.1
0.4.0
0.3.7
0.3.6
0.3.5
0.3.4
0.3.3
0.3.2
0.3.1
0.2.0
0.1.0
FerricFlow durable workflows and queues with native-protocol storage, Raft durability, and Bitcask persistence.
Current section
Files
Jump to
Current section
Files
native/ferricstore_bitcask/src/hint.rs
//! Hint files accelerate startup by storing the keydir index without values.
//!
//! A hint file mirrors the corresponding data file but omits value bytes.
//! Each entry is prefixed with a CRC32 checksum that covers all remaining
//! fields, preventing a corrupt hint file from silently inserting bad disk
//! pointers into the keydir.
//!
//! ```text
//! [ crc32: u32 | file_id: u64 | offset: u64 | value_size: u32 | expire_at_ms: u64 | key_size: u16 | key: [u8] ]
//! ```
//!
//! The CRC covers: `file_id || offset || value_size || expire_at_ms || key_size || key`
//! (everything after the `crc32` field).
//!
//! Reading hint files rebuilds the full keydir in milliseconds on startup —
//! no need to scan the entire value log.
use std::fs::{File, OpenOptions};
use std::io::{self, BufWriter, Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use crate::keydir::{KeyDir, KeyEntry};
/// CRC32 field size prepended to every hint entry.
const CRC_SIZE: usize = 4;
/// Fixed-size body header per hint record (after the CRC, before the key bytes).
/// `file_id`(8) + `offset`(8) + `value_size`(4) + `expire_at_ms`(8) + `key_size`(2) = 30
const HINT_BODY_HEADER_SIZE: usize = 30;
/// Total header bytes per hint record (before the key bytes):
/// `crc32`(4) + `file_id`(8) + `offset`(8) + `value_size`(4) + `expire_at_ms`(8) + `key_size`(2) = 34
pub const HINT_HEADER_SIZE: usize = CRC_SIZE + HINT_BODY_HEADER_SIZE;
#[derive(Debug)]
pub struct HintError(pub String);
impl std::fmt::Display for HintError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "HintError: {}", self.0)
}
}
impl std::error::Error for HintError {}
impl From<io::Error> for HintError {
fn from(e: io::Error) -> Self {
HintError(e.to_string())
}
}
pub type Result<T> = std::result::Result<T, HintError>;
/// A single entry read from a hint file.
#[derive(Debug, PartialEq, Eq)]
pub struct HintEntry {
pub file_id: u64,
pub offset: u64,
pub value_size: u32,
pub expire_at_ms: u64,
pub key: Vec<u8>,
}
/// Writes a hint file alongside a data file using an atomic write-then-rename
/// strategy.
///
/// All content is written to a `.hint.tmp` temporary file. Only when
/// [`HintWriter::commit`] is called are the contents flushed, synced to disk,
/// and the temporary file atomically renamed to the final `.hint` path.
///
/// If `commit` is never called (e.g. the process panics or an error occurs),
/// the [`Drop`] implementation removes the `.hint.tmp` file so no partial
/// content is left on disk.
pub struct HintWriter {
writer: BufWriter<File>,
tmp_path: PathBuf,
final_path: PathBuf,
}
impl HintWriter {
/// Open a new hint writer targeting `path`.
///
/// Content is written to `<path>.tmp` and only moved to `path` on
/// [`commit`](HintWriter::commit).
///
/// # Errors
///
/// Returns a `HintError` if the temporary file cannot be created or opened.
pub fn open(path: &Path) -> Result<Self> {
let tmp_path = path.with_extension("hint.tmp");
let file = OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(&tmp_path)?;
Ok(Self {
writer: BufWriter::new(file),
tmp_path,
final_path: path.to_path_buf(),
})
}
/// # Errors
///
/// Returns a `HintError` if the entry cannot be written to disk.
pub fn write_entry(&mut self, entry: &HintEntry) -> Result<()> {
#[allow(clippy::cast_possible_truncation)]
let key_size = entry.key.len() as u16;
// Build the body first so we can compute the CRC over it.
let mut body = Vec::with_capacity(HINT_BODY_HEADER_SIZE + entry.key.len());
body.extend_from_slice(&entry.file_id.to_le_bytes());
body.extend_from_slice(&entry.offset.to_le_bytes());
body.extend_from_slice(&entry.value_size.to_le_bytes());
body.extend_from_slice(&entry.expire_at_ms.to_le_bytes());
body.extend_from_slice(&key_size.to_le_bytes());
body.extend_from_slice(&entry.key);
let crc = crc32(&body);
self.writer.write_all(&crc.to_le_bytes())?;
self.writer.write_all(&body)?;
Ok(())
}
/// Flush all buffered data, sync to disk, and atomically rename the
/// temporary file to the final hint path.
///
/// After a successful `commit` the `.hint.tmp` file no longer exists (it
/// has been renamed to `.hint`). The subsequent `drop` will attempt to
/// remove the `.hint.tmp` path and fail silently, which is correct.
///
/// # Errors
///
/// Returns a `HintError` if flushing, syncing, or renaming fails. On
/// error the temporary file is left in place; the [`Drop`] impl will
/// remove it when the writer is dropped.
pub fn commit(mut self) -> Result<()> {
// Drain the BufWriter into the OS, then fdatasync the file data.
// We use `sync_data()` (fdatasync) instead of `sync_all()` (fsync)
// because hint files never change size after this call — all
// body bytes were written up-front — and mtime/atime metadata is
// not a durability concern for recovery.
//
// The rename below makes the final filename visible but the
// rename's directory entry is not durable until the parent
// directory is fsynced. The caller (Elixir site) is responsible
// for `v2_fsync_dir(shard_data_path)` after this returns —
// rotation's dir-fsync step already covers that.
self.writer.flush()?;
self.writer.get_ref().sync_data()?;
std::fs::rename(&self.tmp_path, &self.final_path)?;
Ok(())
}
}
impl Drop for HintWriter {
/// Best-effort cleanup of the temporary file if [`commit`](HintWriter::commit)
/// was never called (crash / error path). The `remove_file` call is allowed
/// to fail silently: after a successful `commit` the file has already been
/// renamed away, so `remove_file` will return `NotFound`, which is harmless.
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.tmp_path);
}
}
/// Reads hint entries and reconstructs the keydir.
pub struct HintReader {
file: File,
}
impl HintReader {
/// # Errors
///
/// Returns a `HintError` if the file cannot be opened.
pub fn open(path: &Path) -> Result<Self> {
let file = File::open(path)?;
Ok(Self { file })
}
/// Load all entries from the hint file into `keydir`.
/// Tombstones (`value_size` == 0) are skipped -- they represent deleted keys.
///
/// # Errors
///
/// Returns a `HintError` if the hint file cannot be read or is malformed.
pub fn load_into(&mut self, keydir: &mut KeyDir) -> Result<()> {
self.file.seek(SeekFrom::Start(0))?;
while let Some(entry) = read_hint_entry(&mut self.file)? {
if entry.value_size == 0 {
keydir.delete(&entry.key);
} else {
keydir.put(
entry.key.clone(),
KeyEntry {
file_id: entry.file_id,
offset: entry.offset,
value_size: entry.value_size,
expire_at_ms: entry.expire_at_ms,
ref_bit: false,
},
);
}
}
Ok(())
}
/// Collect all hint entries (used in tests and compaction).
///
/// # Errors
///
/// Returns a `HintError` if the hint file cannot be read or is malformed.
pub fn read_all(&mut self) -> Result<Vec<HintEntry>> {
self.file.seek(SeekFrom::Start(0))?;
let mut entries = Vec::new();
while let Some(entry) = read_hint_entry(&mut self.file)? {
entries.push(entry);
}
Ok(entries)
}
}
fn read_hint_entry(reader: &mut impl Read) -> Result<Option<HintEntry>> {
// Read the CRC32 (4 bytes). A clean EOF here means the file ended normally.
let mut crc_buf = [0u8; CRC_SIZE];
match reader.read_exact(&mut crc_buf) {
Ok(()) => {}
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(None),
Err(e) => return Err(e.into()),
}
let stored_crc = u32::from_le_bytes(crc_buf);
// Read the fixed-size body header (30 bytes).
let mut header = [0u8; HINT_BODY_HEADER_SIZE];
reader.read_exact(&mut header).map_err(HintError::from)?;
let file_id = u64::from_le_bytes(
header[0..8]
.try_into()
.map_err(|_| HintError("slice conversion failed".to_string()))?,
);
let offset = u64::from_le_bytes(
header[8..16]
.try_into()
.map_err(|_| HintError("slice conversion failed".to_string()))?,
);
let value_size = u32::from_le_bytes(
header[16..20]
.try_into()
.map_err(|_| HintError("slice conversion failed".to_string()))?,
);
let expire_at_ms = u64::from_le_bytes(
header[20..28]
.try_into()
.map_err(|_| HintError("slice conversion failed".to_string()))?,
);
let key_size = u16::from_le_bytes(
header[28..30]
.try_into()
.map_err(|_| HintError("slice conversion failed".to_string()))?,
) as usize;
let mut key = vec![0u8; key_size];
reader.read_exact(&mut key).map_err(HintError::from)?;
// Validate CRC over body (header + key).
let mut body = Vec::with_capacity(HINT_BODY_HEADER_SIZE + key.len());
body.extend_from_slice(&header);
body.extend_from_slice(&key);
let computed_crc = crc32(&body);
if computed_crc != stored_crc {
return Err(HintError(format!(
"hint entry CRC mismatch: stored={stored_crc:#010x}, computed={computed_crc:#010x}"
)));
}
Ok(Some(HintEntry {
file_id,
offset,
value_size,
expire_at_ms,
key,
}))
}
/// CRC-32/ISO-HDLC — delegates to `crc32fast::hash` for hardware-accelerated
/// CRC computation (SSE 4.2 / ARMv8 CRC instructions when available).
///
/// H-REMAIN-1 fix: replaced byte-at-a-time hand-rolled CRC32 with
/// `crc32fast::hash()`. Both use the same CRC-32/ISO-HDLC polynomial
/// (0xEDB88320), so existing hint files remain compatible with no
/// migration needed.
fn crc32(data: &[u8]) -> u32 {
crc32fast::hash(data)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn tmp() -> TempDir {
tempfile::TempDir::new().unwrap()
}
fn sample_entry(key: &[u8], file_id: u64, offset: u64) -> HintEntry {
HintEntry {
file_id,
offset,
value_size: 42,
expire_at_ms: 0,
key: key.to_vec(),
}
}
#[test]
fn write_and_read_single_entry() {
let dir = tmp();
let path = dir.path().join("data.hint");
let original = sample_entry(b"hello", 3, 128);
let mut writer = HintWriter::open(&path).unwrap();
writer.write_entry(&original).unwrap();
writer.commit().unwrap();
let mut reader = HintReader::open(&path).unwrap();
let entries = reader.read_all().unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0], original);
}
#[test]
fn write_and_read_multiple_entries() {
let dir = tmp();
let path = dir.path().join("data.hint");
let entries_in = vec![
sample_entry(b"alpha", 1, 0),
sample_entry(b"beta", 1, 100),
sample_entry(b"gamma", 2, 0),
];
let mut writer = HintWriter::open(&path).unwrap();
for e in &entries_in {
writer.write_entry(e).unwrap();
}
writer.commit().unwrap();
let mut reader = HintReader::open(&path).unwrap();
let entries_out = reader.read_all().unwrap();
assert_eq!(entries_out, entries_in);
}
#[test]
fn load_into_keydir_populates_entries() {
let dir = tmp();
let path = dir.path().join("data.hint");
let mut writer = HintWriter::open(&path).unwrap();
writer
.write_entry(&HintEntry {
file_id: 5,
offset: 256,
value_size: 20,
expire_at_ms: 99_000,
key: b"mykey".to_vec(),
})
.unwrap();
writer.commit().unwrap();
let mut kd = KeyDir::new();
let mut reader = HintReader::open(&path).unwrap();
reader.load_into(&mut kd).unwrap();
let entry = kd.get(b"mykey").unwrap();
assert_eq!(entry.file_id, 5);
assert_eq!(entry.offset, 256);
assert_eq!(entry.value_size, 20);
assert_eq!(entry.expire_at_ms, 99_000);
}
#[test]
fn load_into_keydir_skips_tombstones() {
let dir = tmp();
let path = dir.path().join("data.hint");
let mut writer = HintWriter::open(&path).unwrap();
writer
.write_entry(&HintEntry {
file_id: 1,
offset: 0,
value_size: 10,
expire_at_ms: 0,
key: b"live".to_vec(),
})
.unwrap();
writer
.write_entry(&HintEntry {
file_id: 2,
offset: 0,
value_size: 0, // tombstone
expire_at_ms: 0,
key: b"dead".to_vec(),
})
.unwrap();
writer.commit().unwrap();
let mut kd = KeyDir::new();
let mut reader = HintReader::open(&path).unwrap();
reader.load_into(&mut kd).unwrap();
assert!(kd.get(b"live").is_some());
assert!(kd.get(b"dead").is_none());
}
#[test]
fn load_into_keydir_later_entry_overwrites_earlier() {
let dir = tmp();
let path = dir.path().join("data.hint");
let mut writer = HintWriter::open(&path).unwrap();
writer
.write_entry(&HintEntry {
file_id: 1,
offset: 0,
value_size: 5,
expire_at_ms: 0,
key: b"k".to_vec(),
})
.unwrap();
writer
.write_entry(&HintEntry {
file_id: 2,
offset: 500,
value_size: 8,
expire_at_ms: 0,
key: b"k".to_vec(),
})
.unwrap();
writer.commit().unwrap();
let mut kd = KeyDir::new();
let mut reader = HintReader::open(&path).unwrap();
reader.load_into(&mut kd).unwrap();
let entry = kd.get(b"k").unwrap();
assert_eq!(entry.file_id, 2);
assert_eq!(entry.offset, 500);
}
#[test]
fn empty_hint_file_loads_empty_keydir() {
let dir = tmp();
let path = dir.path().join("empty.hint");
let writer = HintWriter::open(&path).unwrap();
writer.commit().unwrap();
let mut kd = KeyDir::new();
let mut reader = HintReader::open(&path).unwrap();
reader.load_into(&mut kd).unwrap();
assert!(kd.is_empty());
}
// ------------------------------------------------------------------
// Atomicity tests (Issue 5.2)
// ------------------------------------------------------------------
/// Dropping a `HintWriter` without calling `commit` must not leave any
/// file at the final `.hint` path, and must also clean up the `.hint.tmp`
/// file (best-effort via `Drop`).
#[test]
fn hint_write_is_atomic_on_crash() {
let dir = tmp();
let path = dir.path().join("data.hint");
let tmp_path = dir.path().join("data.hint.tmp");
// Open a writer and write some entries, but never call commit().
{
let mut writer = HintWriter::open(&path).unwrap();
writer.write_entry(&sample_entry(b"key1", 1, 0)).unwrap();
writer.write_entry(&sample_entry(b"key2", 1, 64)).unwrap();
// writer drops here without commit — simulates a crash
}
// The final .hint file must NOT exist (was never renamed into place).
assert!(
!path.exists(),
"final .hint must not exist when commit was never called"
);
// The .hint.tmp file must also be gone (cleaned up by Drop).
assert!(
!tmp_path.exists(),
".hint.tmp must be cleaned up by Drop when commit was never called"
);
}
/// Writing a second hint file over an existing one must atomically replace
/// the content: only the new keys are visible after commit, and no `.tmp`
/// file remains.
#[test]
fn hint_commit_replaces_existing_hint_atomically() {
let dir = tmp();
let path = dir.path().join("data.hint");
let tmp_path = dir.path().join("data.hint.tmp");
// Write the first hint file with "old_key".
{
let mut writer = HintWriter::open(&path).unwrap();
writer.write_entry(&sample_entry(b"old_key", 1, 0)).unwrap();
writer.commit().unwrap();
}
// Verify "old_key" is readable.
{
let mut reader = HintReader::open(&path).unwrap();
let entries = reader.read_all().unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].key, b"old_key");
}
// Write a second hint file with "new_key" over the same path.
{
let mut writer = HintWriter::open(&path).unwrap();
writer
.write_entry(&sample_entry(b"new_key", 2, 128))
.unwrap();
writer.commit().unwrap();
}
// Only "new_key" must be present.
let mut reader = HintReader::open(&path).unwrap();
let entries = reader.read_all().unwrap();
assert_eq!(
entries.len(),
1,
"only new_key should be present after second commit"
);
assert_eq!(
entries[0].key, b"new_key",
"old_key must be replaced by new_key"
);
// No .tmp file should remain after a successful commit.
assert!(
!tmp_path.exists(),
".hint.tmp must not exist after a successful commit"
);
}
// ------------------------------------------------------------------
// CRC integrity tests (Issue 5.1)
// ------------------------------------------------------------------
/// A valid hint entry round-trips correctly; flipping a byte in the offset
/// field must cause a CRC mismatch error on re-read.
#[test]
fn hint_entry_crc_validated_on_read() {
let dir = tmp();
let path = dir.path().join("data.hint");
let original = sample_entry(b"hello", 3, 128);
{
let mut writer = HintWriter::open(&path).unwrap();
writer.write_entry(&original).unwrap();
writer.commit().unwrap();
}
// Clean read — must succeed.
{
let mut reader = HintReader::open(&path).unwrap();
let entries = reader.read_all().unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0], original);
}
// Corrupt the offset field. The entry layout on disk is:
// [crc32: 4][file_id: 8][offset: 8][value_size: 4][expire_at_ms: 8][key_size: 2][key]
// The offset field starts at byte 4 + 8 = 12.
{
use std::fs::OpenOptions;
use std::io::{Seek, SeekFrom, Write};
let mut f = OpenOptions::new().write(true).open(&path).unwrap();
f.seek(SeekFrom::Start(12)).unwrap();
f.write_all(&[0xFF, 0xFF, 0xFF, 0xFF]).unwrap();
}
// Re-read — must return a CRC error.
let mut reader = HintReader::open(&path).unwrap();
assert!(
reader.read_all().is_err(),
"corrupted offset field must trigger a CRC mismatch error"
);
}
/// Corrupting the `file_id` field (not the key) must also be caught by the CRC.
#[test]
fn hint_entry_crc_covers_all_fields() {
let dir = tmp();
let path = dir.path().join("data.hint");
let entry = HintEntry {
file_id: 42,
offset: 1024,
value_size: 7,
expire_at_ms: 999_000,
key: b"integrity".to_vec(),
};
{
let mut writer = HintWriter::open(&path).unwrap();
writer.write_entry(&entry).unwrap();
writer.commit().unwrap();
}
// Verify clean read.
{
let mut reader = HintReader::open(&path).unwrap();
let entries = reader.read_all().unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0], entry);
}
// Corrupt the file_id field. It starts at byte 4 (right after the 4-byte CRC).
{
use std::fs::OpenOptions;
use std::io::{Seek, SeekFrom, Write};
let mut f = OpenOptions::new().write(true).open(&path).unwrap();
f.seek(SeekFrom::Start(4)).unwrap();
f.write_all(&[0xDE, 0xAD, 0xBE, 0xEF]).unwrap();
}
let mut reader = HintReader::open(&path).unwrap();
assert!(
reader.read_all().is_err(),
"corrupted file_id field must trigger a CRC mismatch error"
);
}
/// Writing 10 entries and reading them all back must succeed and produce
/// identical entries in the same order.
#[test]
fn hint_multiple_entries_round_trip() {
let dir = tmp();
let path = dir.path().join("data.hint");
let entries_in: Vec<HintEntry> = (0..10)
.map(|i| HintEntry {
file_id: i,
offset: i * 64,
value_size: (i as u32) + 1,
expire_at_ms: i * 1_000,
key: format!("key-{i}").into_bytes(),
})
.collect();
{
let mut writer = HintWriter::open(&path).unwrap();
for e in &entries_in {
writer.write_entry(e).unwrap();
}
writer.commit().unwrap();
}
let mut reader = HintReader::open(&path).unwrap();
let entries_out = reader.read_all().unwrap();
assert_eq!(entries_out.len(), entries_in.len());
for (a, b) in entries_in.iter().zip(entries_out.iter()) {
assert_eq!(a, b);
}
}
// ------------------------------------------------------------------
// CRC integrity (additional)
// ------------------------------------------------------------------
/// Write a valid entry, flip a byte in the key area, re-read returns error.
#[test]
fn crc32_in_hint_entry_is_validated() {
let dir = tmp();
let path = dir.path().join("data.hint");
let entry = sample_entry(b"validate_me", 7, 512);
{
let mut writer = HintWriter::open(&path).unwrap();
writer.write_entry(&entry).unwrap();
writer.commit().unwrap();
}
// Compute where the key starts:
// Layout: [crc32: 4][file_id: 8][offset: 8][value_size: 4][expire_at_ms: 8][key_size: 2][key]
// Key starts at byte 4 + 8 + 8 + 4 + 8 + 2 = 34.
{
use std::fs::OpenOptions;
use std::io::{Seek, SeekFrom, Write};
let mut f = OpenOptions::new().write(true).open(&path).unwrap();
f.seek(SeekFrom::Start(34)).unwrap();
f.write_all(&[0xAB]).unwrap();
}
let mut reader = HintReader::open(&path).unwrap();
assert!(
reader.read_all().is_err(),
"flipped byte in key area must trigger CRC mismatch"
);
}
/// Empty key round-trips through `write_entry` / `read_all`.
#[test]
fn hint_entry_empty_key_round_trips() {
let dir = tmp();
let path = dir.path().join("data.hint");
let entry = HintEntry {
file_id: 1,
offset: 0,
value_size: 5,
expire_at_ms: 0,
key: Vec::new(), // empty key
};
{
let mut writer = HintWriter::open(&path).unwrap();
writer.write_entry(&entry).unwrap();
writer.commit().unwrap();
}
let mut reader = HintReader::open(&path).unwrap();
let entries = reader.read_all().unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(
entries[0].key,
Vec::<u8>::new(),
"empty key must round-trip"
);
assert_eq!(entries[0].file_id, 1);
assert_eq!(entries[0].value_size, 5);
}
/// Write 20 entries and verify all read back without CRC error.
#[test]
fn hint_multiple_entries_all_have_valid_crcs() {
let dir = tmp();
let path = dir.path().join("data.hint");
let entries_in: Vec<HintEntry> = (0u64..20)
.map(|i| HintEntry {
file_id: i % 3,
offset: i * 128,
value_size: (i as u32) * 10 + 1,
expire_at_ms: if i % 2 == 0 { 0 } else { i * 1000 },
key: format!("crc_check_key_{i}").into_bytes(),
})
.collect();
{
let mut writer = HintWriter::open(&path).unwrap();
for e in &entries_in {
writer.write_entry(e).unwrap();
}
writer.commit().unwrap();
}
// All 20 entries must be readable without CRC error.
let mut reader = HintReader::open(&path).unwrap();
let entries_out = reader.read_all().unwrap();
assert_eq!(entries_out.len(), 20, "all 20 entries must be present");
for (a, b) in entries_in.iter().zip(entries_out.iter()) {
assert_eq!(a, b, "entry must survive round-trip with correct CRC");
}
}
// ------------------------------------------------------------------
// commit() atomicity (additional)
// ------------------------------------------------------------------
/// After `commit()`, no `.hint.tmp` file exists and `.hint` file does.
#[test]
fn commit_renames_tmp_to_final() {
let dir = tmp();
let path = dir.path().join("data.hint");
let tmp_path = dir.path().join("data.hint.tmp");
{
let mut writer = HintWriter::open(&path).unwrap();
writer
.write_entry(&sample_entry(b"committed", 1, 0))
.unwrap();
writer.commit().unwrap();
}
assert!(path.exists(), "final .hint file must exist after commit");
assert!(
!tmp_path.exists(),
".hint.tmp file must not exist after commit"
);
}
/// Dropping the writer without calling commit must remove the .tmp file
/// and not create the final .hint file.
#[test]
fn drop_without_commit_cleans_up_tmp() {
let dir = tmp();
let path = dir.path().join("data.hint");
let tmp_path = dir.path().join("data.hint.tmp");
{
let mut writer = HintWriter::open(&path).unwrap();
writer
.write_entry(&sample_entry(b"uncommitted", 1, 0))
.unwrap();
// Drop without commit.
}
assert!(
!path.exists(),
"final .hint must not exist when commit was never called"
);
assert!(!tmp_path.exists(), ".hint.tmp must be cleaned up by Drop");
}
// ------------------------------------------------------------------
// load_into: detailed edge cases
// ------------------------------------------------------------------
/// Loading an empty hint file results in an empty keydir.
#[test]
fn load_into_empty_file_returns_empty_keydir() {
let dir = tmp();
let path = dir.path().join("empty2.hint");
let writer = HintWriter::open(&path).unwrap();
writer.commit().unwrap();
let mut kd = KeyDir::new();
let mut reader = HintReader::open(&path).unwrap();
reader.load_into(&mut kd).unwrap();
assert!(kd.is_empty(), "empty hint file must produce empty keydir");
assert_eq!(kd.len(), 0);
}
/// Writing 15 entries and loading them populates the keydir with 15 entries.
#[test]
fn load_into_all_entries_added_to_keydir() {
let dir = tmp();
let path = dir.path().join("data.hint");
let entries: Vec<HintEntry> = (0u64..15)
.map(|i| HintEntry {
file_id: 1,
offset: i * 50,
value_size: 10,
expire_at_ms: 0,
key: format!("load_key_{i}").into_bytes(),
})
.collect();
{
let mut writer = HintWriter::open(&path).unwrap();
for e in &entries {
writer.write_entry(e).unwrap();
}
writer.commit().unwrap();
}
let mut kd = KeyDir::new();
let mut reader = HintReader::open(&path).unwrap();
reader.load_into(&mut kd).unwrap();
assert_eq!(kd.len(), 15, "all 15 entries must be loaded into keydir");
for e in &entries {
assert!(kd.get(&e.key).is_some(), "keydir must contain {:?}", e.key);
}
}
/// A hint file with a corrupt CRC in the first entry causes `load_into` to return Err.
#[test]
fn load_into_corrupt_crc_returns_error() {
let dir = tmp();
let path = dir.path().join("data.hint");
{
let mut writer = HintWriter::open(&path).unwrap();
writer
.write_entry(&sample_entry(b"crc_load_test", 1, 0))
.unwrap();
writer.commit().unwrap();
}
// Flip a byte in the key area (byte 34) to invalidate the CRC.
{
use std::fs::OpenOptions;
use std::io::{Seek, SeekFrom, Write};
let mut f = OpenOptions::new().write(true).open(&path).unwrap();
f.seek(SeekFrom::Start(34)).unwrap();
f.write_all(&[0xCC]).unwrap();
}
let mut kd = KeyDir::new();
let mut reader = HintReader::open(&path).unwrap();
assert!(
reader.load_into(&mut kd).is_err(),
"load_into on corrupt hint must return Err"
);
}
// ------------------------------------------------------------------
// H-REMAIN-1: crc32fast backward compatibility
// ------------------------------------------------------------------
/// Verify that `crc32fast::hash()` produces the same output as the
/// old hand-rolled CRC-32/ISO-HDLC implementation. This ensures
/// existing hint files written with the old code remain readable.
#[test]
fn crc32fast_matches_old_hand_rolled_implementation() {
/// Original hand-rolled CRC-32/ISO-HDLC for reference.
fn old_crc32(data: &[u8]) -> u32 {
let mut crc: u32 = 0xFFFF_FFFF;
for &byte in data {
crc ^= u32::from(byte);
for _ in 0..8 {
if crc & 1 == 1 {
crc = (crc >> 1) ^ 0xEDB8_8320;
} else {
crc >>= 1;
}
}
}
crc ^ 0xFFFF_FFFF
}
// Empty data
assert_eq!(crc32(&[]), old_crc32(&[]));
// Small data
assert_eq!(crc32(b"hello"), old_crc32(b"hello"));
assert_eq!(crc32(b"ferricstore"), old_crc32(b"ferricstore"));
// Binary data
let binary_data: Vec<u8> = (0u8..=255).collect();
assert_eq!(crc32(&binary_data), old_crc32(&binary_data));
// Large data (simulating a hint entry body)
let large_data = vec![0xABu8; 10_000];
assert_eq!(crc32(&large_data), old_crc32(&large_data));
}
/// Write 1000 hint entries and verify all round-trip correctly with
/// hardware-accelerated CRC. Exercises the hot path that H-REMAIN-1 fixes.
#[test]
fn hint_1000_entries_round_trip_with_crc32fast() {
let dir = tmp();
let path = dir.path().join("large.hint");
let entries_in: Vec<HintEntry> = (0u64..1000)
.map(|i| HintEntry {
file_id: i % 10,
offset: i * 256,
value_size: (i as u32) * 3 + 1,
expire_at_ms: if i % 3 == 0 { 0 } else { i * 5000 },
key: format!("key_{i:06}").into_bytes(),
})
.collect();
{
let mut writer = HintWriter::open(&path).unwrap();
for e in &entries_in {
writer.write_entry(e).unwrap();
}
writer.commit().unwrap();
}
let mut reader = HintReader::open(&path).unwrap();
let entries_out = reader.read_all().unwrap();
assert_eq!(entries_out.len(), 1000);
for (a, b) in entries_in.iter().zip(entries_out.iter()) {
assert_eq!(a, b, "entry must survive round-trip with crc32fast CRC");
}
}
/// Corrupted CRC detection still works after switching to crc32fast.
#[test]
fn crc32fast_detects_corruption() {
let dir = tmp();
let path = dir.path().join("corrupt_detect.hint");
let entry = sample_entry(b"detect_me", 42, 1024);
{
let mut writer = HintWriter::open(&path).unwrap();
writer.write_entry(&entry).unwrap();
writer.commit().unwrap();
}
// Clean read succeeds.
{
let mut reader = HintReader::open(&path).unwrap();
let entries = reader.read_all().unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0], entry);
}
// Corrupt the value_size field (starts at byte 4+8+8=20).
{
use std::fs::OpenOptions;
use std::io::{Seek, SeekFrom, Write};
let mut f = OpenOptions::new().write(true).open(&path).unwrap();
f.seek(SeekFrom::Start(20)).unwrap();
f.write_all(&[0xFF, 0xFF]).unwrap();
}
let mut reader = HintReader::open(&path).unwrap();
assert!(
reader.read_all().is_err(),
"corruption must still be detected after crc32fast migration"
);
}
// -------------------------------------------------------------------
// Durability tests for HintWriter::commit
// -------------------------------------------------------------------
//
// These lock in the contract that the design doc requires:
//
// 1. After commit() returns Ok, the final `.hint` file exists on
// the filesystem with all the entries readable.
// 2. The temporary `.hint.tmp` file no longer exists (it was
// renamed to final_path).
// 3. commit() does NOT error with a read-back of the file (data
// was flushed + sync_data'd before rename).
//
// We can't test "fsync actually hit the disk" from userspace without
// a crash injector, but we can assert the full round-trip works
// bit-for-bit immediately after commit() returns — which is the
// observable contract sync_data() is supposed to provide.
#[test]
fn commit_leaves_final_file_readable_and_removes_tmp() {
let dir = tmp();
let final_path = dir.path().join("data.hint");
let tmp_path = final_path.with_extension("hint.tmp");
let entry = HintEntry {
file_id: 9,
offset: 1024,
value_size: 64,
expire_at_ms: 123_456,
key: b"committed".to_vec(),
};
let mut writer = HintWriter::open(&final_path).unwrap();
writer.write_entry(&entry).unwrap();
writer.commit().unwrap();
assert!(
final_path.exists(),
"commit() must leave the final .hint file on disk"
);
assert!(
!tmp_path.exists(),
"commit() must rename away the .hint.tmp file"
);
let mut reader = HintReader::open(&final_path).unwrap();
let out = reader.read_all().unwrap();
assert_eq!(out, vec![entry]);
}
#[test]
fn commit_with_many_entries_fully_round_trips() {
// Stress-test the flush + sync_data + rename sequence with a
// payload larger than a BufWriter default buffer (8 KiB).
let dir = tmp();
let path = dir.path().join("big.hint");
let entries: Vec<HintEntry> = (0..2_000)
.map(|i| HintEntry {
file_id: i as u64,
offset: (i as u64) * 32,
value_size: 16,
expire_at_ms: 0,
key: format!("key_{:08}", i).into_bytes(),
})
.collect();
let mut writer = HintWriter::open(&path).unwrap();
for e in &entries {
writer.write_entry(e).unwrap();
}
writer.commit().unwrap();
let mut reader = HintReader::open(&path).unwrap();
let out = reader.read_all().unwrap();
assert_eq!(
out, entries,
"every entry written before commit must be readable after commit"
);
}
#[test]
fn drop_without_commit_leaves_no_final_file() {
// Documents the other half of the contract: if commit() is not
// called (crash / early error), the final file must NOT appear
// and the tmp file is cleaned up by Drop.
let dir = tmp();
let path = dir.path().join("aborted.hint");
let tmp_path = path.with_extension("hint.tmp");
{
let mut writer = HintWriter::open(&path).unwrap();
writer
.write_entry(&HintEntry {
file_id: 1,
offset: 0,
value_size: 4,
expire_at_ms: 0,
key: b"abc".to_vec(),
})
.unwrap();
// Deliberately do not call commit(). Drop runs here.
}
assert!(
!path.exists(),
"no commit means the final .hint file must not appear"
);
assert!(
!tmp_path.exists(),
"Drop must clean up the .hint.tmp file when commit was skipped"
);
}
}