Packages
ferricstore
0.4.0
0.11.14
0.11.12
0.11.11
0.11.10
0.11.9
0.11.8
0.11.7
0.11.6
0.11.5
0.11.4
0.11.3
0.11.2
0.11.1
0.11.0
0.10.3
0.10.2
0.10.1
0.10.0
0.9.1
0.9.0
0.8.0
0.7.5
0.7.4
0.7.3
0.7.2
0.7.1
0.7.0
0.6.0
0.5.7
0.5.6
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.3
0.4.2
0.4.1
0.4.0
0.3.7
0.3.6
0.3.5
0.3.4
0.3.3
0.3.2
0.3.1
0.2.0
0.1.0
FerricFlow durable workflows and queues with native-protocol storage, Raft durability, and Bitcask persistence.
Current section
Files
Jump to
Current section
Files
native/ferricstore_wal_nif/src/background_thread.rs
// Background WAL I/O thread.
//
// Handles: write() + fdatasync() on a dedicated OS thread.
// Communicates with NIF layer via:
// - Mutex<AlignedBuffer> for write data (shared with NIF)
// - crossbeam channel for FlushRequest/CloseRequest signals
//
// The thread never touches BEAM schedulers. All BEAM notifications
// go through OwnedEnv::send_and_clear.
use crate::aligned_buffer::AlignedBuffer;
use crossbeam_channel::{Receiver, RecvTimeoutError, Sender};
use rustler::Encoder;
use std::fs::File;
use std::io::{self, Read, Seek, SeekFrom, Write};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
#[cfg(target_os = "linux")]
use std::os::unix::io::AsRawFd;
/// Messages sent from NIF to background thread (flush signals only, not data).
pub type ThreadResult = std::result::Result<(), String>;
pub enum ThreadMsg {
/// Request fdatasync. Caller will be notified on completion.
Flush(FlushCaller),
/// Shutdown: drain, sync, close, exit.
Close(Option<Sender<ThreadResult>>),
}
/// Caller information for flush notification.
pub struct FlushCaller {
pub target: FlushTarget,
pub commit_delay: Duration,
}
pub enum FlushTarget {
Beam {
pid: rustler::LocalPid,
env: rustler::OwnedEnv,
saved_ref: rustler::env::SavedTerm,
},
#[cfg(test)]
Test(Sender<u64>),
}
// FlushCaller must be Send to cross thread boundary
unsafe impl Send for FlushCaller {}
/// Configuration for the background thread.
pub struct ThreadConfig {
pub file: File,
pub buffer: Arc<Mutex<AlignedBuffer>>,
pub rx: Receiver<ThreadMsg>,
pub alive: Arc<AtomicBool>,
pub file_size: Arc<AtomicU64>,
pub commit_delay: Duration,
pub _use_o_direct: bool,
}
trait SyncData {
fn sync_data(&mut self) -> io::Result<()>;
}
impl SyncData for File {
fn sync_data(&mut self) -> io::Result<()> {
File::sync_data(self)
}
}
/// Run the background thread loop.
/// This is called from std::thread::spawn — runs entirely outside BEAM.
pub fn thread_loop(config: ThreadConfig) {
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
thread_loop_inner(
config.file,
config.buffer,
config.rx,
config.file_size,
config.commit_delay,
);
}));
// On panic or normal exit, mark thread as dead
config.alive.store(false, Ordering::Release);
if let Err(panic) = result {
eprintln!(
"[ferricstore_wal_nif] WAL thread panicked: {:?}",
panic
.downcast_ref::<String>()
.map(|s| s.as_str())
.or_else(|| panic.downcast_ref::<&str>().copied())
.unwrap_or("unknown")
);
}
}
fn thread_loop_inner(
mut file: File,
buffer: Arc<Mutex<AlignedBuffer>>,
rx: Receiver<ThreadMsg>,
file_size: Arc<AtomicU64>,
_commit_delay: Duration,
) {
let mut callers: Vec<FlushCaller> = Vec::new();
loop {
// =====================================================================
// Phase 1: IDLE — block until first message (zero CPU when idle)
// =====================================================================
let msg = match rx.recv() {
Ok(msg) => msg,
Err(_) => {
finish_disconnect(&mut file, &buffer, &file_size, &mut callers);
return;
}
};
match msg {
ThreadMsg::Close(response) => {
finish_close(&mut file, &buffer, &file_size, &mut callers, response);
return;
}
ThreadMsg::Flush(caller) => {
let commit_delay = caller.commit_delay;
callers.push(caller);
run_sync_cycle(
&mut file,
&buffer,
&file_size,
&rx,
&mut callers,
commit_delay,
);
continue;
}
}
}
}
fn run_sync_cycle<W>(
file: &mut W,
buffer: &Arc<Mutex<AlignedBuffer>>,
file_size: &Arc<AtomicU64>,
rx: &Receiver<ThreadMsg>,
callers: &mut Vec<FlushCaller>,
commit_delay: Duration,
) where
W: Write + SyncData,
{
// =====================================================================
// Phase 2: Drain buffer to kernel immediately (before sync decision)
// =====================================================================
if let Err(e) = drain_to_kernel(file, buffer, file_size) {
notify_callers_error(callers, &e);
return;
}
// =====================================================================
// Phase 3: Commit delay — collect more Flush requests, keep draining
// =====================================================================
if !commit_delay.is_zero() {
let deadline = Instant::now() + commit_delay;
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
break;
}
match rx.recv_timeout(remaining) {
Ok(ThreadMsg::Flush(c)) => callers.push(c),
Ok(ThreadMsg::Close(response)) => {
finish_close(file, buffer, file_size, callers, response);
return;
}
Err(RecvTimeoutError::Timeout) => break,
Err(RecvTimeoutError::Disconnected) => {
finish_disconnect(file, buffer, file_size, callers);
return;
}
}
if let Err(e) = drain_to_kernel(file, buffer, file_size) {
notify_callers_error(callers, &e);
return;
}
}
}
// =====================================================================
// Phase 4: one Erlang-owned sync cycle
// =====================================================================
// Erlang owns sync_in_flight and the pending/syncing frontier. Do not
// self-drive extra syncs here: Flush messages that arrive during fdatasync
// must stay queued until Erlang observes this completion and explicitly
// schedules the next sync cycle.
if let Err(e) = drain_to_kernel(file, buffer, file_size) {
notify_callers_error(callers, &e);
return;
}
if !callers.is_empty() {
let synced_position = file_size.load(Ordering::Acquire);
match file.sync_data() {
Ok(()) => {
notify_callers_success(callers, synced_position);
}
Err(e) => {
notify_callers_error(callers, &e);
}
}
}
}
fn finish_close<W>(
file: &mut W,
buffer: &Mutex<AlignedBuffer>,
file_size: &AtomicU64,
callers: &mut Vec<FlushCaller>,
response: Option<Sender<ThreadResult>>,
) where
W: Write + SyncData,
{
let result = close_drain_and_sync(file, buffer, file_size);
notify_callers_for_close(callers, &result);
send_close_result(response, &result);
}
fn finish_disconnect<W>(
file: &mut W,
buffer: &Mutex<AlignedBuffer>,
file_size: &AtomicU64,
callers: &mut Vec<FlushCaller>,
) where
W: Write + SyncData,
{
let result = close_drain_and_sync(file, buffer, file_size);
notify_callers_for_close(callers, &result);
if let Err(e) = result {
eprintln!("[ferricstore_wal_nif] WAL drain/sync failed during disconnect: {e}");
}
}
fn notify_callers_for_close(callers: &mut Vec<FlushCaller>, result: &io::Result<u64>) {
match result {
Ok(synced_position) => notify_callers_success(callers, *synced_position),
Err(e) => notify_callers_error(callers, e),
}
}
fn send_close_result(response: Option<Sender<ThreadResult>>, result: &io::Result<u64>) {
if let Some(response) = response {
let _ = response.send(result.as_ref().map(|_| ()).map_err(ToString::to_string));
}
}
fn close_drain_and_sync<W>(
writer: &mut W,
buffer: &Mutex<AlignedBuffer>,
file_size: &AtomicU64,
) -> io::Result<u64>
where
W: Write + SyncData,
{
drain_to_kernel_writer(writer, buffer, file_size)?;
let synced_position = file_size.load(Ordering::Acquire);
writer.sync_data()?;
Ok(synced_position)
}
/// Drain shared buffer to kernel page cache. No fdatasync.
/// Returns true if data was written.
fn drain_to_kernel<W>(
file: &mut W,
buffer: &Mutex<AlignedBuffer>,
file_size: &AtomicU64,
) -> io::Result<bool>
where
W: Write,
{
drain_to_kernel_writer(file, buffer, file_size)
}
fn drain_to_kernel_writer<W: Write>(
writer: &mut W,
buffer: &Mutex<AlignedBuffer>,
file_size: &AtomicU64,
) -> io::Result<bool> {
let taken = {
let mut buf = buffer
.lock()
.map_err(|_| io::Error::new(io::ErrorKind::Other, "buffer mutex poisoned"))?;
buf.take()
};
if taken.is_empty() {
return Ok(false);
}
let bytes = taken.logical_len as u64;
if let Err(e) = write_all_retry(writer, taken.as_logical_slice()) {
let mut buf = buffer
.lock()
.map_err(|_| io::Error::new(io::ErrorKind::Other, "buffer mutex poisoned"))?;
buf.prepend(taken.as_logical_slice());
return Err(e);
}
file_size.fetch_add(bytes, Ordering::Release);
Ok(true)
}
/// Write with retry on EINTR.
fn write_all_retry<W: Write>(file: &mut W, data: &[u8]) -> io::Result<()> {
let mut written = 0;
while written < data.len() {
match file.write(&data[written..]) {
Ok(0) => {
return Err(io::Error::new(
io::ErrorKind::WriteZero,
"failed to write WAL bytes",
));
}
Ok(n) => written += n,
Err(ref e) if e.kind() == io::ErrorKind::Interrupted => continue,
Err(e) => return Err(e),
}
}
Ok(())
}
/// Notify all callers that sync completed successfully.
fn notify_callers_success(callers: &mut Vec<FlushCaller>, synced_position: u64) {
for caller in callers.drain(..) {
match caller.target {
FlushTarget::Beam {
pid,
mut env,
saved_ref,
} => {
let _ = env.send_and_clear(&pid, |env| {
let ref_term = saved_ref.load(env);
rustler::types::tuple::make_tuple(
env,
&[
crate::atoms::wal_sync_complete().encode(env),
ref_term,
synced_position.encode(env),
],
)
});
}
#[cfg(test)]
FlushTarget::Test(tx) => {
let _ = tx.send(synced_position);
}
}
}
}
/// Notify all callers that sync failed.
fn notify_callers_error(callers: &mut Vec<FlushCaller>, error: &io::Error) {
let reason = format!("{error}");
for caller in callers.drain(..) {
match caller.target {
FlushTarget::Beam {
pid,
mut env,
saved_ref,
} => {
let _ = env.send_and_clear(&pid, |env| {
let ref_term = saved_ref.load(env);
rustler::types::tuple::make_tuple(
env,
&[
crate::atoms::wal_sync_error().encode(env),
ref_term,
reason.as_str().encode(env),
],
)
});
}
#[cfg(test)]
FlushTarget::Test(_tx) => {}
}
}
}
/// WARaft segment logs start at byte 0.
pub const WAL_HEADER_SIZE: u64 = 0;
// ---------------------------------------------------------------------------
// File opening
// ---------------------------------------------------------------------------
/// Open an append log file with platform-appropriate flags.
pub fn open_wal_file(path: &str, pre_allocate_bytes: u64) -> io::Result<(File, bool)> {
#[cfg(target_os = "linux")]
let (mut file, o_direct) = open_wal_file_linux(path, pre_allocate_bytes)?;
#[cfg(not(target_os = "linux"))]
let (mut file, o_direct) = open_wal_file_fallback(path, pre_allocate_bytes)?;
file.seek(SeekFrom::Start(WAL_HEADER_SIZE))?;
Ok((file, o_direct))
}
/// Open a generic append log file and seek to the supplied logical end.
///
/// WARaft segment files are self-framed from byte 0. Segment preallocation is handled separately with
/// KEEP_SIZE so recovery never scans zero-filled preallocated trailers.
pub fn open_raw_append_file(path: &str, start_offset: u64) -> io::Result<(File, bool)> {
let mut file = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.read(true)
.open(path)?;
file.seek(SeekFrom::Start(start_offset))?;
Ok((file, false))
}
/// Reserve disk space without changing the logical file length.
///
/// Segment logs append framed records and recover by scanning until EOF. Extending
/// the visible file length during preallocation would leave zero-filled trailer
/// bytes that look like a torn/corrupt record. Linux has a keep-size fallocate
/// mode for this; other platforms keep the call as a safe no-op.
pub fn preallocate_keep_size_path(path: &str, bytes: u64) -> io::Result<()> {
if bytes == 0 {
return Ok(());
}
#[cfg(target_os = "linux")]
{
let f = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.read(true)
.open(path)?;
let ret = unsafe {
libc::fallocate(
f.as_raw_fd(),
libc::FALLOC_FL_KEEP_SIZE,
0,
bytes as libc::off_t,
)
};
if ret != 0 {
return Err(io::Error::last_os_error());
}
}
#[cfg(not(target_os = "linux"))]
{
let _ = path;
}
Ok(())
}
#[cfg(target_os = "linux")]
fn open_wal_file_linux(path: &str, pre_allocate_bytes: u64) -> io::Result<(File, bool)> {
// Keep buffered writes and rely on explicit fdatasync for durability.
let f = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.read(true)
.open(path)?;
if pre_allocate_bytes > 0 {
let ret = unsafe { libc::fallocate(f.as_raw_fd(), 0, 0, pre_allocate_bytes as i64) };
if ret != 0 {
return Err(io::Error::last_os_error());
}
};
Ok((f, false))
}
#[cfg(not(target_os = "linux"))]
fn open_wal_file_fallback(path: &str, _pre_allocate_bytes: u64) -> io::Result<(File, bool)> {
let f = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.read(true)
.open(path)?;
Ok((f, false)) // No O_DIRECT on macOS
}
/// Read bytes from file at offset (for recovery).
pub fn pread_from_file(file: &mut File, offset: u64, len: u64) -> io::Result<Vec<u8>> {
file.seek(SeekFrom::Start(offset))?;
let mut buf = vec![0u8; len as usize];
file.read_exact(&mut buf)?;
Ok(buf)
}
// ---------------------------------------------------------------------------
// Tests (pure Rust, no BEAM)
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::AtomicUsize;
use tempfile::NamedTempFile;
struct FailingWriter;
impl Write for FailingWriter {
fn write(&mut self, _buf: &[u8]) -> io::Result<usize> {
Err(io::Error::new(io::ErrorKind::Other, "forced write failure"))
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
struct FailingSyncWriter {
writes: Vec<u8>,
}
impl Write for FailingSyncWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.writes.extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
impl SyncData for FailingSyncWriter {
fn sync_data(&mut self) -> io::Result<()> {
Err(io::Error::new(io::ErrorKind::Other, "forced sync failure"))
}
}
struct BlockingSyncWriter {
writes: Vec<u8>,
sync_started_tx: Sender<()>,
release_sync_rx: Receiver<()>,
sync_count: Arc<AtomicUsize>,
}
impl Write for BlockingSyncWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.writes.extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
impl SyncData for BlockingSyncWriter {
fn sync_data(&mut self) -> io::Result<()> {
self.sync_count.fetch_add(1, Ordering::AcqRel);
self.sync_started_tx.send(()).unwrap();
self.release_sync_rx
.recv_timeout(Duration::from_secs(5))
.unwrap();
Ok(())
}
}
fn test_flush(tx: Sender<u64>, commit_delay: Duration) -> FlushCaller {
FlushCaller {
target: FlushTarget::Test(tx),
commit_delay,
}
}
#[test]
fn preallocate_keep_size_does_not_extend_logical_file_size() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("segment.seg");
std::fs::write(&path, b"abc").unwrap();
preallocate_keep_size_path(path.to_str().unwrap(), 1024 * 1024).unwrap();
let metadata = std::fs::metadata(&path).unwrap();
assert_eq!(metadata.len(), 3);
}
#[test]
fn drain_to_kernel_returns_write_error_instead_of_panicking() {
let buffer = Mutex::new(AlignedBuffer::new());
{
let mut guard = buffer.lock().unwrap();
guard.extend(b"entry");
}
let file_size = AtomicU64::new(WAL_HEADER_SIZE);
let mut writer = FailingWriter;
let result = drain_to_kernel_writer(&mut writer, &buffer, &file_size);
assert!(result.is_err());
assert_eq!(file_size.load(Ordering::Acquire), WAL_HEADER_SIZE);
}
#[test]
fn drain_to_kernel_restores_failed_bytes_before_concurrent_writes() {
struct AppendThenFail {
buffer: Arc<Mutex<AlignedBuffer>>,
}
impl Write for AppendThenFail {
fn write(&mut self, _buf: &[u8]) -> io::Result<usize> {
self.buffer.lock().unwrap().extend(b"new");
Err(io::Error::new(io::ErrorKind::Other, "forced write failure"))
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
let buffer = Arc::new(Mutex::new(AlignedBuffer::new()));
{
let mut guard = buffer.lock().unwrap();
guard.extend(b"entry");
}
let file_size = AtomicU64::new(WAL_HEADER_SIZE);
let mut writer = AppendThenFail {
buffer: Arc::clone(&buffer),
};
let result = drain_to_kernel_writer(&mut writer, &buffer, &file_size);
assert!(result.is_err());
assert_eq!(file_size.load(Ordering::Acquire), WAL_HEADER_SIZE);
let restored = buffer.lock().unwrap().take();
assert_eq!(restored.as_logical_slice(), b"entrynew");
}
#[test]
fn close_drain_and_sync_returns_sync_error() {
let buffer = Mutex::new(AlignedBuffer::new());
{
let mut guard = buffer.lock().unwrap();
guard.extend(b"entry");
}
let file_size = AtomicU64::new(WAL_HEADER_SIZE);
let mut writer = FailingSyncWriter { writes: Vec::new() };
let err = close_drain_and_sync(&mut writer, &buffer, &file_size).unwrap_err();
assert!(err.to_string().contains("forced sync failure"));
assert_eq!(file_size.load(Ordering::Acquire), WAL_HEADER_SIZE + 5);
assert!(!writer.writes.is_empty());
}
#[test]
fn sync_cycle_does_not_self_drive_flushes_arriving_during_fdatasync() {
let buffer = Arc::new(Mutex::new(AlignedBuffer::new()));
{
let mut guard = buffer.lock().unwrap();
guard.extend(b"first");
}
let file_size = Arc::new(AtomicU64::new(0));
let (flush_tx, flush_rx) = crossbeam_channel::unbounded();
let (first_done_tx, first_done_rx) = crossbeam_channel::bounded(1);
let (second_done_tx, second_done_rx) = crossbeam_channel::bounded(1);
let (sync_started_tx, sync_started_rx) = crossbeam_channel::bounded(1);
let (release_sync_tx, release_sync_rx) = crossbeam_channel::bounded(1);
let sync_count = Arc::new(AtomicUsize::new(0));
let thread_buffer = Arc::clone(&buffer);
let thread_file_size = Arc::clone(&file_size);
let thread_sync_count = Arc::clone(&sync_count);
let handle = std::thread::spawn(move || {
let mut writer = BlockingSyncWriter {
writes: Vec::new(),
sync_started_tx,
release_sync_rx,
sync_count: thread_sync_count,
};
let mut callers = vec![test_flush(first_done_tx, Duration::ZERO)];
run_sync_cycle(
&mut writer,
&thread_buffer,
&thread_file_size,
&flush_rx,
&mut callers,
Duration::ZERO,
);
});
sync_started_rx
.recv_timeout(Duration::from_secs(5))
.unwrap();
{
let mut guard = buffer.lock().unwrap();
guard.extend(b"second");
}
flush_tx
.send(ThreadMsg::Flush(test_flush(second_done_tx, Duration::ZERO)))
.unwrap();
release_sync_tx.send(()).unwrap();
assert_eq!(
first_done_rx.recv_timeout(Duration::from_secs(5)).unwrap(),
5
);
let second_sync_started = sync_started_rx
.recv_timeout(Duration::from_millis(100))
.is_ok();
if second_sync_started {
let _ = release_sync_tx.send(());
}
assert!(
!second_sync_started,
"flush arriving during fdatasync must wait for the next Erlang-owned sync cycle"
);
assert!(second_done_rx
.recv_timeout(Duration::from_millis(20))
.is_err());
handle.join().unwrap();
assert_eq!(sync_count.load(Ordering::Acquire), 1);
}
/// Helper: create a thread config for testing (no BEAM notifications).
#[allow(dead_code)]
fn test_config(commit_delay_us: u64) -> (ThreadConfig, crossbeam_channel::Sender<ThreadMsg>) {
let tmp = NamedTempFile::new().unwrap();
let file = tmp.reopen().unwrap();
let buffer = Arc::new(Mutex::new(AlignedBuffer::new()));
let (tx, rx) = crossbeam_channel::unbounded();
let alive = Arc::new(AtomicBool::new(true));
let file_size = Arc::new(AtomicU64::new(0));
let config = ThreadConfig {
file,
buffer: buffer.clone(),
rx,
alive: alive.clone(),
file_size: file_size.clone(),
commit_delay: Duration::from_micros(commit_delay_us),
_use_o_direct: false,
};
(config, tx)
}
#[test]
fn test_open_wal_file_fallback() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.wal");
let (file, o_direct) = open_wal_file(path.to_str().unwrap(), 0).unwrap();
drop(file);
assert!(!o_direct);
// File should exist
assert!(path.exists());
}
#[test]
fn test_open_with_preallocate() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.wal");
let (file, _) = open_wal_file(path.to_str().unwrap(), 4096).unwrap();
drop(file);
// On Linux with fallocate, file size would be 4096.
// On macOS, fallocate is skipped, file size is 0.
assert!(path.exists());
}
#[cfg(target_os = "linux")]
#[test]
fn test_open_wal_file_linux_preallocate_failure_preserves_existing_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.wal");
std::fs::write(&path, b"existing bytes").unwrap();
let result = open_wal_file_linux(path.to_str().unwrap(), u64::MAX);
assert!(result.is_err());
assert_eq!(std::fs::read(&path).unwrap(), b"existing bytes");
}
#[test]
fn test_write_all_retry() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.wal");
let mut file = std::fs::File::create(&path).unwrap();
write_all_retry(&mut file, b"hello world").unwrap();
file.sync_all().unwrap();
let contents = std::fs::read(&path).unwrap();
assert_eq!(&contents, b"hello world");
}
#[test]
fn test_write_all_retry_empty() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.wal");
let mut file = std::fs::File::create(&path).unwrap();
write_all_retry(&mut file, b"").unwrap();
let contents = std::fs::read(&path).unwrap();
assert!(contents.is_empty());
}
#[test]
fn test_write_all_retry_zero_progress_is_write_zero() {
struct ZeroThenError;
impl Write for ZeroThenError {
fn write(&mut self, _buf: &[u8]) -> io::Result<usize> {
Ok(0)
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
let err = write_all_retry(&mut ZeroThenError, b"not written").unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::WriteZero);
}
#[test]
fn test_pread() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.wal");
std::fs::write(&path, b"hello world").unwrap();
let mut file = std::fs::File::open(&path).unwrap();
let data = pread_from_file(&mut file, 6, 5).unwrap();
assert_eq!(&data, b"world");
}
#[test]
fn test_pread_at_offset_zero() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.wal");
std::fs::write(&path, b"hello").unwrap();
let mut file = std::fs::File::open(&path).unwrap();
let data = pread_from_file(&mut file, 0, 5).unwrap();
assert_eq!(&data, b"hello");
}
#[test]
fn test_drain_to_kernel() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.wal");
let mut file = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.read(true)
.open(&path)
.unwrap();
let buffer = Arc::new(Mutex::new(AlignedBuffer::new()));
let file_size = Arc::new(AtomicU64::new(0));
{
let mut buf = buffer.lock().unwrap();
buf.extend(b"test data 12345");
}
assert!(drain_to_kernel(&mut file, &buffer, &file_size).unwrap());
assert_eq!(file_size.load(Ordering::Acquire), 15);
let contents = std::fs::read(&path).unwrap();
assert!(contents.len() >= 15);
assert_eq!(&contents[..15], b"test data 12345");
}
#[test]
fn test_drain_empty_buffer() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.wal");
let mut file = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.read(true)
.open(&path)
.unwrap();
let buffer = Arc::new(Mutex::new(AlignedBuffer::new()));
let file_size = Arc::new(AtomicU64::new(0));
assert!(!drain_to_kernel(&mut file, &buffer, &file_size).unwrap());
assert_eq!(file_size.load(Ordering::Acquire), 0);
}
#[test]
fn test_drain_multiple_writes() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.wal");
let mut file = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.read(true)
.open(&path)
.unwrap();
let buffer = Arc::new(Mutex::new(AlignedBuffer::new()));
let file_size = Arc::new(AtomicU64::new(0));
{
let mut buf = buffer.lock().unwrap();
buf.extend(b"first ");
}
assert!(drain_to_kernel(&mut file, &buffer, &file_size).unwrap());
assert_eq!(file_size.load(Ordering::Acquire), 6);
{
let mut buf = buffer.lock().unwrap();
buf.extend(b"second");
}
assert!(drain_to_kernel(&mut file, &buffer, &file_size).unwrap());
assert_eq!(file_size.load(Ordering::Acquire), 12);
}
#[test]
fn drain_to_kernel_appends_logical_bytes_from_byte_zero() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.wal");
let mut file = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.read(true)
.open(&path)
.unwrap();
file.seek(SeekFrom::Start(WAL_HEADER_SIZE)).unwrap();
let buffer = Arc::new(Mutex::new(AlignedBuffer::new()));
let file_size = Arc::new(AtomicU64::new(WAL_HEADER_SIZE));
{
let mut buf = buffer.lock().unwrap();
buf.extend(b"abc");
}
assert!(drain_to_kernel(&mut file, &buffer, &file_size).unwrap());
{
let mut buf = buffer.lock().unwrap();
buf.extend(b"def");
}
assert!(drain_to_kernel(&mut file, &buffer, &file_size).unwrap());
file.sync_data().unwrap();
assert_eq!(file_size.load(Ordering::Acquire), WAL_HEADER_SIZE + 6);
let contents = std::fs::read(&path).unwrap();
let logical_start = WAL_HEADER_SIZE as usize;
let logical_end = logical_start + 6;
assert_eq!(&contents[logical_start..logical_end], b"abcdef");
assert_eq!(contents.len(), logical_end);
}
#[test]
fn test_drain_then_sync() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.wal");
let mut file = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.read(true)
.open(&path)
.unwrap();
let buffer = Arc::new(Mutex::new(AlignedBuffer::new()));
let file_size = Arc::new(AtomicU64::new(0));
{
let mut buf = buffer.lock().unwrap();
buf.extend(b"final data");
}
drain_to_kernel(&mut file, &buffer, &file_size).unwrap();
file.sync_data().unwrap();
assert_eq!(file_size.load(Ordering::Acquire), 10);
}
#[test]
fn test_thread_close_flushes_data() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.wal");
let file = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.read(true)
.open(&path)
.unwrap();
let buffer = Arc::new(Mutex::new(AlignedBuffer::new()));
let alive = Arc::new(AtomicBool::new(true));
let file_size = Arc::new(AtomicU64::new(0));
let (tx, rx) = crossbeam_channel::unbounded();
let buf_clone = buffer.clone();
let alive_clone = alive.clone();
let fs_clone = file_size.clone();
let handle = std::thread::Builder::new()
.name("test-wal".into())
.spawn(move || {
thread_loop(ThreadConfig {
file,
buffer: buf_clone,
rx,
alive: alive_clone,
file_size: fs_clone,
commit_delay: Duration::ZERO,
_use_o_direct: false,
});
})
.unwrap();
// Write data to buffer
{
let mut buf = buffer.lock().unwrap();
buf.extend(b"thread close test");
}
// Send close — thread should flush before exiting
let (close_tx, close_rx) = crossbeam_channel::bounded(1);
tx.send(ThreadMsg::Close(Some(close_tx))).unwrap();
assert_eq!(
close_rx.recv_timeout(Duration::from_secs(5)).unwrap(),
Ok(())
);
handle.join().unwrap();
// Thread should be marked dead
assert!(!alive.load(Ordering::Acquire));
// Data should be on disk
let contents = std::fs::read(&path).unwrap();
assert!(contents.len() >= 17);
assert_eq!(&contents[..17], b"thread close test");
assert_eq!(file_size.load(Ordering::Acquire), 17);
}
#[test]
fn test_thread_channel_disconnect_exits() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.wal");
let file = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.read(true)
.open(&path)
.unwrap();
let buffer = Arc::new(Mutex::new(AlignedBuffer::new()));
let alive = Arc::new(AtomicBool::new(true));
let file_size = Arc::new(AtomicU64::new(0));
let (tx, rx) = crossbeam_channel::unbounded();
let buf_clone = buffer.clone();
let alive_clone = alive.clone();
let fs_clone = file_size.clone();
let handle = std::thread::Builder::new()
.name("test-wal".into())
.spawn(move || {
thread_loop(ThreadConfig {
file,
buffer: buf_clone,
rx,
alive: alive_clone,
file_size: fs_clone,
commit_delay: Duration::ZERO,
_use_o_direct: false,
});
})
.unwrap();
// Write data without sending an explicit close message. Disconnect
// must still drain and sync the buffer before the thread exits.
{
let mut buf = buffer.lock().unwrap();
buf.extend(b"disconnect data");
}
// Drop the sender — channel disconnects
drop(tx);
// Thread should exit cleanly
handle.join().unwrap();
assert!(!alive.load(Ordering::Acquire));
let contents = std::fs::read(&path).unwrap();
assert!(contents.len() >= 15);
assert_eq!(&contents[..15], b"disconnect data");
assert_eq!(file_size.load(Ordering::Acquire), 15);
}
#[test]
fn test_commit_delay_collects_multiple_flushes() {
// This test verifies that during the commit delay window,
// multiple flush requests are collected and served by one fdatasync.
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.wal");
let file = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.read(true)
.open(&path)
.unwrap();
let buffer = Arc::new(Mutex::new(AlignedBuffer::new()));
let alive = Arc::new(AtomicBool::new(true));
let file_size = Arc::new(AtomicU64::new(0));
let (tx, rx) = crossbeam_channel::unbounded();
let buf_clone = buffer.clone();
let alive_clone = alive.clone();
let fs_clone = file_size.clone();
let handle = std::thread::Builder::new()
.name("test-wal".into())
.spawn(move || {
thread_loop(ThreadConfig {
file,
buffer: buf_clone,
rx,
alive: alive_clone,
file_size: fs_clone,
commit_delay: Duration::from_millis(50), // 50ms delay
_use_o_direct: false,
});
})
.unwrap();
// Write data
{
let mut buf = buffer.lock().unwrap();
buf.extend(b"batch data");
}
// Send multiple flush requests rapidly (no BEAM callers in this test)
// We can't send FlushCaller without BEAM, so just test Close behavior
let (close_tx, close_rx) = crossbeam_channel::bounded(1);
tx.send(ThreadMsg::Close(Some(close_tx))).unwrap();
assert_eq!(
close_rx.recv_timeout(Duration::from_secs(5)).unwrap(),
Ok(())
);
handle.join().unwrap();
let contents = std::fs::read(&path).unwrap();
assert!(contents.len() >= 10);
assert_eq!(&contents[..10], b"batch data");
}
#[test]
fn test_large_buffer_flush() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.wal");
let mut file = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.read(true)
.open(&path)
.unwrap();
let buffer = Arc::new(Mutex::new(AlignedBuffer::new()));
let file_size = Arc::new(AtomicU64::new(0));
// 10 MB of data
let data = vec![0xABu8; 10 * 1024 * 1024];
{
let mut buf = buffer.lock().unwrap();
buf.extend(&data);
}
drain_to_kernel(&mut file, &buffer, &file_size).unwrap();
file.sync_data().unwrap();
assert_eq!(file_size.load(Ordering::Acquire), 10 * 1024 * 1024);
let contents = std::fs::read(&path).unwrap();
assert!(contents.len() >= 10 * 1024 * 1024);
assert_eq!(&contents[..10 * 1024 * 1024], &data[..]);
}
}