use crate::wal::{decode_wal_record, WAL_FORMAT_VERSION};
use std::fs::{File, OpenOptions};
use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};
const LOG_SEGMENT_MAGIC: [u8; 8] = *b"RDBWSEG1";
const LOG_SEGMENT_HEADER_LEN: usize = 8 + 4 + 4 + 8;
const LOG_RECORD_HEADER_LEN: usize = 32;
pub(crate) const MAX_RECORD_BYTES: usize = 64 * 1024 * 1024;
#[derive(Debug, thiserror::Error)]
pub enum LogError {
#[error("log I/O error: {0}")]
Io(#[from] io::Error),
#[error("corrupt log segment {path}: {detail}")]
Corrupt {
path: String,
detail: String,
},
#[error("log append out of order: expected LSN {expected}, got {actual}")]
OutOfOrder {
expected: u64,
actual: u64,
},
#[error("log record {0} bytes exceeds the 64 MiB ceiling")]
Oversized(usize),
}
#[derive(Debug, Clone)]
pub struct SegmentInfo {
pub first_lsn: u64,
pub last_lsn: u64,
pub path: PathBuf,
pub bytes: u64,
}
#[derive(Debug)]
struct ActiveSegment {
file: File,
first_lsn: u64,
bytes: u64,
}
#[derive(Debug)]
pub struct LogStore {
dir: PathBuf,
shard_id: u32,
segment_bytes: u64,
segments: Vec<SegmentInfo>,
next_lsn: u64,
active: Option<ActiveSegment>,
}
impl LogStore {
pub fn open(dir: &Path, shard_id: u32, segment_bytes: u64) -> Result<Self, LogError> {
std::fs::create_dir_all(dir)?;
let mut paths: Vec<(u64, PathBuf)> = std::fs::read_dir(dir)?
.filter_map(Result::ok)
.filter_map(|entry| {
let name = entry.file_name().into_string().ok()?;
let first_lsn = name.strip_suffix(".seg")?.parse::<u64>().ok()?;
Some((first_lsn, entry.path()))
})
.collect();
paths.sort_by_key(|(first_lsn, _)| *first_lsn);
let mut segments = Vec::with_capacity(paths.len());
let mut next_lsn = 1u64;
let last_index = paths.len().saturating_sub(1);
for (index, (first_lsn, path)) in paths.iter().enumerate() {
let is_active = index == last_index;
let (records, valid_bytes, file_len) = read_segment(path, shard_id, is_active)?;
if valid_bytes != file_len {
if !is_active {
return Err(LogError::Corrupt {
path: path.display().to_string(),
detail: "sealed segment ends with a torn record".into(),
});
}
let file = OpenOptions::new().write(true).open(path)?;
file.set_len(valid_bytes)?;
file.sync_all()?;
}
let last_lsn = records
.last()
.map_or(first_lsn.saturating_sub(1), |(lsn, _)| *lsn);
next_lsn = last_lsn.saturating_add(1);
segments.push(SegmentInfo {
first_lsn: *first_lsn,
last_lsn,
path: path.clone(),
bytes: valid_bytes,
});
}
let active = match segments.last() {
Some(last) if last.bytes < segment_bytes => {
let file = OpenOptions::new().append(true).open(&last.path)?;
Some(ActiveSegment {
file,
first_lsn: last.first_lsn,
bytes: last.bytes,
})
}
_ => None,
};
Ok(Self {
dir: dir.to_path_buf(),
shard_id,
segment_bytes,
segments,
next_lsn,
active,
})
}
pub(crate) fn seed_next_lsn_if_empty(&mut self, floor: u64) {
if self.segments.is_empty() && self.active.is_none() && floor > self.next_lsn {
self.next_lsn = floor;
}
}
#[must_use]
pub fn shard_id(&self) -> u32 {
self.shard_id
}
#[must_use]
pub fn next_lsn(&self) -> u64 {
self.next_lsn
}
#[must_use]
pub fn durable_lsn(&self) -> u64 {
self.segments.last().map_or(0, |segment| segment.last_lsn)
}
#[must_use]
pub fn segments(&self) -> &[SegmentInfo] {
&self.segments
}
pub fn append_batch(&mut self, records: &[(u64, Vec<u8>)]) -> Result<(), LogError> {
let mut i = 0;
while i < records.len() {
let lsn = records[i].0;
if lsn != self.next_lsn {
return Err(LogError::OutOfOrder {
expected: self.next_lsn,
actual: lsn,
});
}
let mut group_end = i + 1;
while group_end < records.len() && records[group_end].0 == lsn {
group_end += 1;
}
let group = &records[i..group_end];
for (_, framed) in group {
if framed.len() > MAX_RECORD_BYTES {
return Err(LogError::Oversized(framed.len()));
}
}
let group_bytes: u64 = group.iter().map(|(_, framed)| framed.len() as u64).sum();
let would_overflow = self.active.as_ref().is_some_and(|active| {
active.bytes > 0 && active.bytes + group_bytes > self.segment_bytes
});
if would_overflow {
self.seal_active()?;
}
if self.active.is_none() {
self.create_active(lsn)?;
}
for (_, framed) in group {
let active = self.active.as_mut().ok_or_else(|| LogError::Corrupt {
path: self.dir.display().to_string(),
detail: "active segment missing after creation".into(),
})?;
active.file.write_all(framed)?;
active.bytes += framed.len() as u64;
if let Some(last) = self
.segments
.last_mut()
.filter(|last| last.first_lsn == active.first_lsn)
{
last.last_lsn = lsn;
last.bytes = active.bytes;
}
}
self.next_lsn = lsn.saturating_add(1);
i = group_end;
}
Ok(())
}
pub fn sync(&mut self) -> Result<(), LogError> {
if let Some(active) = self.active.as_mut() {
active.file.sync_data()?;
}
sync_dir(&self.dir)?;
Ok(())
}
pub fn read_from(&self, from_lsn: u64) -> Result<Vec<(u64, Vec<u8>)>, LogError> {
let last_index = self.segments.len().saturating_sub(1);
let mut out = Vec::new();
for (index, segment) in self.segments.iter().enumerate() {
if segment.last_lsn < from_lsn {
continue;
}
let (records, _, _) = read_segment(&segment.path, self.shard_id, index == last_index)?;
for (lsn, framed) in records {
if lsn >= from_lsn {
out.push((lsn, framed));
}
}
}
Ok(out)
}
#[must_use]
pub fn segments_in(&self, from_lsn: u64, until_lsn: u64) -> Vec<(SegmentInfo, bool)> {
let last_index = self.segments.len().saturating_sub(1);
self.segments
.iter()
.enumerate()
.filter(|(_, segment)| segment.last_lsn >= from_lsn && segment.first_lsn < until_lsn)
.map(|(index, segment)| (segment.clone(), index == last_index))
.collect()
}
pub fn truncate_before(&mut self, lsn: u64) -> Result<usize, LogError> {
let active_first = self.active.as_ref().map(|active| active.first_lsn);
let mut removed = 0;
let mut keep = Vec::with_capacity(self.segments.len());
for segment in &self.segments {
let is_active = active_first == Some(segment.first_lsn);
if !is_active && segment.last_lsn < lsn {
std::fs::remove_file(&segment.path)?;
removed += 1;
} else {
keep.push(segment.clone());
}
}
self.segments = keep;
Ok(removed)
}
fn seal_active(&mut self) -> Result<(), LogError> {
if let Some(active) = self.active.take() {
active.file.sync_all()?;
}
sync_dir(&self.dir)?;
Ok(())
}
fn create_active(&mut self, first_lsn: u64) -> Result<(), LogError> {
let path = self.dir.join(segment_file_name(first_lsn));
let mut file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&path)?;
let mut header = Vec::with_capacity(LOG_SEGMENT_HEADER_LEN);
header.extend_from_slice(&LOG_SEGMENT_MAGIC);
header.extend_from_slice(&WAL_FORMAT_VERSION.to_le_bytes());
header.extend_from_slice(&self.shard_id.to_le_bytes());
header.extend_from_slice(&first_lsn.to_le_bytes());
file.write_all(&header)?;
self.segments.push(SegmentInfo {
first_lsn,
last_lsn: first_lsn.saturating_sub(1),
path,
bytes: LOG_SEGMENT_HEADER_LEN as u64,
});
self.active = Some(ActiveSegment {
file,
first_lsn,
bytes: LOG_SEGMENT_HEADER_LEN as u64,
});
sync_dir(&self.dir)?;
Ok(())
}
}
fn segment_file_name(first_lsn: u64) -> String {
format!("{first_lsn:020}.seg")
}
fn sync_dir(dir: &Path) -> io::Result<()> {
File::open(dir)?.sync_all()
}
enum ReadFull {
Full,
CleanEof,
Partial,
}
fn read_full<R: Read>(reader: &mut R, buf: &mut [u8]) -> io::Result<ReadFull> {
let mut filled = 0;
while filled < buf.len() {
match reader.read(&mut buf[filled..]) {
Ok(0) => {
return Ok(if filled == 0 {
ReadFull::CleanEof
} else {
ReadFull::Partial
});
}
Ok(n) => filled += n,
Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
Err(error) => return Err(error),
}
}
Ok(ReadFull::Full)
}
pub fn read_segment_records(
path: &Path,
shard_id: u32,
allow_tail: bool,
) -> Result<Vec<(u64, Vec<u8>)>, LogError> {
read_segment(path, shard_id, allow_tail).map(|(records, _, _)| records)
}
fn read_segment(
path: &Path,
shard_id: u32,
allow_tail: bool,
) -> Result<(Vec<(u64, Vec<u8>)>, u64, u64), LogError> {
let mut file = File::open(path)?;
let file_len = file.metadata()?.len();
let corrupt = |detail: String| LogError::Corrupt {
path: path.display().to_string(),
detail,
};
let mut header = [0u8; LOG_SEGMENT_HEADER_LEN];
if !matches!(read_full(&mut file, &mut header)?, ReadFull::Full) {
return Err(corrupt("segment shorter than its header".into()));
}
if header[0..8] != LOG_SEGMENT_MAGIC {
return Err(corrupt("invalid segment magic".into()));
}
let version = u32::from_le_bytes(header[8..12].try_into().expect("slice"));
if version != WAL_FORMAT_VERSION {
return Err(corrupt(format!("unsupported segment version {version}")));
}
let segment_shard = u32::from_le_bytes(header[12..16].try_into().expect("slice"));
if segment_shard != shard_id {
return Err(corrupt(format!(
"segment shard {segment_shard} does not match {shard_id}"
)));
}
let mut offset = LOG_SEGMENT_HEADER_LEN as u64;
let mut records = Vec::new();
let mut record_offsets = Vec::new();
loop {
let record_start = offset;
let mut record_header = [0u8; LOG_RECORD_HEADER_LEN];
match read_full(&mut file, &mut record_header)? {
ReadFull::Full => {}
ReadFull::CleanEof => break,
ReadFull::Partial => {
if allow_tail {
break;
}
return Err(corrupt("torn record header".into()));
}
}
let payload_len =
u32::from_le_bytes(record_header[24..28].try_into().expect("slice")) as usize;
if payload_len > MAX_RECORD_BYTES {
if allow_tail {
break;
}
return Err(corrupt("record payload exceeds 64 MiB".into()));
}
let mut raw = Vec::with_capacity(LOG_RECORD_HEADER_LEN + payload_len);
raw.extend_from_slice(&record_header);
raw.resize(LOG_RECORD_HEADER_LEN + payload_len, 0);
if !matches!(
read_full(&mut file, &mut raw[LOG_RECORD_HEADER_LEN..])?,
ReadFull::Full
) {
if allow_tail {
break;
}
return Err(corrupt("torn record payload".into()));
}
match decode_wal_record(&raw) {
Ok((record, _)) => {
records.push((record.lsn, raw));
record_offsets.push(record_start);
offset += (LOG_RECORD_HEADER_LEN + payload_len) as u64;
}
Err(error) => {
if allow_tail {
break;
}
return Err(corrupt(format!("record checksum/format: {error}")));
}
}
}
let mut assembler = crate::wal::ChainAssembler::default();
let mut valid_count = records.len();
for (i, (_, raw)) in records.iter().enumerate() {
if let Err(detail) = assembler.feed(raw, record_offsets[i]) {
if !allow_tail {
return Err(corrupt(detail));
}
valid_count = i;
break;
}
}
if valid_count == records.len() {
if let Some(open_start) = assembler.open_start_offset() {
if !allow_tail {
return Err(corrupt(
"sealed segment ends with an incomplete chained commit".into(),
));
}
valid_count = record_offsets.partition_point(|&o| o < open_start);
}
}
let valid_bytes = record_offsets.get(valid_count).copied().unwrap_or(offset);
records.truncate(valid_count);
Ok((records, valid_bytes, file_len))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::wal::{encode_chained_ops_record, encode_wal_record};
fn temp_dir() -> crate::TempPath {
crate::TempPath::dir("logstore")
}
fn chained_records(
lsn: u64,
ops: &[crate::Op],
max_record_bytes: usize,
) -> Vec<(u64, Vec<u8>)> {
encode_chained_ops_record(0, lsn, ops, None, max_record_bytes)
.into_iter()
.map(|framed| (lsn, framed))
.collect()
}
fn big_ops(n: usize, value_len: usize) -> Vec<crate::Op> {
(0..n)
.map(|i| crate::Op::Put(format!("k{i}").into_bytes(), vec![b'x'; value_len]))
.collect()
}
#[test]
fn chained_commit_advances_next_lsn_once_not_per_fragment() {
let dir = temp_dir();
let mut log = LogStore::open(&dir, 0, 1 << 30).unwrap();
let ops = big_ops(20, 200);
let fragments = chained_records(1, &ops, 1024);
assert!(fragments.len() > 1, "expected this commit to need chaining");
log.append_batch(&fragments).unwrap();
assert_eq!(
log.next_lsn(),
2,
"a multi-fragment commit must advance next_lsn exactly once"
);
log.sync().unwrap();
let all = log.read_from(1).unwrap();
assert_eq!(
all.len(),
fragments.len(),
"every physical fragment is durable"
);
log.append_batch(&[record(2, b"small")]).unwrap();
assert_eq!(log.next_lsn(), 3);
}
#[test]
fn chained_commit_never_splits_across_a_segment_boundary() {
let dir = temp_dir();
let mut log = LogStore::open(&dir, 0, 512).unwrap();
let ops = big_ops(30, 100);
let fragments = chained_records(1, &ops, 1024);
assert!(fragments.len() > 1);
log.append_batch(&fragments).unwrap();
log.sync().unwrap();
let owning_segments: std::collections::HashSet<u64> = log
.segments()
.iter()
.filter(|s| s.first_lsn <= 1 && s.last_lsn >= 1)
.map(|s| s.first_lsn)
.collect();
assert_eq!(
owning_segments.len(),
1,
"a chained commit must not be split across segment files"
);
}
#[test]
fn reopen_discards_an_incomplete_trailing_chain_and_retry_at_the_same_lsn_succeeds() {
let dir = temp_dir();
{
let mut log = LogStore::open(&dir, 0, 1 << 30).unwrap();
log.append_batch(&[record(1, b"first")]).unwrap();
log.sync().unwrap();
}
let ops = big_ops(10, 200);
let fragments = encode_chained_ops_record(0, 2, &ops, None, 512);
assert!(
fragments.len() > 2,
"need at least one continue + the final"
);
{
let mut file = OpenOptions::new()
.append(true)
.open(dir.join(segment_file_name(1)))
.unwrap();
for framed in &fragments[..fragments.len() - 1] {
file.write_all(framed).unwrap();
}
file.sync_all().unwrap();
}
let before_len = std::fs::metadata(dir.join(segment_file_name(1)))
.unwrap()
.len();
let mut log = LogStore::open(&dir, 0, 1 << 30).unwrap();
assert_eq!(
log.next_lsn(),
2,
"the incomplete chain must not advance past its own lsn"
);
assert_eq!(
log.read_from(1).unwrap().len(),
1,
"only the first, complete commit survives"
);
let after_len = std::fs::metadata(dir.join(segment_file_name(1)))
.unwrap()
.len();
assert!(
after_len < before_len,
"the incomplete chain's bytes must be truncated away"
);
log.append_batch(&[record(2, b"retry")]).unwrap();
log.sync().unwrap();
assert_eq!(log.next_lsn(), 3);
let all = log.read_from(1).unwrap();
assert_eq!(all.len(), 2);
let (_, ops, _) = crate::wal::decode_ops_record(&all[1].1).unwrap();
assert_eq!(ops, vec![crate::Op::Put(Vec::new(), b"retry".to_vec())]);
}
#[test]
fn reopen_never_truncates_a_structurally_complete_record_with_corrupt_content() {
let dir = temp_dir();
{
let mut log = LogStore::open(&dir, 0, 1 << 30).unwrap();
let framed = crate::wal::encode_ops_record(0, 1, &[], Some(b"not-json"));
log.append_batch(&[(1, framed)]).unwrap();
log.sync().unwrap();
}
let log = LogStore::open(&dir, 0, 1 << 30).unwrap();
assert_eq!(
log.next_lsn(),
2,
"a structurally complete record must not be discarded"
);
let all = log.read_from(1).unwrap();
assert_eq!(
all.len(),
1,
"the corrupt-content record must survive reopen"
);
assert!(crate::wal::decode_ops_record(&all[0].1).is_err());
}
fn record(lsn: u64, payload: &[u8]) -> (u64, Vec<u8>) {
let ops = vec![crate::Op::Put(Vec::new(), payload.to_vec())];
(lsn, crate::wal::encode_ops_record(0, lsn, &ops, None))
}
#[test]
fn append_sync_read_round_trip() {
let dir = temp_dir();
let mut log = LogStore::open(&dir, 0, 1 << 20).unwrap();
assert_eq!(log.next_lsn(), 1);
assert_eq!(log.durable_lsn(), 0);
log.append_batch(&[record(1, b"a"), record(2, b"bb"), record(3, b"ccc")])
.unwrap();
log.sync().unwrap();
assert_eq!(log.durable_lsn(), 3);
assert_eq!(log.next_lsn(), 4);
let all = log.read_from(1).unwrap();
assert_eq!(
all.iter().map(|(lsn, _)| *lsn).collect::<Vec<_>>(),
[1, 2, 3]
);
let tail = log.read_from(2).unwrap();
assert_eq!(tail.iter().map(|(lsn, _)| *lsn).collect::<Vec<_>>(), [2, 3]);
assert!(log.read_from(4).unwrap().is_empty());
}
#[test]
fn seed_next_lsn_if_empty_continues_past_a_restored_checkpoint() {
let dir = temp_dir();
let mut log = LogStore::open(&dir, 0, 1 << 20).unwrap();
assert_eq!(log.next_lsn(), 1);
log.seed_next_lsn_if_empty(51);
assert_eq!(
log.next_lsn(),
51,
"an empty log must adopt the materialization's checkpoint + 1"
);
log.append_batch(&[record(51, b"a")]).unwrap();
assert_eq!(log.next_lsn(), 52);
let fresh_dir = temp_dir();
let mut fresh = LogStore::open(&fresh_dir, 0, 1 << 20).unwrap();
fresh.seed_next_lsn_if_empty(1);
assert_eq!(fresh.next_lsn(), 1);
log.seed_next_lsn_if_empty(1_000);
assert_eq!(
log.next_lsn(),
52,
"seeding must not touch a log with real segments"
);
}
#[test]
fn rotates_segments_and_reads_across_them() {
let dir = temp_dir();
let mut log = LogStore::open(&dir, 0, 120).unwrap();
for lsn in 1..=6 {
log.append_batch(&[record(lsn, &[0xABu8; 40])]).unwrap();
}
log.sync().unwrap();
assert!(
log.segments().len() > 1,
"expected rotation, got {} segment(s)",
log.segments().len()
);
let all = log.read_from(1).unwrap();
assert_eq!(
all.iter().map(|(lsn, _)| *lsn).collect::<Vec<_>>(),
[1, 2, 3, 4, 5, 6]
);
}
#[test]
fn reopen_recovers_state() {
let dir = temp_dir();
{
let mut log = LogStore::open(&dir, 0, 1 << 20).unwrap();
log.append_batch(&[record(1, b"x"), record(2, b"y")])
.unwrap();
log.sync().unwrap();
}
let log = LogStore::open(&dir, 0, 1 << 20).unwrap();
assert_eq!(log.next_lsn(), 3);
assert_eq!(log.durable_lsn(), 2);
assert_eq!(log.read_from(1).unwrap().len(), 2);
}
#[test]
fn torn_tail_is_truncated_on_open() {
let dir = temp_dir();
{
let mut log = LogStore::open(&dir, 0, 1 << 20).unwrap();
log.append_batch(&[record(1, b"x"), record(2, b"y")])
.unwrap();
log.sync().unwrap();
}
let segment = dir.join(segment_file_name(1));
{
let mut file = OpenOptions::new().append(true).open(&segment).unwrap();
let framed = encode_wal_record(0, 3, b"a much longer payload");
file.write_all(&framed[..framed.len() - 5]).unwrap();
}
let before = std::fs::metadata(&segment).unwrap().len();
let log = LogStore::open(&dir, 0, 1 << 20).unwrap();
assert_eq!(log.next_lsn(), 3, "torn record must not advance the LSN");
assert_eq!(log.read_from(1).unwrap().len(), 2);
let after = std::fs::metadata(&segment).unwrap().len();
assert!(after < before, "torn tail must be truncated");
}
#[test]
fn sealed_torn_tail_is_corruption() {
let dir = temp_dir();
{
let mut log = LogStore::open(&dir, 0, 120).unwrap();
for lsn in 1..=4 {
log.append_batch(&[record(lsn, &[0xABu8; 40])]).unwrap();
}
log.sync().unwrap();
}
let first = dir.join(segment_file_name(1));
let mut bytes = std::fs::read(&first).unwrap();
let last = bytes.len() - 1;
bytes[last] ^= 0xFF;
std::fs::write(&first, &bytes).unwrap();
let error = LogStore::open(&dir, 0, 120).unwrap_err();
assert!(matches!(error, LogError::Corrupt { .. }), "got: {error}");
}
#[test]
fn out_of_order_append_is_rejected() {
let dir = temp_dir();
let mut log = LogStore::open(&dir, 0, 1 << 20).unwrap();
log.append_batch(&[record(1, b"a")]).unwrap();
let error = log.append_batch(&[record(3, b"c")]).unwrap_err();
assert!(
matches!(
error,
LogError::OutOfOrder {
expected: 2,
actual: 3
}
),
"got: {error}"
);
}
#[test]
fn truncate_before_removes_only_sealed_segments() {
let dir = temp_dir();
let mut log = LogStore::open(&dir, 0, 120).unwrap();
for lsn in 1..=6 {
log.append_batch(&[record(lsn, &[0xABu8; 40])]).unwrap();
}
log.sync().unwrap();
let before = log.segments().len();
assert!(before > 1);
let active_first = log.segments().last().unwrap().first_lsn;
let removed = log.truncate_before(u64::MAX).unwrap();
assert_eq!(removed, before - 1, "all sealed segments must go");
assert_eq!(log.segments().len(), 1);
assert_eq!(log.segments()[0].first_lsn, active_first);
assert!(!log.read_from(active_first).unwrap().is_empty());
}
#[test]
fn rejects_foreign_shard_segment() {
let dir = temp_dir();
{
let mut log = LogStore::open(&dir, 7, 1 << 20).unwrap();
log.append_batch(&[record(1, b"a")]).unwrap();
log.sync().unwrap();
}
let error = LogStore::open(&dir, 0, 1 << 20).unwrap_err();
assert!(matches!(error, LogError::Corrupt { .. }), "got: {error}");
}
}