use std::fs::{self, File, OpenOptions};
use std::io::{self, Read};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use super::writer::Writer;
use super::{
get_segment_range,
segment_name,
BufferedFileWriter,
CompressionType,
Error,
IOError,
Options,
RecordType,
Result,
BLOCK_SIZE,
HEADER_SIZE,
};
pub struct Wal {
active_writer: Writer,
sync_fd: Arc<File>,
active_log_number: u64,
dir: PathBuf,
opts: Options,
closed: bool,
}
impl Wal {
pub(crate) fn open(dir: &Path, opts: Options) -> Result<Self> {
opts.validate()?;
Self::prepare_directory(dir, &opts)?;
Self::cleanup_stale_repair_files(dir)?;
let active_log_number = Self::calculate_active_log_number(dir)?;
let (active_writer, sync_fd) = Self::create_writer(dir, active_log_number, &opts)?;
Ok(Self {
active_writer,
sync_fd,
active_log_number,
dir: dir.to_path_buf(),
opts,
closed: false,
})
}
pub(crate) fn open_with_min_log_number(
dir: &Path,
min_log_number: u64,
opts: Options,
) -> Result<Self> {
opts.validate()?;
Self::prepare_directory(dir, &opts)?;
Self::cleanup_stale_repair_files(dir)?;
let highest_on_disk = Self::calculate_active_log_number(dir).unwrap_or(0);
let active_log_number = std::cmp::max(min_log_number, highest_on_disk);
if active_log_number > min_log_number {
log::info!(
"WAL: advancing from min_log_number {} to {} (highest existing segment)",
min_log_number,
active_log_number
);
}
let (active_writer, sync_fd) = Self::create_writer(dir, active_log_number, &opts)?;
Ok(Self {
active_writer,
sync_fd,
active_log_number,
dir: dir.to_path_buf(),
opts,
closed: false,
})
}
fn create_writer(dir: &Path, log_number: u64, opts: &Options) -> Result<(Writer, Arc<File>)> {
let extension = opts.file_extension.as_deref().unwrap_or("wal");
let file_name = segment_name(log_number, extension);
let file_path = dir.join(&file_name);
let file = Self::open_wal_file(&file_path, opts)?;
let sync_fd = Arc::new(file.try_clone()?);
let existing_size = file.metadata()?.len();
if existing_size > 0 {
let detected_compression = Self::detect_compression_type(&file_path)?;
let block_offset = (existing_size as usize) % BLOCK_SIZE;
let buffered_writer = BufferedFileWriter::new(file, BLOCK_SIZE);
Ok((Writer::new(buffered_writer, false, detected_compression, block_offset), sync_fd))
} else {
let compression_type = opts.compression_type;
let buffered_writer = BufferedFileWriter::new(file, BLOCK_SIZE);
let mut writer = Writer::new(buffered_writer, false, compression_type, 0);
if compression_type != CompressionType::None {
writer.add_compression_type_record()?;
}
Ok((writer, sync_fd))
}
}
fn detect_compression_type(file_path: &Path) -> Result<CompressionType> {
let mut file = File::open(file_path)?;
let mut header = [0u8; HEADER_SIZE];
match file.read_exact(&mut header) {
Ok(_) => {}
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => {
return Ok(CompressionType::None);
}
Err(e) => return Err(Error::IO(IOError::new(e.kind(), &e.to_string()))),
}
let record_type_byte = header[6];
let record_type = RecordType::from_u8(record_type_byte)?;
if record_type == RecordType::SetCompressionType {
let length = u16::from_be_bytes([header[4], header[5]]);
if length >= 1 {
let mut compression_byte = [0u8; 1];
file.read_exact(&mut compression_byte)?;
return CompressionType::from_u8(compression_byte[0]);
}
}
Ok(CompressionType::None)
}
#[allow(unused_variables)] fn open_wal_file(file_path: &Path, opts: &Options) -> Result<File> {
let mut open_options = OpenOptions::new();
open_options.read(true).write(true).create(true).append(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
if let Some(file_mode) = opts.file_mode {
open_options.mode(file_mode);
}
}
Ok(open_options.open(file_path)?)
}
#[allow(unused_variables)] fn prepare_directory(dir: &Path, opts: &Options) -> Result<()> {
#[cfg(unix)]
if let Ok(metadata) = fs::metadata(dir) {
let mut permissions = metadata.permissions();
use std::os::unix::fs::PermissionsExt;
permissions.set_mode(opts.dir_mode.unwrap_or(0o750));
fs::set_permissions(dir, permissions)?;
}
Ok(())
}
fn calculate_active_log_number(dir: &Path) -> Result<u64> {
let (_, last) = get_segment_range(dir, Some("wal"))?;
Ok(last)
}
fn cleanup_stale_repair_files(dir: &Path) -> Result<()> {
if !dir.exists() {
return Ok(());
}
let entries = match fs::read_dir(dir) {
Ok(entries) => entries,
Err(e) if e.kind() == io::ErrorKind::NotFound => {
return Ok(());
}
Err(e) => {
return Err(Error::IO(IOError::new(
io::ErrorKind::Other,
&format!("Failed to read WAL directory: {}", e),
)));
}
};
let mut removed_count = 0;
for entry in entries.flatten() {
let path = entry.path();
if let Some(filename) = path.file_name() {
let filename_str = filename.to_string_lossy();
if filename_str.ends_with(".wal.repair") {
match fs::remove_file(&path) {
Ok(()) => {
removed_count += 1;
log::warn!(
"Removed stale repair file from previous crashed repair: {}",
filename_str
);
}
Err(e) => {
log::error!(
"Failed to remove stale repair file {}: {}",
filename_str,
e
);
}
}
}
}
}
if removed_count > 0 {
log::info!("Cleaned up {} stale .wal.repair files", removed_count);
}
Ok(())
}
pub(crate) fn append(&mut self, rec: &[u8]) -> Result<u64> {
if self.closed {
return Err(Error::IO(IOError::new(io::ErrorKind::Other, "WAL is closed")));
}
if rec.is_empty() {
return Err(Error::IO(IOError::new(io::ErrorKind::Other, "buf is empty")));
}
log::trace!("WAL append: log_number={}, bytes={}", self.active_log_number, rec.len());
self.active_writer.add_record(rec)?;
Ok(0)
}
pub(crate) fn sync(&mut self) -> Result<()> {
if self.closed {
return Ok(());
}
self.active_writer.sync()
}
pub(crate) fn flush(&mut self) -> Result<()> {
if self.closed {
return Ok(());
}
self.active_writer.write_buffer()
}
pub(crate) fn sync_fd(&self) -> Arc<File> {
Arc::clone(&self.sync_fd)
}
pub(crate) fn close(&mut self) -> Result<()> {
if self.closed {
return Ok(());
}
let log_number = self.active_log_number;
log::debug!("Closing WAL #{:020}", log_number);
self.closed = true;
self.active_writer.close()?;
crate::lsm::fsync_directory(&self.dir)
.map_err(|e| Error::IO(IOError::new(e.kind(), &e.to_string())))?;
log::debug!("WAL #{:020} closed and synced successfully", log_number);
Ok(())
}
pub(crate) fn get_dir_path(&self) -> &Path {
&self.dir
}
pub(crate) fn get_active_log_number(&self) -> u64 {
self.active_log_number
}
pub(crate) fn rotate(&mut self) -> Result<u64> {
let old_log_number = self.active_log_number;
self.active_writer.sync()?;
self.active_log_number += 1;
log::debug!("WAL rotating: {:020} -> {:020}", old_log_number, self.active_log_number);
let (new_writer, new_sync_fd) =
Self::create_writer(&self.dir, self.active_log_number, &self.opts)?;
self.active_writer = new_writer;
self.sync_fd = new_sync_fd;
crate::lsm::fsync_directory(&self.dir)
.map_err(|e| Error::IO(IOError::new(e.kind(), &e.to_string())))?;
log::info!(
"WAL rotated and fsynced: {:020} -> {:020}",
old_log_number,
self.active_log_number
);
Ok(self.active_log_number)
}
}
impl Drop for Wal {
fn drop(&mut self) {
if !self.closed {
self.close().ok();
}
}
}
pub(crate) struct WalManager {
inner: parking_lot::RwLock<Wal>,
}
impl WalManager {
pub(crate) fn new(wal: Wal) -> Self {
Self {
inner: parking_lot::RwLock::new(wal),
}
}
pub(crate) fn sync(&self) -> Result<()> {
let sync_fd = {
let mut wal = self.inner.write();
wal.flush()?;
wal.sync_fd()
};
sync_fd.sync_all().map_err(|e| Error::IO(IOError::new(e.kind(), &e.to_string())))?;
Ok(())
}
pub(crate) fn flush(&self) -> Result<()> {
let mut wal = self.inner.write();
wal.flush()?;
Ok(())
}
pub(crate) fn write(&self) -> parking_lot::RwLockWriteGuard<'_, Wal> {
self.inner.write()
}
pub(crate) fn read(&self) -> parking_lot::RwLockReadGuard<'_, Wal> {
self.inner.read()
}
}
#[cfg(test)]
mod tests {
use tempdir::TempDir;
use test_log::test;
use super::*;
fn create_temp_directory() -> TempDir {
TempDir::new("test").unwrap()
}
#[test]
fn test_wal_basic_operations() {
let temp_dir = create_temp_directory();
let opts = Options::default();
let mut wal = Wal::open(temp_dir.path(), opts).expect("should create WAL");
let r = wal.append(&[]);
assert!(r.is_err());
let r = wal.append(&[0, 1, 2, 3]);
assert!(r.is_ok());
let r = wal.sync();
assert!(r.is_ok());
let r = wal.append(&[4, 5, 6, 7, 8, 9, 10]);
assert!(r.is_ok());
assert!(wal.close().is_ok());
}
#[test]
fn test_wal_rotation() {
let temp_dir = create_temp_directory();
let opts = Options::default();
let mut wal = Wal::open(temp_dir.path(), opts).expect("should create WAL");
wal.append(&[0, 1, 2, 3]).expect("should append");
let new_log_num = wal.rotate().expect("should rotate");
assert_eq!(new_log_num, 1);
wal.append(&[4, 5, 6, 7]).expect("should append to new WAL");
wal.close().expect("should close");
assert!(temp_dir.path().join("00000000000000000000.wal").exists());
assert!(temp_dir.path().join("00000000000000000001.wal").exists());
}
#[test]
fn test_cleanup_stale_repair_files() {
use std::fs;
let temp_dir = create_temp_directory();
let wal_dir = temp_dir.path();
fs::write(wal_dir.join("00000000000000000000.wal.repair"), b"stale repair 1").unwrap();
fs::write(wal_dir.join("00000000000000000001.wal.repair"), b"stale repair 2").unwrap();
assert!(wal_dir.join("00000000000000000000.wal.repair").exists());
assert!(wal_dir.join("00000000000000000001.wal.repair").exists());
let wal = Wal::open(wal_dir, Options::default());
assert!(wal.is_ok(), "WAL should open successfully after cleanup");
assert!(
!wal_dir.join("00000000000000000000.wal.repair").exists(),
".wal.repair files should be cleaned up"
);
assert!(
!wal_dir.join("00000000000000000001.wal.repair").exists(),
".wal.repair files should be cleaned up"
);
assert!(
wal_dir.join("00000000000000000000.wal").exists(),
"New WAL file should be created"
);
}
#[test]
fn test_wal_append_to_existing() {
let temp_dir = create_temp_directory();
{
let opts = Options::default();
let mut wal = Wal::open(temp_dir.path(), opts).unwrap();
wal.append(&[1, 2, 3, 4]).unwrap();
wal.close().unwrap();
}
assert!(temp_dir.path().join("00000000000000000000.wal").exists());
let size_after_first =
fs::metadata(temp_dir.path().join("00000000000000000000.wal")).unwrap().len();
{
let opts = Options::default();
let mut wal = Wal::open(temp_dir.path(), opts).unwrap();
wal.append(&[5, 6, 7, 8]).unwrap();
wal.close().unwrap();
}
let files: Vec<_> = fs::read_dir(temp_dir.path())
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().is_some_and(|ext| ext == "wal"))
.collect();
assert_eq!(files.len(), 1, "Should still have only one WAL file");
let size_after_second =
fs::metadata(temp_dir.path().join("00000000000000000000.wal")).unwrap().len();
assert!(size_after_second > size_after_first, "File should be larger after appending");
}
#[test]
fn test_wal_block_offset_across_sessions() {
use std::fs::File;
use crate::wal::reader::Reader;
let temp_dir = create_temp_directory();
let test_records: Vec<Vec<u8>> = vec![
vec![1; 10], vec![2; 100], vec![3; 1000], vec![4; 50], vec![5; 5000], vec![6; 7], vec![7; 2500], vec![8; 33], vec![9; 16000], vec![10; 100], ];
for (i, record) in test_records.iter().enumerate() {
let opts = Options::default();
let mut wal = Wal::open(temp_dir.path(), opts).unwrap();
wal.append(record).unwrap();
wal.close().unwrap();
let size =
fs::metadata(temp_dir.path().join("00000000000000000000.wal")).unwrap().len();
assert!(size > 0, "File should have content after write {}", i);
}
let files: Vec<_> = fs::read_dir(temp_dir.path())
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().is_some_and(|ext| ext == "wal"))
.collect();
assert_eq!(files.len(), 1, "Should have only one WAL file after all sessions");
let file =
File::open(temp_dir.path().join("00000000000000000000.wal")).expect("should open file");
let mut reader = Reader::new(file);
for (i, expected) in test_records.iter().enumerate() {
let result = reader.read();
assert!(
result.is_ok(),
"Should be able to read record {} (block_offset was wrong if this fails)",
i
);
let (data, _) = result.unwrap();
assert_eq!(
data,
expected,
"Record {} content should match (got {} bytes, expected {} bytes)",
i,
data.len(),
expected.len()
);
}
let result = reader.read();
assert!(
result.is_err(),
"Should have no more records after reading all {} expected",
test_records.len()
);
}
#[test]
fn test_wal_compression_type_detected_on_reopen() {
use std::fs::File;
use crate::wal::reader::Reader;
let temp_dir = create_temp_directory();
{
let opts = Options::default(); let mut wal = Wal::open(temp_dir.path(), opts).unwrap();
wal.append(b"uncompressed_record_1").unwrap();
wal.append(b"uncompressed_record_2").unwrap();
wal.close().unwrap();
}
{
let opts = Options::default().with_compression(CompressionType::Lz4);
let mut wal = Wal::open(temp_dir.path(), opts).unwrap();
wal.append(b"should_also_be_uncompressed").unwrap();
wal.close().unwrap();
}
let file =
File::open(temp_dir.path().join("00000000000000000000.wal")).expect("should open file");
let mut reader = Reader::new(file);
let (data1, _) = reader.read().expect("should read first record");
assert_eq!(data1, b"uncompressed_record_1");
let (data2, _) = reader.read().expect("should read second record");
assert_eq!(data2, b"uncompressed_record_2");
let (data3, _) = reader.read().expect("should read third record");
assert_eq!(
data3, b"should_also_be_uncompressed",
"Third record should be correctly read (was written with detected compression type)"
);
}
#[test]
fn test_wal_compressed_file_detected_on_reopen() {
use std::fs::File;
use crate::wal::reader::Reader;
let temp_dir = create_temp_directory();
{
let opts = Options::default().with_compression(CompressionType::Lz4);
let mut wal = Wal::open(temp_dir.path(), opts).unwrap();
wal.append(b"compressed_record_1").unwrap();
wal.append(b"compressed_record_2").unwrap();
wal.close().unwrap();
}
{
let opts = Options::default(); let mut wal = Wal::open(temp_dir.path(), opts).unwrap();
wal.append(b"should_also_be_compressed").unwrap();
wal.close().unwrap();
}
let file =
File::open(temp_dir.path().join("00000000000000000000.wal")).expect("should open file");
let mut reader = Reader::new(file);
let (data1, _) = reader.read().expect("should read first record");
assert_eq!(data1, b"compressed_record_1");
let (data2, _) = reader.read().expect("should read second record");
assert_eq!(data2, b"compressed_record_2");
let (data3, _) = reader.read().expect("should read third record");
assert_eq!(
data3, b"should_also_be_compressed",
"Third record should be correctly decompressed"
);
}
#[test]
fn test_wal_compression_type_readable_by_reader() {
use std::fs::File;
use crate::wal::reader::Reader;
let temp_dir = create_temp_directory();
{
let opts = Options::default().with_compression(CompressionType::Lz4);
let mut wal = Wal::open(temp_dir.path(), opts).unwrap();
wal.append(b"test_data").unwrap();
wal.close().unwrap();
}
let wal_path = temp_dir.path().join("00000000000000000000.wal");
let file = File::open(&wal_path).expect("should open WAL file");
let mut reader = Reader::new(file);
assert_eq!(
reader.get_compression_type(),
CompressionType::None,
"Compression type should be None before reading any records"
);
let (data, _) = reader.read().expect("should read first record");
assert_eq!(data, b"test_data", "Data should match what was written");
assert_eq!(
reader.get_compression_type(),
CompressionType::Lz4,
"Reader should detect LZ4 compression type from SetCompressionType record"
);
}
#[test]
fn test_wal_no_compression_type_when_disabled() {
use std::fs::File;
use crate::wal::reader::Reader;
let temp_dir = create_temp_directory();
{
let opts = Options::default(); let mut wal = Wal::open(temp_dir.path(), opts).unwrap();
wal.append(b"test_data").unwrap();
wal.close().unwrap();
}
let wal_path = temp_dir.path().join("00000000000000000000.wal");
let file = File::open(&wal_path).expect("should open WAL file");
let mut reader = Reader::new(file);
let (data, _) = reader.read().expect("should read first record");
assert_eq!(data, b"test_data", "Data should match what was written");
assert_eq!(
reader.get_compression_type(),
CompressionType::None,
"Reader should report no compression when WAL was created without compression"
);
}
#[test]
fn test_open_with_min_log_number_uses_highest_segment() {
let temp_dir = create_temp_directory();
let wal_path = temp_dir.path();
{
let opts = Options::default();
let mut wal = Wal::open_with_min_log_number(wal_path, 1, opts).unwrap();
assert_eq!(wal.get_active_log_number(), 1);
wal.append(b"data_in_segment_1").unwrap();
wal.close().unwrap();
}
{
let opts = Options::default();
let mut wal = Wal::open_with_min_log_number(wal_path, 2, opts).unwrap();
assert_eq!(wal.get_active_log_number(), 2);
wal.append(b"data_in_segment_2").unwrap();
wal.close().unwrap();
}
{
let opts = Options::default();
let mut wal = Wal::open_with_min_log_number(wal_path, 3, opts).unwrap();
assert_eq!(wal.get_active_log_number(), 3);
wal.append(b"data_in_segment_3").unwrap();
wal.close().unwrap();
}
let segments: Vec<u64> = std::fs::read_dir(wal_path)
.unwrap()
.filter_map(|e| e.ok())
.filter_map(|e| {
e.path()
.file_name()
.and_then(|n| n.to_str())
.and_then(|n| n.strip_suffix(".wal"))
.and_then(|n| n.parse::<u64>().ok())
})
.collect();
assert!(segments.contains(&1), "Segment 1 should exist");
assert!(segments.contains(&2), "Segment 2 should exist");
assert!(segments.contains(&3), "Segment 3 should exist");
{
let opts = Options::default();
let mut wal = Wal::open_with_min_log_number(wal_path, 1, opts).unwrap();
let active = wal.get_active_log_number();
log::info!("After open_with_min_log_number(1): active_log_number = {}", active);
assert_eq!(
active, 3,
"WAL should open at highest existing segment (3), not at min_log_number (1)"
);
wal.close().unwrap();
}
{
let opts = Options::default();
let mut wal = Wal::open_with_min_log_number(wal_path, 5, opts).unwrap();
assert_eq!(
wal.get_active_log_number(),
5,
"WAL should open at min_log_number (5) when it's higher than any segment on disk (3)"
);
wal.close().unwrap();
}
}
}