use std::borrow::Cow;
use std::cmp::Ordering;
use std::fmt;
use std::fs::{self, File};
use std::io;
use std::mem;
use std::os::unix::fs::FileExt;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, RwLock};
use thiserror::Error;
use seglog::read::{ReadError, ReadHint, Reader, RecordKind};
use seglog::write::{WriteError, Writer};
use seglog::{COMMIT_MARKER_PAYLOAD, FlushedOffset, RECORD_HEAD_SIZE};
use crate::Position;
use crate::log::header::{HeaderError, SEGMENT_HEADER_SIZE, SegmentHeader};
const NAME_DIGITS: usize = 20;
const FIRST_POSITION: u64 = 1;
pub const RECORD_OVERHEAD: usize = RECORD_HEAD_SIZE;
pub const BATCH_OVERHEAD: usize = RECORD_HEAD_SIZE + COMMIT_MARKER_PAYLOAD;
#[derive(Clone, Copy, Debug)]
pub struct SegmentConfig {
pub segment_size: usize,
pub max_record_len: usize,
pub header_size: usize,
}
impl SegmentConfig {
pub fn new(segment_size: usize) -> Self {
SegmentConfig {
segment_size,
max_record_len: segment_size / 4,
header_size: SEGMENT_HEADER_SIZE,
}
}
pub fn validate(&self) -> Result<(), LogError> {
let invalid = |reason: String| Err(LogError::InvalidConfig { reason });
if self.segment_size <= self.header_size {
return invalid(format!(
"segment_size {} must exceed header_size {}",
self.segment_size, self.header_size
));
}
if self.segment_size > u32::MAX as usize {
return invalid(format!(
"segment_size {} exceeds u32::MAX; byte offsets are stored as u32",
self.segment_size
));
}
if self.max_record_len < RECORD_HEAD_SIZE {
return invalid(format!(
"max_record_len {} is smaller than a record header ({RECORD_HEAD_SIZE} bytes)",
self.max_record_len
));
}
let usable = self.segment_size - self.header_size;
let need = self.max_record_len + RECORD_HEAD_SIZE + COMMIT_MARKER_PAYLOAD;
if need > usable {
return invalid(format!(
"a max-size record plus its commit marker ({need} bytes) does not fit a \
segment's usable space ({usable} bytes)"
));
}
Ok(())
}
}
pub struct Segment {
base_position: Position,
path: PathBuf,
flushed_offset: Option<FlushedOffset>,
offsets: RwLock<Vec<u32>>,
reader: Mutex<Option<Reader<0>>>,
}
impl Segment {
pub fn base_position(&self) -> Position {
self.base_position
}
pub fn event_count(&self) -> u64 {
self.offsets.read().unwrap().len() as u64
}
pub(crate) fn data_offset(&self, local: usize) -> Option<u32> {
self.offsets.read().unwrap().get(local).copied()
}
pub(crate) fn open_reader(&self) -> Result<Reader<0>, LogError> {
Reader::<0>::open(&self.path, self.flushed_offset.clone())
.map_err(|source| LogError::read(&self.path, source))
}
pub(crate) fn read_at_local(
&self,
reader: &mut Reader<0>,
local: usize,
) -> Result<Option<Record>, LogError> {
let Some(offset) = self.data_offset(local) else {
return Ok(None);
};
let record = reader
.read_record(offset as u64, ReadHint::Random)
.map_err(|source| LogError::read(&self.path, source))?;
Ok(Some(Record {
position: Position::new(self.base_position.get() + local as u64),
data: record.data.into_owned(),
}))
}
}
pub trait SegmentSource {
fn header_size(&self) -> u64;
fn segment_count(&self) -> usize;
fn segment_at(&self, idx: usize) -> Option<&Arc<Segment>>;
fn locate(&self, pos: Position) -> Option<(usize, &Arc<Segment>)> {
if pos == Position::ZERO {
return None;
}
let active_idx = self.segment_count();
if let Some(active) = self.segment_at(active_idx)
&& pos >= active.base_position
{
return Some((active_idx, active));
}
let mut lo = 0usize;
let mut hi = active_idx; let mut found = None;
while lo < hi {
let mid = lo + (hi - lo) / 2;
let base = self.segment_at(mid)?.base_position;
if base <= pos {
found = Some(mid);
lo = mid + 1;
} else {
hi = mid;
}
}
found.and_then(|idx| self.segment_at(idx).map(|seg| (idx, seg)))
}
}
impl fmt::Debug for Segment {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Segment")
.field("base_position", &self.base_position)
.field("path", &self.path)
.field("event_count", &self.offsets.read().unwrap().len())
.finish_non_exhaustive()
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Record {
pub position: Position,
pub data: Vec<u8>,
}
#[derive(Clone, Copy, Debug)]
pub struct RecordRef<'a> {
pub position: Position,
pub data: &'a [u8],
}
impl RecordRef<'_> {
pub fn to_owned(&self) -> Record {
Record {
position: self.position,
data: self.data.to_vec(),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PositionRange {
pub first: Position,
pub last: Position,
}
impl PositionRange {
pub fn count(&self) -> u64 {
(self.last - self.first) + 1
}
}
#[derive(Debug)]
pub struct SegmentSet {
dir: PathBuf,
config: SegmentConfig,
sealed: Vec<Arc<Segment>>,
active: Arc<Segment>,
active_writer: Writer<0>,
next_position: Position,
}
impl SegmentSet {
pub fn open(dir: impl AsRef<Path>, config: SegmentConfig) -> Result<Self, LogError> {
config.validate()?;
let dir = dir.as_ref().to_path_buf();
if !dir.exists() {
fs::create_dir_all(&dir).map_err(|source| LogError::io(&dir, source))?;
if let Some(parent) = dir.parent()
&& !parent.as_os_str().is_empty()
{
sync_dir(parent).map_err(|source| LogError::io(parent, source))?;
}
}
let mut entries: Vec<(Position, PathBuf)> = Vec::new();
for entry in fs::read_dir(&dir).map_err(|source| LogError::io(&dir, source))? {
let entry = entry.map_err(|source| LogError::io(&dir, source))?;
if let Some(base) = parse_base_position(&entry.file_name().to_string_lossy()) {
entries.push((base, entry.path()));
}
}
entries.sort_by_key(|(base, _)| *base);
let mut unwritten = vec![false; entries.len()];
for (i, (name_base, path)) in entries.iter().enumerate() {
match read_header(path)? {
None => unwritten[i] = true,
Some(buf) => match SegmentHeader::from_bytes(&buf) {
Ok(header) => {
if header.base_position != *name_base {
return Err(LogError::BasePositionMismatch {
path: path.clone(),
header: header.base_position,
name: *name_base,
});
}
}
Err(HeaderError::Unwritten) => unwritten[i] = true,
Err(source) => {
return Err(LogError::Header {
path: path.clone(),
source,
});
}
},
}
}
let last_written = (0..entries.len()).rev().find(|&i| !unwritten[i]);
let mut valid: Vec<(Position, PathBuf)> = Vec::new();
for (i, (name_base, path)) in entries.iter().enumerate() {
if !unwritten[i] {
valid.push((*name_base, path.clone()));
} else if last_written.is_none_or(|lw| i > lw) {
#[cfg(feature = "tracing")]
tracing::warn!(
"deleting unwritten trailing segment {path:?} (creation did not finish)"
);
fs::remove_file(path).map_err(|source| LogError::io(path, source))?;
sync_dir(&dir).map_err(|source| LogError::io(&dir, source))?;
} else {
return Err(LogError::UnwrittenNonLast { path: path.clone() });
}
}
if valid.is_empty() {
let (writer, active) =
Self::create_segment(&dir, &config, Position::new(FIRST_POSITION))?;
#[cfg(feature = "tracing")]
tracing::trace!("initialized empty segment set at {dir:?}");
return Ok(SegmentSet {
dir,
config,
sealed: Vec::new(),
active,
active_writer: writer,
next_position: Position::new(FIRST_POSITION),
});
}
let (active_entry, sealed_entries) = valid.split_last().unwrap();
let mut sealed = Vec::with_capacity(sealed_entries.len());
let mut expected_base = Position::new(FIRST_POSITION);
for (base, path) in sealed_entries {
if *base != expected_base {
return Err(LogError::NonContiguous {
path: path.clone(),
found: *base,
expected: expected_base,
});
}
let offsets = scan_offsets(path, None, config.header_size as u64)?;
expected_base = Position::new(*base + offsets.len() as u64);
sealed.push(Arc::new(Segment {
base_position: *base,
path: path.clone(),
flushed_offset: None,
offsets: RwLock::new(offsets),
reader: Mutex::new(None),
}));
}
let (active_base, active_path) = active_entry;
if *active_base != expected_base {
return Err(LogError::NonContiguous {
path: active_path.clone(),
found: *active_base,
expected: expected_base,
});
}
let mut writer =
Writer::<0>::open(active_path, config.segment_size, config.header_size as u64)
.map_err(|source| LogError::write(active_path, source))?;
configure_writer(&mut writer, &config);
#[cfg(feature = "tracing")]
{
let committed = writer.write_offset();
if trailing_bytes_present(&writer, committed, config.segment_size) {
tracing::warn!(
"segment {active_path:?} recovered with rollback, discarding bytes from offset {committed}"
);
} else {
tracing::trace!("segment {active_path:?} opened cleanly at offset {committed}");
}
}
let flushed = writer.flushed_offset();
let offsets = scan_offsets(
active_path,
Some(flushed.clone()),
config.header_size as u64,
)?;
let count = offsets.len() as u64;
if let Some(highest) = writer.last_committed_position()
&& highest + 1 != *active_base + count
{
return Err(LogError::PositionMismatch {
path: active_path.clone(),
found: Position::new(highest),
expected: Position::new(*active_base + count - 1),
});
}
let next_position = Position::new(*active_base + count);
let active = Arc::new(Segment {
base_position: *active_base,
path: active_path.clone(),
flushed_offset: Some(flushed),
offsets: RwLock::new(offsets),
reader: Mutex::new(None),
});
Ok(SegmentSet {
dir,
config,
sealed,
active,
active_writer: writer,
next_position,
})
}
pub fn append_batch(&mut self, records: &[&[u8]]) -> Result<PositionRange, LogError> {
if records.is_empty() {
return Err(LogError::EmptyBatch);
}
for record in records {
if record.is_empty() {
return Err(LogError::EmptyRecord);
}
let record_len = RECORD_HEAD_SIZE + record.len();
if record_len > self.config.max_record_len {
return Err(LogError::RecordTooLarge {
size: record_len,
max: self.config.max_record_len,
});
}
}
let records_len: usize = records.iter().map(|r| RECORD_HEAD_SIZE + r.len()).sum();
let total_size = records_len + RECORD_HEAD_SIZE + COMMIT_MARKER_PAYLOAD;
let capacity = self.config.segment_size - self.config.header_size;
if total_size > capacity {
return Err(LogError::BatchTooLarge {
size: total_size,
capacity,
});
}
if total_size as u64 > self.active_writer.remaining_bytes() {
self.rollover()?;
}
let first = self.next_position;
let last = Position::new(first + records.len() as u64 - 1);
let rewind_to = self.active_writer.write_offset();
let path = &self.active.path;
let mut new_offsets = Vec::with_capacity(records.len());
let outcome = (|| {
for record in records {
let (offset, _len) = self
.active_writer
.append_data(record)
.map_err(|source| LogError::write(path, source))?;
new_offsets.push(
u32::try_from(offset)
.expect("segment_size <= u32::MAX enforced by SegmentConfig::validate"),
);
}
self.active_writer
.commit(last.get())
.map_err(|source| LogError::write(path, source))?;
Ok(())
})();
if let Err(err) = outcome {
self.active_writer
.rewind_to(rewind_to)
.map_err(|source| LogError::write(path, source))?;
return Err(err);
}
self.active
.offsets
.write()
.unwrap()
.extend_from_slice(&new_offsets);
self.next_position = last.next();
Ok(PositionRange { first, last })
}
fn rollover(&mut self) -> Result<(), LogError> {
self.active_writer
.sync()
.map_err(|source| LogError::write(&self.active.path, source))?;
let (writer, new_active) =
Self::create_segment(&self.dir, &self.config, self.next_position)?;
seglog::crash_point!("segment_created_before_commit");
let old_active = mem::replace(&mut self.active, new_active);
self.sealed.push(old_active);
self.active_writer = writer;
#[cfg(feature = "tracing")]
tracing::trace!(
"rolled over to segment with base_position {}",
self.next_position
);
Ok(())
}
fn create_segment(
dir: &Path,
config: &SegmentConfig,
base: Position,
) -> Result<(Writer<0>, Arc<Segment>), LogError> {
let path = dir.join(segment_file_name(base));
let mut writer = Writer::<0>::create(&path, config.segment_size, config.header_size as u64)
.map_err(|source| LogError::write(&path, source))?;
configure_writer(&mut writer, config);
let header = SegmentHeader::new(base);
writer
.file()
.write_all_at(&header.to_bytes(), 0)
.map_err(|source| LogError::io(&path, source))?;
writer
.file()
.sync_all()
.map_err(|source| LogError::io(&path, source))?;
let segment = Arc::new(Segment {
base_position: base,
path,
flushed_offset: Some(writer.flushed_offset()),
offsets: RwLock::new(Vec::new()),
reader: Mutex::new(None),
});
Ok((writer, segment))
}
pub fn read_at(&self, pos: Position) -> Result<Record, LogError> {
let segment = self
.segment_for(pos)
.ok_or(LogError::NotFound { position: pos })?;
let local = pos.offset_from(segment.base_position) as usize;
let offset = {
let offsets = segment.offsets.read().unwrap();
match offsets.get(local) {
Some(offset) => *offset as u64,
None => return Err(LogError::NotFound { position: pos }),
}
};
let mut guard = segment.reader.lock().unwrap();
if guard.is_none() {
*guard = Some(segment.open_reader()?);
}
let record = guard
.as_mut()
.unwrap()
.read_record(offset, ReadHint::Random)
.map_err(|source| LogError::read(&segment.path, source))?;
Ok(Record {
position: pos,
data: record.data.into_owned(),
})
}
pub fn scan_from(&self, pos: Position) -> Scan<&SegmentSet> {
self.scan_at(pos.max(Position::new(FIRST_POSITION)))
}
pub fn scan_after(&self, pos: Position) -> Scan<&SegmentSet> {
self.scan_at(Position::new(pos.get().saturating_add(1)))
}
fn scan_at(&self, first: Position) -> Scan<&SegmentSet> {
Scan::start(self, first, self.last_position())
}
pub fn last_position(&self) -> Position {
Position::new(self.next_position - 1)
}
pub fn next_position(&self) -> Position {
self.next_position
}
pub fn segment_capacity(&self) -> usize {
self.config.segment_size - self.config.header_size
}
pub fn max_record_len(&self) -> usize {
self.config.max_record_len
}
pub fn sealed_len(&self) -> usize {
self.sealed.len()
}
pub fn sealed_arcs(&self) -> &[Arc<Segment>] {
&self.sealed
}
pub fn active_arc(&self) -> Arc<Segment> {
Arc::clone(&self.active)
}
pub fn dir(&self) -> &Path {
&self.dir
}
pub fn active_base(&self) -> Position {
self.active.base_position
}
pub fn sealed_segments(&self) -> impl Iterator<Item = (Position, u64)> + '_ {
self.sealed
.iter()
.map(|s| (s.base_position(), s.event_count()))
}
pub fn segment_for(&self, pos: Position) -> Option<&Arc<Segment>> {
if pos >= self.next_position {
return None;
}
self.locate(pos).map(|(_, segment)| segment)
}
}
impl SegmentSource for SegmentSet {
fn header_size(&self) -> u64 {
self.config.header_size as u64
}
fn segment_count(&self) -> usize {
self.sealed.len()
}
fn segment_at(&self, idx: usize) -> Option<&Arc<Segment>> {
match idx.cmp(&self.sealed.len()) {
Ordering::Less => Some(&self.sealed[idx]),
Ordering::Equal => Some(&self.active),
Ordering::Greater => None,
}
}
}
macro_rules! forward_segment_source {
($wrapper:ty) => {
impl<T: SegmentSource + ?Sized> SegmentSource for $wrapper {
fn header_size(&self) -> u64 {
(**self).header_size()
}
fn segment_count(&self) -> usize {
(**self).segment_count()
}
fn segment_at(&self, idx: usize) -> Option<&Arc<Segment>> {
(**self).segment_at(idx)
}
}
};
}
forward_segment_source!(&T);
forward_segment_source!(Arc<T>);
pub struct Scan<S: SegmentSource> {
source: S,
seg_idx: usize,
offset: u64,
position: Position,
upto: Position,
reader: Option<Reader<0>>,
pending_err: Option<LogError>,
done: bool,
}
impl<S: SegmentSource> Scan<S> {
pub(crate) fn start(source: S, first: Position, upto: Position) -> Self {
if first == Position::ZERO || first > upto {
return Scan::empty(source);
}
let (seg_idx, offset) = match source.locate(first) {
Some((seg_idx, segment)) => {
let local = first.offset_from(segment.base_position) as usize;
match segment.data_offset(local) {
Some(offset) => (seg_idx, offset),
None => return Scan::failed(source, LogError::NotFound { position: first }),
}
}
None => return Scan::failed(source, LogError::NotFound { position: first }),
};
let reader = match source.segment_at(seg_idx).unwrap().open_reader() {
Ok(reader) => reader,
Err(err) => return Scan::failed(source, err),
};
Scan {
source,
seg_idx,
offset: offset as u64,
position: first,
upto,
reader: Some(reader),
pending_err: None,
done: false,
}
}
fn empty(source: S) -> Self {
Scan {
source,
seg_idx: 0,
offset: 0,
position: Position::ZERO,
upto: Position::ZERO,
reader: None,
pending_err: None,
done: true,
}
}
fn failed(source: S, err: LogError) -> Self {
Scan {
pending_err: Some(err),
done: false,
..Scan::empty(source)
}
}
fn advance_segment(&mut self) -> Result<bool, LogError> {
let next_idx = self.seg_idx + 1;
let Some(segment) = self.source.segment_at(next_idx) else {
return Ok(false);
};
self.reader = Some(segment.open_reader()?);
self.offset = self.source.header_size();
self.seg_idx = next_idx;
Ok(true)
}
}
impl<S: SegmentSource> Scan<S> {
#[allow(clippy::should_implement_trait)]
pub fn next(&mut self) -> Option<Result<RecordRef<'_>, LogError>> {
if let Some(err) = self.pending_err.take() {
self.done = true;
return Some(Err(err));
}
if self.done {
return None;
}
if self.position > self.upto {
self.done = true;
return None;
}
let total_len = match self.position_at_data() {
Ok(Some(total_len)) => total_len,
Ok(None) => {
self.done = true;
return None;
}
Err(err) => {
self.done = true;
return Some(Err(err));
}
};
let position = self.position;
let offset = self.offset;
self.offset = offset + total_len as u64;
self.position = position.next();
let source = &self.source;
let seg_idx = self.seg_idx;
let reader = self.reader.as_mut().unwrap();
match reader.read_record(offset, ReadHint::Sequential) {
Ok(record) => {
let data = match record.data {
Cow::Borrowed(bytes) => bytes,
Cow::Owned(_) => {
unreachable!(
"sequential reads borrow the read-ahead buffer \
(seglog::test_sequential_read_borrows_even_large_records)"
)
}
};
Some(Ok(RecordRef { position, data }))
}
Err(err) => {
self.done = true;
Some(Err(LogError::read(scan_segment_path(source, seg_idx), err)))
}
}
}
fn position_at_data(&mut self) -> Result<Option<usize>, LogError> {
loop {
if self.reader.is_none() && !self.advance_segment()? {
return Ok(None);
}
let seg_idx = self.seg_idx;
let path = scan_segment_path(&self.source, seg_idx);
let reader = self.reader.as_mut().unwrap();
let kind = reader
.peek(self.offset)
.map_err(|err| LogError::read(path, err))?;
match kind {
RecordKind::Data { total_len } => return Ok(Some(total_len)),
RecordKind::Control { total_len } => self.offset += total_len as u64,
RecordKind::End => self.reader = None, }
}
}
}
fn scan_segment_path<S: SegmentSource>(source: &S, idx: usize) -> PathBuf {
source
.segment_at(idx)
.map(|segment| segment.path.clone())
.unwrap_or_default()
}
#[derive(Debug, Error)]
pub enum LogError {
#[error("invalid segment config: {reason}")]
InvalidConfig { reason: String },
#[error("i/o error at {path:?}: {source}")]
Io {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("segment header error in {path:?}: {source}")]
Header {
path: PathBuf,
#[source]
source: HeaderError,
},
#[error(
"segment {path:?}: header base_position {header} disagrees with filename position {name}"
)]
BasePositionMismatch {
path: PathBuf,
header: Position,
name: Position,
},
#[error("unwritten segment {path:?} is not the last segment; refusing to open")]
UnwrittenNonLast { path: PathBuf },
#[error("non-contiguous segments: {path:?} has base_position {found}, expected {expected}")]
NonContiguous {
path: PathBuf,
found: Position,
expected: Position,
},
#[error(
"recovered commit position {found} disagrees with event count (expected highest {expected}) in {path:?}"
)]
PositionMismatch {
path: PathBuf,
found: Position,
expected: Position,
},
#[error("record of {size} bytes exceeds the maximum record length of {max} bytes")]
RecordTooLarge { size: usize, max: usize },
#[error("batch of {size} bytes cannot fit in a segment (capacity {capacity} bytes)")]
BatchTooLarge { size: usize, capacity: usize },
#[error("empty batch")]
EmptyBatch,
#[error("empty record")]
EmptyRecord,
#[error("position {position} not found")]
NotFound { position: Position },
#[error("write error at {path:?}: {source}")]
Write {
path: PathBuf,
#[source]
source: WriteError,
},
#[error("read error at {path:?}: {source}")]
Read {
path: PathBuf,
#[source]
source: ReadError,
},
}
impl LogError {
fn io(path: impl AsRef<Path>, source: io::Error) -> Self {
LogError::Io {
path: path.as_ref().to_path_buf(),
source,
}
}
fn write(path: impl AsRef<Path>, source: WriteError) -> Self {
LogError::Write {
path: path.as_ref().to_path_buf(),
source,
}
}
fn read(path: impl AsRef<Path>, source: ReadError) -> Self {
LogError::Read {
path: path.as_ref().to_path_buf(),
source,
}
}
}
fn segment_file_name(base: Position) -> String {
format!("{:0width$}.log", base.get(), width = NAME_DIGITS)
}
fn parse_base_position(name: &str) -> Option<Position> {
let stem = name.strip_suffix(".log")?;
if stem.len() != NAME_DIGITS || !stem.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
stem.parse::<u64>().ok().map(Position::new)
}
fn read_header(path: &Path) -> Result<Option<[u8; SEGMENT_HEADER_SIZE]>, LogError> {
let file = File::open(path).map_err(|source| LogError::io(path, source))?;
let mut buf = [0u8; SEGMENT_HEADER_SIZE];
match file.read_exact_at(&mut buf, 0) {
Ok(()) => Ok(Some(buf)),
Err(source) if source.kind() == std::io::ErrorKind::UnexpectedEof => Ok(None),
Err(source) => Err(LogError::io(path, source)),
}
}
fn scan_offsets(
path: &Path,
flushed: Option<FlushedOffset>,
header_size: u64,
) -> Result<Vec<u32>, LogError> {
let mut reader =
Reader::<0>::open(path, flushed).map_err(|source| LogError::read(path, source))?;
let mut offsets = Vec::new();
let mut iter = reader.iter(header_size);
while let Some(record) = iter
.next_record()
.map_err(|source| LogError::read(path, source))?
{
offsets.push(
u32::try_from(record.offset)
.expect("segment_size <= u32::MAX enforced by SegmentConfig::validate"),
);
}
Ok(offsets)
}
fn configure_writer(writer: &mut Writer<0>, config: &SegmentConfig) {
writer.set_max_record(config.max_record_len);
}
#[cfg(feature = "tracing")]
fn trailing_bytes_present(writer: &Writer<0>, offset: u64, segment_size: usize) -> bool {
if offset + RECORD_HEAD_SIZE as u64 > segment_size as u64 {
return false;
}
let mut head = [0u8; RECORD_HEAD_SIZE];
writer.file().read_exact_at(&mut head, offset).is_ok() && head.iter().any(|&b| b != 0)
}
fn sync_dir(dir: &Path) -> io::Result<()> {
File::open(dir)?.sync_all()
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
const HEADER: usize = SEGMENT_HEADER_SIZE;
const REC_OVERHEAD: usize = RECORD_HEAD_SIZE;
const MARKER: usize = RECORD_HEAD_SIZE + COMMIT_MARKER_PAYLOAD;
fn open(dir: &Path, segment_size: usize) -> SegmentSet {
SegmentSet::open(dir, SegmentConfig::new(segment_size)).unwrap()
}
fn append_one(set: &mut SegmentSet, data: &[u8]) -> Position {
let range = set.append_batch(&[data]).unwrap();
assert_eq!(range.first, range.last);
range.first
}
fn drain<S: SegmentSource>(mut scan: Scan<S>) -> Vec<Record> {
let mut out = Vec::new();
while let Some(item) = scan.next() {
out.push(item.unwrap().to_owned());
}
out
}
#[test]
fn open_empty_creates_first_segment() {
let dir = TempDir::new().unwrap();
let set = open(dir.path(), 4096);
assert_eq!(set.next_position(), Position::new(1));
assert_eq!(set.last_position(), Position::new(0));
assert_eq!(set.sealed_len(), 0);
assert!(dir.path().join("00000000000000000001.log").exists());
}
#[test]
fn tiny_config_rejected() {
let dir = TempDir::new().unwrap();
let err = SegmentSet::open(dir.path(), SegmentConfig::new(64)).unwrap_err();
assert!(matches!(err, LogError::InvalidConfig { .. }), "got {err:?}");
}
#[test]
fn reopen_after_clean_shutdown_preserves_state() {
let dir = TempDir::new().unwrap();
{
let mut set = open(dir.path(), 4096);
for i in 1..=5u64 {
append_one(&mut set, format!("event-{i}").as_bytes());
}
assert_eq!(set.next_position(), Position::new(6));
}
let set = open(dir.path(), 4096);
assert_eq!(set.next_position(), Position::new(6));
assert_eq!(set.last_position(), Position::new(5));
for i in 1..=5u64 {
let record = set.read_at(Position::new(i)).unwrap();
assert_eq!(record.position, Position::new(i));
assert_eq!(record.data, format!("event-{i}").into_bytes());
}
}
#[test]
fn open_deletes_short_trailing_segment_and_recovers() {
let dir = TempDir::new().unwrap();
{
let mut set = open(dir.path(), 4096);
for i in 1..=5u64 {
append_one(&mut set, format!("event-{i}").as_bytes());
}
}
let stray1 = dir.path().join(segment_file_name(Position::new(6)));
let stray2 = dir.path().join(segment_file_name(Position::new(7)));
File::create(&stray1).unwrap();
File::create(&stray2).unwrap();
assert_eq!(fs::metadata(&stray1).unwrap().len(), 0);
let set = open(dir.path(), 4096);
assert!(
!stray1.exists(),
"trailing 0-byte segment 6 should be deleted on open"
);
assert!(
!stray2.exists(),
"trailing 0-byte segment 7 should be deleted on open"
);
assert_eq!(set.next_position(), Position::new(6));
assert_eq!(set.last_position(), Position::new(5));
for i in 1..=5u64 {
assert_eq!(
set.read_at(Position::new(i)).unwrap().data,
format!("event-{i}").into_bytes()
);
}
}
#[test]
fn rollover_keeps_positions_contiguous() {
let dir = TempDir::new().unwrap();
let mut set = open(dir.path(), 256);
let n = 20u64;
for i in 1..=n {
let pos = append_one(&mut set, format!("evt{i:03}").as_bytes());
assert_eq!(pos, Position::new(i));
}
assert_eq!(set.next_position(), Position::new(n + 1));
assert!(set.sealed_len() >= 1, "expected at least one rollover");
for i in 1..=n {
let record = set.read_at(Position::new(i)).unwrap();
assert_eq!(record.data, format!("evt{i:03}").into_bytes());
}
}
#[test]
fn read_at_across_boundary_for_every_position() {
let dir = TempDir::new().unwrap();
let mut set = open(dir.path(), 200);
let n = 30u64;
for i in 1..=n {
append_one(&mut set, format!("r{i:04}").as_bytes());
}
assert!(set.sealed_len() >= 2);
for i in 1..=n {
assert_eq!(
set.read_at(Position::new(i)).unwrap().data,
format!("r{i:04}").into_bytes()
);
}
assert!(matches!(
set.read_at(Position::new(0)),
Err(LogError::NotFound { .. })
));
assert!(matches!(
set.read_at(Position::new(n + 1)),
Err(LogError::NotFound { .. })
));
}
#[test]
fn scan_from_mid_segment_yields_expected_order() {
let dir = TempDir::new().unwrap();
let mut set = open(dir.path(), 4096);
let n = 12u64;
for i in 1..=n {
append_one(&mut set, format!("s{i}").as_bytes());
}
let start = 5u64;
let got = drain(set.scan_from(Position::new(start)));
assert_eq!(got.len() as u64, n - start + 1);
for (idx, record) in got.iter().enumerate() {
let pos = start + idx as u64;
assert_eq!(record.position, Position::new(pos));
assert_eq!(record.data, format!("s{pos}").into_bytes());
}
}
#[test]
fn scan_across_segments_is_contiguous_and_ordered() {
let dir = TempDir::new().unwrap();
let mut set = open(dir.path(), 200);
let n = 25u64;
for i in 1..=n {
append_one(&mut set, format!("x{i:04}").as_bytes());
}
assert!(set.sealed_len() >= 2);
let got = drain(set.scan_from(Position::new(1)));
assert_eq!(got.len() as u64, n);
for (idx, record) in got.iter().enumerate() {
let pos = idx as u64 + 1;
assert_eq!(record.position, Position::new(pos));
assert_eq!(record.data, format!("x{pos:04}").into_bytes());
}
}
#[test]
fn scan_from_zero_clamps_to_whole_log() {
let dir = TempDir::new().unwrap();
let mut set = open(dir.path(), 4096);
for i in 1..=3u64 {
append_one(&mut set, format!("e{i}").as_bytes());
}
let positions: Vec<Position> = drain(set.scan_from(Position::new(0)))
.iter()
.map(|r| r.position)
.collect();
assert_eq!(
positions,
vec![Position::new(1), Position::new(2), Position::new(3)]
);
assert!(set.scan_from(Position::new(5)).next().is_none());
}
#[test]
fn scan_after_is_exclusive() {
let dir = TempDir::new().unwrap();
let mut set = open(dir.path(), 4096);
for i in 1..=3u64 {
append_one(&mut set, format!("e{i}").as_bytes());
}
let all: Vec<Position> = drain(set.scan_after(Position::new(0)))
.iter()
.map(|r| r.position)
.collect();
assert_eq!(
all,
vec![Position::new(1), Position::new(2), Position::new(3)]
);
let resumed: Vec<Position> = drain(set.scan_after(Position::new(1)))
.iter()
.map(|r| r.position)
.collect();
assert_eq!(resumed, vec![Position::new(2), Position::new(3)]);
assert!(set.scan_after(set.last_position()).next().is_none());
let from2: Vec<Position> = drain(set.scan_from(Position::new(2)))
.iter()
.map(|r| r.position)
.collect();
assert_eq!(from2, resumed);
}
#[test]
fn oversized_record_rejected() {
let dir = TempDir::new().unwrap();
let mut config = SegmentConfig::new(4096);
config.max_record_len = 100;
let mut set = SegmentSet::open(dir.path(), config).unwrap();
let big = vec![0u8; 200];
let err = set.append_batch(&[&big]).unwrap_err();
assert!(
matches!(err, LogError::RecordTooLarge { .. }),
"got {err:?}"
);
}
#[test]
fn batch_larger_than_segment_rejected() {
let dir = TempDir::new().unwrap();
let mut set = open(dir.path(), 200);
let records: Vec<Vec<u8>> = (0..5).map(|_| vec![0u8; 30]).collect();
let refs: Vec<&[u8]> = records.iter().map(|r| r.as_slice()).collect();
let err = set.append_batch(&refs).unwrap_err();
assert!(matches!(err, LogError::BatchTooLarge { .. }), "got {err:?}");
}
#[test]
fn empty_batch_and_empty_record_rejected() {
let dir = TempDir::new().unwrap();
let mut set = open(dir.path(), 4096);
assert!(matches!(set.append_batch(&[]), Err(LogError::EmptyBatch)));
assert!(matches!(
set.append_batch(&[b""]),
Err(LogError::EmptyRecord)
));
assert_eq!(set.next_position(), Position::new(1));
}
#[test]
fn crash_after_create_before_header_write_is_deleted() {
let dir = TempDir::new().unwrap();
let segment_size = 4096;
{
let mut set = open(dir.path(), segment_size);
for i in 1..=3u64 {
append_one(&mut set, format!("e{i}").as_bytes());
}
assert_eq!(set.next_position(), Position::new(4));
}
let ghost = dir.path().join(segment_file_name(Position::new(4)));
fs::write(&ghost, vec![0u8; segment_size]).unwrap();
let set = open(dir.path(), segment_size);
assert!(
!ghost.exists(),
"zero-filled trailing segment should be deleted"
);
assert_eq!(set.next_position(), Position::new(4));
for i in 1..=3u64 {
assert_eq!(
set.read_at(Position::new(i)).unwrap().data,
format!("e{i}").into_bytes()
);
}
}
#[test]
fn missing_middle_segment_fails_open() {
let dir = TempDir::new().unwrap();
let segment_size = 200;
{
let mut set = open(dir.path(), segment_size);
for i in 1..=15u64 {
append_one(&mut set, format!("m{i:03}").as_bytes());
}
assert!(set.sealed_len() >= 3, "need several sealed segments");
}
let mut files: Vec<PathBuf> = fs::read_dir(dir.path())
.unwrap()
.map(|e| e.unwrap().path())
.filter(|p| p.extension().is_some_and(|e| e == "log"))
.collect();
files.sort();
assert!(files.len() >= 3);
fs::remove_file(&files[1]).unwrap();
let err = SegmentSet::open(dir.path(), SegmentConfig::new(segment_size)).unwrap_err();
assert!(matches!(err, LogError::NonContiguous { .. }), "got {err:?}");
}
#[test]
fn header_base_position_disagreeing_with_filename_fails() {
let dir = TempDir::new().unwrap();
let segment_size = 4096;
{
let mut set = open(dir.path(), segment_size);
append_one(&mut set, b"only");
}
let path = dir.path().join(segment_file_name(Position::new(1)));
let bogus = SegmentHeader::new(Position::new(7));
let file = File::options().write(true).open(&path).unwrap();
file.write_all_at(&bogus.to_bytes(), 0).unwrap();
file.sync_all().unwrap();
drop(file);
let err = SegmentSet::open(dir.path(), SegmentConfig::new(segment_size)).unwrap_err();
assert!(
matches!(err, LogError::BasePositionMismatch { .. }),
"got {err:?}"
);
}
#[test]
fn multi_record_batch_shares_positions() {
let dir = TempDir::new().unwrap();
let mut set = open(dir.path(), 4096);
let range = set.append_batch(&[b"a", b"bb", b"ccc"]).unwrap();
assert_eq!(range.first, Position::new(1));
assert_eq!(range.last, Position::new(3));
assert_eq!(range.count(), 3);
assert_eq!(set.next_position(), Position::new(4));
assert_eq!(set.read_at(Position::new(1)).unwrap().data, b"a");
assert_eq!(set.read_at(Position::new(2)).unwrap().data, b"bb");
assert_eq!(set.read_at(Position::new(3)).unwrap().data, b"ccc");
}
#[test]
fn append_continues_after_recovery() {
let dir = TempDir::new().unwrap();
let segment_size = 4096;
{
let mut set = open(dir.path(), segment_size);
append_one(&mut set, b"before"); }
let mut set = open(dir.path(), segment_size);
let pos = append_one(&mut set, b"after"); assert_eq!(pos, Position::new(2));
assert_eq!(set.read_at(Position::new(1)).unwrap().data, b"before");
assert_eq!(set.read_at(Position::new(2)).unwrap().data, b"after");
}
fn payload_for(position: u64, record_len: usize) -> Vec<u8> {
vec![((position - 1) as u8).wrapping_add(1); record_len]
}
fn build_single_segment(
dir: &Path,
segment_size: usize,
batches: usize,
record_len: usize,
) -> (Vec<u8>, Vec<usize>) {
let mut set = open(dir, segment_size);
for p in 1..=batches as u64 {
append_one(&mut set, &payload_for(p, record_len));
}
assert_eq!(set.sealed_len(), 0, "test assumes a single segment");
drop(set);
let path = dir.join(segment_file_name(Position::new(FIRST_POSITION)));
let bytes = fs::read(&path).unwrap();
let batch_size = REC_OVERHEAD + record_len + MARKER;
let commit_ends: Vec<usize> = (0..batches)
.map(|i| HEADER + (i + 1) * batch_size)
.collect();
(bytes, commit_ends)
}
#[test]
fn truncation_mid_batch_rolls_back_to_previous_commit() {
let segment_size = 4096;
let batches = 8;
let record_len = 10;
let source = TempDir::new().unwrap();
let (good_bytes, commit_ends) =
build_single_segment(source.path(), segment_size, batches, record_len);
let total_end = *commit_ends.last().unwrap();
for cutoff in HEADER..=total_end {
let dir = TempDir::new().unwrap();
let mut corrupt = good_bytes.clone();
for byte in corrupt.iter_mut().skip(cutoff) {
*byte = 0xFF;
}
let path = dir
.path()
.join(segment_file_name(Position::new(FIRST_POSITION)));
fs::write(&path, &corrupt).unwrap();
let set = open(dir.path(), segment_size);
let survived = commit_ends.iter().filter(|&&end| end <= cutoff).count() as u64;
assert_eq!(
set.next_position(),
Position::new(survived + 1),
"cutoff {cutoff}: expected {survived} surviving events"
);
for p in 1..=survived {
let record = set.read_at(Position::new(p)).unwrap();
assert_eq!(
record.data,
payload_for(p, record_len),
"cutoff {cutoff}, position {p}"
);
}
}
}
#[test]
fn corrupt_record_with_intact_marker_rejects_whole_batch() {
let dir = TempDir::new().unwrap();
let segment_size = 4096;
let rec_len = 6;
{
let mut set = open(dir.path(), segment_size);
append_one(&mut set, b"aaaa"); let recs: Vec<Vec<u8>> = (0..5).map(|i| vec![b'B' + i as u8; rec_len]).collect();
let refs: Vec<&[u8]> = recs.iter().map(|r| r.as_slice()).collect();
set.append_batch(&refs).unwrap(); assert_eq!(set.next_position(), Position::new(7));
}
let batch_a = REC_OVERHEAD + 4 + MARKER;
let rec_stride = REC_OVERHEAD + rec_len;
let rec2_data = HEADER + batch_a + rec_stride + REC_OVERHEAD;
let path = dir
.path()
.join(segment_file_name(Position::new(FIRST_POSITION)));
let file = File::options().read(true).write(true).open(&path).unwrap();
let mut byte = [0u8; 1];
file.read_exact_at(&mut byte, rec2_data as u64).unwrap();
byte[0] ^= 0xFF;
file.write_all_at(&byte, rec2_data as u64).unwrap();
file.sync_all().unwrap();
drop(file);
let set = open(dir.path(), segment_size);
assert_eq!(
set.next_position(),
Position::new(2),
"whole batch B must roll back"
);
assert_eq!(set.read_at(Position::new(1)).unwrap().data, b"aaaa");
assert!(matches!(
set.read_at(Position::new(2)),
Err(LogError::NotFound { .. })
));
}
#[test]
fn physical_truncation_mid_batch_rolls_back() {
let dir = TempDir::new().unwrap();
let segment_size = 4096;
let record_len = 10;
let batches = 6;
{
let mut set = open(dir.path(), segment_size);
for p in 1..=batches as u64 {
append_one(&mut set, &payload_for(p, record_len));
}
}
let batch_size = REC_OVERHEAD + record_len + MARKER;
let survive = 3usize;
let cut = HEADER + survive * batch_size + 5;
let path = dir
.path()
.join(segment_file_name(Position::new(FIRST_POSITION)));
let file = File::options().write(true).open(&path).unwrap();
file.set_len(cut as u64).unwrap();
file.sync_all().unwrap();
drop(file);
let set = open(dir.path(), segment_size);
assert_eq!(set.next_position(), Position::new(survive as u64 + 1));
for p in 1..=survive as u64 {
assert_eq!(
set.read_at(Position::new(p)).unwrap().data,
payload_for(p, record_len)
);
}
}
}