#![cfg(loom)]
use std::collections::HashMap;
use loom::sync::{Arc, Mutex};
use loom::thread;
use segment_buffer::{
DurabilityPolicy, FlushPolicy, Result, SegmentBuffer, SegmentConfig, SegmentRange, SegmentStore,
};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
struct Item {
id: u64,
}
fn loom_config() -> SegmentConfig {
SegmentConfig::builder()
.flush_policy(FlushPolicy::Manual)
.max_size_bytes(u64::MAX)
.compression_level(3)
.build()
}
#[derive(Debug)]
struct MockStore {
files: Mutex<HashMap<SegmentRange, Vec<u8>>>,
}
impl MockStore {
fn new() -> Self {
Self {
files: Mutex::new(HashMap::new()),
}
}
}
impl SegmentStore for MockStore {
fn create_dir_all(&self) -> Result<()> {
Ok(())
}
fn scan(&self) -> Result<Vec<SegmentRange>> {
let files = self.files.lock().unwrap();
let mut ranges: Vec<SegmentRange> = files.keys().copied().collect();
ranges.sort_by_key(|r| r.start);
Ok(ranges)
}
fn clean_tmp(&self) -> Result<usize> {
Ok(0)
}
fn segment_size(&self, range: SegmentRange) -> u64 {
self.files
.lock()
.unwrap()
.get(&range)
.map(|v| v.len() as u64)
.unwrap_or(0)
}
fn remove_segment(&self, range: SegmentRange) -> Result<bool> {
Ok(self.files.lock().unwrap().remove(&range).is_some())
}
fn write_atomic(
&self,
range: SegmentRange,
payload: &[u8],
_policy: DurabilityPolicy,
) -> Result<u64> {
let len = payload.len() as u64;
self.files.lock().unwrap().insert(range, payload.to_vec());
Ok(len)
}
fn read_bytes(&self, range: SegmentRange) -> Result<Vec<u8>> {
self.files
.lock()
.unwrap()
.get(&range)
.cloned()
.ok_or_else(|| {
std::io::Error::from(std::io::ErrorKind::NotFound).into()
})
}
}
#[test]
fn mock_store_write_scan_read_remove_roundtrip() {
loom::model(|| {
let store = MockStore::new();
let range = SegmentRange { start: 0, end: 3 };
assert_eq!(store.segment_size(range), 0);
assert!(store.scan().unwrap().is_empty());
assert!(store.read_bytes(range).is_err());
let payload = b"hello world";
let written = store
.write_atomic(range, payload, DurabilityPolicy::Segment)
.unwrap();
assert_eq!(written, payload.len() as u64);
assert_eq!(store.segment_size(range), payload.len() as u64);
let scanned = store.scan().unwrap();
assert_eq!(scanned, vec![range]);
assert_eq!(store.read_bytes(range).unwrap(), payload);
assert!(store.remove_segment(range).unwrap());
assert!(!store.remove_segment(range).unwrap());
assert_eq!(store.segment_size(range), 0);
assert!(store.scan().unwrap().is_empty());
});
}
#[test]
fn mock_store_scan_returns_segments_sorted_by_start() {
loom::model(|| {
let store = MockStore::new();
store
.write_atomic(
SegmentRange { start: 10, end: 19 },
b"b",
DurabilityPolicy::Segment,
)
.unwrap();
store
.write_atomic(
SegmentRange { start: 0, end: 9 },
b"a",
DurabilityPolicy::Segment,
)
.unwrap();
store
.write_atomic(
SegmentRange { start: 20, end: 29 },
b"c",
DurabilityPolicy::Segment,
)
.unwrap();
let scanned = store.scan().unwrap();
assert_eq!(
scanned,
vec![
SegmentRange { start: 0, end: 9 },
SegmentRange { start: 10, end: 19 },
SegmentRange { start: 20, end: 29 },
]
);
});
}
#[test]
fn two_writers_concurrent_append_never_loses_items() {
loom::model(|| {
let dir = tempfile::tempdir().unwrap();
let buf: Arc<SegmentBuffer<Item>> =
Arc::new(SegmentBuffer::open(dir.path(), loom_config()).unwrap());
let b1 = buf.clone();
let h1 = thread::spawn(move || {
b1.append(Item { id: 1 }).unwrap();
b1.append(Item { id: 2 }).unwrap();
});
let b2 = buf.clone();
let h2 = thread::spawn(move || {
b2.append(Item { id: 3 }).unwrap();
b2.append(Item { id: 4 }).unwrap();
});
h1.join().unwrap();
h2.join().unwrap();
assert_eq!(buf.pending_count(), 4, "every append must be counted");
assert_eq!(
buf.latest_sequence(),
3,
"sequence must be 0-indexed monotonic"
);
let snapshot = buf.stats();
assert_eq!(snapshot.pending_count, 4);
assert_eq!(snapshot.latest_sequence, 3);
assert_eq!(snapshot.next_sequence, 4);
});
}
#[test]
fn writer_and_reader_do_not_observe_torn_snapshot() {
loom::model(|| {
let dir = tempfile::tempdir().unwrap();
let buf: Arc<SegmentBuffer<Item>> =
Arc::new(SegmentBuffer::open(dir.path(), loom_config()).unwrap());
buf.append(Item { id: 0 }).unwrap();
buf.append(Item { id: 1 }).unwrap();
let b1 = buf.clone();
let h1 = thread::spawn(move || {
b1.append(Item { id: 2 }).unwrap();
});
let b2 = buf.clone();
let h2 = thread::spawn(move || {
let s = b2.stats();
if s.pending_count == 3 {
assert_eq!(
s.next_sequence, 3,
"stats() snapshot is torn: pending_count={} next_sequence={}",
s.pending_count, s.next_sequence
);
} else {
assert_eq!(s.pending_count, 2);
assert_eq!(s.next_sequence, 2);
}
});
h1.join().unwrap();
h2.join().unwrap();
});
}
#[test]
fn append_all_batch_atomicity_under_concurrent_append() {
loom::model(|| {
let dir = tempfile::tempdir().unwrap();
let buf: Arc<SegmentBuffer<Item>> =
Arc::new(SegmentBuffer::open(dir.path(), loom_config()).unwrap());
let b1 = buf.clone();
let h1 = thread::spawn(move || {
b1.append_all([Item { id: 10 }, Item { id: 11 }, Item { id: 12 }])
.unwrap();
});
let b2 = buf.clone();
let h2 = thread::spawn(move || {
b2.append(Item { id: 99 }).unwrap();
});
h1.join().unwrap();
h2.join().unwrap();
assert_eq!(buf.pending_count(), 4);
assert_eq!(buf.latest_sequence(), 3);
});
}
fn open_with_mock(config: SegmentConfig) -> Arc<SegmentBuffer<Item>> {
let dir = tempfile::tempdir().unwrap();
let store: std::sync::Arc<dyn SegmentStore + Send + Sync> =
std::sync::Arc::new(MockStore::new());
let buf = SegmentBuffer::open_with_store(dir.path(), config, store)
.expect("open_with_store must succeed on a fresh mock");
Arc::new(buf)
}
#[test]
fn delete_acked_during_append_never_loses_head() {
loom::model(|| {
let buf = open_with_mock(loom_config());
for i in 0..4u64 {
buf.append(Item { id: i }).unwrap();
}
buf.flush().unwrap();
buf.append(Item { id: 4 }).unwrap();
let b1 = buf.clone();
let h1 = thread::spawn(move || {
b1.delete_acked(3).unwrap();
});
let b2 = buf.clone();
let h2 = thread::spawn(move || {
b2.append(Item { id: 5 }).unwrap();
});
h1.join().unwrap();
h2.join().unwrap();
let s = buf.stats();
assert!(
s.pending_count >= 2,
"pending_count under-reported: {}, expected >= 2 (item4 + item5)",
s.pending_count
);
assert_eq!(
s.pending_count,
s.next_sequence.saturating_sub(s.head_sequence),
"stats() snapshot is torn"
);
});
}
#[test]
fn delete_acked_past_flush_boundary_with_concurrent_append() {
loom::model(|| {
let buf = open_with_mock(loom_config());
for i in 0..4u64 {
buf.append(Item { id: i }).unwrap();
}
buf.flush().unwrap();
buf.append(Item { id: 4 }).unwrap();
let b1 = buf.clone();
let h1 = thread::spawn(move || {
b1.delete_acked(5).unwrap();
});
let b2 = buf.clone();
let h2 = thread::spawn(move || {
b2.append(Item { id: 5 }).unwrap();
});
h1.join().unwrap();
h2.join().unwrap();
let s = buf.stats();
assert!(
s.pending_count >= 2,
"pending_count under-reported: {}, expected >= 2 (item4 + item5)",
s.pending_count
);
assert_eq!(
s.pending_count,
s.next_sequence.saturating_sub(s.head_sequence),
"stats() snapshot is torn"
);
});
}
#[test]
fn stats_snapshot_consistent_under_delete_plus_append() {
loom::model(|| {
let buf = open_with_mock(loom_config());
for i in 0..4u64 {
buf.append(Item { id: i }).unwrap();
}
buf.flush().unwrap();
buf.append(Item { id: 4 }).unwrap();
let b1 = buf.clone();
let h1 = thread::spawn(move || {
b1.delete_acked(3).unwrap();
});
let b2 = buf.clone();
let h2 = thread::spawn(move || {
b2.append(Item { id: 5 }).unwrap();
});
let b3 = buf.clone();
let h3 = thread::spawn(move || {
let s = b3.stats();
assert_eq!(
s.pending_count,
s.next_sequence.saturating_sub(s.head_sequence),
"stats() snapshot is torn: pending_count={} next={} head={}",
s.pending_count,
s.next_sequence,
s.head_sequence
);
assert!(
s.head_sequence <= s.next_sequence,
"head_seq {} exceeded next_seq {}",
s.head_sequence,
s.next_sequence
);
});
h1.join().unwrap();
h2.join().unwrap();
h3.join().unwrap();
});
}
#[test]
fn delete_acked_idempotent_under_concurrent_append() {
loom::model(|| {
let buf = open_with_mock(loom_config());
for i in 0..4u64 {
buf.append(Item { id: i }).unwrap();
}
buf.flush().unwrap();
buf.append(Item { id: 4 }).unwrap();
let b1 = buf.clone();
let h1 = thread::spawn(move || {
b1.delete_acked(3).unwrap()
});
let b2 = buf.clone();
let h2 = thread::spawn(move || b2.delete_acked(3).unwrap());
let b3 = buf.clone();
let h3 = thread::spawn(move || b3.append(Item { id: 5 }).unwrap());
let d1 = h1.join().unwrap();
let d2 = h2.join().unwrap();
h3.join().unwrap();
assert!(
d1 + d2 <= 1,
"concurrent delete_acked double-counted: d1={d1} d2={d2} (sum should be <= 1)"
);
let s = buf.stats();
assert!(
s.pending_count >= 2,
"pending_count under-reported after concurrent delete+delete+append: {}",
s.pending_count
);
});
}