Packages
ferricstore
0.3.5
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};
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 enum ThreadMsg {
/// Request fdatasync. Caller will be notified on completion.
Flush(FlushCaller),
/// Shutdown: drain, sync, close, exit.
Close,
}
/// Caller information for flush notification.
pub struct FlushCaller {
pub pid: rustler::LocalPid,
pub env: rustler::OwnedEnv,
pub saved_ref: rustler::env::SavedTerm,
}
// 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,
}
/// 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(_) => return,
};
match msg {
ThreadMsg::Close => {
drain_to_kernel(&mut file, &buffer, &file_size);
let _ = file.sync_data();
return;
}
ThreadMsg::Flush(caller) => {
callers.push(caller);
}
}
// =====================================================================
// Phase 2: Drain buffer to kernel immediately (before sync decision)
// =====================================================================
drain_to_kernel(&mut file, &buffer, &file_size);
// =====================================================================
// 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) => {
drain_to_kernel(&mut file, &buffer, &file_size);
let _ = file.sync_data();
notify_callers_success(&mut callers);
return;
}
Err(RecvTimeoutError::Timeout) => break,
Err(RecvTimeoutError::Disconnected) => {
drain_to_kernel(&mut file, &buffer, &file_size);
let _ = file.sync_data();
notify_callers_success(&mut callers);
return;
}
}
drain_to_kernel(&mut file, &buffer, &file_size);
}
}
// =====================================================================
// Phase 4: Self-driving — drain, sync when callers waiting, repeat
// =====================================================================
loop {
// Drain any remaining buffer to kernel
drain_to_kernel(&mut file, &buffer, &file_size);
// Sync + notify if we have callers
if !callers.is_empty() {
match file.sync_data() {
Ok(()) => {
notify_callers_success(&mut callers);
}
Err(e) => {
notify_callers_error(&mut callers, &e);
return;
}
}
}
// Collect messages that arrived during I/O
loop {
match rx.try_recv() {
Ok(ThreadMsg::Flush(c)) => callers.push(c),
Ok(ThreadMsg::Close) => {
drain_to_kernel(&mut file, &buffer, &file_size);
let _ = file.sync_data();
notify_callers_success(&mut callers);
return;
}
Err(_) => break,
}
}
// More callers arrived? Drain and loop for another sync.
if !callers.is_empty() {
continue;
}
// Buffer has data? Drain it to kernel (writes flow continuously).
let has_data = {
let buf = buffer.lock().expect("buffer mutex poisoned");
!buf.is_empty()
};
if has_data {
continue;
}
// Truly idle. Return to Phase 1.
break;
}
}
}
/// Drain shared buffer to kernel page cache. No fdatasync.
/// Returns true if data was written.
fn drain_to_kernel(file: &mut File, buffer: &Mutex<AlignedBuffer>, file_size: &AtomicU64) -> bool {
let taken = {
let mut buf = buffer.lock().expect("buffer mutex poisoned");
buf.take()
};
if taken.is_empty() {
return false;
}
let bytes = taken.logical_len as u64;
// write_all_retry only fails on persistent I/O errors — panic is appropriate
// since the WAL is unrecoverable at that point.
write_all_retry(file, taken.as_padded_slice()).expect("WAL write failed");
file_size.fetch_add(bytes, Ordering::Release);
true
}
/// Write with retry on EINTR.
fn write_all_retry(file: &mut File, data: &[u8]) -> io::Result<()> {
let mut written = 0;
while written < data.len() {
match file.write(&data[written..]) {
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>) {
for mut caller in callers.drain(..) {
let _ = caller.env.send_and_clear(&caller.pid, |env| {
let ref_term = caller.saved_ref.load(env);
rustler::types::tuple::make_tuple(
env,
&[crate::atoms::wal_sync_complete().encode(env), ref_term],
)
});
}
}
/// Notify all callers that sync failed.
fn notify_callers_error(callers: &mut Vec<FlushCaller>, error: &io::Error) {
let reason = format!("{error}");
for mut caller in callers.drain(..) {
let _ = caller.env.send_and_clear(&caller.pid, |env| {
let ref_term = caller.saved_ref.load(env);
rustler::types::tuple::make_tuple(
env,
&[
crate::atoms::wal_sync_error().encode(env),
ref_term,
reason.as_str().encode(env),
],
)
});
}
}
/// ra WAL header: "RAWA" (4 bytes) + version (1 byte) = 5 bytes.
/// Written by ra_log_wal:make_tmp/1 before the NIF opens the file.
pub const WAL_HEADER_SIZE: u64 = 5;
// ---------------------------------------------------------------------------
// File opening
// ---------------------------------------------------------------------------
/// Open a WAL file with platform-appropriate flags.
/// Seeks past the WAL header so writes start at offset 5.
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))
}
#[cfg(target_os = "linux")]
fn open_wal_file_linux(path: &str, pre_allocate_bytes: u64) -> io::Result<(File, bool)> {
use std::os::unix::fs::OpenOptionsExt;
// Try O_DIRECT first
let file = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.read(true)
.custom_flags(libc::O_DIRECT) // NO O_DSYNC — fdatasync is explicit
.open(path);
match file {
Ok(f) => {
if pre_allocate_bytes > 0 {
let ret =
unsafe { libc::fallocate(f.as_raw_fd(), 0, 0, pre_allocate_bytes as i64) };
if ret != 0 {
// fallocate failed (e.g., ENOSPC) — close and cleanup
drop(f);
let _ = std::fs::remove_file(path);
return Err(io::Error::last_os_error());
}
}
Ok((f, true)) // O_DIRECT enabled
}
Err(_) => {
// O_DIRECT not supported on this filesystem — fall back
let f = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.read(true)
.open(path)?;
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 tempfile::NamedTempFile;
/// 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);
#[cfg(not(target_os = "linux"))]
assert!(!o_direct);
#[cfg(target_os = "linux")]
let _ = 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());
}
#[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_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));
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));
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));
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));
assert_eq!(file_size.load(Ordering::Acquire), 12);
}
#[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);
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
tx.send(ThreadMsg::Close).unwrap();
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();
// Drop the sender — channel disconnects
drop(tx);
// Thread should exit cleanly
handle.join().unwrap();
assert!(!alive.load(Ordering::Acquire));
}
#[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
tx.send(ThreadMsg::Close).unwrap();
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);
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[..]);
}
}