use futures::StreamExt;
use writeahead::logfile::{
recover_and_seal, FILE_HEADER_SIZE, FOOTER_SIZE, FORMAT_VERSION, MAGIC_NUMBER,
RECORD_HEADER_SIZE,
};
use writeahead::write_ahead::WriteAheadError;
use writeahead::{SimpleFile, WriteAhead, WriteAheadOptions};
fn opts(dir: &std::path::Path) -> WriteAheadOptions {
WriteAheadOptions {
log_dir: dir.to_path_buf(),
..Default::default()
}
}
fn create_max_id_active(dir: &std::path::Path) -> std::path::PathBuf {
let mut wal = WriteAhead::<SimpleFile>::with_options(WriteAheadOptions {
log_dir: dir.to_path_buf(),
preallocation_chunk_size: None,
..Default::default()
});
wal.start().unwrap();
drop(wal);
let source = dir.join("0000000000.log");
let target = dir.join(format!("{}.log", u64::MAX));
std::fs::rename(source, &target).unwrap();
target
}
async fn write_str(wal: &WriteAhead<SimpleFile>, s: &str) -> writeahead::RecordID {
wal.write_batch(vec![s.as_bytes().to_vec()])
.await
.unwrap()
.remove(0)
}
async fn collect_stream(wal: &WriteAhead<SimpleFile>) -> Vec<Vec<u8>> {
let mut out = Vec::new();
let mut stream = wal.create_stream().unwrap();
while let Some(r) = stream.next().await {
out.push(r.unwrap().1);
}
out
}
#[tokio::test]
async fn restart_resumes_appending() {
let dir = tempfile::tempdir().unwrap();
let mut wal = WriteAhead::<SimpleFile>::with_options(opts(dir.path()));
wal.start().unwrap();
let id1 = write_str(&wal, "before restart").await;
drop(wal);
let mut wal = WriteAhead::<SimpleFile>::with_options(opts(dir.path()));
wal.start().unwrap();
let id2 = write_str(&wal, "after restart").await;
assert_eq!(
wal.read(id1.file_id, id1.file_offset).unwrap(),
b"before restart"
);
assert_eq!(
wal.read(id2.file_id, id2.file_offset).unwrap(),
b"after restart"
);
assert_eq!(collect_stream(&wal).await.len(), 2);
}
#[tokio::test]
async fn crash_after_seal_before_next_file_rotates() {
let dir = tempfile::tempdir().unwrap();
let mut wal = WriteAhead::<SimpleFile>::with_options(opts(dir.path()));
wal.start().unwrap();
let id1 = write_str(&wal, "sealed away").await;
drop(wal);
let path = dir.path().join("0000000000.log");
recover_and_seal::<SimpleFile>(&path, 1).unwrap();
let mut wal = WriteAhead::<SimpleFile>::with_options(opts(dir.path()));
wal.start().unwrap();
let id2 = write_str(&wal, "fresh file").await;
assert_eq!(id2.file_id, 1, "writer must rotate off a sealed file");
assert_eq!(
wal.read(id1.file_id, id1.file_offset).unwrap(),
b"sealed away"
);
let all = collect_stream(&wal).await;
assert_eq!(all, vec![b"sealed away".to_vec(), b"fresh file".to_vec()]);
}
#[tokio::test]
async fn crash_mid_record_truncates_torn_tail() {
let dir = tempfile::tempdir().unwrap();
let mut wal = WriteAhead::<SimpleFile>::with_options(opts(dir.path()));
wal.start().unwrap();
let id1 = write_str(&wal, "acknowledged").await;
let id2 = write_str(&wal, "will be torn").await;
drop(wal);
let path = dir.path().join("0000000000.log");
let logical_end = id2.file_offset + RECORD_HEADER_SIZE + b"will be torn".len() as u64;
let f = std::fs::OpenOptions::new().write(true).open(&path).unwrap();
f.set_len(logical_end - 5).unwrap();
let mut wal = WriteAhead::<SimpleFile>::with_options(opts(dir.path()));
wal.start().unwrap();
assert_eq!(
wal.read(id1.file_id, id1.file_offset).unwrap(),
b"acknowledged"
);
let id3 = write_str(&wal, "appended after recovery").await;
assert_eq!(id3.file_id, 0);
let all = collect_stream(&wal).await;
assert_eq!(
all,
vec![
b"acknowledged".to_vec(),
b"appended after recovery".to_vec()
]
);
}
#[tokio::test]
async fn crash_before_header_heals_empty_active_file() {
let dir = tempfile::tempdir().unwrap();
let mut wal = WriteAhead::<SimpleFile>::with_options(opts(dir.path()));
wal.start().unwrap();
let id1 = write_str(&wal, "in file zero").await;
drop(wal);
recover_and_seal::<SimpleFile>(&dir.path().join("0000000000.log"), 1).unwrap();
std::fs::write(dir.path().join("0000000001.log"), b"").unwrap();
let mut wal = WriteAhead::<SimpleFile>::with_options(opts(dir.path()));
wal.start().unwrap();
let id2 = write_str(&wal, "in file one").await;
assert_eq!(id2.file_id, 1);
assert_eq!(id2.file_offset, FILE_HEADER_SIZE);
assert_eq!(
wal.read(id1.file_id, id1.file_offset).unwrap(),
b"in file zero"
);
assert_eq!(collect_stream(&wal).await.len(), 2);
}
#[tokio::test]
async fn full_reserved_header_without_valid_slot_is_reinitialized() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("0000000000.log");
let mut torn = vec![0u8; FILE_HEADER_SIZE as usize];
torn[0..8].copy_from_slice(&MAGIC_NUMBER);
torn[8] = FORMAT_VERSION;
std::fs::write(&path, torn).unwrap();
let mut wal = WriteAhead::<SimpleFile>::with_options(opts(dir.path()));
wal.start().unwrap();
let id = write_str(&wal, "after torn header").await;
assert_eq!(id.file_id, 0);
assert_eq!(id.file_offset, FILE_HEADER_SIZE);
assert_eq!(
wal.read(id.file_id, id.file_offset).unwrap(),
b"after torn header"
);
}
#[tokio::test]
async fn max_file_id_stream_exhaustion_and_writer_rotation_are_controlled() {
let dir = tempfile::tempdir().unwrap();
create_max_id_active(dir.path());
let mut wal = WriteAhead::<SimpleFile>::with_options(WriteAheadOptions {
log_dir: dir.path().to_path_buf(),
max_file_size: FILE_HEADER_SIZE - 1,
preallocation_chunk_size: None,
..Default::default()
});
wal.start().unwrap();
let mut stream = wal.create_stream().unwrap();
assert!(stream.next().await.is_none());
let first = write_str(&wal, "fills tiny max").await;
assert_eq!(first.file_id, u64::MAX);
let err = wal
.write_batch(vec![b"needs rotation".to_vec()])
.await
.unwrap_err();
assert!(format!("{err:#}").contains("Log file id space exhausted"));
}
#[test]
fn sealed_max_file_id_fails_start_without_overflow() {
let dir = tempfile::tempdir().unwrap();
let path = create_max_id_active(dir.path());
recover_and_seal::<SimpleFile>(&path, 1).unwrap();
let mut wal = WriteAhead::<SimpleFile>::with_options(opts(dir.path()));
let err = wal.start().unwrap_err();
assert!(matches!(
err.downcast_ref::<WriteAheadError>(),
Some(WriteAheadError::FileIdExhausted)
));
}
#[tokio::test]
async fn torn_footer_on_non_active_file_is_healed() {
let dir = tempfile::tempdir().unwrap();
let mut wal = WriteAhead::<SimpleFile>::with_options(opts(dir.path()));
wal.start().unwrap();
let id1 = write_str(&wal, "old file record").await;
drop(wal);
let path0 = dir.path().join("0000000000.log");
recover_and_seal::<SimpleFile>(&path0, 1).unwrap();
let len = std::fs::metadata(&path0).unwrap().len();
let f = std::fs::OpenOptions::new()
.write(true)
.open(&path0)
.unwrap();
f.set_len(len - FOOTER_SIZE / 2).unwrap();
std::fs::write(dir.path().join("0000000001.log"), b"").unwrap();
let mut wal = WriteAhead::<SimpleFile>::with_options(opts(dir.path()));
wal.start().unwrap();
let id2 = write_str(&wal, "active file record").await;
assert_eq!(id2.file_id, 1);
assert_eq!(
wal.read(id1.file_id, id1.file_offset).unwrap(),
b"old file record"
);
let all = collect_stream(&wal).await;
assert_eq!(
all,
vec![b"old file record".to_vec(), b"active file record".to_vec()]
);
}
#[tokio::test]
async fn stray_files_are_skipped() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("notes.txt"), b"hi").unwrap();
std::fs::write(dir.path().join(".hidden"), b"hi").unwrap();
std::fs::write(dir.path().join("abc.log"), b"not a wal file").unwrap();
let mut wal = WriteAhead::<SimpleFile>::with_options(opts(dir.path()));
wal.start().unwrap();
let id = write_str(&wal, "works").await;
assert_eq!(wal.read(id.file_id, id.file_offset).unwrap(), b"works");
}