mod index;
mod lock;
mod manifest;
pub(crate) mod reader;
mod record;
mod segment;
pub use manifest::Manifest;
pub use reader::{
partition_of, LogReader, RecordReader, ReplayEnd, ReplayPlan, StreamSelector, VerificationMode,
};
pub use segment::{Segment, SegmentMeta};
use std::cell::RefCell;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::format::{generate_id_bytes, DatabaseId, OwnedStoredRecord, RecordEnvelopeV2};
use crate::{Result, SalamanderError};
use lock::DirLock;
use reader::{FrameFilter, PlannedSegment, ResolvedFilter, SegmentSource};
const DEFAULT_SEGMENT_MAX_BYTES: u64 = 64 * 1024 * 1024;
pub(crate) const MIGRATION_IN_PROGRESS: &str = "migration.in-progress.json";
pub struct Log {
dir: PathBuf,
closed: Vec<SegmentMeta>,
active: Segment,
manifest: Manifest,
segment_max_bytes: u64,
sidecars: RefCell<HashMap<u64, Arc<index::Sidecar>>>,
_lock: DirLock,
}
pub struct StoredLogIter<'log> {
reader: LogReader<'log>,
failed: bool,
}
impl Iterator for StoredLogIter<'_> {
type Item = Result<OwnedStoredRecord>;
fn next(&mut self) -> Option<Self::Item> {
if self.failed {
return None;
}
match self.reader.next_owned() {
Ok(Some(record)) => Some(Ok(record)),
Ok(None) => None,
Err(error) => {
self.failed = true;
Some(Err(error))
}
}
}
}
impl Log {
pub fn open(dir: &Path) -> Result<Self> {
Self::open_internal(dir, DEFAULT_SEGMENT_MAX_BYTES, false)
}
#[allow(dead_code)]
pub(crate) fn open_with_segment_max_bytes(dir: &Path, segment_max_bytes: u64) -> Result<Self> {
Self::open_internal(dir, segment_max_bytes, false)
}
pub(crate) fn open_for_migration(dir: &Path) -> Result<Self> {
Self::open_internal(dir, DEFAULT_SEGMENT_MAX_BYTES, true)
}
fn open_internal(dir: &Path, segment_max_bytes: u64, allow_migration: bool) -> Result<Self> {
std::fs::create_dir_all(dir)?;
if !allow_migration && dir.join(MIGRATION_IN_PROGRESS).exists() {
return Err(SalamanderError::MigrationIncomplete(
dir.display().to_string(),
));
}
let dir_lock = DirLock::acquire(dir)?;
let segs_dir = segments_dir(dir);
std::fs::create_dir_all(&segs_dir)?;
let manifest = match Manifest::read(dir) {
Ok(m) => m,
Err(SalamanderError::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => {
bootstrap_manifest(dir, &segs_dir)?
}
Err(other) => return Err(other),
};
if manifest.storage_format_version != manifest::STORAGE_FORMAT_VERSION {
return Err(SalamanderError::UnsupportedStorageFormat {
found: manifest.storage_format_version,
supported: manifest::STORAGE_FORMAT_VERSION,
});
}
if manifest.payload_format_version != manifest::PAYLOAD_FORMAT_VERSION {
return Err(SalamanderError::UnsupportedFormat {
found: manifest.payload_format_version,
supported: manifest::PAYLOAD_FORMAT_VERSION,
});
}
let discovered = discover_segments(&segs_dir)?;
let mut closed = Vec::new();
let mut active = None;
for (base, path) in discovered {
match base.cmp(&manifest.active_segment_base) {
std::cmp::Ordering::Equal => {
active = Some(Segment::open(&path)?); }
std::cmp::Ordering::Less => {
closed.push(SegmentMeta {
base_offset: base,
path,
});
}
std::cmp::Ordering::Greater => {
let len = std::fs::metadata(&path)?.len();
if len != 0 {
return Err(SalamanderError::Manifest(format!(
"orphaned segment {path:?} beyond the active one is non-empty ({len} bytes) — refusing to delete"
)));
}
std::fs::remove_file(&path)?;
}
}
}
let active = active.ok_or_else(|| {
SalamanderError::Manifest(format!(
"manifest points at missing active segment base {}",
manifest.active_segment_base
))
})?;
let mut manifest = manifest;
manifest.next_offset = active.next_offset();
Ok(Log {
dir: dir.to_path_buf(),
closed,
active,
manifest,
segment_max_bytes,
sidecars: RefCell::new(HashMap::new()),
_lock: dir_lock,
})
}
#[allow(dead_code)]
pub fn append(&mut self, payload: &[u8]) -> Result<u64> {
if self.active.len_bytes() >= self.segment_max_bytes {
self.roll()?;
}
self.active.append(payload)
}
pub fn append_enveloped(&mut self, envelope: &RecordEnvelopeV2, payload: &[u8]) -> Result<u64> {
if self.active.len_bytes() >= self.segment_max_bytes {
self.roll()?;
}
self.active.append_enveloped(envelope, payload)
}
pub fn append_batch(&mut self, events: &[(RecordEnvelopeV2, Vec<u8>)]) -> Result<(u64, u64)> {
if self.active.len_bytes() >= self.segment_max_bytes {
self.roll()?;
}
self.active.append_batch(events)
}
pub fn append_system(&mut self, envelope: &RecordEnvelopeV2, payload: &[u8]) -> Result<()> {
if self.active.len_bytes() >= self.segment_max_bytes {
self.roll()?;
}
self.active.append_system(envelope, payload)
}
pub fn commit(&mut self) -> Result<u64> {
self.active.sync()?;
Ok(self.head())
}
pub fn head(&self) -> u64 {
self.active.next_offset()
}
pub fn database_id(&self) -> DatabaseId {
DatabaseId::from_bytes(self.manifest.database_id)
}
pub fn records_from(&self, offset: u64) -> StoredLogIter<'_> {
let filter = ResolvedFilter::raw(offset, self.head(), FrameFilter::UserEvents);
StoredLogIter {
reader: self.plan_reader(filter),
failed: false,
}
}
pub fn system_records(&self) -> StoredLogIter<'_> {
let filter = ResolvedFilter::raw(0, u64::MAX, FrameFilter::SystemOnly);
StoredLogIter {
reader: self.plan_reader(filter),
failed: false,
}
}
pub(crate) fn plan_reader(&self, filter: ResolvedFilter) -> LogReader<'_> {
let active_base = self.active.base_offset();
let head = self.head();
let mut segments = Vec::new();
let closed_end = |i: usize| -> u64 {
self.closed
.get(i + 1)
.map_or(active_base, |m| m.base_offset)
};
match filter.kinds {
FrameFilter::SystemOnly => {
for (i, meta) in self.closed.iter().enumerate() {
segments.push(PlannedSegment {
base: meta.base_offset,
end: closed_end(i),
source: SegmentSource::Closed(meta.path.clone()),
});
}
segments.push(PlannedSegment {
base: active_base,
end: head,
source: SegmentSource::Active,
});
}
FrameFilter::UserEvents => {
let bases: Vec<u64> = self
.closed
.iter()
.map(|m| m.base_offset)
.chain([active_base])
.collect();
for i in reader::intersecting_range(&bases, head, filter.from, filter.until) {
if i < self.closed.len() {
segments.push(PlannedSegment {
base: self.closed[i].base_offset,
end: closed_end(i),
source: SegmentSource::Closed(self.closed[i].path.clone()),
});
} else {
segments.push(PlannedSegment {
base: active_base,
end: head,
source: SegmentSource::Active,
});
}
}
}
}
LogReader::new(self, filter, segments)
}
pub(crate) fn active_handle(&self) -> Result<std::fs::File> {
self.active.clone_handle()
}
pub(crate) fn sidecar_for(
&self,
path: &Path,
base: u64,
end: u64,
) -> Option<Arc<index::Sidecar>> {
if let Some(sidecar) = self.sidecars.borrow().get(&base) {
return Some(sidecar.clone());
}
let sidecar = index::load(path, base, end).or_else(|| {
let built = reader::build_sidecar(path, base).ok()?;
if built.end != end {
return None;
}
if let Err(error) = index::persist(path, &built) {
eprintln!(
"salamander: could not persist sidecar for {}: {error}",
path.display()
);
}
Some(built)
})?;
let sidecar = Arc::new(sidecar);
self.sidecars.borrow_mut().insert(base, sidecar.clone());
Some(sidecar)
}
fn roll(&mut self) -> Result<()> {
let new_base = self.active.next_offset();
let segs_dir = segments_dir(&self.dir);
self.active.sync()?;
let new_active = Segment::create(&segs_dir, new_base)?;
sync_dir(&segs_dir)?;
let old_active = std::mem::replace(&mut self.active, new_active);
let retired_base = old_active.base_offset();
let retired_path = segment::segment_path(&segs_dir, retired_base);
self.closed.push(SegmentMeta {
base_offset: retired_base,
path: retired_path.clone(),
});
self.manifest.active_segment_base = new_base;
self.manifest.next_offset = new_base;
self.manifest.write(&self.dir)?;
match reader::build_sidecar(&retired_path, retired_base) {
Ok(sidecar) => {
if let Err(error) = index::persist(&retired_path, &sidecar) {
eprintln!(
"salamander: could not persist sidecar for {}: {error}",
retired_path.display()
);
}
self.sidecars
.borrow_mut()
.insert(retired_base, Arc::new(sidecar));
}
Err(error) => eprintln!(
"salamander: could not index retired segment {}: {error}",
retired_path.display()
),
}
Ok(())
}
}
fn bootstrap_manifest(dir: &Path, segs_dir: &Path) -> Result<Manifest> {
let discovered = discover_segments(segs_dir)?;
if discovered.len() > 1 {
return Err(SalamanderError::Manifest(
"multiple segments on disk but no manifest — unexpected state".into(),
));
}
let active_base = match discovered.first() {
Some((base, _)) => *base,
None => {
Segment::create(segs_dir, 0)?;
sync_dir(segs_dir)?;
0
}
};
let manifest = Manifest {
format_version: manifest::MANIFEST_FORMAT_VERSION,
storage_format_version: manifest::STORAGE_FORMAT_VERSION,
database_id: generate_id_bytes(),
payload_format_version: manifest::PAYLOAD_FORMAT_VERSION,
active_segment_base: active_base,
next_offset: active_base,
};
manifest.write(dir)?;
Ok(manifest)
}
fn discover_segments(segs_dir: &Path) -> Result<Vec<(u64, PathBuf)>> {
let mut found = Vec::new();
for entry in std::fs::read_dir(segs_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("seg") {
continue;
}
match segment::parse_base_offset(&path) {
Ok(base) => found.push((base, path)),
Err(_) => {
eprintln!(
"salamander: ignoring non-segment file in log dir: {}",
path.display()
);
}
}
}
found.sort_by_key(|(base, _)| *base);
Ok(found)
}
fn segments_dir(dir: &Path) -> PathBuf {
dir.join("log")
}
#[cfg(unix)]
fn sync_dir(dir: &Path) -> Result<()> {
std::fs::File::open(dir)?.sync_all()?;
Ok(())
}
#[cfg(not(unix))]
fn sync_dir(_dir: &Path) -> Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn payloads_from(log: &Log, offset: u64) -> Vec<(u64, Vec<u8>)> {
log.records_from(offset)
.map(|item| item.map(|record| (record.position, record.payload)))
.collect::<Result<_>>()
.unwrap()
}
#[test]
fn fresh_open_creates_segment_zero_and_manifest() {
let dir = tempdir().unwrap();
let log = Log::open(dir.path()).unwrap();
assert_eq!(log.head(), 0);
assert!(dir.path().join("manifest.json").exists());
assert!(dir
.path()
.join("log")
.join("00000000000000000000.seg")
.exists());
}
#[test]
fn append_commit_reopen_round_trip() {
let dir = tempdir().unwrap();
{
let mut log = Log::open(dir.path()).unwrap();
log.append(b"a").unwrap();
log.append(b"bb").unwrap();
log.append(b"ccc").unwrap();
log.commit().unwrap();
}
let log = Log::open(dir.path()).unwrap();
assert_eq!(log.head(), 3);
let records = payloads_from(&log, 0);
assert_eq!(
records,
vec![
(0, b"a".to_vec()),
(1, b"bb".to_vec()),
(2, b"ccc".to_vec())
]
);
}
#[test]
fn records_from_skips_earlier_offsets() {
let dir = tempdir().unwrap();
let mut log = Log::open(dir.path()).unwrap();
for i in 0..5u8 {
log.append(&[i]).unwrap();
}
let records = payloads_from(&log, 3);
assert_eq!(records, vec![(3, vec![3]), (4, vec![4])]);
}
#[test]
fn segment_rolls_when_max_bytes_exceeded() {
let dir = tempdir().unwrap();
let mut log = Log::open_with_segment_max_bytes(dir.path(), 64).unwrap();
for i in 0..20u32 {
log.append(format!("record-{i:03}").as_bytes()).unwrap();
}
log.commit().unwrap();
assert!(!log.closed.is_empty(), "expected at least one segment roll");
let records = payloads_from(&log, 0);
assert_eq!(records.len(), 20);
for (i, (offset, payload)) in records.iter().enumerate() {
assert_eq!(*offset, i as u64);
assert_eq!(payload, &format!("record-{i:03}").into_bytes());
}
}
#[test]
fn reopen_after_roll_recovers_closed_and_active_segments() {
let dir = tempdir().unwrap();
{
let mut log = Log::open_with_segment_max_bytes(dir.path(), 64).unwrap();
for i in 0..20u32 {
log.append(format!("record-{i:03}").as_bytes()).unwrap();
}
log.commit().unwrap();
}
let log = Log::open(dir.path()).unwrap();
assert!(!log.closed.is_empty());
assert_eq!(log.head(), 20);
let records = payloads_from(&log, 0);
assert_eq!(records.len(), 20);
}
#[test]
fn reopen_after_roll_without_commit_keeps_pre_roll_records() {
let dir = tempdir().unwrap();
{
let mut log = Log::open_with_segment_max_bytes(dir.path(), 64).unwrap();
for i in 0..20u32 {
log.append(format!("record-{i:03}").as_bytes()).unwrap();
}
assert!(!log.closed.is_empty(), "expected at least one segment roll");
}
let log = Log::open(dir.path()).unwrap();
let records = payloads_from(&log, 0);
assert_eq!(records.len(), 20);
for (i, (offset, payload)) in records.iter().enumerate() {
assert_eq!(*offset, i as u64);
assert_eq!(payload, &format!("record-{i:03}").into_bytes());
}
}
#[test]
fn crashed_bootstrap_adopts_orphaned_segment_zero_on_reopen() {
let dir = tempdir().unwrap();
let segs_dir = dir.path().join("log");
std::fs::create_dir_all(&segs_dir).unwrap();
Segment::create(&segs_dir, 0).unwrap();
assert!(!dir.path().join("manifest.json").exists());
let log = Log::open(dir.path()).unwrap();
assert_eq!(log.head(), 0);
assert!(dir.path().join("manifest.json").exists());
}
#[test]
fn crashed_roll_deletes_orphaned_empty_segment_on_reopen() {
let dir = tempdir().unwrap();
{
let mut log = Log::open(dir.path()).unwrap();
log.append(b"first").unwrap();
log.commit().unwrap();
}
let segs_dir = dir.path().join("log");
Segment::create(&segs_dir, 1).unwrap();
let orphan_path = segs_dir.join("00000000000000000001.seg");
assert!(orphan_path.exists());
let log = Log::open(dir.path()).unwrap();
assert_eq!(log.head(), 1); assert!(
!orphan_path.exists(),
"orphaned empty segment should be deleted"
);
let records = payloads_from(&log, 0);
assert_eq!(records, vec![(0, b"first".to_vec())]);
}
#[test]
fn second_open_of_locked_dir_fails() {
let dir = tempdir().unwrap();
let _held = Log::open(dir.path()).unwrap();
assert!(matches!(
Log::open(dir.path()),
Err(SalamanderError::Locked(_))
));
}
#[test]
fn open_succeeds_after_previous_log_is_dropped() {
let dir = tempdir().unwrap();
{
let log = Log::open(dir.path()).unwrap();
assert!(dir.path().join("LOCK").exists());
drop(log);
}
assert!(!dir.path().join("LOCK").exists());
let _reopened = Log::open(dir.path()).unwrap();
}
#[test]
fn fresh_manifest_declares_current_versions() {
let dir = tempdir().unwrap();
let log = Log::open(dir.path()).unwrap();
assert_eq!(
log.manifest.format_version,
manifest::MANIFEST_FORMAT_VERSION
);
assert_eq!(
log.manifest.payload_format_version,
manifest::PAYLOAD_FORMAT_VERSION
);
}
fn edit_manifest_json(
dir: &Path,
edit: impl FnOnce(&mut serde_json::Map<String, serde_json::Value>),
) {
let path = dir.join("manifest.json");
let raw = std::fs::read(&path).unwrap();
let mut val: serde_json::Value = serde_json::from_slice(&raw).unwrap();
edit(val.as_object_mut().unwrap());
std::fs::write(&path, serde_json::to_vec_pretty(&val).unwrap()).unwrap();
}
#[test]
fn pre_wp2_manifest_without_payload_version_is_adopted_as_v1() {
let dir = tempdir().unwrap();
{
let mut log = Log::open(dir.path()).unwrap();
log.append(b"a").unwrap();
log.commit().unwrap();
}
edit_manifest_json(dir.path(), |m| {
m.insert("format_version".into(), serde_json::json!(1));
m.remove("payload_format_version");
});
let log = Log::open(dir.path()).unwrap();
assert_eq!(log.head(), 1);
assert_eq!(log.manifest.payload_format_version, 1);
let records = payloads_from(&log, 0);
assert_eq!(records, vec![(0, b"a".to_vec())]);
}
#[test]
fn manifest_with_unknown_payload_version_is_rejected() {
let dir = tempdir().unwrap();
{
let mut log = Log::open(dir.path()).unwrap();
log.append(b"a").unwrap();
log.commit().unwrap();
}
edit_manifest_json(dir.path(), |m| {
m.insert("payload_format_version".into(), serde_json::json!(99));
});
assert!(matches!(
Log::open(dir.path()),
Err(SalamanderError::UnsupportedFormat {
found: 99,
supported: 1
})
));
}
mod reader_behavior {
use super::*;
use crate::format::{FormatLimits, FrameKind, StreamId};
use crate::log::reader::{
build_sidecar, FrameFilter, RecordReader, ResolvedFilter, StreamSelector,
VerificationMode,
};
fn env_for(stream_byte: u8, ts: i64) -> RecordEnvelopeV2 {
let mut envelope = record::raw_test_envelope();
envelope.stream_id = StreamId::from_bytes([stream_byte; 16]);
envelope.timestamp_unix_nanos = ts;
envelope
}
fn record_size() -> u64 {
let mut buf = Vec::new();
record::encode_kind(FrameKind::Event, 0, &env_for(0, 0), &[0], &mut buf).unwrap();
buf.len() as u64
}
fn build_log(dir: &Path, per_segment: usize, streams: &[u8]) -> Log {
let seg_max = per_segment as u64 * record_size();
let mut log = Log::open_with_segment_max_bytes(dir, seg_max).unwrap();
for (i, stream) in streams.iter().enumerate() {
log.append_enveloped(&env_for(*stream, 10 * i as i64), &[*stream])
.unwrap();
}
log.commit().unwrap();
log
}
fn positions(log: &Log, filter: ResolvedFilter) -> Result<Vec<u64>> {
let mut reader = log.plan_reader(filter);
let mut out = Vec::new();
while let Some(record) = reader.next()? {
out.push(record.position);
}
Ok(out)
}
fn user_filter(from: u64, until: u64) -> ResolvedFilter {
ResolvedFilter::raw(from, until, FrameFilter::UserEvents)
}
#[test]
fn seek_at_every_boundary_matches_the_position_window() {
let dir = tempdir().unwrap();
let streams: Vec<u8> = (0..20).map(|i| i % 3).collect();
let log = build_log(dir.path(), 5, &streams);
assert!(log.closed.len() >= 2, "need multiple segments");
for from in 0..=log.head() + 3 {
for until in 0..=log.head() + 3 {
let got = positions(&log, user_filter(from, until.min(log.head()))).unwrap();
let want: Vec<u64> = (from..until.min(log.head())).collect();
assert_eq!(got, want, "window {from}..{until}");
}
}
}
#[test]
fn stream_and_time_and_max_filters_select_exactly() {
let dir = tempdir().unwrap();
let streams: Vec<u8> = (0..18).map(|i| i % 3).collect();
let log = build_log(dir.path(), 6, &streams);
let mut filter = user_filter(0, log.head());
filter.selector = StreamSelector::Streams(vec![StreamId::from_bytes([1; 16])]);
let want: Vec<u64> = (0..18).filter(|p| p % 3 == 1).collect();
assert_eq!(positions(&log, filter).unwrap(), want);
let mut filter = user_filter(0, log.head());
filter.time = Some(30..90); assert_eq!(positions(&log, filter).unwrap(), vec![3, 4, 5, 6, 7, 8]);
let mut filter = user_filter(4, log.head());
filter.max_events = Some(3);
let mut reader = log.plan_reader(filter);
let mut got = Vec::new();
while let Some(record) = reader.next().unwrap() {
got.push(record.position);
}
assert_eq!(got, vec![4, 5, 6]);
assert_eq!(reader.continuation(), 7);
}
#[test]
fn continuation_pages_have_no_gaps_or_duplicates() {
let dir = tempdir().unwrap();
let streams: Vec<u8> = (0..23).map(|_| 0).collect();
let log = build_log(dir.path(), 4, &streams);
let mut collected = Vec::new();
let mut from = 0;
loop {
let mut filter = user_filter(from, log.head());
filter.max_events = Some(4);
let mut reader = log.plan_reader(filter);
let mut page = Vec::new();
while let Some(record) = reader.next().unwrap() {
page.push(record.position);
}
if page.is_empty() {
break;
}
collected.extend(page);
from = reader.continuation();
}
assert_eq!(collected, (0..23).collect::<Vec<u64>>());
}
#[test]
fn selective_read_skips_a_corrupt_unselected_segment_without_payload_io() {
let dir = tempdir().unwrap();
let log = build_log(dir.path(), 1, &[7, 1, 1]);
assert_eq!(log.closed.len(), 2);
let seg0 = &log.closed[0].path;
let mut bytes = std::fs::read(seg0).unwrap();
let last = bytes.len() - 1;
bytes[last] ^= 0xFF;
std::fs::write(seg0, &bytes).unwrap();
let mut filter = user_filter(0, log.head());
filter.selector = StreamSelector::Streams(vec![StreamId::from_bytes([1; 16])]);
assert_eq!(positions(&log, filter).unwrap(), vec![1, 2]);
let error = positions(&log, user_filter(0, log.head())).unwrap_err();
assert!(matches!(error, SalamanderError::Corrupt { offset: 0, .. }));
}
#[test]
fn missing_or_corrupt_sidecars_change_io_only_never_answers() {
let dir = tempdir().unwrap();
let streams: Vec<u8> = (0..12).map(|i| i % 2).collect();
let log = build_log(dir.path(), 3, &streams);
let mut filter = user_filter(2, log.head());
filter.selector = StreamSelector::Streams(vec![StreamId::from_bytes([0; 16])]);
let want = positions(&log, filter.clone()).unwrap();
drop(log);
let segs: Vec<_> = std::fs::read_dir(dir.path().join("log"))
.unwrap()
.map(|e| e.unwrap().path())
.filter(|p| p.extension().is_some_and(|e| e == "sidx"))
.collect();
assert!(segs.len() >= 2, "expected sidecars from rolls");
let mut bytes = std::fs::read(&segs[0]).unwrap();
bytes[10] ^= 0xFF;
std::fs::write(&segs[0], &bytes).unwrap();
std::fs::remove_file(&segs[1]).unwrap();
let log = Log::open(dir.path()).unwrap();
assert_eq!(positions(&log, filter).unwrap(), want);
}
#[test]
fn reader_buffer_stays_bounded_across_many_records() {
let dir = tempdir().unwrap();
let streams: Vec<u8> = (0..600u32).map(|i| (i % 4) as u8).collect();
let log = build_log(dir.path(), 100, &streams);
let mut reader = log.plan_reader(user_filter(0, log.head()));
let mut count = 0u64;
while reader.next().unwrap().is_some() {
count += 1;
}
assert_eq!(count, 600);
assert!(
reader.max_buffer_bytes() <= 3 * 128 * 1024,
"buffer grew to {} bytes",
reader.max_buffer_bytes()
);
}
#[test]
fn batch_digest_mode_detects_a_swapped_event_frame() {
let dir = tempdir().unwrap();
let mut log = Log::open(dir.path()).unwrap();
let batch: Vec<(RecordEnvelopeV2, Vec<u8>)> = (0..2)
.map(|i| {
let mut envelope = env_for(1, 0);
envelope.batch_id = crate::format::BatchId::from_bytes([9; 16]);
envelope.batch_index = i;
(envelope, vec![i as u8; 8])
})
.collect();
log.append_batch(&batch).unwrap();
log.commit().unwrap();
let seg_path = segment::segment_path(&segments_dir(dir.path()), 0);
let bytes = std::fs::read(&seg_path).unwrap();
let limits = FormatLimits::default();
let mut out = Vec::new();
let mut at = 0;
while let Some((frame, consumed)) = crate::format::decode(&bytes[at..], limits).unwrap()
{
if frame.kind == FrameKind::Event && frame.envelope.batch_index == 1 {
record::encode_kind(
FrameKind::Event,
frame.position,
&frame.envelope,
b"tampered",
&mut out,
)
.unwrap();
} else {
out.extend_from_slice(&bytes[at..at + consumed]);
}
at += consumed;
}
std::fs::write(&seg_path, &out).unwrap();
let got = positions(&log, user_filter(0, log.head())).unwrap();
assert_eq!(got, vec![0, 1]);
let mut filter = user_filter(0, log.head());
filter.verification = VerificationMode::BatchDigests;
let error = positions(&log, filter).unwrap_err();
assert!(matches!(error, SalamanderError::Corrupt { .. }));
}
#[test]
fn system_reader_skips_segments_without_system_frames() {
let dir = tempdir().unwrap();
let mut log = Log::open_with_segment_max_bytes(dir.path(), 200).unwrap();
let mut system_env = env_for(0, 0);
system_env.event_type =
crate::format::EventType::new("salamander.branch.created").unwrap();
log.append_system(&system_env, b"{}").unwrap();
for i in 0..6u8 {
log.append_enveloped(&env_for(i % 2, 0), &[i]).unwrap();
}
log.append_system(&system_env, b"{}").unwrap();
log.commit().unwrap();
let system: Vec<_> = log.system_records().collect::<Result<_>>().unwrap();
assert_eq!(system.len(), 2);
assert!(system.iter().all(|record| record.kind == FrameKind::System));
let with_system: usize = log
.closed
.iter()
.enumerate()
.filter_map(|(i, meta)| {
let end = log
.closed
.get(i + 1)
.map_or(log.active.base_offset(), |m| m.base_offset);
log.sidecar_for(&meta.path, meta.base_offset, end)
})
.filter(|sidecar| sidecar.system_frames > 0)
.count();
assert!(with_system >= 1);
}
#[test]
fn built_sidecar_matches_segment_contents() {
let dir = tempdir().unwrap();
let streams: Vec<u8> = (0..10).map(|i| i % 2).collect();
let log = build_log(dir.path(), 5, &streams);
let meta = &log.closed[0];
let expected_end = log
.closed
.get(1)
.map_or(log.active.base_offset(), |m| m.base_offset);
let sidecar = build_sidecar(&meta.path, meta.base_offset).unwrap();
assert_eq!(sidecar.base, 0);
assert_eq!(sidecar.end, expected_end);
assert_eq!(
sidecar.postings.len(),
if expected_end >= 2 { 2 } else { 1 }
);
let total: u64 = sidecar.postings.iter().map(|p| p.count).sum();
assert_eq!(total, expected_end);
assert_eq!(sidecar.seek_points.first(), Some(&(0, 0)));
}
mod proptests {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn reader_matches_independent_model(
streams in prop::collection::vec(0u8..4, 1..50),
seg_max in 64u64..2048,
from in 0u64..60,
until in 0u64..60,
select in 0u8..4,
) {
let dir = tempdir().unwrap();
let mut log = Log::open_with_segment_max_bytes(dir.path(), seg_max).unwrap();
let mut model: Vec<(u64, u8)> = Vec::new();
for (i, stream) in streams.iter().enumerate() {
let position = log
.append_enveloped(&env_for(*stream, i as i64), &[*stream])
.unwrap();
model.push((position, *stream));
}
log.commit().unwrap();
let until = until.min(log.head());
let selector = match select {
0 => StreamSelector::All,
1 => StreamSelector::Streams(vec![StreamId::from_bytes([1; 16])]),
2 => StreamSelector::PartitionClass { count: 3, index: 0 },
_ => StreamSelector::PartitionClass { count: 3, index: 2 },
};
let mut filter = user_filter(from, until);
filter.selector = selector.clone();
let got = positions(&log, filter).unwrap();
let want: Vec<u64> = model
.iter()
.filter(|(p, s)| {
*p >= from
&& *p < until
&& selector.matches(StreamId::from_bytes([*s; 16]))
})
.map(|(p, _)| *p)
.collect();
prop_assert_eq!(got, want);
}
}
}
}
#[test]
fn stray_non_segment_file_is_ignored_not_fatal() {
let dir = tempdir().unwrap();
{
let mut log = Log::open(dir.path()).unwrap();
log.append(b"real").unwrap();
log.commit().unwrap();
}
let segs_dir = dir.path().join("log");
std::fs::write(segs_dir.join("backup.seg"), b"not a real segment").unwrap();
let log = Log::open(dir.path()).unwrap();
assert_eq!(log.head(), 1);
let records = payloads_from(&log, 0);
assert_eq!(records, vec![(0, b"real".to_vec())]);
}
}