macro_rules! internal_mod {
($name:ident) => {
#[cfg(not(feature = "testing"))]
#[allow(dead_code)]
pub(crate) mod $name;
#[cfg(feature = "testing")]
#[doc(hidden)]
pub mod $name;
};
}
#[macro_use]
mod observe;
internal_mod!(config);
internal_mod!(error);
internal_mod!(health);
internal_mod!(pipeline);
internal_mod!(platform);
internal_mod!(reader);
internal_mod!(record);
internal_mod!(ring);
internal_mod!(shard);
internal_mod!(storage);
mod pusher;
internal_mod!(recovery);
internal_mod!(tailer);
pub use config::{
Config, DurabilityMode, IoBackend, QueueFullPolicy, RetentionPolicy, WaitStrategy,
};
pub use error::{
AppendError, ConfigError, FlushError, OpenError, ReadError, ShutdownError, ShutdownReport,
TailerError,
};
pub use reader::ScanIter;
pub use record::{Record, RecordId};
pub use shard::{decode_record_id, encode_record_id, shard_bits};
pub use tailer::Tailer;
#[derive(Debug, Clone)]
pub struct RecoveryReport {
pub from_sequence: u64,
pub to_sequence: u64,
pub count: u64,
}
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use zeroize::Zeroizing;
use health::HealthState;
pub(crate) type KeyHandle = Arc<zeroize::Zeroizing<[u8; 32]>>;
pub struct KeyRing {
active: KeyHandle,
active_id: u128,
pub(crate) decrypt_keys: Vec<(u128, KeyHandle)>,
}
impl KeyRing {
pub fn single(key: [u8; 32]) -> Arc<Self> {
let id = key_id_from_bytes(&key);
let h: KeyHandle = Arc::new(Zeroizing::new(key));
Arc::new(Self {
active: h.clone(),
active_id: id,
decrypt_keys: vec![(id, h)],
})
}
pub fn new(active: [u8; 32], prior: Vec<[u8; 32]>) -> Arc<Self> {
let active_id = key_id_from_bytes(&active);
let active_h: KeyHandle = Arc::new(Zeroizing::new(active));
let mut decrypt_keys = vec![(active_id, active_h.clone())];
for k in prior {
let id = key_id_from_bytes(&k);
decrypt_keys.push((id, Arc::new(Zeroizing::new(k))));
}
Arc::new(Self {
active: active_h,
active_id,
decrypt_keys,
})
}
pub fn with_ids(active_id: u128, active: [u8; 32], prior: Vec<(u128, [u8; 32])>) -> Arc<Self> {
let active_h: KeyHandle = Arc::new(Zeroizing::new(active));
let mut decrypt_keys = vec![(active_id, active_h.clone())];
for (id, k) in prior {
decrypt_keys.push((id, Arc::new(Zeroizing::new(k))));
}
Arc::new(Self {
active: active_h,
active_id,
decrypt_keys,
})
}
pub(crate) fn active_slice(&self) -> &[u8; 32] {
&self.active
}
pub(crate) fn active_id(&self) -> u128 {
self.active_id
}
#[cfg_attr(not(feature = "hash-chain"), allow(dead_code))]
pub(crate) fn key_for_id(&self, id: u128) -> Option<&KeyHandle> {
self.decrypt_keys
.iter()
.find(|(kid, _)| *kid == id)
.map(|(_, k)| k)
}
}
fn key_id_from_bytes(b: &[u8]) -> u128 {
let mut h: u128 = 0x6c62_272e_07bb_0142_62b8_2175_6295_c58d;
for &byte in b {
h ^= byte as u128;
h = h.wrapping_mul(0x0000_0000_0100_0000_0000_0000_0000_013b);
}
if h == 0 {
1
} else {
h
}
}
impl std::fmt::Debug for KeyRing {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("KeyRing")
.field("active", &"<redacted>")
.field("decrypt_keys", &self.decrypt_keys.len())
.finish()
}
}
use pipeline::signal::{FlushSignal, ShutdownState};
use pipeline::trigger::CommitTrigger;
use ring::Ring;
use shard::ShardMap;
use storage::SegmentManager;
pub struct LogDb {
inner: Arc<LogDbInner>,
}
struct LogDbInner {
config: config::Config,
encryption_keys: Option<Arc<KeyRing>>,
shards: ShardMap,
health: Arc<HealthState>,
flush: Arc<FlushSignal>,
shutdown: Arc<ShutdownState>,
committer_handle: Option<std::thread::JoinHandle<()>>,
#[cfg(feature = "hash-chain")]
sealer_handles: Vec<std::thread::JoinHandle<()>>,
data_dir: std::path::PathBuf,
checkpoint_sequence: Arc<AtomicU64>,
manifests: Vec<Arc<Mutex<reader::SegmentManifest>>>,
}
impl LogDb {
pub fn open(mut config: Config) -> Result<Self, OpenError> {
config.validate()?;
let data_dir = config.data_dir.clone();
let hash_enabled = config.hash_enabled;
let encryption_keys: Option<Arc<KeyRing>> = config.encryption_keys.take();
let num_shards = config.shards;
let shard_bits = shard::shard_bits(num_shards);
let mut seg_mgrs: Vec<SegmentManager> = Vec::with_capacity(num_shards);
let mut initial_seqs: Vec<u64> = Vec::with_capacity(num_shards);
let mut shard_hash_inits: Vec<[u8; 32]> = Vec::with_capacity(num_shards);
let mut shard_last_hashes: Vec<[u8; 32]> = Vec::with_capacity(num_shards);
for s in 0..num_shards {
let sdir = if num_shards == 1 {
data_dir.clone()
} else {
data_dir.join(format!("s{}", s))
};
let has_data = sdir.exists() && sdir.join("segment-00000001.log").exists();
if has_data {
let st = recovery::recover_shard(
&sdir,
shard_bits,
config.segment_size,
config.retention.clone(),
encryption_keys.clone(),
)
.map_err(|reason| OpenError::Recovery { shard: s, reason })?;
let initial_local = if st.recovered_count == 0 {
0
} else {
shard::decode_record_id(st.last_sequence, shard_bits).1 + 1
};
shard_hash_inits.push(st.hash_init);
shard_last_hashes.push(st.last_hash);
seg_mgrs.push(st.segment_manager);
initial_seqs.push(initial_local);
} else {
let hi = generate_hash_init();
shard_hash_inits.push(hi);
shard_last_hashes.push([0u8; 32]);
#[cfg(feature = "hash-chain")]
let header_hash_init = match &encryption_keys {
Some(kr) if hash_enabled => xor32(hi, derive_hash_init(kr.active_slice())),
_ => hi,
};
#[cfg(not(feature = "hash-chain"))]
let header_hash_init = hi;
let sm = SegmentManager::create(
sdir,
config.segment_size,
hash_enabled,
config.compression_enabled,
encryption_keys.clone(),
header_hash_init,
config.retention.clone(),
0,
)
.map_err(OpenError::SegmentCreate)?;
seg_mgrs.push(sm);
initial_seqs.push(0);
}
}
for (s, m) in seg_mgrs.iter_mut().enumerate() {
m.set_index_stride(config.index_stride);
m.set_shard(s, shard_bits);
}
let shards = ShardMap::new_with_initial(
config.shards,
config.ring_size,
hash_enabled,
&initial_seqs,
);
let health = Arc::new(HealthState::new());
let flush = Arc::new(FlushSignal::new(num_shards));
let shutdown = Arc::new(ShutdownState::new());
let trigger = CommitTrigger {
bytes: 256 * 1024,
records: 1024,
interval: Duration::from_millis(10),
durability: config.durability_mode,
};
let wait = config.wait_strategy;
let committer_rings = shards.all_rings().to_vec();
let committer_flush = Arc::clone(&flush);
let committer_shutdown = Arc::clone(&shutdown);
let committer_health = Arc::clone(&health);
let checkpoint = Arc::new(AtomicU64::new(Self::load_checkpoint(&data_dir)));
let committer_checkpoint = Arc::clone(&checkpoint);
let committer_handle = std::thread::Builder::new()
.name("logdb-committer".into())
.spawn(move || {
pipeline::committer::run_committer(
committer_rings,
seg_mgrs,
shard_bits,
trigger,
committer_flush,
committer_shutdown,
committer_health,
committer_checkpoint,
wait,
);
})
.map_err(OpenError::ThreadSpawn)?;
#[cfg(feature = "hash-chain")]
let mut sealer_handles: Vec<std::thread::JoinHandle<()>> = Vec::new();
#[cfg(feature = "hash-chain")]
if hash_enabled {
for s in 0..num_shards {
let sealer_ring = Arc::clone(shards.ring(s));
let sealer_shutdown = Arc::clone(&shutdown);
let hi = shard_hash_inits[s];
let lh = shard_last_hashes[s];
let iseq = initial_seqs[s];
let name = format!("logdb-sealer-{}", s);
sealer_handles.push(
std::thread::Builder::new()
.name(name)
.spawn(move || {
pipeline::sealer::run_sealer(
sealer_ring,
hi,
lh,
iseq,
sealer_shutdown,
wait,
);
})
.map_err(OpenError::ThreadSpawn)?,
);
}
}
let manifests: Vec<Arc<Mutex<reader::SegmentManifest>>> = (0..num_shards)
.map(|s| {
let dir = if num_shards == 1 {
data_dir.clone()
} else {
data_dir.join(format!("s{}", s))
};
Arc::new(Mutex::new(reader::SegmentManifest::new(dir)))
})
.collect();
Ok(Self {
inner: Arc::new(LogDbInner {
config,
encryption_keys,
shards,
health,
flush,
shutdown,
committer_handle: Some(committer_handle),
#[cfg(feature = "hash-chain")]
sealer_handles,
data_dir,
checkpoint_sequence: checkpoint,
manifests,
}),
})
}
pub fn append_batch(&self, contents: &[&[u8]]) -> Result<u64, AppendError> {
if contents.is_empty() {
return Err(AppendError::EmptyBatch);
}
let inner = &self.inner;
if let Some(code) = inner.health.check() {
return Err(match code {
health::HEALTH_DISK_FULL => AppendError::DiskFull,
_ => AppendError::Io("unhealthy".into()),
});
}
for content in contents {
if content.len() > inner.config.max_content_size {
return Err(AppendError::ContentTooLarge {
size: content.len(),
max: inner.config.max_content_size,
});
}
}
if !inner.shutdown.enter() {
return Err(AppendError::ShuttingDown);
}
let _guard = scopeguard::guard((), |_| inner.shutdown.leave());
let n = contents.len() as u64;
let (first_id, shard_id, local_first) = inner
.shards
.claim_batch(n, inner.config.queue_full_policy)?;
let ts = platform::clock_realtime_coarse_ns();
let ring = inner.shards.ring(shard_id);
let shard_bits = inner.shards.shard_bits();
for (i, content) in contents.iter().enumerate() {
let local_seq = local_first + i as u64;
let global_id = shard::encode_record_id(shard_id, local_seq, shard_bits);
unsafe {
ring.slot(local_seq).producer_write(global_id, ts, content);
}
ring.slot(local_seq).publish(local_seq);
}
metric_counter!("logdb.appends", n);
Ok(first_id)
}
pub fn append(&self, content: &[u8]) -> Result<u64, AppendError> {
let shard_id = self.inner.shards.select_shard();
self.append_routed(content, shard_id)
}
pub fn append_with_key(&self, content: &[u8], shard_key: &[u8]) -> Result<u64, AppendError> {
let shard_id = self.inner.shards.select_shard_by_key(shard_key);
self.append_routed(content, shard_id)
}
fn append_routed(&self, content: &[u8], shard_id: usize) -> Result<u64, AppendError> {
let inner = &self.inner;
if let Some(code) = inner.health.check() {
return Err(match code {
health::HEALTH_DISK_FULL => AppendError::DiskFull,
_ => AppendError::Io("health check failed".into()),
});
}
if content.len() > inner.config.max_content_size {
return Err(AppendError::ContentTooLarge {
size: content.len(),
max: inner.config.max_content_size,
});
}
if !inner.shutdown.enter() {
return Err(AppendError::ShuttingDown);
}
let _guard = scopeguard::guard((), |_| inner.shutdown.leave());
let (global_id, _, local_seq) = inner
.shards
.claim_on_shard(shard_id, inner.config.queue_full_policy)?;
let ts = platform::clock_realtime_coarse_ns();
let ring = inner.shards.ring(shard_id);
unsafe {
ring.slot(local_seq).producer_write(global_id, ts, content);
}
ring.slot(local_seq).publish(local_seq);
metric_counter!("logdb.appends", 1);
Ok(global_id)
}
pub fn replicate(
&self,
sequence: u64,
timestamp_ns: u64,
content: &[u8],
) -> Result<(), AppendError> {
let inner = &self.inner;
if inner.shards.num_shards() != 1 {
return Err(AppendError::Io("replicate requires shards=1".into()));
}
if content.len() > inner.config.max_content_size {
return Err(AppendError::ContentTooLarge {
size: content.len(),
max: inner.config.max_content_size,
});
}
if let Some(code) = inner.health.check() {
return Err(match code {
health::HEALTH_DISK_FULL => AppendError::DiskFull,
_ => AppendError::Io("health check failed".into()),
});
}
if !inner.shutdown.enter() {
return Err(AppendError::ShuttingDown);
}
let _guard = scopeguard::guard((), |_| inner.shutdown.leave());
let ring = inner.shards.ring(0);
let ring_size = ring.ring_size() as u64;
let cur = ring.producer_cursor.inner.load(Ordering::Acquire);
if sequence < cur {
return Ok(());
}
if sequence != cur {
return Err(AppendError::Io(format!(
"replicate out of order: expected {}, got {}",
cur, sequence
)));
}
let wm = ring.consume_watermark();
if sequence.wrapping_sub(wm) >= ring_size {
return Err(AppendError::QueueFull);
}
unsafe {
ring.slot(sequence)
.producer_write(sequence, timestamp_ns, content);
}
ring.slot(sequence).publish(sequence);
ring.producer_cursor
.inner
.store(sequence + 1, Ordering::Release);
Ok(())
}
pub fn flush(&self) -> Result<(), FlushError> {
let _t0 = Instant::now();
let inner = &self.inner;
let targets = inner.shards.producer_cursors();
if targets.iter().all(|&t| t == 0) {
metric_histogram!("logdb.flush.duration", _t0.elapsed());
return Ok(());
}
#[cfg(feature = "hash-chain")]
if inner.config.hash_enabled {
let target0 = targets[0];
wait_until(
&inner.shutdown,
|| inner.shards.ring(0).sealed_cursor.load(Ordering::Acquire) >= target0,
inner.config.flush_timeout,
)?;
}
inner.flush.request(&targets);
wait_until(
&inner.shutdown,
|| inner.flush.is_done(&targets),
inner.config.flush_timeout,
)?;
metric_histogram!("logdb.flush.duration", _t0.elapsed());
Ok(())
}
pub fn read(&self, record_id: u64) -> Result<Option<Record>, ReadError> {
let inner = &self.inner;
let (shard, local) = shard::decode_record_id(record_id, inner.shards.shard_bits());
let durable_s = inner
.shards
.durable_cursors()
.get(shard)
.copied()
.unwrap_or(0);
if local >= durable_s {
return Ok(None);
}
let reader = reader::Reader::new(
Arc::clone(&inner.manifests[shard]),
inner.encryption_keys.clone(),
);
reader.read(record_id)
}
pub fn refresh_manifests(&self) -> Result<(), ReadError> {
for manifest in &self.inner.manifests {
manifest.lock().unwrap().force_refresh()?;
}
Ok(())
}
pub fn read_batch(&self, ids: &[u64]) -> Result<Vec<Option<Record>>, ReadError> {
let mut results: Vec<Option<Record>> = vec![None; ids.len()];
if ids.is_empty() {
return Ok(results);
}
let inner = &self.inner;
let bits = inner.shards.shard_bits();
let durable = inner.shards.durable_cursors();
let mut by_shard: std::collections::HashMap<usize, Vec<usize>> =
std::collections::HashMap::new();
for (i, &id) in ids.iter().enumerate() {
let (shard, _local) = shard::decode_record_id(id, bits);
by_shard.entry(shard).or_default().push(i);
}
for (shard, slots) in by_shard {
let durable_s = durable.get(shard).copied().unwrap_or(0);
let live: Vec<(usize, u64)> = slots
.iter()
.map(|&slot| (slot, ids[slot]))
.filter(|&(_slot, id)| shard::decode_record_id(id, bits).1 < durable_s)
.collect();
if live.is_empty() {
continue;
}
let reader = reader::Reader::new(
Arc::clone(&inner.manifests[shard]),
inner.encryption_keys.clone(),
);
let shard_ids: Vec<u64> = live.iter().map(|&(_, id)| id).collect();
let mut batch = reader.read_batch(&shard_ids)?;
for (k, (slot, _id)) in live.iter().enumerate() {
results[*slot] = batch[k].take(); }
}
Ok(results)
}
pub fn producer_cursor(&self) -> u64 {
self.inner.shards.max_producer_cursor()
}
pub fn committed_cursor(&self) -> u64 {
self.inner.shards.min_committed_cursor()
}
pub fn durable_cursor(&self) -> u64 {
self.inner.shards.min_durable_cursor()
}
pub fn health_code(&self) -> Option<u8> {
self.inner.health.check()
}
pub fn record_gauges(&self) {
let _producer = self.producer_cursor();
let _durable = self.durable_cursor();
let _committed = self.committed_cursor();
metric_gauge!("logdb.durable_lag", _producer.saturating_sub(_durable));
metric_gauge!("logdb.queue_depth", _producer.saturating_sub(_committed));
metric_gauge!("logdb.wal_bytes", self.wal_usage().0);
}
pub fn ring_size(&self) -> usize {
self.inner.shards.num_shards() * self.inner.shards.ring(0).ring_size()
}
pub fn new_tailer(&self, name: &str) -> Tailer {
let rings: Vec<Arc<Ring>> = self
.inner
.shards
.all_rings()
.iter()
.map(Arc::clone)
.collect();
let manifests: Vec<Arc<std::sync::Mutex<reader::SegmentManifest>>> =
self.inner.manifests.iter().map(Arc::clone).collect();
crate::tailer::Tailer::open(
manifests,
rings,
self.inner.shards.shard_bits(),
name,
self.inner.encryption_keys.clone(),
self.inner.data_dir.clone(),
)
}
pub fn scan(&self, from_id: u64, to_id: u64) -> Result<ScanIter, ReadError> {
let manifests = self.inner.manifests.iter().map(Arc::clone).collect();
reader::ScanIter::build(manifests, self.inner.encryption_keys.clone(), from_id, to_id)
}
pub fn scan_shard(
&self,
shard_id: usize,
from_id: u64,
to_id: u64,
) -> Result<ScanIter, ReadError> {
let manifest = Arc::clone(&self.inner.manifests[shard_id]);
reader::ScanIter::build_single(
manifest,
self.inner.encryption_keys.clone(),
from_id,
to_id,
)
}
pub fn checkpoint(&self, sequence: u64) {
let mut cur = self.inner.checkpoint_sequence.load(Ordering::Acquire);
while sequence > cur {
match self.inner.checkpoint_sequence.compare_exchange_weak(
cur,
sequence,
Ordering::Release,
Ordering::Acquire,
) {
Ok(_) => break,
Err(v) => cur = v,
}
}
let _ = save_checkpoint(&self.inner.data_dir, sequence);
}
pub fn checkpoint_sequence(&self) -> u64 {
self.inner.checkpoint_sequence.load(Ordering::Acquire)
}
pub(crate) fn load_checkpoint(data_dir: &std::path::Path) -> u64 {
let path = data_dir.join("checkpoint.dat");
match std::fs::read(&path) {
Ok(data) if data.len() == 12 => {
let seq = u64::from_le_bytes([
data[0], data[1], data[2], data[3], data[4], data[5], data[6], data[7],
]);
let crc = u32::from_le_bytes([data[8], data[9], data[10], data[11]]);
if crc32c::crc32c(&data[..8]) == crc {
seq
} else {
0
}
}
_ => 0,
}
}
pub fn wal_usage(&self) -> (u64, u64) {
let mut total = count_log_bytes(&self.inner.data_dir);
if let Ok(entries) = std::fs::read_dir(&self.inner.data_dir) {
for e in entries.flatten() {
let path = e.path();
if path.is_dir() {
total += count_log_bytes(&path);
}
}
}
(total, self.inner.config.segment_size)
}
pub fn recovery_report(&self) -> RecoveryReport {
let cp = self.checkpoint_sequence();
let shards = &self.inner.shards;
let bits = shards.shard_bits();
let stride: u64 = 1u64 << bits;
let mut count: u64 = 0;
let mut watermark = u64::MAX;
for s in 0..shards.num_shards() {
let durable_s = shards.ring(s).durable_cursor.load(Ordering::Acquire);
let first_local: u64 = if cp <= s as u64 {
0
} else {
(cp - s as u64 + stride - 1) / stride };
count += durable_s.saturating_sub(first_local);
let wm = (durable_s << bits) | s as u64; if wm < watermark {
watermark = wm;
}
}
RecoveryReport {
from_sequence: cp,
to_sequence: if watermark == u64::MAX { 0 } else { watermark },
count,
}
}
pub fn replay_from(&self, sequence: u64) -> Result<ScanIter, ReadError> {
self.scan(sequence, u64::MAX)
}
pub fn drain(&self, timeout: Duration) -> Result<ShutdownReport, FlushError> {
let inner = &self.inner;
let deadline = Instant::now() + timeout;
inner.shutdown.start_drain();
loop {
if inner.shutdown.in_flight.load(Ordering::Acquire) == 0 {
break;
}
if Instant::now() >= deadline {
inner.shutdown.abort();
return Err(FlushError::Timeout);
}
std::hint::spin_loop();
}
let targets = inner.shards.producer_cursors();
let max_target = targets.iter().copied().max().unwrap_or(0);
inner
.shutdown
.drain_target
.store(max_target, Ordering::Release);
inner.flush.request(&targets);
let remaining = deadline.saturating_duration_since(Instant::now());
let durable_ok =
wait_until(&inner.shutdown, || inner.flush.is_done(&targets), remaining).is_ok();
Ok(if durable_ok {
ShutdownReport::Clean
} else {
ShutdownReport::PartialDurable
})
}
pub fn shutdown(mut self, timeout: Duration) -> Result<ShutdownReport, ShutdownError> {
let report = self.drain(timeout).map_err(|_| ShutdownError::Timeout)?;
let inner = match Arc::get_mut(&mut self.inner) {
Some(i) => i,
None => return Err(ShutdownError::JoinError("LogDb still referenced".into())),
};
if let Some(h) = inner.committer_handle.take() {
let _ = h.join();
}
#[cfg(feature = "hash-chain")]
for h in inner.sealer_handles.drain(..) {
let _ = h.join();
}
Ok(report)
}
}
fn wait_until(
shutdown: &ShutdownState,
cond: impl Fn() -> bool,
timeout: Duration,
) -> Result<(), FlushError> {
let deadline = Instant::now() + timeout;
let mut spins: u32 = 0;
loop {
if cond() {
return Ok(());
}
if shutdown.aborted() {
return Err(FlushError::Aborted);
}
if Instant::now() >= deadline {
log_warn!("logdb flush/drain timed out waiting for the Committer");
return Err(FlushError::Timeout);
}
spins = spins.saturating_add(1);
if spins <= 64 {
std::hint::spin_loop();
} else if spins <= 256 {
std::thread::yield_now();
} else {
std::thread::sleep(Duration::from_micros(100));
spins = 128;
}
}
}
fn save_checkpoint(dir: &std::path::Path, seq: u64) -> std::io::Result<()> {
let path = dir.join("checkpoint.dat");
let tmp = dir.join("checkpoint.tmp");
let mut buf = [0u8; 12];
buf[0..8].copy_from_slice(&seq.to_le_bytes());
let crc = crc32c::crc32c(&buf[..8]);
buf[8..12].copy_from_slice(&crc.to_le_bytes());
let mut f = std::fs::File::create(&tmp)?;
std::io::Write::write_all(&mut f, &buf)?;
platform::fdatasync(&f)?;
drop(f);
std::fs::rename(&tmp, &path)?;
let d = std::fs::File::open(dir)?;
platform::sync_dir(&d)?;
Ok(())
}
fn count_log_bytes(dir: &std::path::Path) -> u64 {
let mut total = 0u64;
if let Ok(entries) = std::fs::read_dir(dir) {
for e in entries.flatten() {
if e.file_name()
.to_str()
.map_or(false, |n| n.ends_with(".log"))
{
if let Ok(meta) = e.metadata() {
total += meta.len();
}
}
}
}
total
}
#[cfg(feature = "hash-chain")]
pub(crate) fn derive_hash_init(key: &[u8; 32]) -> [u8; 32] {
*blake3::keyed_hash(key, b"logdb-hash-chain-init-v1").as_bytes()
}
#[cfg(feature = "hash-chain")]
pub(crate) fn xor32(a: [u8; 32], b: [u8; 32]) -> [u8; 32] {
let mut out = [0u8; 32];
for i in 0..32 {
out[i] = a[i] ^ b[i];
}
out
}
fn generate_hash_init() -> [u8; 32] {
#[cfg(feature = "hash-chain")]
{
let mut hasher = blake3::Hasher::new();
hasher.update(&platform::clock_realtime_coarse_ns().to_le_bytes());
hasher.update(b"logdb-hash-init-v0.2.0");
*hasher.finalize().as_bytes()
}
#[cfg(not(feature = "hash-chain"))]
{
[0u8; 32]
}
}
impl Drop for LogDb {
fn drop(&mut self) {
if !std::thread::panicking() {
match self.drain(DROP_DRAIN_TIMEOUT) {
Ok(ShutdownReport::Clean) => {}
Ok(_) => {
log_warn!(
timeout_secs = DROP_DRAIN_TIMEOUT.as_secs(),
"logdb dropped with records not fully fsynced; call shutdown()/drain() for guaranteed durability"
);
}
Err(_) => {
log_warn!(
timeout_secs = DROP_DRAIN_TIMEOUT.as_secs(),
"logdb best-effort drain on drop failed/timed out; in-flight records may be lost"
);
}
}
}
self.inner.shutdown.abort();
}
}
const DROP_DRAIN_TIMEOUT: Duration = Duration::from_secs(5);
#[cfg(test)]
mod tests {
use super::*;
use config::DurabilityMode;
use std::sync::Arc;
#[test]
fn open_and_append_and_read() {
let dir = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.data_dir = dir.path().to_path_buf();
config.ring_size = 64;
config.durability_mode = DurabilityMode::Sync;
config.flush_timeout = Duration::from_secs(5);
let db = LogDb::open(config).unwrap();
let id = db.append(b"hello logdb").unwrap();
db.flush().unwrap();
std::thread::sleep(Duration::from_millis(100));
let record = db.read(id).unwrap().unwrap();
assert_eq!(record.id.sequence, id);
assert_eq!(record.content, b"hello logdb");
}
#[cfg(all(feature = "hash-chain", feature = "encryption"))]
#[test]
fn hash_chain_with_key_derives_mac_and_hides_it() {
use crate::storage::format::{SEGMENT_HEADER_SIZE, SegmentHeader};
use std::io::Read;
let key = [0x99u8; 32];
let dir = tempfile::tempdir().unwrap();
let data_dir = dir.path().to_path_buf();
let mk = || {
let mut c = Config::default();
c.data_dir = data_dir.clone();
c.hash_enabled = true;
c.encryption_keys = Some(KeyRing::single(key));
c.ring_size = 64;
c.durability_mode = DurabilityMode::Sync;
c.flush_timeout = Duration::from_secs(5);
c
};
{
let db = LogDb::open(mk()).unwrap();
for i in 0..5u64 {
db.append(format!("r-{}", i).as_bytes()).unwrap();
}
db.flush().unwrap();
for _ in 0..100 {
if db.durable_cursor() >= 5 {
break;
}
std::thread::sleep(Duration::from_millis(100));
}
}
let seg = data_dir.join("segment-00000001.log");
let mut buf = [0u8; SEGMENT_HEADER_SIZE];
let mut f = std::fs::File::open(&seg).unwrap();
f.read_exact(&mut buf).unwrap();
let header = SegmentHeader::deserialize(&buf).unwrap();
assert_ne!(
header.hash_init, [0u8; 32],
"header must not store zeros — the chain key is now masked, not derived at runtime"
);
let derived = crate::derive_hash_init(&key);
assert_ne!(
header.hash_init, derived,
"header must store the MASKED chain key, not the bare key-derived value"
);
assert_ne!(
header.encryption_key_id, 0,
"header must carry the active key id for O(1) recovery / retirement"
);
let db = LogDb::open(mk()).unwrap();
for i in 0..5u64 {
let rec = db.read(i).unwrap().expect("record readable after reopen");
assert_eq!(rec.content, format!("r-{}", i).as_bytes());
}
}
#[test]
fn read_batch_matches_single_reads() {
let dir = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.data_dir = dir.path().to_path_buf();
config.shards = 4; config.ring_size = 256;
config.durability_mode = DurabilityMode::Sync;
config.flush_timeout = Duration::from_secs(5);
let db = Arc::new(LogDb::open(config).unwrap());
let mut all_ids = Vec::new();
let mut handles = Vec::new();
for t in 0..4u64 {
let db = Arc::clone(&db);
handles.push(std::thread::spawn(move || {
(0..8u64)
.map(|i| db.append(format!("t{}-{}", t, i).as_bytes()).unwrap())
.collect::<Vec<_>>()
}));
}
for h in handles {
all_ids.extend(h.join().unwrap());
}
db.flush().unwrap();
for _ in 0..100 {
if db.scan(0, u64::MAX).unwrap().filter_map(|r| r.ok()).count() >= all_ids.len() {
break;
}
std::thread::sleep(Duration::from_millis(100));
}
let mut batch_ids = all_ids.clone();
batch_ids.push(u64::MAX); batch_ids.sort_by_key(|_| std::cmp::Reverse(0)); batch_ids.reverse();
let batch = db.read_batch(&batch_ids).unwrap();
assert_eq!(batch.len(), batch_ids.len());
for (i, &id) in batch_ids.iter().enumerate() {
let single = db.read(id).unwrap();
match (&batch[i], &single) {
(Some(a), Some(b)) => {
assert_eq!(a.content, b.content, "content mismatch id {}", id)
}
(None, None) => {}
other => panic!(
"slot {} (id {}): batch {:?} vs single {:?}",
i, id, other.0, other.1
),
}
}
let missing_pos = batch_ids.iter().position(|&id| id == u64::MAX).unwrap();
assert!(batch[missing_pos].is_none());
}
#[test]
fn read_batch_empty() {
let dir = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.data_dir = dir.path().to_path_buf();
let db = LogDb::open(config).unwrap();
assert!(db.read_batch(&[]).unwrap().is_empty());
}
#[test]
fn append_rejected_content_too_large() {
let dir = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.data_dir = dir.path().to_path_buf();
config.max_content_size = 100;
let db = LogDb::open(config).unwrap();
let err = db.append(&vec![0u8; 200]).unwrap_err();
assert!(matches!(err, AppendError::ContentTooLarge { .. }));
}
#[test]
fn open_rejects_invalid_config_with_structured_error() {
let dir = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.data_dir = dir.path().to_path_buf();
config.ring_size = 8; assert!(matches!(
LogDb::open(config),
Err(OpenError::InvalidConfig(ConfigError::InvalidRingSize(8)))
));
}
#[test]
fn append_batch_rejects_empty_with_structured_error() {
let dir = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.data_dir = dir.path().to_path_buf();
let db = LogDb::open(config).unwrap();
let err = db.append_batch(&[]).unwrap_err();
assert!(matches!(err, AppendError::EmptyBatch));
}
#[test]
fn shutdown_clean() {
let dir = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.data_dir = dir.path().to_path_buf();
config.durability_mode = DurabilityMode::Sync;
config.flush_timeout = Duration::from_secs(5);
let db = LogDb::open(config).unwrap();
for i in 0..10 {
db.append(format!("r-{}", i).as_bytes()).unwrap();
}
let report = db.shutdown(Duration::from_secs(5)).unwrap();
assert!(matches!(report, ShutdownReport::Clean));
}
#[test]
fn drain_flushes_to_durable_and_rejects_appends_after() {
let dir = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.data_dir = dir.path().to_path_buf();
config.ring_size = 64;
config.durability_mode = DurabilityMode::Sync;
config.flush_timeout = Duration::from_secs(5);
let db = LogDb::open(config).unwrap();
for i in 0..20 {
db.append(format!("r-{}", i).as_bytes()).unwrap();
}
let report = db.drain(Duration::from_secs(5)).unwrap();
assert!(
matches!(report, ShutdownReport::Clean),
"drain must complete clean"
);
assert!(
db.durable_cursor() >= 20,
"all appended records must be durable after drain"
);
for i in 0..20 {
assert!(
db.read(i).unwrap().is_some(),
"record {} readable after drain",
i
);
}
let err = db.append(b"after-drain").unwrap_err();
assert!(
matches!(err, AppendError::ShuttingDown),
"append after drain must be rejected"
);
}
#[test]
fn replicate_preserves_sequence_and_is_readable() {
let dir = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.data_dir = dir.path().to_path_buf();
config.ring_size = 64;
config.durability_mode = DurabilityMode::Sync;
config.flush_timeout = Duration::from_secs(5);
let db = LogDb::open(config).unwrap();
for i in 0..5u64 {
db.replicate(i, 1_000_000 + i, format!("replica-{}", i).as_bytes())
.unwrap();
}
assert_eq!(db.producer_cursor(), 5, "producer cursor must advance");
db.flush().unwrap();
for _ in 0..20 {
std::thread::sleep(Duration::from_millis(25));
if db.durable_cursor() >= 5 {
break;
}
}
assert!(db.durable_cursor() >= 5);
for i in 0..5u64 {
let rec = db.read(i).unwrap().unwrap();
assert_eq!(rec.id.sequence, i);
assert_eq!(rec.timestamp_ns, 1_000_000 + i);
assert_eq!(rec.content, format!("replica-{}", i).as_bytes());
}
}
#[test]
fn replicate_rejects_out_of_order_and_is_idempotent() {
let dir = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.data_dir = dir.path().to_path_buf();
config.ring_size = 64;
config.durability_mode = DurabilityMode::Sync;
config.flush_timeout = Duration::from_secs(5);
let db = LogDb::open(config).unwrap();
db.replicate(0, 0, b"a").unwrap();
let err = db.replicate(2, 0, b"c").unwrap_err();
assert!(
matches!(err, AppendError::Io(_)),
"expected out-of-order error"
);
db.replicate(1, 0, b"b").unwrap();
db.replicate(0, 0, b"REPLAY").unwrap();
assert_eq!(db.producer_cursor(), 2);
}
fn read_all_in_dir(dir: &std::path::Path) -> Vec<Vec<u8>> {
use std::sync::{Arc, Mutex};
let manifest = Arc::new(Mutex::new(reader::SegmentManifest::new(dir.to_path_buf())));
let reader = reader::Reader::new(manifest, None);
let mut out = Vec::new();
if let Ok(iter) = reader.scan(0, u64::MAX) {
for r in iter {
if let Ok(rec) = r {
out.push(rec.content);
}
}
}
out
}
#[test]
fn append_under_sharding_is_durable_per_shard() {
let dir = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.data_dir = dir.path().to_path_buf();
config.shards = 2;
config.ring_size = 64;
config.durability_mode = DurabilityMode::Sync;
config.flush_timeout = Duration::from_secs(5);
let db = LogDb::open(config).unwrap();
for i in 0..6u64 {
db.append(format!("rec-{}", i).as_bytes()).unwrap();
}
db.flush().unwrap();
let mut got: Vec<Vec<u8>> = (0..2u32)
.flat_map(|s| read_all_in_dir(&dir.path().join(format!("s{}", s))))
.collect();
got.sort();
let mut want: Vec<Vec<u8>> = (0..6u64)
.map(|i| format!("rec-{}", i).into_bytes())
.collect();
want.sort();
assert_eq!(
got, want,
"all appended records must be durable per-shard under sharding"
);
}
#[test]
fn append_batch_under_sharding_is_durable_per_shard() {
let dir = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.data_dir = dir.path().to_path_buf();
config.shards = 2;
config.ring_size = 64;
config.durability_mode = DurabilityMode::Sync;
config.flush_timeout = Duration::from_secs(5);
let db = LogDb::open(config).unwrap();
let batch: Vec<&[u8]> = vec![b"alpha", b"beta", b"gamma", b"delta"];
db.append_batch(&batch).unwrap();
db.flush().unwrap();
let mut got: Vec<Vec<u8>> = (0..2u32)
.flat_map(|s| read_all_in_dir(&dir.path().join(format!("s{}", s))))
.collect();
got.sort();
let mut want: Vec<Vec<u8>> = batch.iter().map(|b| b.to_vec()).collect();
want.sort();
assert_eq!(
got, want,
"append_batch records must all be durable per-shard under sharding"
);
}
#[test]
fn read_under_sharding_returns_record_by_global_id() {
let dir = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.data_dir = dir.path().to_path_buf();
config.shards = 2;
config.ring_size = 64;
config.durability_mode = DurabilityMode::Sync;
config.flush_timeout = Duration::from_secs(5);
let db = LogDb::open(config).unwrap();
let mut ids = Vec::new();
for i in 0..6u64 {
let id = db.append(format!("rec-{}", i).as_bytes()).unwrap();
ids.push((id, format!("rec-{}", i).into_bytes()));
}
db.flush().unwrap();
for (id, want) in &ids {
let rec = db.read(*id).unwrap().expect("readable by global id");
assert_eq!(&rec.content, want);
assert_eq!(rec.id.sequence, *id);
}
}
#[test]
fn read_under_sharding_append_batch_first_record() {
let dir = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.data_dir = dir.path().to_path_buf();
config.shards = 2;
config.ring_size = 64;
config.durability_mode = DurabilityMode::Sync;
config.flush_timeout = Duration::from_secs(5);
let db = LogDb::open(config).unwrap();
let batch: Vec<&[u8]> = vec![b"alpha", b"beta", b"gamma", b"delta"];
let first = db.append_batch(&batch).unwrap();
db.flush().unwrap();
let rec = db
.read(first)
.unwrap()
.expect("first batch record readable");
assert_eq!(rec.content, b"alpha");
assert_eq!(rec.id.sequence, first);
}
#[test]
fn read_under_sharding_not_visible_before_flush() {
let dir = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.data_dir = dir.path().to_path_buf();
config.shards = 2;
config.ring_size = 64;
config.durability_mode = DurabilityMode::Async; config.flush_timeout = Duration::from_secs(5);
let db = LogDb::open(config).unwrap();
let id = db.append(b"not-yet-durable").unwrap();
assert!(db.read(id).unwrap().is_none(), "not visible before durable");
db.flush().unwrap();
let rec = db.read(id).unwrap().expect("visible after flush");
assert_eq!(rec.content, b"not-yet-durable");
}
#[test]
fn scan_under_sharding_is_complete_and_ordered() {
let dir = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.data_dir = dir.path().to_path_buf();
config.shards = 4;
config.ring_size = 256;
config.durability_mode = DurabilityMode::Sync;
config.flush_timeout = Duration::from_secs(5);
let db = LogDb::open(config).unwrap();
let db = Arc::new(db);
let mut handles = Vec::new();
let mut all_ids = Vec::new();
for t in 0..4u64 {
let db = Arc::clone(&db);
handles.push(std::thread::spawn(move || {
let mut ids = Vec::new();
for i in 0..10u64 {
ids.push(db.append(format!("t{}-{}", t, i).as_bytes()).unwrap());
}
ids
}));
}
for h in handles {
all_ids.extend(h.join().unwrap());
}
db.flush().unwrap();
let scanned: Vec<u64> = db
.scan(0, u64::MAX)
.unwrap()
.filter_map(|r| r.ok())
.map(|r| r.id.sequence)
.collect();
assert_eq!(
scanned.len(),
all_ids.len(),
"scan must see every record across shards"
);
assert!(
all_ids.iter().all(|id| scanned.contains(id)),
"scan missing some ids"
);
assert!(
scanned.windows(2).all(|w| w[0] < w[1]),
"scan must be strictly ascending"
);
}
#[test]
fn scan_under_sharding_respects_range() {
let dir = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.data_dir = dir.path().to_path_buf();
config.shards = 2;
config.ring_size = 128;
config.durability_mode = DurabilityMode::Sync;
config.flush_timeout = Duration::from_secs(5);
let db = Arc::new(LogDb::open(config).unwrap());
let mut all_ids = Vec::new();
let mut handles = Vec::new();
for t in 0..2u64 {
let db = Arc::clone(&db);
handles.push(std::thread::spawn(move || {
(0..12u64)
.map(|i| db.append(format!("t{}-{}", t, i).as_bytes()).unwrap())
.collect::<Vec<_>>()
}));
}
for h in handles {
all_ids.extend(h.join().unwrap());
}
db.flush().unwrap();
all_ids.sort();
let from = all_ids[5];
let to = all_ids[15];
let got: Vec<u64> = db
.scan(from, to)
.unwrap()
.filter_map(|r| r.ok())
.map(|r| r.id.sequence)
.collect();
let want: Vec<u64> = all_ids
.iter()
.copied()
.filter(|&id| id >= from && id < to)
.collect();
assert_eq!(
got, want,
"scan([from,to)) must clip to the global-id range"
);
}
#[test]
fn scan_crosses_segment_boundary_single_shard() {
let dir = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.data_dir = dir.path().to_path_buf();
config.shards = 1;
config.segment_size = 1 * 1024 * 1024; config.ring_size = 8192;
config.durability_mode = DurabilityMode::Sync;
config.flush_timeout = Duration::from_secs(60);
let db = LogDb::open(config).unwrap();
let payload = vec![0xA5u8; 64 * 1024]; let n = 20u64;
for _ in 0..n {
db.append(&payload).unwrap();
}
db.flush().unwrap();
let segs = std::fs::read_dir(dir.path())
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| {
e.file_name()
.to_str()
.map_or(false, |n| n.ends_with(".log"))
})
.count();
assert!(
segs >= 2,
"expected a segment roll, found {} segment files",
segs
);
let scanned: Vec<u64> = db
.scan(0, u64::MAX)
.unwrap()
.filter_map(|r| r.ok())
.map(|r| r.id.sequence)
.collect();
assert_eq!(
scanned.len(),
n as usize,
"scan must cross the segment boundary"
);
assert!(scanned.windows(2).all(|w| w[0] < w[1]));
assert_eq!((0..n).collect::<Vec<_>>(), scanned);
}
#[test]
fn replay_from_under_sharding_returns_tail_ordered() {
let dir = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.data_dir = dir.path().to_path_buf();
config.shards = 4;
config.ring_size = 256;
config.durability_mode = DurabilityMode::Sync;
config.flush_timeout = Duration::from_secs(5);
let db = Arc::new(LogDb::open(config).unwrap());
let mut all_ids = Vec::new();
let mut handles = Vec::new();
for t in 0..4u64 {
let db = Arc::clone(&db);
handles.push(std::thread::spawn(move || {
(0..8u64)
.map(|i| db.append(format!("t{}-{}", t, i).as_bytes()).unwrap())
.collect::<Vec<_>>()
}));
}
for h in handles {
all_ids.extend(h.join().unwrap());
}
db.flush().unwrap();
all_ids.sort();
let pivot = all_ids[10];
let tail: Vec<u64> = db
.replay_from(pivot)
.unwrap()
.filter_map(|r| r.ok())
.map(|r| r.id.sequence)
.collect();
let want: Vec<u64> = all_ids.iter().copied().filter(|&id| id >= pivot).collect();
assert_eq!(
tail, want,
"replay_from must return the ordered tail across shards"
);
}
#[test]
fn reopen_under_sharding_preserves_all_records() {
let dir = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.data_dir = dir.path().to_path_buf();
config.shards = 4;
config.ring_size = 256;
config.durability_mode = DurabilityMode::Sync;
config.flush_timeout = Duration::from_secs(5);
let mut all_ids = Vec::new();
{
let db = Arc::new(LogDb::open(config.clone()).unwrap());
let mut handles = Vec::new();
for t in 0..4u64 {
let db = Arc::clone(&db);
handles.push(std::thread::spawn(move || {
(0..10u64)
.map(|i| db.append(format!("t{}-{}", t, i).as_bytes()).unwrap())
.collect::<Vec<_>>()
}));
}
for h in handles {
all_ids.extend(h.join().unwrap());
}
db.flush().unwrap();
let db = Arc::try_unwrap(db).ok().unwrap();
db.shutdown(Duration::from_secs(5)).unwrap();
}
let db = LogDb::open(config).unwrap();
let scanned: Vec<u64> = db
.scan(0, u64::MAX)
.unwrap()
.filter_map(|r| r.ok())
.map(|r| r.id.sequence)
.collect();
assert_eq!(
scanned.len(),
all_ids.len(),
"reopen must preserve every record across shards"
);
assert!(
all_ids.iter().all(|id| scanned.contains(id)),
"reopen lost some ids"
);
assert!(
scanned.windows(2).all(|w| w[0] < w[1]),
"scanned ids must be strictly ascending"
);
}
#[test]
fn reopen_under_sharding_single_thread_handles_empty_shards() {
let dir = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.data_dir = dir.path().to_path_buf();
config.shards = 4;
config.ring_size = 256;
config.durability_mode = DurabilityMode::Sync;
config.flush_timeout = Duration::from_secs(5);
let ids: Vec<u64> = {
let db = LogDb::open(config.clone()).unwrap();
let mut ids = Vec::new();
for i in 0..6u64 {
ids.push(db.append(format!("r-{}", i).as_bytes()).unwrap());
}
db.flush().unwrap();
db.shutdown(Duration::from_secs(5)).unwrap();
ids
};
let db = LogDb::open(config).unwrap();
let scanned: Vec<u64> = db
.scan(0, u64::MAX)
.unwrap()
.filter_map(|r| r.ok())
.map(|r| r.id.sequence)
.collect();
assert_eq!(
scanned, ids,
"empty shards must not lose the written shard's records"
);
let nid = db.append(b"after-reopen").unwrap();
db.flush().unwrap();
assert!(
db.read(nid).unwrap().is_some(),
"post-reopen append must read back"
);
}
#[test]
fn reopen_under_sharding_then_append_no_id_collision() {
let dir = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.data_dir = dir.path().to_path_buf();
config.shards = 4;
config.ring_size = 256;
config.durability_mode = DurabilityMode::Sync;
config.flush_timeout = Duration::from_secs(5);
let mut all_ids = Vec::new();
{
let db = Arc::new(LogDb::open(config.clone()).unwrap());
let mut handles = Vec::new();
for t in 0..4u64 {
let db = Arc::clone(&db);
handles.push(std::thread::spawn(move || {
(0..8u64)
.map(|i| db.append(format!("t{}-{}", t, i).as_bytes()).unwrap())
.collect::<Vec<_>>()
}));
}
for h in handles {
all_ids.extend(h.join().unwrap());
}
db.flush().unwrap();
let db = Arc::try_unwrap(db).ok().unwrap();
db.shutdown(Duration::from_secs(5)).unwrap();
}
let db = Arc::new(LogDb::open(config).unwrap());
let mut new_ids = Vec::new();
let mut handles = Vec::new();
for t in 0..4u64 {
let db = Arc::clone(&db);
handles.push(std::thread::spawn(move || {
(0..5u64)
.map(|i| db.append(format!("n{}-{}", t, i).as_bytes()).unwrap())
.collect::<Vec<_>>()
}));
}
for h in handles {
new_ids.extend(h.join().unwrap());
}
db.flush().unwrap();
let mut seen = std::collections::HashSet::new();
for id in all_ids.iter().chain(new_ids.iter()) {
assert!(seen.insert(*id), "global id collision after reopen: {}", id);
}
for id in &all_ids {
assert!(
db.read(*id).unwrap().is_some(),
"old id {} lost after reopen+append",
id
);
}
}
#[test]
fn reopen_under_sharding_detects_torn_write() {
let dir = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.data_dir = dir.path().to_path_buf();
config.shards = 2;
config.ring_size = 128;
config.durability_mode = DurabilityMode::Sync;
config.flush_timeout = Duration::from_secs(5);
{
let db = LogDb::open(config.clone()).unwrap();
for i in 0..20u64 {
db.append(format!("r-{}", i).as_bytes()).unwrap();
}
db.flush().unwrap();
db.shutdown(Duration::from_secs(5)).unwrap();
}
let victim = (0..2u32)
.map(|s| {
dir.path()
.join(format!("s{}", s))
.join("segment-00000001.log")
})
.find(|p| p.exists() && std::fs::metadata(p).map(|m| m.len() > 200).unwrap_or(false))
.expect("some shard must have received records");
let len = std::fs::metadata(&victim).unwrap().len();
std::fs::OpenOptions::new()
.write(true)
.open(&victim)
.unwrap()
.set_len(len - 5)
.unwrap();
let db = LogDb::open(config).unwrap();
let scanned: Vec<Vec<u8>> = db
.scan(0, u64::MAX)
.unwrap()
.filter_map(|r| r.ok())
.map(|r| r.content)
.collect();
assert!(
scanned.len() >= 18,
"torn-write recovery lost too many records: {}",
scanned.len()
);
}
#[test]
fn reopen_shards1_still_recovers() {
let dir = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.data_dir = dir.path().to_path_buf();
config.ring_size = 64;
config.durability_mode = DurabilityMode::Sync;
config.flush_timeout = Duration::from_secs(5);
let id = {
let db = LogDb::open(config.clone()).unwrap();
let id = db.append(b"shards1-recovery").unwrap();
db.flush().unwrap();
db.shutdown(Duration::from_secs(5)).unwrap();
id
};
let db = LogDb::open(config).unwrap();
let rec = db
.read(id)
.unwrap()
.expect("shards=1 record must survive reopen");
assert_eq!(rec.content, b"shards1-recovery");
let nid = db.append(b"after").unwrap();
db.flush().unwrap();
assert!(db.read(nid).unwrap().is_some());
}
#[test]
fn tailer_under_sharding_reads_all_shards_merged() {
let dir = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.data_dir = dir.path().to_path_buf();
config.shards = 4;
config.ring_size = 256;
config.durability_mode = DurabilityMode::Sync;
config.flush_timeout = Duration::from_secs(5);
let db = Arc::new(LogDb::open(config).unwrap());
let total = 40u64; let mut handles = Vec::new();
for t in 0..4u64 {
let db = Arc::clone(&db);
handles.push(std::thread::spawn(move || {
(0..10u64)
.map(|i| db.append(format!("t{}-{}", t, i).as_bytes()).unwrap())
.collect::<Vec<_>>()
}));
}
let mut all_ids = Vec::new();
for h in handles {
all_ids.extend(h.join().unwrap());
}
db.flush().unwrap();
for _ in 0..100 {
if db.durable_cursor() >= 10 {
break;
}
std::thread::sleep(Duration::from_millis(100));
}
let mut t = db.new_tailer("merge");
let mut got: Vec<u64> = Vec::new();
for _ in 0..200 {
match t.next_batch(1000).unwrap() {
Some(batch) => got.extend(batch.iter().map(|r| r.id.sequence)),
None => break,
}
std::thread::sleep(Duration::from_millis(5));
}
assert_eq!(
got.len(),
total as usize,
"tailer must see every record across ALL shards"
);
assert!(
got.windows(2).all(|w| w[0] < w[1]),
"tailer batch must be ascending global id"
);
assert!(
all_ids.iter().all(|id| got.contains(id)),
"tailer missing some ids: got={:?}",
got
);
let shards_seen: std::collections::HashSet<usize> = got
.iter()
.map(|&g| crate::shard::decode_record_id(g, 2).0)
.collect();
assert!(
shards_seen.len() > 1,
"tailer should have merged multiple shards, saw {:?}",
shards_seen
);
}
#[test]
fn tailer_under_sharding_single_shard_is_delivered() {
let dir = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.data_dir = dir.path().to_path_buf();
config.shards = 4;
config.ring_size = 256;
config.durability_mode = DurabilityMode::Sync;
config.flush_timeout = Duration::from_secs(5);
let db = LogDb::open(config).unwrap();
let n = 60u64;
let mut ids = Vec::new();
for i in 0..n {
ids.push(db.append(format!("s-{}", i).as_bytes()).unwrap());
}
db.flush().unwrap();
for _ in 0..100 {
if db.durable_cursor() >= 1 {
break;
}
std::thread::sleep(Duration::from_millis(100));
}
let mut t = db.new_tailer("one-shard");
let mut got: Vec<u64> = Vec::new();
for _ in 0..200 {
match t.next_batch(1000).unwrap() {
Some(b) => got.extend(b.iter().map(|r| r.id.sequence)),
None => break,
}
std::thread::sleep(Duration::from_millis(5));
}
assert_eq!(
got.len(),
n as usize,
"single-thread writes (one shard) must all be delivered"
);
assert!(ids.iter().all(|id| got.contains(id)));
}
#[test]
fn tailer_under_sharding_persists_per_shard_progress() {
let dir = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.data_dir = dir.path().to_path_buf();
config.shards = 4;
config.ring_size = 256;
config.durability_mode = DurabilityMode::Sync;
config.flush_timeout = Duration::from_secs(5);
let db = Arc::new(LogDb::open(config).unwrap());
let mut all_ids = Vec::new();
let mut handles = Vec::new();
for t in 0..4u64 {
let db = Arc::clone(&db);
handles.push(std::thread::spawn(move || {
(0..10u64)
.map(|i| db.append(format!("t{}-{}", t, i).as_bytes()).unwrap())
.collect::<Vec<_>>()
}));
}
for h in handles {
all_ids.extend(h.join().unwrap());
}
db.flush().unwrap();
for _ in 0..100 {
if db.durable_cursor() >= 10 {
break;
}
std::thread::sleep(Duration::from_millis(100));
}
let mut delivered = Vec::new();
{
let mut t = db.new_tailer("persist");
while delivered.len() < 12 {
match t.next_batch(12).unwrap() {
Some(b) => delivered.extend(b.iter().map(|r| r.id.sequence)),
None => break,
}
}
t.commit().unwrap();
}
let saved_positions = db.new_tailer("persist").positions().to_vec();
assert!(
saved_positions.iter().any(|&p| p > 0),
"some shard progress must have persisted"
);
let mut t = db.new_tailer("persist");
assert_eq!(
t.positions(),
saved_positions.as_slice(),
"reopened tailer must restore per-shard positions"
);
let mut rest = Vec::new();
for _ in 0..200 {
match t.next_batch(1000).unwrap() {
Some(b) => rest.extend(b.iter().map(|r| r.id.sequence)),
None => break,
}
std::thread::sleep(Duration::from_millis(5));
}
let mut all = delivered.clone();
all.extend(rest);
all.sort();
all_ids.sort();
assert_eq!(
all, all_ids,
"commit+reopen must deliver every record exactly once"
);
}
#[test]
fn tailer_under_sharding_resumes_without_loss() {
let dir = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.data_dir = dir.path().to_path_buf();
config.shards = 4;
config.ring_size = 256;
config.durability_mode = DurabilityMode::Sync;
config.flush_timeout = Duration::from_secs(5);
let db = Arc::new(LogDb::open(config).unwrap());
let spawn = |db: &Arc<LogDb>| -> Vec<u64> {
let mut handles = Vec::new();
for t in 0..4u64 {
let db = Arc::clone(db);
handles.push(std::thread::spawn(move || {
(0..8u64)
.map(|i| db.append(format!("t{}-{}", t, i).as_bytes()).unwrap())
.collect::<Vec<_>>()
}));
}
let mut ids = Vec::new();
for h in handles {
ids.extend(h.join().unwrap());
}
ids
};
let first = spawn(&db);
db.flush().unwrap();
for _ in 0..100 {
if db.durable_cursor() >= 8 {
break;
}
std::thread::sleep(Duration::from_millis(100));
}
{
let mut t = db.new_tailer("resume");
let mut got = Vec::new();
for _ in 0..200 {
match t.next_batch(1000).unwrap() {
Some(b) => got.extend(b.iter().map(|r| r.id.sequence)),
None => break,
}
std::thread::sleep(Duration::from_millis(5));
}
assert_eq!(got.len(), first.len());
t.commit().unwrap();
}
let second = spawn(&db);
db.flush().unwrap();
for _ in 0..100 {
if db.durable_cursor() >= 8 {
break;
}
std::thread::sleep(Duration::from_millis(100));
}
let mut t = db.new_tailer("resume");
let mut got = Vec::new();
for _ in 0..200 {
match t.next_batch(1000).unwrap() {
Some(b) => got.extend(b.iter().map(|r| r.id.sequence)),
None => break,
}
std::thread::sleep(Duration::from_millis(5));
}
let mut want = second.clone();
let mut got_sorted = got.clone();
want.sort();
got_sorted.sort();
assert_eq!(
got_sorted, want,
"reopened tailer must deliver only the newly-appended records"
);
}
#[test]
fn tailer_crosses_segment_under_sharding() {
let dir = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.data_dir = dir.path().to_path_buf();
config.shards = 2;
config.segment_size = 1 * 1024 * 1024; config.ring_size = 8192;
config.durability_mode = DurabilityMode::Sync;
config.flush_timeout = Duration::from_secs(60);
let db = Arc::new(LogDb::open(config).unwrap());
let mut handles = Vec::new();
for t in 0..2u64 {
let db = Arc::clone(&db);
handles.push(std::thread::spawn(move || {
let payload = vec![0xA5u8 + t as u8; 64 * 1024]; for _ in 0..20u64 {
db.append(&payload).unwrap();
}
}));
}
for h in handles {
h.join().unwrap();
}
db.flush().unwrap();
for _ in 0..100 {
if db.durable_cursor() >= 1 {
break;
}
std::thread::sleep(Duration::from_millis(100));
}
let mut t = db.new_tailer("xseg");
let mut count = 0usize;
for _ in 0..300 {
match t.next_batch(1000).unwrap() {
Some(b) => count += b.len(),
None => break,
}
std::thread::sleep(Duration::from_millis(5));
}
assert_eq!(
count,
2 * 20,
"tailer must cross segment boundaries within each shard"
);
}
#[test]
fn wal_usage_under_sharding_sums_all_shard_dirs() {
let dir = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.data_dir = dir.path().to_path_buf();
config.shards = 4;
config.ring_size = 256;
config.durability_mode = DurabilityMode::Sync;
config.flush_timeout = Duration::from_secs(5);
let db = Arc::new(LogDb::open(config).unwrap());
let mut handles = Vec::new();
for t in 0..4u64 {
let db = Arc::clone(&db);
handles.push(std::thread::spawn(move || {
for i in 0..10u64 {
db.append(format!("t{}-{}", t, i).as_bytes()).unwrap();
}
}));
}
for h in handles {
h.join().unwrap();
}
db.flush().unwrap();
for _ in 0..100 {
if db.durable_cursor() >= 10 {
break;
}
std::thread::sleep(Duration::from_millis(100));
}
let (used, total) = db.wal_usage();
assert!(total > 0, "segment_size total should be reported");
assert!(
used > 0,
"wal_usage must sum segment files across ALL shard subdirs (got {})",
used
);
}
#[test]
fn recovery_report_under_sharding_counts_across_shards() {
let dir = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.data_dir = dir.path().to_path_buf();
config.shards = 4;
config.ring_size = 256;
config.durability_mode = DurabilityMode::Sync;
config.flush_timeout = Duration::from_secs(5);
let db = Arc::new(LogDb::open(config).unwrap());
let total = 40u64;
let mut handles = Vec::new();
for t in 0..4u64 {
let db = Arc::clone(&db);
handles.push(std::thread::spawn(move || {
for i in 0..10u64 {
db.append(format!("t{}-{}", t, i).as_bytes()).unwrap();
}
}));
}
for h in handles {
h.join().unwrap();
}
db.flush().unwrap();
let scan_count = loop {
let n = db.scan(0, u64::MAX).unwrap().filter_map(|r| r.ok()).count();
if n >= total as usize {
break n;
}
std::thread::sleep(Duration::from_millis(100));
};
let report = db.recovery_report();
assert_eq!(report.from_sequence, 0);
assert_eq!(
report.count as usize, scan_count,
"recovery_report.count must equal total durable records across shards (got {}, scan={})",
report.count, scan_count
);
}
#[test]
#[serial_test::serial]
fn checkpoint_truncation_under_sharding_preserves_post_checkpoint_data() {
let dir = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.data_dir = dir.path().to_path_buf();
config.shards = 2;
config.segment_size = 1 * 1024 * 1024; config.ring_size = 8192;
config.durability_mode = DurabilityMode::Sync;
config.flush_timeout = Duration::from_secs(60);
let db = Arc::new(LogDb::open(config).unwrap());
let payload = vec![0xA5u8; 64 * 1024];
let mut all_ids = Vec::new();
let mut handles = Vec::new();
for _ in 0..2u64 {
let db = Arc::clone(&db);
let p = payload.clone();
handles.push(std::thread::spawn(move || {
let mut ids = Vec::new();
for _ in 0..20u64 {
ids.push(db.append(&p).unwrap());
}
ids
}));
}
for h in handles {
all_ids.extend(h.join().unwrap());
}
db.flush().unwrap();
db.refresh_manifests().unwrap();
let count = db.scan(0, u64::MAX).unwrap().filter_map(|r| r.ok()).count();
assert_eq!(
count,
all_ids.len(),
"all appended records must be visible after flush + refresh"
);
all_ids.sort();
let cp = all_ids[all_ids.len() / 2];
db.checkpoint(cp);
let mut more_ids = Vec::new();
for _ in 0..20u64 {
more_ids.push(db.append(&payload).unwrap());
}
db.flush().unwrap();
db.refresh_manifests().unwrap();
let surviving = all_ids.iter().filter(|&&id| id >= cp).count() + more_ids.len();
let count = db
.scan(cp, u64::MAX)
.unwrap()
.filter_map(|r| r.ok())
.count();
assert!(
count >= surviving,
"expected at least {surviving} records at/after checkpoint, got {count}"
);
for id in all_ids
.iter()
.chain(more_ids.iter())
.filter(|&&id| id >= cp)
{
assert!(
db.read(*id).unwrap().is_some(),
"post-checkpoint id {} lost after truncation",
id
);
}
}
#[test]
fn point_read_across_segment_roll_under_sharding() {
let dir = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.data_dir = dir.path().to_path_buf();
config.shards = 2;
config.segment_size = 1 * 1024 * 1024; config.ring_size = 8192;
config.durability_mode = DurabilityMode::Sync;
config.flush_timeout = Duration::from_secs(60);
let db = Arc::new(LogDb::open(config).unwrap());
let payload = vec![0xA5u8; 64 * 1024];
let mut all_ids = Vec::new();
let mut handles = Vec::new();
for _ in 0..2u64 {
let db = Arc::clone(&db);
let p = payload.clone();
handles.push(std::thread::spawn(move || {
let mut ids = Vec::new();
for _ in 0..20u64 {
ids.push(db.append(&p).unwrap());
}
ids
}));
}
for h in handles {
all_ids.extend(h.join().unwrap());
}
db.flush().unwrap();
for _ in 0..100 {
if db.scan(0, u64::MAX).unwrap().filter_map(|r| r.ok()).count() >= all_ids.len() {
break;
}
std::thread::sleep(Duration::from_millis(100));
}
let rolled = (0..2).any(|s| {
std::fs::read_dir(dir.path().join(format!("s{}", s)))
.map(|rd| {
rd.filter_map(|e| e.ok())
.filter(|e| {
e.file_name()
.to_str()
.map_or(false, |n| n.ends_with(".log"))
})
.count()
>= 2
})
.unwrap_or(false)
});
assert!(rolled, "test should have triggered a segment roll");
for id in &all_ids {
assert!(
db.read(*id).unwrap().is_some(),
"point read of id {} failed across segment roll",
id
);
}
}
#[test]
fn retention_maxbytes_applies_per_shard() {
use crate::config::RetentionPolicy;
let dir = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.data_dir = dir.path().to_path_buf();
config.shards = 2;
config.segment_size = 1 * 1024 * 1024;
config.ring_size = 8192;
config.durability_mode = DurabilityMode::Sync;
config.flush_timeout = Duration::from_secs(60);
config.retention = RetentionPolicy::MaxBytes(2 * 1024 * 1024); let db = Arc::new(LogDb::open(config).unwrap());
let payload = vec![0xA5u8; 64 * 1024];
let mut handles = Vec::new();
for _ in 0..2u64 {
let db = Arc::clone(&db);
let p = payload.clone();
handles.push(std::thread::spawn(move || {
for _ in 0..40u64 {
db.append(&p).unwrap();
}
}));
}
for h in handles {
h.join().unwrap();
}
db.flush().unwrap();
for _ in 0..100 {
if db.scan(0, u64::MAX).unwrap().filter_map(|r| r.ok()).count() >= 40 {
break;
}
std::thread::sleep(Duration::from_millis(100));
}
for s in 0..2 {
let sd = dir.path().join(format!("s{}", s));
let bytes: u64 = std::fs::read_dir(&sd)
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| {
e.file_name()
.to_str()
.map_or(false, |n| n.ends_with(".log"))
})
.filter_map(|e| e.metadata().ok())
.map(|m| m.len())
.sum();
assert!(
bytes <= 4 * 1024 * 1024,
"shard {} retained {} bytes (retention not applied)",
s,
bytes
);
}
}
#[test]
fn non_power_of_two_shards_end_to_end() {
let dir = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.data_dir = dir.path().to_path_buf();
config.shards = 3; config.ring_size = 256;
config.durability_mode = DurabilityMode::Sync;
config.flush_timeout = Duration::from_secs(5);
let db = Arc::new(LogDb::open(config.clone()).unwrap());
let mut all_ids = Vec::new();
let mut handles = Vec::new();
for t in 0..6u64 {
let db = Arc::clone(&db);
handles.push(std::thread::spawn(move || {
(0..5u64)
.map(|i| db.append(format!("t{}-{}", t, i).as_bytes()).unwrap())
.collect::<Vec<_>>()
}));
}
for h in handles {
all_ids.extend(h.join().unwrap());
}
db.flush().unwrap();
for _ in 0..100 {
if db.scan(0, u64::MAX).unwrap().filter_map(|r| r.ok()).count() >= all_ids.len() {
break;
}
std::thread::sleep(Duration::from_millis(100));
}
for id in &all_ids {
let (shard, _local) = crate::shard::decode_record_id(*id, 2);
assert!(
shard < 3,
"non-power-of-2 must not produce shard id >= num_shards"
);
assert!(db.read(*id).unwrap().is_some(), "id {} must read back", id);
}
let scanned: Vec<u64> = db
.scan(0, u64::MAX)
.unwrap()
.filter_map(|r| r.ok())
.map(|r| r.id.sequence)
.collect();
assert_eq!(scanned.len(), all_ids.len());
assert!(scanned.windows(2).all(|w| w[0] < w[1]));
drop(db);
let db = LogDb::open(config).unwrap();
let rescanned: Vec<u64> = db
.scan(0, u64::MAX)
.unwrap()
.filter_map(|r| r.ok())
.map(|r| r.id.sequence)
.collect();
assert_eq!(
rescanned.len(),
all_ids.len(),
"non-power-of-2 shards must survive reopen"
);
}
#[test]
fn concurrent_append_scan_tailer_under_sharding() {
let dir = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.data_dir = dir.path().to_path_buf();
config.shards = 4;
config.ring_size = 512;
config.durability_mode = DurabilityMode::Sync;
config.flush_timeout = Duration::from_secs(5);
let db = Arc::new(LogDb::open(config).unwrap());
let total = 80u64;
let db2 = Arc::clone(&db);
let writer = std::thread::spawn(move || {
let mut ids = Vec::new();
for i in 0..total {
ids.push(db2.append(format!("x-{}", i).as_bytes()).unwrap());
}
db2.flush().unwrap();
ids
});
let db3 = Arc::clone(&db);
let reader = std::thread::spawn(move || {
let mut last = 0usize;
for _ in 0..20 {
let n = db3
.scan(0, u64::MAX)
.unwrap()
.filter_map(|r| r.ok())
.count();
if n > last {
last = n;
}
std::thread::sleep(Duration::from_millis(2));
}
last
});
let ids = writer.join().unwrap();
let _seen = reader.join().unwrap();
for _ in 0..100 {
if db.scan(0, u64::MAX).unwrap().filter_map(|r| r.ok()).count() >= total as usize {
break;
}
std::thread::sleep(Duration::from_millis(100));
}
let mut t = db.new_tailer("stress");
let mut got: Vec<u64> = Vec::new();
for _ in 0..400 {
match t.next_batch(1000).unwrap() {
Some(b) => got.extend(b.iter().map(|r| r.id.sequence)),
None => break,
}
std::thread::sleep(Duration::from_millis(2));
}
assert_eq!(
got.len(),
total as usize,
"tailer must deliver all under concurrency"
);
assert!(ids.iter().all(|id| got.contains(id)));
}
#[test]
fn scan_raw_large_records_across_chunk_boundary() {
let dir = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.data_dir = dir.path().to_path_buf();
config.shards = 1;
config.ring_size = 4096;
config.durability_mode = DurabilityMode::Sync;
config.flush_timeout = Duration::from_secs(5);
let db = LogDb::open(config).unwrap();
let big = vec![0xA5u8; 80 * 1024]; let n = 40u64;
let mut ids = Vec::new();
for _ in 0..n {
ids.push(db.append(&big).unwrap());
}
db.flush().unwrap();
for _ in 0..100 {
if db.scan(0, u64::MAX).unwrap().filter_map(|r| r.ok()).count() >= n as usize {
break;
}
std::thread::sleep(Duration::from_millis(100));
}
let scanned: Vec<(u64, Vec<u8>)> = db
.scan(0, u64::MAX)
.unwrap()
.filter_map(|r| r.ok())
.map(|r| (r.id.sequence, r.content.clone()))
.collect();
assert_eq!(scanned.len(), n as usize);
for (id, content) in &scanned {
assert_eq!(content, &big, "large-record content must survive buffering");
assert!(ids.contains(id));
}
}
#[test]
fn scan_raw_respects_range_after_buffering() {
let dir = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.data_dir = dir.path().to_path_buf();
config.shards = 1;
config.ring_size = 4096;
config.durability_mode = DurabilityMode::Sync;
config.flush_timeout = Duration::from_secs(5);
let db = LogDb::open(config).unwrap();
let mut ids = Vec::new();
for i in 0..200u64 {
ids.push(db.append(format!("r-{}", i).as_bytes()).unwrap());
}
db.flush().unwrap();
for _ in 0..100 {
if db.scan(0, u64::MAX).unwrap().filter_map(|r| r.ok()).count() >= 200 {
break;
}
std::thread::sleep(Duration::from_millis(100));
}
ids.sort();
let from = ids[50];
let to = ids[150];
let got: Vec<u64> = db
.scan(from, to)
.unwrap()
.filter_map(|r| r.ok())
.map(|r| r.id.sequence)
.collect();
let want: Vec<u64> = ids
.iter()
.copied()
.filter(|&id| id >= from && id < to)
.collect();
assert_eq!(got, want, "range scan must be exact after buffering");
}
#[test]
fn replicate_rejects_shards_gt_1() {
let dir = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.data_dir = dir.path().to_path_buf();
config.shards = 2;
config.durability_mode = DurabilityMode::Sync;
config.flush_timeout = Duration::from_secs(5);
let db = LogDb::open(config).unwrap();
let err = db.replicate(0, 0, b"x").unwrap_err();
assert!(
matches!(err, crate::AppendError::Io(_)),
"replicate must reject shards>1"
);
assert!(
format!("{}", err).contains("shards=1"),
"error should explain the constraint, got: {}",
err
);
}
#[cfg(feature = "compression")]
#[test]
fn sharded_compressed_log_end_to_end() {
let dir = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.data_dir = dir.path().to_path_buf();
config.shards = 4;
config.ring_size = 256;
config.compression_enabled = true;
config.durability_mode = DurabilityMode::Sync;
config.flush_timeout = Duration::from_secs(5);
let db = Arc::new(LogDb::open(config.clone()).unwrap());
let mut by_id = std::collections::HashMap::<u64, Vec<u8>>::new();
let mut handles = Vec::new();
for t in 0..4u64 {
let db = Arc::clone(&db);
handles.push(std::thread::spawn(move || {
let mut v = Vec::new();
for i in 0..10u64 {
let c = format!("t{}-{}", t, i).into_bytes();
let id = db.append(&c).unwrap();
v.push((id, c));
}
v
}));
}
for h in handles {
for (id, c) in h.join().unwrap() {
by_id.insert(id, c);
}
}
let all_ids: Vec<u64> = by_id.keys().copied().collect();
db.flush().unwrap();
for _ in 0..100 {
if db.scan(0, u64::MAX).unwrap().filter_map(|r| r.ok()).count() >= all_ids.len() {
break;
}
std::thread::sleep(Duration::from_millis(100));
}
let scanned: Vec<u64> = db
.scan(0, u64::MAX)
.unwrap()
.filter_map(|r| r.ok())
.map(|r| r.id.sequence)
.collect();
assert_eq!(
scanned.len(),
all_ids.len(),
"compressed scan must see all shards"
);
assert!(scanned.windows(2).all(|w| w[0] < w[1]));
for (id, expected) in &by_id {
let rec = db
.read(*id)
.unwrap()
.expect("compressed point read under shards>1");
assert_eq!(&rec.content, expected, "content mismatch for id {}", id);
}
drop(db);
let db = LogDb::open(config).unwrap();
let rescanned: Vec<u64> = db
.scan(0, u64::MAX)
.unwrap()
.filter_map(|r| r.ok())
.map(|r| r.id.sequence)
.collect();
assert_eq!(
rescanned.len(),
all_ids.len(),
"compressed shards>1 must survive reopen"
);
}
#[cfg(feature = "encryption")]
#[test]
fn sharded_encrypted_log_end_to_end() {
let dir = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.data_dir = dir.path().to_path_buf();
config.shards = 4;
config.ring_size = 256;
config.encryption_keys = Some(KeyRing::single([0x42u8; 32]));
config.durability_mode = DurabilityMode::Sync;
config.flush_timeout = Duration::from_secs(5);
let db = Arc::new(LogDb::open(config.clone()).unwrap());
let mut by_id = std::collections::HashMap::<u64, Vec<u8>>::new();
let mut handles = Vec::new();
for t in 0..4u64 {
let db = Arc::clone(&db);
handles.push(std::thread::spawn(move || {
let mut v = Vec::new();
for i in 0..10u64 {
let c = format!("t{}-{}", t, i).into_bytes();
let id = db.append(&c).unwrap();
v.push((id, c));
}
v
}));
}
for h in handles {
for (id, c) in h.join().unwrap() {
by_id.insert(id, c);
}
}
let all_ids: Vec<u64> = by_id.keys().copied().collect();
db.flush().unwrap();
for _ in 0..100 {
if db.scan(0, u64::MAX).unwrap().filter_map(|r| r.ok()).count() >= all_ids.len() {
break;
}
std::thread::sleep(Duration::from_millis(100));
}
let scanned: Vec<u64> = db
.scan(0, u64::MAX)
.unwrap()
.filter_map(|r| r.ok())
.map(|r| r.id.sequence)
.collect();
assert_eq!(
scanned.len(),
all_ids.len(),
"encrypted scan must see all shards"
);
assert!(scanned.windows(2).all(|w| w[0] < w[1]));
for (id, expected) in &by_id {
let rec = db
.read(*id)
.unwrap()
.expect("encrypted point read under shards>1");
assert_eq!(&rec.content, expected, "content mismatch for id {}", id);
}
drop(db);
let db = LogDb::open(config).unwrap();
let rescanned: Vec<u64> = db
.scan(0, u64::MAX)
.unwrap()
.filter_map(|r| r.ok())
.map(|r| r.id.sequence)
.collect();
assert_eq!(
rescanned.len(),
all_ids.len(),
"encrypted shards>1 must survive reopen"
);
}
#[cfg(feature = "compression")]
#[test]
fn compressed_scan_crosses_segment_roll() {
compressed_or_encrypted_scan_crosses_roll(true, None);
}
#[cfg(feature = "encryption")]
#[test]
fn encrypted_scan_crosses_segment_roll() {
compressed_or_encrypted_scan_crosses_roll(false, Some([0x99u8; 32]));
}
fn compressed_or_encrypted_scan_crosses_roll(compressed: bool, key: Option<[u8; 32]>) {
let dir = tempfile::tempdir().unwrap();
let mut config = Config::default();
config.data_dir = dir.path().to_path_buf();
config.shards = 1;
config.segment_size = 1 * 1024 * 1024; config.ring_size = 8192;
config.compression_enabled = compressed;
config.encryption_keys = key.map(KeyRing::single);
config.durability_mode = DurabilityMode::Sync;
config.flush_timeout = Duration::from_secs(60);
let db = LogDb::open(config).unwrap();
let smix = |x: u64| -> u8 {
let mut z = x;
z = (z ^ (z >> 30)).wrapping_mul(0xbf58476d1ce4e5b9);
z = (z ^ (z >> 27)).wrapping_mul(0x94d049bb133111eb);
(z ^ (z >> 31)) as u8
};
let n = 40u64; let mut payloads: Vec<Vec<u8>> = Vec::with_capacity(n as usize);
for r in 0..n {
let base = r * (64 * 1024) as u64;
let p: Vec<u8> = (0..64 * 1024).map(|j| smix(base + j as u64)).collect();
db.append(&p).unwrap();
payloads.push(p);
}
db.flush().unwrap();
for _ in 0..100 {
if db.scan(0, u64::MAX).unwrap().filter_map(|r| r.ok()).count() >= n as usize {
break;
}
std::thread::sleep(Duration::from_millis(100));
}
let segs = std::fs::read_dir(dir.path())
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| {
e.file_name()
.to_str()
.map_or(false, |n| n.ends_with(".log"))
})
.count();
assert!(
segs >= 2,
"expected a frame-mode segment roll, found {}",
segs
);
let scanned: Vec<(u64, Vec<u8>)> = db
.scan(0, u64::MAX)
.unwrap()
.filter_map(|r| r.ok())
.map(|r| (r.id.sequence, r.content.clone()))
.collect();
assert_eq!(
scanned.len(),
n as usize,
"frame-mode scan must cross the segment roll"
);
assert_eq!(
scanned.iter().map(|(id, _)| *id).collect::<Vec<_>>(),
(0..n).collect::<Vec<_>>(),
);
for (id, content) in &scanned {
assert_eq!(
content, &payloads[*id as usize],
"content mismatch for id {}",
id
);
}
}
}