Files
bichon/crates/blob/src/bucket.rs
T
rustmailer 4cdf3ee5f1 refactor(blob): add delete_batch, gc_if_needed, background flush, and fix bincode compat
- Replace bincode 3.0.0 (empty crate) with bincode_reloaded 3.1.10
  - Add delete_batch for efficient grouped tombstone writes
  - Add gc_if_needed to skip GC when no segment exceeds threshold
  - Add flush() for lightweight fsync+meta checkpoint without compact
  - Add Config::flush_interval_secs to spawn a background flush thread
  - Remove per-put/per-delete fsync; persistence via background flush
  - Split gc_segments into gc_prepare/gc_finish to reduce write lock hold time
2026-07-10 01:52:04 +08:00

609 lines
19 KiB
Rust

use std::collections::HashMap;
use std::fs::{self, File, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use memmap2::Mmap;
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;
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,
}
}
}
/// 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
}
// ── BucketStore ────────────────────────────────────────────────────────────
/// Per-bucket mutable state behind a Mutex.
struct BucketState {
/// mmap of the clean, sorted, deduplicated portion of the bucket file.
mmap: Mmap,
/// Number of sorted records in the mmap.
compacted_records: usize,
/// Recent writes not yet merged into the mmap. Key → latest record.
pending: HashMap<[u8; 32], IndexRecord>,
/// Append-only file for durability of pending writes.
file: File,
/// Path to the bucket file.
path: PathBuf,
}
/// Zero-heap bucket index store backed by mmap.
///
/// Each bucket's clean portion is mmap'd — binary search reads directly
/// from the OS page cache without allocating heap memory proportional to
/// the number of stored keys. Only pending writes (since the last compact)
/// live in a small in-memory HashMap.
pub struct BucketStore {
states: Vec<Mutex<BucketState>>,
compact_threshold: usize,
}
impl BucketStore {
/// Open all bucket files. On first open or after a crash, each file is
/// loaded, sorted, deduplicated, and rewritten into a clean mmap'd form.
pub fn open(dir: &Path, compact_threshold: usize) -> Result<Self> {
fs::create_dir_all(dir)?;
let mut states = Vec::with_capacity(BUCKET_COUNT as usize);
for bid in 0..BUCKET_COUNT {
let path = bucket_path(dir, bid);
let (mmap, compacted_records) = if path.exists() {
// Load, sort, dedup, rewrite clean, then mmap.
let records = load_records_from_file(&path)?;
let deduped = sort_and_dedup(records);
let count = deduped.len();
rewrite_file(&path, &deduped)?;
let file = fs::File::open(&path)?;
let mmap = unsafe { Mmap::map(&file)? };
(mmap, count)
} else {
// Create empty bucket file.
let file = fs::File::create(&path)?;
file.set_len(0)?;
let mmap = unsafe { Mmap::map(&file)? };
(mmap, 0)
};
let file = OpenOptions::new()
.create(true)
.append(true)
.open(&path)?;
states.push(Mutex::new(BucketState {
mmap,
compacted_records,
pending: HashMap::new(),
file,
path,
}));
}
Ok(Self {
states,
compact_threshold,
})
}
/// Look up a key. Returns the latest IndexRecord, or None if absent/tombstone.
pub fn get(&self, key: &[u8; 32]) -> Result<Option<IndexRecord>> {
let bid = bucket_id(key) as usize;
let state = self.states[bid].lock().unwrap();
// 1. Check pending (most recent wins)
if let Some(rec) = state.pending.get(key) {
return Ok(if rec.is_tombstone() { None } else { Some(rec.clone()) });
}
// 2. Binary search the mmap'd sorted portion
let bytes: &[u8] = &state.mmap;
if bytes.is_empty() {
return Ok(None);
}
let count = bytes.len() / INDEX_RECORD_SIZE;
let result = binary_search_records(bytes, key, count);
match result {
Some(idx) => {
let rec = read_record_at(bytes, idx);
Ok(if rec.is_tombstone() { None } else { Some(rec) })
}
None => Ok(None),
}
}
/// Check whether a key exists (non-tombstone) in the store.
pub fn exists(&self, key: &[u8; 32]) -> Result<bool> {
self.get(key).map(|r| r.is_some())
}
/// Insert or update a record for a key. Appends to the file for durability,
/// then inserts into the pending HashMap.
pub fn insert(&self, record: IndexRecord) -> Result<()> {
let bid = bucket_id(&record.key) as usize;
let mut state = self.states[bid].lock().unwrap();
// Durability: append to file
state.file.write_all(&record.encode())?;
// Update pending
state.pending.insert(record.key, record);
// Auto-compact if pending grows too large
if state.pending.len() >= self.compact_threshold {
drop(state);
self.compact_bucket(bid as u16)?;
}
Ok(())
}
/// Batch insert multiple records. Appends all, then updates pending.
pub fn insert_batch(&self, records: &[IndexRecord]) -> Result<()> {
// Group by bucket
let mut grouped: HashMap<u16, Vec<&IndexRecord>> = HashMap::new();
for r in records {
let bid = bucket_id(&r.key);
grouped.entry(bid).or_default().push(r);
}
for (bid, recs) in &grouped {
let bid_usize = *bid as usize;
let mut state = self.states[bid_usize].lock().unwrap();
for r in recs {
state.file.write_all(&r.encode())?;
state.pending.insert(r.key, (*r).clone());
}
}
// Compact overfull buckets
for &bid in grouped.keys() {
let state = self.states[bid as usize].lock().unwrap();
let needs_compact = state.pending.len() >= self.compact_threshold;
drop(state);
if needs_compact {
self.compact_bucket(bid)?;
}
}
Ok(())
}
/// Run stats across all buckets: total keys (non-tombstone) and total data bytes.
pub fn total_keys(&self) -> usize {
let mut count = 0usize;
for state in self.states.iter() {
let s = state.lock().unwrap();
// Count from pending
for r in s.pending.values() {
if !r.is_tombstone() {
count += 1;
}
}
// Count from mmap
let bytes: &[u8] = &s.mmap;
let n = bytes.len() / INDEX_RECORD_SIZE;
for i in 0..n {
let rec = read_record_at(bytes, i);
// Skip keys that are overridden in pending
if s.pending.contains_key(&rec.key) {
continue;
}
if !rec.is_tombstone() {
count += 1;
}
}
}
count
}
/// Compact a single bucket: merge mmap + pending, sort+dedup, rewrite file, remap.
fn compact_bucket(&self, bid: u16) -> Result<()> {
let idx = bid as usize;
let mut state = self.states[idx].lock().unwrap();
if state.pending.is_empty() {
return Ok(());
}
// Collect mmap records + pending records
let mut all: Vec<IndexRecord> = Vec::new();
let bytes: &[u8] = &state.mmap;
let n = bytes.len() / INDEX_RECORD_SIZE;
all.reserve(n + state.pending.len());
for i in 0..n {
all.push(read_record_at(bytes, i));
}
for r in state.pending.values() {
all.push(r.clone());
}
let deduped = sort_and_dedup(all);
let new_count = deduped.len();
// Write new file atomically
rewrite_file(&state.path, &deduped)?;
// Remap
let file = fs::File::open(&state.path)?;
let new_mmap = unsafe { Mmap::map(&file)? };
// Re-open append file descriptor (old one was truncated)
let new_file = OpenOptions::new()
.create(true)
.append(true)
.open(&state.path)?;
state.mmap = new_mmap;
state.compacted_records = new_count;
state.pending.clear();
state.file = new_file;
Ok(())
}
/// Compact all buckets.
pub fn compact_all(&self) -> Result<()> {
for bid in 0..BUCKET_COUNT {
self.compact_bucket(bid)?;
}
Ok(())
}
/// Reload all mmaps from disk and reset pending state.
/// Used after GC rewrites bucket files externally (via rebuild_from_segments).
pub fn reload_all(&self) -> Result<()> {
for bid in 0..BUCKET_COUNT {
let mut state = self.states[bid as usize].lock().unwrap();
let path = state.path.clone();
// Load, sort, dedup, rewrite clean
let records = load_records_from_file(&path)?;
let deduped = sort_and_dedup(records);
let count = deduped.len();
rewrite_file(&path, &deduped)?;
// Remap
let file = fs::File::open(&path)?;
let new_mmap = unsafe { Mmap::map(&file)? };
// Reopen append file
let new_file = OpenOptions::new()
.create(true)
.append(true)
.open(&path)?;
state.mmap = new_mmap;
state.compacted_records = count;
state.pending.clear();
state.file = new_file;
}
Ok(())
}
/// Rebuild all bucket files from scratch by scanning segment entries.
/// Used by GC and recovery.
pub fn rebuild_from_segments(
dir: &Path,
segments: &[(u32, &Path)],
) -> Result<()> {
use crate::segment::SegmentReader;
let mut bucket_records: HashMap<u16, Vec<IndexRecord>> = HashMap::new();
for i in 0..BUCKET_COUNT {
bucket_records.insert(i, Vec::new());
}
for &(seg_id, seg_path) in segments {
if !seg_path.exists() {
continue;
}
let reader = SegmentReader::open(seg_path.to_path_buf(), seg_id)?;
reader.scan_entries(0, |entry, offset| {
let bid = bucket_id(&entry.key);
let rec = IndexRecord::new(
entry.key,
seg_id,
offset,
entry.data.len() as u32,
entry.flags,
);
bucket_records.entry(bid).or_default().push(rec);
Ok(())
})?;
}
for (bid, records) in &bucket_records {
let deduped = sort_and_dedup(records.clone());
let path = bucket_path(dir, *bid);
rewrite_file(&path, &deduped)?;
}
Ok(())
}
}
// ── Helpers ────────────────────────────────────────────────────────────────
fn bucket_path(dir: &Path, bid: u16) -> PathBuf {
dir.join(format!("{:02x}.idx", bid))
}
/// Read records from a raw bucket file (may contain duplicates, not sorted).
fn load_records_from_file(path: &Path) -> Result<Vec<IndexRecord>> {
let data = fs::read(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: path.to_path_buf(),
reason: "unexpected file size".into(),
}
})?;
records.push(IndexRecord::decode(buf));
}
if remainder > 0 {
tracing::warn!(
"Bucket file {:?} has {} trailing bytes, ignoring",
path,
remainder
);
}
Ok(records)
}
/// Sort records by key, deduplicate keeping the one with the highest (segment_id, offset).
fn sort_and_dedup(mut records: Vec<IndexRecord>) -> Vec<IndexRecord> {
records.sort_by_key(|a| a.key);
let mut out = 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].segment_id > records[best].segment_id
|| (records[j].segment_id == records[best].segment_id
&& records[j].offset > records[best].offset)
{
best = j;
}
j += 1;
}
out.push(records[best].clone());
i = j;
}
out
}
/// Binary search for `key` in `bytes` (array of INDEX_RECORD_SIZE records, sorted).
fn binary_search_records(bytes: &[u8], key: &[u8; 32], count: usize) -> Option<usize> {
let mut lo = 0usize;
let mut hi = count;
while lo < hi {
let mid = lo + (hi - lo) / 2;
let rec_key = read_key_at(bytes, mid);
match rec_key.cmp(key) {
std::cmp::Ordering::Less => lo = mid + 1,
std::cmp::Ordering::Greater => hi = mid,
std::cmp::Ordering::Equal => return Some(mid),
}
}
None
}
/// Read the key at index `idx` from a byte slice of INDEX_RECORD_SIZE records.
fn read_key_at(bytes: &[u8], idx: usize) -> &[u8; 32] {
let start = idx * INDEX_RECORD_SIZE;
bytes[start..start + 32].try_into().unwrap()
}
/// Read a full IndexRecord at index `idx`.
fn read_record_at(bytes: &[u8], idx: usize) -> IndexRecord {
let start = idx * INDEX_RECORD_SIZE;
let buf: &[u8; INDEX_RECORD_SIZE] = bytes[start..start + INDEX_RECORD_SIZE]
.try_into()
.unwrap();
IndexRecord::decode(buf)
}
/// Atomically rewrite a bucket file with sorted, deduplicated records.
fn rewrite_file(path: &Path, records: &[IndexRecord]) -> Result<()> {
let mut buf = Vec::with_capacity(records.len() * INDEX_RECORD_SIZE);
for r in records {
buf.extend_from_slice(&r.encode());
}
crate::fs::create_atomic(path, &buf)
}
#[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), 16);
}
#[test]
fn test_sort_and_dedup_keeps_latest() {
let recs = vec![
IndexRecord::new([1u8; 32], 1, 100, 50, 0),
IndexRecord::new([1u8; 32], 2, 200, 50, 0),
IndexRecord::new([2u8; 32], 1, 300, 60, 0),
];
let deduped = sort_and_dedup(recs);
assert_eq!(deduped.len(), 2);
let found = &deduped[0];
assert_eq!(found.segment_id, 2);
assert_eq!(found.offset, 200);
}
#[test]
fn test_bucket_store_put_and_get() {
let dir = TempDir::new().unwrap();
let store = BucketStore::open(dir.path(), 100).unwrap();
let key = [0xAA; 32];
let rec = IndexRecord::new(key, 1, 0, 500, 0);
store.insert(rec).unwrap();
let found = store.get(&key).unwrap();
assert!(found.is_some());
assert_eq!(found.unwrap().data_size, 500);
}
#[test]
fn test_bucket_store_get_missing() {
let dir = TempDir::new().unwrap();
let store = BucketStore::open(dir.path(), 100).unwrap();
let result = store.get(&[0xFF; 32]).unwrap();
assert!(result.is_none());
}
#[test]
fn test_bucket_store_tombstone() {
let dir = TempDir::new().unwrap();
let store = BucketStore::open(dir.path(), 100).unwrap();
let key = [0xBB; 32];
// Write then tombstone
store
.insert(IndexRecord::new(key, 1, 0, 100, 0))
.unwrap();
store
.insert(IndexRecord::new(key, 2, 0, 0, 1))
.unwrap();
let result = store.get(&key).unwrap();
assert!(result.is_none());
}
#[test]
fn test_bucket_store_compact() {
let dir = TempDir::new().unwrap();
// Use small threshold to trigger auto-compact
let store = BucketStore::open(dir.path(), 5).unwrap();
// Write 10 records for same bucket (all same first 2 bytes → same bucket)
for i in 0u8..10 {
let mut key = [0u8; 32];
key[0..2].copy_from_slice(&[0x00, 0x00]); // same bucket
key[2] = i;
store
.insert(IndexRecord::new(key, 1, i as u64 * 100, 50, 0))
.unwrap();
}
// All should be readable after auto-compact
for i in 0u8..10 {
let mut key = [0u8; 32];
key[0..2].copy_from_slice(&[0x00, 0x00]);
key[2] = i;
let found = store.get(&key).unwrap();
assert!(found.is_some(), "key {} should exist after compact", i);
}
}
#[test]
fn test_bucket_store_persistence() {
let dir = TempDir::new().unwrap();
let dir_path = dir.path().to_path_buf();
let key = [0xCC; 32];
{
let store = BucketStore::open(&dir_path, 100).unwrap();
store
.insert(IndexRecord::new(key, 1, 42, 512, 0))
.unwrap();
store.compact_all().unwrap();
}
// Reopen
{
let store = BucketStore::open(&dir_path, 100).unwrap();
let found = store.get(&key).unwrap();
assert!(found.is_some());
assert_eq!(found.unwrap().offset, 42);
}
}
}