mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-03 07:48:34 +02:00
334 lines
10 KiB
Rust
334 lines
10 KiB
Rust
use std::fs::{File, OpenOptions};
|
|
use std::io::Write;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use crate::error::Result;
|
|
use crate::types::{BUCKET_COUNT, INDEX_RECORD_SIZE};
|
|
|
|
/// On-disk format: 52 bytes per record.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct IndexRecord {
|
|
pub key: [u8; 32],
|
|
pub segment_id: u32,
|
|
pub offset: u64,
|
|
pub data_size: u32,
|
|
pub flags: u8,
|
|
}
|
|
|
|
impl IndexRecord {
|
|
pub fn new(key: [u8; 32], segment_id: u32, offset: u64, data_size: u32, flags: u8) -> Self {
|
|
Self {
|
|
key,
|
|
segment_id,
|
|
offset,
|
|
data_size,
|
|
flags,
|
|
}
|
|
}
|
|
|
|
pub fn is_tombstone(&self) -> bool {
|
|
self.flags == 1
|
|
}
|
|
|
|
pub fn encode(&self) -> [u8; INDEX_RECORD_SIZE] {
|
|
let mut buf = [0u8; INDEX_RECORD_SIZE];
|
|
buf[0..32].copy_from_slice(&self.key);
|
|
buf[32..36].copy_from_slice(&self.segment_id.to_le_bytes());
|
|
buf[36..44].copy_from_slice(&self.offset.to_le_bytes());
|
|
buf[44..48].copy_from_slice(&self.data_size.to_le_bytes());
|
|
buf[48] = self.flags;
|
|
// bytes 49..52 are padding (keep zero)
|
|
buf
|
|
}
|
|
|
|
pub fn decode(buf: &[u8; INDEX_RECORD_SIZE]) -> Self {
|
|
let mut key = [0u8; 32];
|
|
key.copy_from_slice(&buf[0..32]);
|
|
let segment_id = u32::from_le_bytes(buf[32..36].try_into().unwrap());
|
|
let offset = u64::from_le_bytes(buf[36..44].try_into().unwrap());
|
|
let data_size = u32::from_le_bytes(buf[44..48].try_into().unwrap());
|
|
let flags = buf[48];
|
|
Self {
|
|
key,
|
|
segment_id,
|
|
offset,
|
|
data_size,
|
|
flags,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Represents a loaded and deduplicated bucket in memory.
|
|
pub struct BucketIndex {
|
|
pub bucket_id: u16,
|
|
/// Records sorted by key, deduplicated (one record per key, latest wins).
|
|
pub records: Vec<IndexRecord>,
|
|
}
|
|
|
|
impl BucketIndex {
|
|
/// Build from raw records: sort by key, dedup keeping the one with max offset.
|
|
pub fn from_records(mut records: Vec<IndexRecord>, bucket_id: u16) -> Self {
|
|
records.sort_by_key(|a| a.key);
|
|
// Dedup: keep last (max offset) for each key
|
|
let mut deduped = Vec::with_capacity(records.len());
|
|
let mut i = 0;
|
|
while i < records.len() {
|
|
let mut best = i;
|
|
let mut j = i + 1;
|
|
while j < records.len() && records[j].key == records[i].key {
|
|
if records[j].offset > records[best].offset {
|
|
best = j;
|
|
}
|
|
j += 1;
|
|
}
|
|
deduped.push(records[best].clone());
|
|
i = j;
|
|
}
|
|
Self {
|
|
bucket_id,
|
|
records: deduped,
|
|
}
|
|
}
|
|
|
|
/// Binary search for a key. Returns the record if found.
|
|
pub fn find(&self, key: &[u8; 32]) -> Option<&IndexRecord> {
|
|
match self.records.binary_search_by(|r| r.key.cmp(key)) {
|
|
Ok(idx) => Some(&self.records[idx]),
|
|
Err(_) => None,
|
|
}
|
|
}
|
|
|
|
/// Append a new record and maintain sorted order.
|
|
pub fn insert(&mut self, record: IndexRecord) {
|
|
match self.records.binary_search_by(|r| r.key.cmp(&record.key)) {
|
|
Ok(idx) => {
|
|
// Replace if newer (larger offset)
|
|
if record.offset > self.records[idx].offset {
|
|
self.records[idx] = record;
|
|
}
|
|
}
|
|
Err(idx) => {
|
|
self.records.insert(idx, record);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn len(&self) -> usize {
|
|
self.records.len()
|
|
}
|
|
|
|
pub fn is_empty(&self) -> bool {
|
|
self.records.is_empty()
|
|
}
|
|
}
|
|
|
|
/// Manages a bucket index file on disk.
|
|
pub struct BucketFile {
|
|
path: PathBuf,
|
|
bucket_id: u16,
|
|
}
|
|
|
|
impl BucketFile {
|
|
pub fn path_for(account_dir: &Path, bucket_id: u16) -> PathBuf {
|
|
account_dir.join("buckets").join(format!("{:02x}.idx", bucket_id))
|
|
}
|
|
|
|
pub fn open(account_dir: &Path, bucket_id: u16) -> Self {
|
|
Self {
|
|
path: Self::path_for(account_dir, bucket_id),
|
|
bucket_id,
|
|
}
|
|
}
|
|
|
|
pub fn path(&self) -> &Path {
|
|
&self.path
|
|
}
|
|
|
|
pub fn bucket_id(&self) -> u16 {
|
|
self.bucket_id
|
|
}
|
|
|
|
/// Ensure the buckets directory exists.
|
|
pub fn ensure_dir(account_dir: &Path) -> Result<()> {
|
|
let dir = account_dir.join("buckets");
|
|
std::fs::create_dir_all(&dir)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Append a single record to the bucket file.
|
|
pub fn append(&self, record: &IndexRecord) -> Result<()> {
|
|
let mut file = OpenOptions::new()
|
|
.create(true)
|
|
.append(true)
|
|
.open(&self.path)?;
|
|
file.write_all(&record.encode())?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Append multiple records at once.
|
|
pub fn append_batch(&self, records: &[IndexRecord]) -> Result<()> {
|
|
if records.is_empty() {
|
|
return Ok(());
|
|
}
|
|
let mut file = OpenOptions::new()
|
|
.create(true)
|
|
.append(true)
|
|
.open(&self.path)?;
|
|
for r in records {
|
|
file.write_all(&r.encode())?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Load all records from the bucket file.
|
|
/// If the file size is not a multiple of INDEX_RECORD_SIZE (partial write),
|
|
/// the trailing bytes are silently ignored.
|
|
pub fn load_all(&self) -> Result<Vec<IndexRecord>> {
|
|
if !self.path.exists() {
|
|
return Ok(Vec::new());
|
|
}
|
|
let data = std::fs::read(&self.path)?;
|
|
let remainder = data.len() % INDEX_RECORD_SIZE;
|
|
let count = data.len() / INDEX_RECORD_SIZE;
|
|
let mut records = Vec::with_capacity(count);
|
|
for i in 0..count {
|
|
let start = i * INDEX_RECORD_SIZE;
|
|
let end = start + INDEX_RECORD_SIZE;
|
|
let buf: &[u8; INDEX_RECORD_SIZE] = data[start..end]
|
|
.try_into()
|
|
.map_err(|_| crate::error::Error::BucketIndexCorrupt {
|
|
path: self.path.clone(),
|
|
reason: format!("unexpected file size {}, not a multiple of {}", data.len(), INDEX_RECORD_SIZE),
|
|
})?;
|
|
records.push(IndexRecord::decode(buf));
|
|
}
|
|
if remainder > 0 {
|
|
tracing::warn!(
|
|
"Bucket file {:?} has {} trailing bytes (expected multiple of {}), ignoring",
|
|
self.path, remainder, INDEX_RECORD_SIZE
|
|
);
|
|
}
|
|
Ok(records)
|
|
}
|
|
|
|
/// Load all records, sort, and deduplicate into a BucketIndex.
|
|
pub fn load_index(&self) -> Result<BucketIndex> {
|
|
let records = self.load_all()?;
|
|
Ok(BucketIndex::from_records(records, self.bucket_id))
|
|
}
|
|
|
|
/// Rewrite the bucket file with a sorted, deduplicated set of records.
|
|
pub fn rewrite(&self, records: &[IndexRecord]) -> Result<()> {
|
|
let temp_path = self.path.with_extension("idx.tmp");
|
|
{
|
|
let mut file = File::create(&temp_path)?;
|
|
for r in records {
|
|
file.write_all(&r.encode())?;
|
|
}
|
|
file.sync_all()?;
|
|
}
|
|
std::fs::rename(&temp_path, &self.path)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Delete the bucket file.
|
|
pub fn delete(&self) -> Result<()> {
|
|
if self.path.exists() {
|
|
std::fs::remove_file(&self.path)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Compute bucket_id from a key's first 2 bytes.
|
|
pub fn bucket_id(key: &[u8; 32]) -> u16 {
|
|
u16::from_be_bytes([key[0], key[1]]) % BUCKET_COUNT
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use tempfile::TempDir;
|
|
|
|
#[test]
|
|
fn test_index_record_encode_decode() {
|
|
let mut key = [0u8; 32];
|
|
key[0..4].copy_from_slice(&[1, 2, 3, 4]);
|
|
let rec = IndexRecord::new(key, 5, 12345, 500, 0);
|
|
let encoded = rec.encode();
|
|
let decoded = IndexRecord::decode(&encoded);
|
|
assert_eq!(rec, decoded);
|
|
}
|
|
|
|
#[test]
|
|
fn test_bucket_id_deterministic() {
|
|
let mut key = [0u8; 32];
|
|
key[0] = 0x00;
|
|
key[1] = 0x0F;
|
|
assert_eq!(bucket_id(&key), 15);
|
|
key[0] = 0x00;
|
|
key[1] = 0x10;
|
|
assert_eq!(bucket_id(&key), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_bucket_append_and_load() {
|
|
let dir = TempDir::new().unwrap();
|
|
let bucket = BucketFile::open(dir.path(), 0);
|
|
BucketFile::ensure_dir(dir.path()).unwrap();
|
|
|
|
let r1 = IndexRecord::new([1u8; 32], 1, 100, 50, 0);
|
|
let r2 = IndexRecord::new([2u8; 32], 1, 200, 60, 0);
|
|
|
|
bucket.append(&r1).unwrap();
|
|
bucket.append(&r2).unwrap();
|
|
|
|
let loaded = bucket.load_all().unwrap();
|
|
assert_eq!(loaded.len(), 2);
|
|
assert_eq!(loaded[0].key, [1u8; 32]);
|
|
assert_eq!(loaded[1].key, [2u8; 32]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_bucket_index_dedup() {
|
|
let recs = vec![
|
|
IndexRecord::new([1u8; 32], 1, 100, 50, 0),
|
|
IndexRecord::new([1u8; 32], 2, 200, 50, 0), // newer offset wins
|
|
IndexRecord::new([2u8; 32], 1, 300, 60, 0),
|
|
];
|
|
let idx = BucketIndex::from_records(recs, 0);
|
|
assert_eq!(idx.len(), 2);
|
|
let found = idx.find(&[1u8; 32]).unwrap();
|
|
assert_eq!(found.segment_id, 2);
|
|
assert_eq!(found.offset, 200);
|
|
}
|
|
|
|
#[test]
|
|
fn test_bucket_index_find_missing() {
|
|
let recs = vec![IndexRecord::new([1u8; 32], 1, 100, 50, 0)];
|
|
let idx = BucketIndex::from_records(recs, 0);
|
|
assert!(idx.find(&[99u8; 32]).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_bucket_rewrite() {
|
|
let dir = TempDir::new().unwrap();
|
|
let bucket = BucketFile::open(dir.path(), 0);
|
|
BucketFile::ensure_dir(dir.path()).unwrap();
|
|
|
|
let r1 = IndexRecord::new([3u8; 32], 1, 300, 70, 0);
|
|
let r2 = IndexRecord::new([1u8; 32], 1, 100, 50, 0);
|
|
bucket.append(&r1).unwrap();
|
|
bucket.append(&r2).unwrap();
|
|
|
|
// Rewrite sorted
|
|
let sorted = vec![r2.clone(), r1.clone()];
|
|
bucket.rewrite(&sorted).unwrap();
|
|
|
|
let loaded = bucket.load_all().unwrap();
|
|
assert_eq!(loaded.len(), 2);
|
|
assert_eq!(loaded[0].key, [1u8; 32]);
|
|
assert_eq!(loaded[1].key, [3u8; 32]);
|
|
}
|
|
}
|