use std::fs::File;
use std::io::{BufReader, Read, Seek, SeekFrom};
use std::path::Path;
use crate::api::errors::{Error, Result};
use crate::api::journal::{
JournalAnchor, JournalEnvelope, JournalEnvelopePage, JournalState, MAX_JOURNAL_RECORD_BYTES,
};
use super::codec::{
decode_file_header, decode_record, file_header_size_from_prefix, FileHeader,
LEGACY_FILE_HEADER_SIZE, LEGACY_FORMAT_VERSION, RECORD_FOOTER_SIZE, RECORD_HEADER_SIZE,
RECORD_MAGIC,
};
use super::wal_op::WalOp;
#[cfg(test)]
thread_local! {
static STREAMING_RECORDS_DECODED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
#[derive(Debug, Clone, Copy)]
pub struct ReplayStats {
pub records_seen: u64,
pub highest_seq: Option<u64>,
pub torn_tail_at: Option<u64>,
}
#[cfg(test)]
pub(crate) struct AttachedEnvelopeScan {
pub(crate) checkpoint: Option<JournalAnchor>,
pub(crate) tail: Option<JournalAnchor>,
pub(crate) envelopes: Vec<JournalEnvelope>,
}
#[derive(Clone, Copy)]
pub(crate) struct AttachedEnvelopeResume {
checkpoint: JournalAnchor,
cursor: JournalAnchor,
record_offset: u64,
}
pub fn replay<F>(path: &Path, mut callback: F) -> Result<(FileHeader, ReplayStats)>
where
F: FnMut(&WalOp, u64, u64) -> Result<()>,
{
let mut file = File::open(path)?;
let mut bytes = Vec::new();
file.read_to_end(&mut bytes)?;
replay_bytes(&bytes, &mut callback)
}
pub(crate) fn preflight_writable_wal(path: &Path) -> Result<()> {
let records = StreamingRecords::open(path)?;
if records.header.version == LEGACY_FORMAT_VERSION
&& records.file_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,
});
}
Ok(())
}
pub fn replay_bytes<F>(bytes: &[u8], callback: &mut F) -> Result<(FileHeader, ReplayStats)>
where
F: FnMut(&WalOp, u64, u64) -> Result<()>,
{
if bytes.len() < LEGACY_FILE_HEADER_SIZE {
return Err(Error::ReplaySanityFailed {
context: "WAL too short — missing file header",
record_offset: 0,
});
}
let header = decode_file_header(bytes)?;
let mut offset = header.record_offset();
let mut records_seen = 0u64;
let mut highest_seq: Option<u64> = None;
let mut torn_tail_at: Option<u64> = None;
while offset < bytes.len() {
match decode_record(&bytes[offset..]) {
Ok(r) => {
if header.version == LEGACY_FORMAT_VERSION
&& matches!(&r.op, WalOp::DbBatchWithEnvelope { .. })
{
return Err(Error::ReplaySanityFailed {
context: "attached batch requires WAL format version 4",
record_offset: offset as u64,
});
}
let batch_ops = match &r.op {
WalOp::Batch { ops } => Some(ops),
WalOp::DbBatchWithEnvelope { envelope, ops } => {
debug_assert!(!envelope.payload().is_empty());
Some(ops)
}
_ => None,
};
if let Some(ops) = batch_ops {
for (i, inner) in ops.iter().enumerate() {
let inner_seq = r.seq.wrapping_add(i as u64);
callback(inner, inner_seq, offset as u64)
.map_err(|e| patch_offset(e, offset))?;
highest_seq = Some(match highest_seq {
None => inner_seq,
Some(s) => s.max(inner_seq),
});
}
} else {
callback(&r.op, r.seq, offset as u64).map_err(|e| patch_offset(e, offset))?;
highest_seq = Some(match highest_seq {
None => r.seq,
Some(s) => s.max(r.seq),
});
}
records_seen += 1;
offset += r.bytes_consumed;
}
Err(Error::ReplaySanityFailed { context, .. }) if is_torn_tail(context) => {
torn_tail_at = Some(offset as u64);
break;
}
Err(e) => {
return Err(patch_offset(e, offset));
}
}
}
Ok((
header,
ReplayStats {
records_seen,
highest_seq,
torn_tail_at,
},
))
}
pub(crate) fn validate_attached_journal(path: &Path) -> Result<Option<JournalState>> {
let mut records = StreamingRecords::open(path)?;
let mut chain = AttachedChain::new(records.header.checkpoint_anchor);
while let Some(record) = records.next_record()? {
if let WalOp::DbBatchWithEnvelope { envelope, .. } = record.op {
chain.observe(records.header.version, &envelope, record.offset)?;
}
}
Ok(chain.state())
}
pub(crate) fn attached_journal_state(path: &Path) -> Result<JournalState> {
validate_attached_journal(path)?.ok_or(Error::JournalStreamUnavailable {
reason: "stream has not been initialized",
})
}
pub(crate) fn scan_attached_envelope_page(
path: &Path,
cursor: JournalAnchor,
row_limit: usize,
payload_byte_limit: usize,
) -> Result<JournalEnvelopePage> {
scan_attached_envelope_page_from(path, cursor, row_limit, payload_byte_limit, None)
.map(|(page, _)| page)
}
pub(crate) fn scan_attached_envelope_page_from(
path: &Path,
cursor: JournalAnchor,
row_limit: usize,
payload_byte_limit: usize,
resume: Option<AttachedEnvelopeResume>,
) -> Result<(JournalEnvelopePage, AttachedEnvelopeResume)> {
if row_limit == 0 {
return Err(Error::InvalidJournalScanLimit {
reason: "row limit must be non-zero",
});
}
let mut records = StreamingRecords::open(path)?;
let Some(checkpoint) = records.header.checkpoint_anchor else {
let mut chain = AttachedChain::new(None);
while let Some(record) = records.next_record()? {
if let WalOp::DbBatchWithEnvelope { envelope, .. } = record.op {
chain.observe(records.header.version, &envelope, record.offset)?;
}
}
return Err(Error::JournalStreamUnavailable {
reason: "stream has not been initialized",
});
};
if cursor.sequence() < checkpoint.sequence() {
return Err(Error::JournalPositionExpired {
requested: cursor.sequence(),
checkpoint: checkpoint.sequence(),
});
}
if cursor.sequence() == checkpoint.sequence() && cursor != checkpoint {
return Err(Error::JournalAnchorMismatch {
requested: cursor.sequence(),
expected: checkpoint.sequence(),
});
}
scan_initialized_envelope_page(
records,
checkpoint,
cursor,
row_limit,
payload_byte_limit,
resume,
)
}
fn scan_initialized_envelope_page(
mut records: StreamingRecords,
checkpoint: JournalAnchor,
cursor: JournalAnchor,
row_limit: usize,
payload_byte_limit: usize,
resume: Option<AttachedEnvelopeResume>,
) -> Result<(JournalEnvelopePage, AttachedEnvelopeResume)> {
let usable_resume = resume.filter(|value| {
value.checkpoint == checkpoint
&& value.cursor == cursor
&& value.record_offset >= records.header.record_offset() as u64
&& value.record_offset <= records.file_len
});
let mut chain = if let Some(value) = usable_resume {
records.seek_to(value.record_offset)?;
AttachedChain::resume(checkpoint, cursor)
} else {
AttachedChain::new(Some(checkpoint))
};
let mut cursor_found = usable_resume.is_some() || cursor == checkpoint;
let mut envelopes = Vec::new();
let mut payload_bytes = 0usize;
while let Some(record) = records.next_record()? {
let record_offset = record.offset;
let WalOp::DbBatchWithEnvelope { envelope, .. } = record.op else {
continue;
};
let retained = chain.observe(records.header.version, &envelope, record_offset)?;
if !retained {
continue;
}
if !cursor_found {
match envelope.current().sequence().cmp(&cursor.sequence()) {
std::cmp::Ordering::Less => continue,
std::cmp::Ordering::Equal => {
if envelope.current() != cursor {
return Err(Error::JournalAnchorMismatch {
requested: cursor.sequence(),
expected: envelope.current().sequence(),
});
}
cursor_found = true;
continue;
}
std::cmp::Ordering::Greater => {
return Err(Error::JournalAnchorMismatch {
requested: cursor.sequence(),
expected: envelope.current().sequence(),
});
}
}
}
let next_bytes = payload_bytes.saturating_add(envelope.payload().len());
let page_full = envelopes.len() == row_limit
|| (!envelopes.is_empty() && next_bytes > payload_byte_limit);
if page_full {
let next = envelopes.last().map_or(cursor, JournalEnvelope::current);
return Ok((
JournalEnvelopePage::new(envelopes, next, true),
AttachedEnvelopeResume {
checkpoint,
cursor: next,
record_offset,
},
));
}
payload_bytes = next_bytes;
envelopes.push(envelope);
}
if !cursor_found {
return Err(Error::JournalAnchorMismatch {
requested: cursor.sequence(),
expected: chain.tail.expect("initialized chain has a tail").sequence(),
});
}
let next = envelopes.last().map_or(cursor, JournalEnvelope::current);
Ok((
JournalEnvelopePage::new(envelopes, next, false),
AttachedEnvelopeResume {
checkpoint,
cursor: next,
record_offset: records.offset,
},
))
}
#[cfg(test)]
pub(crate) fn scan_attached_envelopes(path: &Path) -> Result<AttachedEnvelopeScan> {
let mut records = StreamingRecords::open(path)?;
let checkpoint = records.header.checkpoint_anchor;
let mut chain = AttachedChain::new(checkpoint);
let mut envelopes = Vec::new();
while let Some(record) = records.next_record()? {
if let WalOp::DbBatchWithEnvelope { envelope, .. } = record.op {
if chain.observe(records.header.version, &envelope, record.offset)? {
envelopes.push(envelope);
}
}
}
Ok(AttachedEnvelopeScan {
checkpoint,
tail: chain.tail,
envelopes,
})
}
struct AttachedChain {
checkpoint: Option<JournalAnchor>,
tail: Option<JournalAnchor>,
previous_record: Option<JournalAnchor>,
}
impl AttachedChain {
fn new(checkpoint: Option<JournalAnchor>) -> Self {
Self {
checkpoint,
tail: checkpoint,
previous_record: None,
}
}
fn resume(checkpoint: JournalAnchor, cursor: JournalAnchor) -> Self {
Self {
checkpoint: Some(checkpoint),
tail: Some(cursor),
previous_record: Some(cursor),
}
}
fn observe(
&mut self,
format_version: u32,
envelope: &JournalEnvelope,
record_offset: u64,
) -> Result<bool> {
if format_version == LEGACY_FORMAT_VERSION {
return Err(Error::ReplaySanityFailed {
context: "attached batch requires WAL format version 4",
record_offset,
});
}
let Some(checkpoint) = self.checkpoint else {
return Err(Error::JournalStreamUnavailable {
reason: "WAL contains attached envelopes but its stream anchor is uninitialized",
});
};
if let Some(previous) = self.previous_record {
if envelope.previous() != previous {
return Err(Error::ReplaySanityFailed {
context: "attached journal records do not form a contiguous chain",
record_offset,
});
}
}
self.previous_record = Some(envelope.current());
match envelope.current().sequence().cmp(&checkpoint.sequence()) {
std::cmp::Ordering::Less => Ok(false),
std::cmp::Ordering::Equal => {
if envelope.current() != checkpoint {
return Err(Error::ReplaySanityFailed {
context: "attached journal record conflicts with checkpoint anchor",
record_offset,
});
}
Ok(false)
}
std::cmp::Ordering::Greater => {
let expected = self.tail.expect("checkpoint exists");
if envelope.previous() != expected {
return Err(Error::ReplaySanityFailed {
context: "attached journal suffix does not continue checkpoint anchor",
record_offset,
});
}
self.tail = Some(envelope.current());
Ok(true)
}
}
}
fn state(&self) -> Option<JournalState> {
Some(JournalState::new(self.checkpoint?, self.tail?))
}
}
struct RecordAt {
op: WalOp,
offset: u64,
}
struct StreamingRecords {
reader: BufReader<File>,
header: FileHeader,
file_len: u64,
offset: u64,
}
impl StreamingRecords {
fn open(path: &Path) -> Result<Self> {
let mut file = File::open(path)?;
let file_len = file.metadata()?.len();
let mut prefix = [0u8; LEGACY_FILE_HEADER_SIZE];
file.read_exact(&mut prefix).map_err(|error| {
if error.kind() == std::io::ErrorKind::UnexpectedEof {
Error::ReplaySanityFailed {
context: "WAL too short — missing file header",
record_offset: 0,
}
} else {
Error::BlobStoreIo(error)
}
})?;
let header_size = file_header_size_from_prefix(&prefix)?;
let mut header_bytes = Vec::with_capacity(header_size);
header_bytes.extend_from_slice(&prefix);
if header_size > LEGACY_FILE_HEADER_SIZE {
header_bytes.resize(header_size, 0);
file.read_exact(&mut header_bytes[LEGACY_FILE_HEADER_SIZE..])
.map_err(|error| {
if error.kind() == std::io::ErrorKind::UnexpectedEof {
Error::ReplaySanityFailed {
context: "WAL file header truncated",
record_offset: 0,
}
} else {
Error::BlobStoreIo(error)
}
})?;
}
let header = decode_file_header(&header_bytes)?;
Ok(Self {
reader: BufReader::new(file),
header,
file_len,
offset: header_size as u64,
})
}
fn seek_to(&mut self, offset: u64) -> Result<()> {
self.reader.seek(SeekFrom::Start(offset))?;
self.offset = offset;
Ok(())
}
fn next_record(&mut self) -> Result<Option<RecordAt>> {
if self.offset >= self.file_len {
return Ok(None);
}
let record_offset = self.offset;
let remaining = self.file_len - record_offset;
if remaining < RECORD_HEADER_SIZE as u64 {
return Ok(None);
}
let mut header = [0u8; RECORD_HEADER_SIZE];
self.reader.read_exact(&mut header)?;
let magic = u32::from_le_bytes(header[0..4].try_into().unwrap());
if magic != RECORD_MAGIC {
return Err(Error::ReplaySanityFailed {
context: "record magic mismatch",
record_offset,
});
}
let body_len = u32::from_le_bytes(header[4..8].try_into().unwrap()) as usize;
let total = RECORD_HEADER_SIZE
.checked_add(body_len)
.and_then(|value| value.checked_add(RECORD_FOOTER_SIZE))
.ok_or(Error::ReplaySanityFailed {
context: "record size overflow",
record_offset,
})?;
if total as u64 > remaining {
return Ok(None);
}
if total > MAX_JOURNAL_RECORD_BYTES {
return Err(Error::ReplaySanityFailed {
context: "record exceeds journal size limit",
record_offset,
});
}
let mut bytes = Vec::with_capacity(total);
bytes.extend_from_slice(&header);
bytes.resize(total, 0);
self.reader.read_exact(&mut bytes[RECORD_HEADER_SIZE..])?;
let decoded =
decode_record(&bytes).map_err(|error| patch_offset(error, record_offset as usize))?;
#[cfg(test)]
STREAMING_RECORDS_DECODED.with(|count| count.set(count.get() + 1));
self.offset = self
.offset
.checked_add(decoded.bytes_consumed as u64)
.expect("WAL offset fits in u64");
Ok(Some(RecordAt {
op: decoded.op,
offset: record_offset,
}))
}
}
fn is_torn_tail(context: &'static str) -> bool {
context == "record header truncated" || context == "record body truncated"
}
fn patch_offset(e: Error, offset: usize) -> Error {
match e {
Error::ReplaySanityFailed { context, .. } => Error::ReplaySanityFailed {
context,
record_offset: offset as u64,
},
other => other,
}
}
#[cfg(test)]
mod attached_streaming_tests {
use std::fs;
use std::fs::OpenOptions;
use std::io::{Read, Seek, SeekFrom, Write};
use tempfile::tempdir;
use super::*;
use crate::journal::codec::{
encode_file_header, BatchEncoder, FileHeader, FILE_HEADER_SIZE, LEGACY_FORMAT_VERSION,
};
use crate::journal::writer::WalWriter;
fn anchor(sequence: u64) -> JournalAnchor {
let mut digest = [0u8; 32];
digest[..8].copy_from_slice(&sequence.to_le_bytes());
JournalAnchor::new(sequence, digest)
}
fn write_envelopes(
count: u64,
payload_len: usize,
) -> (tempfile::TempDir, std::path::PathBuf, Vec<u64>) {
let dir = tempdir().unwrap();
let path = dir.path().join("journal.wal");
let mut writer = WalWriter::create(&path, 0).unwrap();
writer.persist_checkpoint_anchor(anchor(0)).unwrap();
let mut offsets = Vec::new();
let mut offset = FILE_HEADER_SIZE as u64;
for sequence in 1..=count {
let envelope = JournalEnvelope::new(
anchor(sequence - 1),
anchor(sequence),
vec![sequence as u8; payload_len],
)
.unwrap();
let mut record = Vec::new();
BatchEncoder::begin_with_envelope(&mut record, sequence, 0, &envelope).finish();
offsets.push(offset);
offset += record.len() as u64;
writer.append_encoded(&record).unwrap();
}
writer.flush().unwrap();
(dir, path, offsets)
}
#[test]
fn resumed_pages_decode_only_one_lookahead_per_page() {
const RECORDS: u64 = 96;
const PAYLOAD_BYTES: usize = 64 * 1024;
let (_dir, path, _offsets) = write_envelopes(RECORDS, PAYLOAD_BYTES);
STREAMING_RECORDS_DECODED.with(|count| count.set(0));
let mut cursor = anchor(0);
let mut resume = None;
let mut seen = 0u64;
let mut pages = 0usize;
loop {
let (page, next_resume) =
scan_attached_envelope_page_from(&path, cursor, 16, PAYLOAD_BYTES * 2, resume)
.unwrap();
assert!(!page.envelopes().is_empty());
assert!(page.envelopes().len() <= 2);
seen += page.envelopes().len() as u64;
pages += 1;
cursor = page.next();
resume = Some(next_resume);
if !page.has_more() {
break;
}
}
assert_eq!(seen, RECORDS);
assert_eq!(cursor, anchor(RECORDS));
assert_eq!(pages, RECORDS as usize / 2);
let decoded = STREAMING_RECORDS_DECODED.with(std::cell::Cell::get);
assert_eq!(decoded, RECORDS as usize + pages - 1);
}
#[test]
fn page_validates_one_lookahead_and_defers_later_corruption() {
let (_dir, path, offsets) = write_envelopes(3, 32);
let corrupt_at = offsets[2] + RECORD_HEADER_SIZE as u64 + 1;
let mut file = OpenOptions::new()
.read(true)
.write(true)
.open(&path)
.unwrap();
file.seek(SeekFrom::Start(corrupt_at)).unwrap();
let mut byte = [0u8; 1];
file.read_exact(&mut byte).unwrap();
byte[0] ^= 0x80;
file.seek(SeekFrom::Start(corrupt_at)).unwrap();
file.write_all(&byte).unwrap();
file.sync_data().unwrap();
let (first, resume) =
scan_attached_envelope_page_from(&path, anchor(0), 1, usize::MAX, None).unwrap();
assert_eq!(first.envelopes()[0].current(), anchor(1));
assert!(first.has_more());
assert!(matches!(
scan_attached_envelope_page_from(
&path,
first.next(),
1,
usize::MAX,
Some(resume),
),
Err(Error::ReplaySanityFailed {
context: "record CRC mismatch",
record_offset,
}) if record_offset == offsets[2]
));
assert!(matches!(
validate_attached_journal(&path),
Err(Error::ReplaySanityFailed {
context: "record CRC mismatch",
record_offset,
}) if record_offset == offsets[2]
));
}
#[test]
fn writable_preflight_accepts_header_only_v3_but_rejects_records() {
let dir = tempdir().unwrap();
let path = dir.path().join("legacy.wal");
let mut bytes = Vec::new();
encode_file_header(
&FileHeader {
version: LEGACY_FORMAT_VERSION,
tree_id: 0,
created_at: 0,
checkpoint_anchor: None,
anchor_generation: 0,
},
&mut bytes,
);
fs::write(&path, &bytes).unwrap();
preflight_writable_wal(&path).unwrap();
bytes.push(0);
fs::write(&path, bytes).unwrap();
assert!(matches!(
preflight_writable_wal(&path),
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,
})
));
}
}