use std::fs::{File, OpenOptions};
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::Path;
use crate::api::errors::{Error, Result};
#[cfg(test)]
use super::codec::encode_record;
use super::codec::{
decode_file_header, encode_anchor_slot, encode_file_header, file_header_size_from_prefix,
FileHeader, ANCHOR_SLOT_OFFSETS, ANCHOR_SLOT_SIZE, FILE_HEADER_SIZE, FORMAT_VERSION,
LEGACY_FILE_HEADER_SIZE,
};
#[cfg(test)]
use super::wal_op::WalOp;
use crate::api::journal::JournalAnchor;
#[cfg(test)]
thread_local! {
static FAIL_ANCHOR_GENERATION_AFTER: std::cell::Cell<Option<usize>> =
const { std::cell::Cell::new(None) };
}
pub const AUTO_FLUSH_THRESHOLD: usize = 64 * 1024;
#[derive(Debug)]
pub struct WalWriter {
file: File,
header_file: File,
pending: Vec<u8>,
bytes_written: u64,
header: FileHeader,
}
impl WalWriter {
pub fn create(path: &Path, tree_id: u64) -> Result<Self> {
let mut header_file = OpenOptions::new()
.read(true)
.write(true)
.create_new(true)
.open(path)?;
let header = FileHeader::now(tree_id);
let mut buf = Vec::with_capacity(FILE_HEADER_SIZE);
encode_file_header(&header, &mut buf);
header_file.write_all(&buf)?;
header_file.sync_data()?;
let file = OpenOptions::new().append(true).open(path)?;
Ok(Self {
file,
header_file,
pending: Vec::with_capacity(4096),
bytes_written: FILE_HEADER_SIZE as u64,
header,
})
}
pub fn open_existing(path: &Path) -> Result<Self> {
let mut header_file = OpenOptions::new().read(true).write(true).open(path)?;
let mut prefix = [0u8; LEGACY_FILE_HEADER_SIZE];
header_file.read_exact(&mut prefix)?;
let encoded_header_size = file_header_size_from_prefix(&prefix)?;
let mut header_bytes = Vec::with_capacity(encoded_header_size);
header_bytes.extend_from_slice(&prefix);
if encoded_header_size > LEGACY_FILE_HEADER_SIZE {
header_bytes.resize(encoded_header_size, 0);
header_file.read_exact(&mut header_bytes[LEGACY_FILE_HEADER_SIZE..])?;
}
let mut header = decode_file_header(&header_bytes)?;
if header.version != FORMAT_VERSION {
if header_file.metadata()?.len() != LEGACY_FILE_HEADER_SIZE as u64 {
return Err(Error::ReplaySanityFailed {
context: "nonempty WAL format 3 is replay-only; checkpoint it with a format-3 Holt binary before v4 writes",
record_offset: 0,
});
}
header.version = FORMAT_VERSION;
header.checkpoint_anchor = None;
header.anchor_generation = 0;
let mut upgraded = Vec::with_capacity(FILE_HEADER_SIZE);
encode_file_header(&header, &mut upgraded);
header_file.seek(SeekFrom::Start(0))?;
header_file.write_all(&upgraded)?;
header_file.sync_data()?;
}
let file = OpenOptions::new().append(true).open(path)?;
let bytes_written = file.metadata()?.len();
Ok(Self {
file,
header_file,
pending: Vec::with_capacity(4096),
bytes_written,
header,
})
}
pub fn open_or_create(path: &Path, tree_id: u64) -> Result<Self> {
if path.exists() {
let w = Self::open_existing(path)?;
if w.header.tree_id != tree_id {
return Err(Error::ReplaySanityFailed {
context: "WAL file tree_id mismatch on open",
record_offset: 0,
});
}
Ok(w)
} else {
Self::create(path, tree_id)
}
}
#[cfg(test)]
#[must_use]
pub fn header(&self) -> FileHeader {
self.header
}
#[cfg(test)]
#[must_use]
pub fn bytes_written(&self) -> u64 {
self.bytes_written + self.pending.len() as u64
}
#[must_use]
pub(crate) fn has_records(&self) -> bool {
self.bytes_written + self.pending.len() as u64 > FILE_HEADER_SIZE as u64
}
#[cfg(test)]
pub fn append(&mut self, op: &WalOp, seq: u64) -> Result<()> {
encode_record(op, seq, &mut self.pending);
self.maybe_drain()
}
pub(crate) fn append_encoded(&mut self, record: &[u8]) -> Result<()> {
self.pending.extend_from_slice(record);
self.maybe_drain()
}
fn maybe_drain(&mut self) -> Result<()> {
if self.pending.len() >= AUTO_FLUSH_THRESHOLD {
self.drain_to_os()?;
}
Ok(())
}
pub(crate) fn drain_to_os(&mut self) -> Result<()> {
if self.pending.is_empty() {
return Ok(());
}
self.file.write_all(&self.pending)?;
self.bytes_written += self.pending.len() as u64;
self.pending.clear();
Ok(())
}
pub fn flush(&mut self) -> Result<()> {
self.drain_to_os()?;
self.file.sync_data()?;
Ok(())
}
#[cfg(test)]
pub fn discard_pending(&mut self) {
self.pending.clear();
}
pub fn truncate(&mut self) -> Result<()> {
self.pending.clear();
let record_offset = self.header.record_offset() as u64;
self.header_file.set_len(record_offset)?;
self.header_file.sync_data()?;
self.bytes_written = record_offset;
#[cfg(feature = "tracing")]
tracing::info!(target: "holt::wal", "wal truncated to header-only");
Ok(())
}
pub(crate) fn checkpoint_and_truncate(&mut self, anchor: Option<JournalAnchor>) -> Result<()> {
self.flush()?;
if let Some(anchor) = anchor {
self.persist_checkpoint_anchor(anchor)?;
}
self.truncate()
}
pub(crate) fn persist_checkpoint_anchor(&mut self, anchor: JournalAnchor) -> Result<()> {
if self.header.version != FORMAT_VERSION {
return Err(Error::JournalStreamUnavailable {
reason: "checkpoint anchors require WAL format 4",
});
}
let generations = if self.header.checkpoint_anchor == Some(anchor) {
1
} else {
2
};
self.header
.anchor_generation
.checked_add(generations)
.ok_or(Error::JournalStreamUnavailable {
reason: "checkpoint anchor generation exhausted",
})?;
for _ in 0..generations {
self.persist_anchor_generation(anchor)?;
}
Ok(())
}
#[cfg(test)]
pub(crate) fn fail_anchor_generation_after_for_test(successful_writes: usize) {
FAIL_ANCHOR_GENERATION_AFTER.with(|remaining| remaining.set(Some(successful_writes)));
}
fn persist_anchor_generation(&mut self, anchor: JournalAnchor) -> Result<()> {
#[cfg(test)]
FAIL_ANCHOR_GENERATION_AFTER.with(|remaining| match remaining.get() {
Some(0) => {
remaining.set(None);
Err(Error::Internal("checkpoint anchor generation test failure"))
}
Some(count) => {
remaining.set(Some(count - 1));
Ok(())
}
None => Ok(()),
})?;
let generation = self.header.anchor_generation.checked_add(1).ok_or(
Error::JournalStreamUnavailable {
reason: "checkpoint anchor generation exhausted",
},
)?;
let slot_index = ((generation - 1) & 1) as usize;
let slot = encode_anchor_slot(anchor, generation);
self.header_file
.seek(SeekFrom::Start(ANCHOR_SLOT_OFFSETS[slot_index] as u64))?;
self.header_file.write_all(&slot[..ANCHOR_SLOT_SIZE])?;
self.header_file.sync_data()?;
self.header.checkpoint_anchor = Some(anchor);
self.header.anchor_generation = generation;
Ok(())
}
}