use std::collections::{BTreeMap, HashMap};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use tracing::{debug, warn};
use crate::segment::stats::ColumnStats;
use chronix_core::{
MeasurementSchema, SegmentId, SegmentState, ShardId, Timestamp, Tombstone, TombstoneSet,
};
use crate::index::error::{IndexError, Result};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CatalogColumnStats {
pub name: String,
pub data_type: u8,
pub role: u8,
pub stats: ColumnStats,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SegmentCatalogEntry {
pub segment_id: SegmentId,
pub shard_id: ShardId,
pub measurement: String,
pub path: PathBuf,
pub min_timestamp: Timestamp,
pub max_timestamp: Timestamp,
pub row_count: u64,
pub series_count: u32,
pub byte_size: u64,
pub row_group_count: u32,
pub column_count: u16,
#[serde(default)]
pub column_stats: Vec<CatalogColumnStats>,
#[serde(default)]
pub state: SegmentState,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
enum ManifestEntry {
AddSegment(SegmentCatalogEntry),
RemoveSegment(u64),
SetSchema(MeasurementSchema),
RemoveSchema(String),
AddTombstone(Tombstone),
RemoveTombstone(Tombstone),
SetWalFloor(u64),
Compacted {
inputs: Vec<u64>,
output: u64,
},
SetRollup {
name: String,
definition: Vec<u8>,
},
RemoveRollup(String),
SetRollupState {
name: String,
state: Vec<u8>,
},
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RollupRecord {
#[serde(default)]
pub definition: Vec<u8>,
#[serde(default)]
pub state: Vec<u8>,
}
#[derive(Debug)]
pub struct SegmentCatalog {
segments: BTreeMap<ShardId, Vec<SegmentCatalogEntry>>,
schemas: HashMap<String, MeasurementSchema>,
tombstones: TombstoneSet,
wal_floor: u64,
rollups: BTreeMap<String, RollupRecord>,
manifest_seq: u64,
manifest_dir: PathBuf,
changes_since_snapshot: u64,
next_segment_id: u64,
manifest_wal_file: Option<std::fs::File>,
pending_sync: u64,
}
const SNAPSHOT_INTERVAL: u64 = 1000;
const SYNC_BATCH_SIZE: u64 = 1;
const _: () = {
const fn _assert_send<T: Send + Sync>() {}
_assert_send::<SegmentCatalog>();
};
impl SegmentCatalog {
pub fn new(manifest_dir: impl Into<PathBuf>) -> Result<Self> {
let manifest_dir = manifest_dir.into();
std::fs::create_dir_all(&manifest_dir)?;
Ok(Self {
segments: BTreeMap::new(),
schemas: HashMap::new(),
tombstones: TombstoneSet::new(),
wal_floor: 0,
rollups: BTreeMap::new(),
manifest_seq: 0,
manifest_dir,
changes_since_snapshot: 0,
next_segment_id: 1,
manifest_wal_file: None,
pending_sync: 0,
})
}
pub fn open(manifest_dir: impl Into<PathBuf>) -> Result<Self> {
let manifest_dir = manifest_dir.into();
std::fs::create_dir_all(&manifest_dir)?;
let bin_snapshot_path = manifest_dir.join("manifest.snapshot.bin");
let mut catalog = if bin_snapshot_path.exists() {
let data = std::fs::read(&bin_snapshot_path)?;
let snapshot: CatalogSnapshot = postcard::from_bytes(&data)
.map_err(|e| IndexError::BinarySerialization(e.to_string()))?;
if snapshot.format_version > CATALOG_FORMAT_VERSION {
return Err(IndexError::BinarySerialization(format!(
"catalog snapshot format version {} is newer than supported version {CATALOG_FORMAT_VERSION}",
snapshot.format_version
)));
}
debug!(
seq = snapshot.manifest_seq,
segments = snapshot.segments.len(),
format_version = snapshot.format_version,
"loaded catalog snapshot (postcard)"
);
Self {
segments: snapshot.segments,
schemas: snapshot.schemas,
tombstones: snapshot.tombstones,
wal_floor: snapshot.wal_floor,
rollups: snapshot.rollups,
manifest_seq: snapshot.manifest_seq,
manifest_dir: manifest_dir.clone(),
changes_since_snapshot: 0,
next_segment_id: snapshot.next_segment_id,
manifest_wal_file: None,
pending_sync: 0,
}
} else {
Self {
segments: BTreeMap::new(),
schemas: HashMap::new(),
tombstones: TombstoneSet::new(),
wal_floor: 0,
rollups: BTreeMap::new(),
manifest_seq: 0,
manifest_dir: manifest_dir.clone(),
changes_since_snapshot: 0,
next_segment_id: 1,
manifest_wal_file: None,
pending_sync: 0,
}
};
let wal_path = manifest_dir.join("manifest.wal");
if wal_path.exists() {
catalog.replay_manifest(&wal_path)?;
}
Ok(catalog)
}
pub fn add_segment(&mut self, entry: SegmentCatalogEntry) -> Result<()> {
let manifest_entry = ManifestEntry::AddSegment(entry.clone());
self.append_manifest(&manifest_entry)?;
self.segments.entry(entry.shard_id).or_default().push(entry);
self.maybe_snapshot()?;
Ok(())
}
pub fn remove_segment(&mut self, segment_id: SegmentId) -> Result<Option<SegmentCatalogEntry>> {
let manifest_entry = ManifestEntry::RemoveSegment(segment_id.0);
self.append_manifest(&manifest_entry)?;
for entries in self.segments.values_mut() {
if let Some(pos) = entries.iter().position(|e| e.segment_id == segment_id) {
let removed = entries.remove(pos);
self.maybe_snapshot()?;
return Ok(Some(removed));
}
}
self.maybe_snapshot()?;
Ok(None)
}
#[must_use]
pub fn get_segments_for_shard(&self, shard_id: &ShardId) -> &[SegmentCatalogEntry] {
self.segments.get(shard_id).map_or(&[], Vec::as_slice)
}
pub fn soft_delete_segment(&mut self, segment_id: SegmentId, now_ms: u64) -> Result<bool> {
let location = self.segments.iter().find_map(|(measurement, entries)| {
entries
.iter()
.position(|e| e.segment_id == segment_id)
.map(|idx| (*measurement, idx))
});
let Some((measurement, idx)) = location else {
return Ok(false);
};
let entry = &self.segments[&measurement][idx];
let mut updated_entry = entry.clone();
updated_entry.state = SegmentState::SoftDeleted {
deleted_at_ms: now_ms,
};
let manifest_entry = ManifestEntry::AddSegment(updated_entry);
self.append_manifest(&manifest_entry)?;
if let Some(entries) = self.segments.get_mut(&measurement) {
entries[idx].state = SegmentState::SoftDeleted {
deleted_at_ms: now_ms,
};
}
Ok(true)
}
#[must_use]
pub fn active_segments_for_measurement(&self, measurement: &str) -> Vec<&SegmentCatalogEntry> {
self.segments
.values()
.flat_map(|v| v.iter())
.filter(|e| e.measurement == measurement && e.state == SegmentState::Active)
.collect()
}
#[must_use]
pub fn expired_soft_deleted(
&self,
now_ms: u64,
grace_period_ms: u64,
) -> Vec<&SegmentCatalogEntry> {
self.segments
.values()
.flat_map(|v| v.iter())
.filter(|e| {
if let SegmentState::SoftDeleted { deleted_at_ms } = e.state {
now_ms.saturating_sub(deleted_at_ms) >= grace_period_ms
} else {
false
}
})
.collect()
}
#[must_use]
pub fn all_segments(&self) -> Vec<&SegmentCatalogEntry> {
self.segments.values().flat_map(|v| v.iter()).collect()
}
#[must_use]
pub fn segments_for_measurement(&self, measurement: &str) -> Vec<&SegmentCatalogEntry> {
self.segments
.values()
.flat_map(|v| v.iter())
.filter(|e| e.measurement == measurement)
.collect()
}
#[must_use]
pub fn max_segments_per_shard_measurement(&self) -> usize {
let mut counts: std::collections::HashMap<(i64, &str), usize> =
std::collections::HashMap::new();
for e in self.all_segments() {
if e.state == chronix_core::SegmentState::Active {
*counts
.entry((e.shard_id.0, e.measurement.as_str()))
.or_default() += 1;
}
}
counts.into_values().max().unwrap_or(0)
}
#[must_use]
pub fn memory_bytes(&self) -> usize {
let entries: usize = self
.segments
.values()
.map(|v| {
v.capacity() * std::mem::size_of::<SegmentCatalogEntry>()
+ v.iter()
.map(|e| {
e.measurement.capacity()
+ e.path.as_os_str().len()
+ e.column_stats.capacity()
* std::mem::size_of::<CatalogColumnStats>()
+ e.column_stats
.iter()
.map(|c| c.name.capacity())
.sum::<usize>()
})
.sum::<usize>()
})
.sum();
let schemas: usize = self
.schemas
.iter()
.map(|(name, ms)| {
name.capacity()
+ ms.columns()
.iter()
.map(|c| c.name.capacity() + std::mem::size_of_val(c))
.sum::<usize>()
})
.sum();
entries + schemas + self.tombstones.len() * std::mem::size_of::<u64>() * 4
}
#[must_use]
pub fn segment_count(&self) -> usize {
self.segments.values().map(Vec::len).sum()
}
pub fn next_segment_id(&mut self) -> SegmentId {
let id = SegmentId(self.next_segment_id);
self.next_segment_id += 1;
id
}
#[must_use]
pub fn manifest_seq(&self) -> u64 {
self.manifest_seq
}
#[must_use]
pub fn rollups(&self) -> &BTreeMap<String, RollupRecord> {
&self.rollups
}
pub fn set_rollup(&mut self, name: &str, definition: Vec<u8>) -> Result<()> {
self.append_manifest(&ManifestEntry::SetRollup {
name: name.to_string(),
definition: definition.clone(),
})?;
self.rollups.entry(name.to_string()).or_default().definition = definition;
self.sync_manifest()?;
self.maybe_snapshot()?;
Ok(())
}
pub fn set_rollup_state(&mut self, name: &str, state: Vec<u8>) -> Result<()> {
self.append_manifest(&ManifestEntry::SetRollupState {
name: name.to_string(),
state: state.clone(),
})?;
self.rollups.entry(name.to_string()).or_default().state = state;
self.sync_manifest()?;
self.maybe_snapshot()?;
Ok(())
}
pub fn remove_rollup(&mut self, name: &str) -> Result<bool> {
if !self.rollups.contains_key(name) {
return Ok(false);
}
self.append_manifest(&ManifestEntry::RemoveRollup(name.to_string()))?;
self.rollups.remove(name);
self.sync_manifest()?;
self.maybe_snapshot()?;
Ok(true)
}
#[must_use]
pub fn tombstones(&self) -> &TombstoneSet {
&self.tombstones
}
pub fn record_tombstones(&mut self, tombstones: &[Tombstone]) -> Result<()> {
for tombstone in tombstones {
self.append_manifest(&ManifestEntry::AddTombstone(tombstone.clone()))?;
self.tombstones.insert(tombstone.clone());
}
self.sync_manifest()?;
self.maybe_snapshot()?;
Ok(())
}
pub fn complete_compaction(
&mut self,
output: SegmentCatalogEntry,
inputs: &[SegmentId],
now_ms: u64,
) -> Result<()> {
let output_id = output.segment_id.0;
self.add_segment(output)?;
for input in inputs {
self.soft_delete_segment(*input, now_ms)?;
}
let inputs: Vec<u64> = inputs.iter().map(|id| id.0).collect();
self.append_manifest(&ManifestEntry::Compacted {
inputs: inputs.clone(),
output: output_id,
})?;
self.tombstones
.extend_to_compaction_output(&inputs, output_id);
self.sync_manifest()?;
self.maybe_snapshot()?;
Ok(())
}
pub fn reclaim_tombstones(&mut self) -> Result<usize> {
let live: std::collections::HashSet<u64> = self
.segments
.values()
.flatten()
.map(|e| e.segment_id.0)
.collect();
let removed = self.tombstones.retain_tombstones(|t| {
t.segments.is_empty() || t.segments.iter().any(|id| live.contains(id))
});
for tombstone in &removed {
self.append_manifest(&ManifestEntry::RemoveTombstone(tombstone.clone()))?;
}
if !removed.is_empty() {
self.sync_manifest()?;
self.maybe_snapshot()?;
}
Ok(removed.len())
}
pub fn set_schema(&mut self, schema: MeasurementSchema) -> Result<()> {
let entry = ManifestEntry::SetSchema(schema.clone());
self.append_manifest(&entry)?;
self.schemas
.insert(schema.measurement().to_string(), schema);
self.maybe_snapshot()?;
Ok(())
}
pub fn remove_schema(&mut self, measurement: &str) -> Result<Option<MeasurementSchema>> {
let entry = ManifestEntry::RemoveSchema(measurement.to_string());
self.append_manifest(&entry)?;
let removed = self.schemas.remove(measurement);
self.maybe_snapshot()?;
Ok(removed)
}
#[must_use]
pub fn wal_floor(&self) -> u64 {
self.wal_floor
}
pub fn set_wal_floor(&mut self, sequence_no: u64) -> Result<()> {
if sequence_no <= self.wal_floor {
return Ok(());
}
self.append_manifest(&ManifestEntry::SetWalFloor(sequence_no))?;
self.wal_floor = sequence_no;
self.maybe_snapshot()?;
Ok(())
}
#[must_use]
pub fn get_schema(&self, measurement: &str) -> Option<&MeasurementSchema> {
self.schemas.get(measurement)
}
#[must_use]
pub fn schema_names(&self) -> Vec<String> {
self.schemas.keys().cloned().collect()
}
pub fn all_schemas(&self) -> impl Iterator<Item = (&String, &MeasurementSchema)> {
self.schemas.iter()
}
pub fn force_snapshot(&mut self) -> Result<()> {
self.write_snapshot()?;
self.changes_since_snapshot = 0;
Ok(())
}
fn append_manifest(&mut self, entry: &ManifestEntry) -> Result<()> {
self.manifest_seq += 1;
if self.manifest_wal_file.is_none() {
let wal_path = self.manifest_dir.join("manifest.wal");
let f = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&wal_path)?;
self.manifest_wal_file = Some(f);
}
let file = self
.manifest_wal_file
.as_mut()
.ok_or_else(|| IndexError::BinarySerialization("WAL file not opened".into()))?;
let payload = postcard::to_stdvec(entry)
.map_err(|e| IndexError::BinarySerialization(e.to_string()))?;
let len = payload.len() as u32;
let crc = crc32c::crc32c(&payload);
file.write_all(&len.to_le_bytes())?;
file.write_all(&payload)?;
file.write_all(&crc.to_le_bytes())?;
file.flush()?;
self.pending_sync += 1;
if self.pending_sync >= SYNC_BATCH_SIZE {
file.sync_data()?;
self.pending_sync = 0;
}
self.changes_since_snapshot += 1;
Ok(())
}
pub fn sync_manifest(&mut self) -> Result<()> {
if self.pending_sync > 0 {
if let Some(ref f) = self.manifest_wal_file {
f.sync_data()?;
}
self.pending_sync = 0;
}
Ok(())
}
fn replay_manifest(&mut self, wal_path: &Path) -> Result<()> {
let mut file = std::fs::File::open(wal_path)?;
let file_len = file.metadata()?.len();
let mut replayed = 0u64;
let mut tail_skipped = 0u64;
let mut pos = 0u64;
loop {
if pos >= file_len {
break;
}
let mut len_buf = [0u8; 4];
match file.read_exact(&mut len_buf) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
tail_skipped += 1;
warn!(
offset = pos,
"manifest replay: truncated length header at tail"
);
break;
}
Err(e) => return Err(e.into()),
}
let payload_len = u32::from_le_bytes(len_buf) as usize;
pos += 4;
if payload_len == 0 || payload_len > 64 * 1024 * 1024 {
if pos + (payload_len as u64) + 4 > file_len {
tail_skipped += 1;
warn!(
offset = pos - 4,
payload_len, "manifest replay: truncated entry at tail"
);
break;
}
return Err(IndexError::Manifest {
detail: format!(
"invalid manifest entry length {payload_len} at offset {}",
pos - 4
),
});
}
let total = payload_len + 4; if pos + total as u64 > file_len {
tail_skipped += 1;
warn!(
offset = pos - 4,
"manifest replay: truncated payload at tail"
);
break;
}
let mut buf = vec![0u8; total];
if let Err(e) = file.read_exact(&mut buf) {
if e.kind() == std::io::ErrorKind::UnexpectedEof {
tail_skipped += 1;
warn!(offset = pos - 4, "manifest replay: truncated read at tail");
break;
}
return Err(e.into());
}
let payload = &buf[..payload_len];
let stored_crc = u32::from_le_bytes([
buf[payload_len],
buf[payload_len + 1],
buf[payload_len + 2],
buf[payload_len + 3],
]);
let computed_crc = crc32c::crc32c(payload);
if stored_crc != computed_crc {
let remaining = file_len - pos - total as u64;
if remaining > 8 {
return Err(IndexError::Manifest {
detail: format!(
"CRC mismatch at offset {} (stored={stored_crc:#x}, computed={computed_crc:#x}) — mid-stream corruption",
pos - 4,
),
});
}
tail_skipped += 1;
warn!(
offset = pos - 4,
"manifest replay: CRC mismatch at tail, skipping"
);
break;
}
match postcard::from_bytes::<ManifestEntry>(payload) {
Ok(entry) => {
self.apply_manifest_entry(entry);
self.manifest_seq += 1;
replayed += 1;
}
Err(e) => {
let remaining = file_len - pos - total as u64;
if remaining > 8 {
return Err(IndexError::Manifest {
detail: format!("deserialization error at offset {}: {e}", pos - 4,),
});
}
tail_skipped += 1;
warn!(offset = pos - 4, error = %e, "manifest replay: skipping corrupt tail entry");
break;
}
}
pos += total as u64;
}
if tail_skipped > 0 {
warn!(
tail_skipped,
"manifest replay: truncated tail entries skipped"
);
}
if replayed > 0 {
debug!(replayed, "replayed manifest entries");
}
Ok(())
}
fn apply_manifest_entry(&mut self, entry: ManifestEntry) {
match entry {
ManifestEntry::AddSegment(seg) => {
if seg.segment_id.0 >= self.next_segment_id {
self.next_segment_id = seg.segment_id.0 + 1;
}
let shard_entries = self.segments.entry(seg.shard_id).or_default();
shard_entries.retain(|e| e.segment_id != seg.segment_id);
shard_entries.push(seg);
}
ManifestEntry::RemoveSegment(id) => {
for entries in self.segments.values_mut() {
entries.retain(|e| e.segment_id.0 != id);
}
}
ManifestEntry::SetSchema(schema) => {
self.schemas
.insert(schema.measurement().to_string(), schema);
}
ManifestEntry::RemoveSchema(name) => {
self.schemas.remove(&name);
}
ManifestEntry::AddTombstone(t) => {
self.tombstones.insert(t);
}
ManifestEntry::RemoveTombstone(t) => {
self.tombstones.retain_tombstones(|existing| existing != &t);
}
ManifestEntry::SetWalFloor(seq) => {
self.wal_floor = self.wal_floor.max(seq);
}
ManifestEntry::SetRollup { name, definition } => {
self.rollups.entry(name).or_default().definition = definition;
}
ManifestEntry::RemoveRollup(name) => {
self.rollups.remove(&name);
}
ManifestEntry::SetRollupState { name, state } => {
self.rollups.entry(name).or_default().state = state;
}
ManifestEntry::Compacted { inputs, output } => {
self.tombstones.extend_to_compaction_output(&inputs, output);
}
}
}
fn write_snapshot(&mut self) -> Result<()> {
let snapshot = CatalogSnapshot {
format_version: CATALOG_FORMAT_VERSION,
segments: self.segments.clone(),
schemas: self.schemas.clone(),
tombstones: self.tombstones.clone(),
wal_floor: self.wal_floor,
rollups: self.rollups.clone(),
manifest_seq: self.manifest_seq,
next_segment_id: self.next_segment_id,
};
let data = postcard::to_stdvec(&snapshot)
.map_err(|e| IndexError::BinarySerialization(e.to_string()))?;
let snapshot_path = self.manifest_dir.join("manifest.snapshot.bin");
let tmp_path = self.manifest_dir.join("manifest.snapshot.bin.tmp");
let write_tmp = (|| -> std::io::Result<()> {
let mut file = std::fs::File::create(&tmp_path)?;
file.write_all(&data)?;
file.sync_all()
})();
if let Err(e) = write_tmp {
let _ = std::fs::remove_file(&tmp_path);
return Err(e.into());
}
if let Err(e) = std::fs::rename(&tmp_path, &snapshot_path) {
let _ = std::fs::remove_file(&tmp_path);
return Err(e.into());
}
let dir = std::fs::File::open(&self.manifest_dir).map_err(|e| {
std::io::Error::new(
e.kind(),
format!("failed to open manifest dir for fsync: {e}"),
)
})?;
dir.sync_all()?;
let wal_path = self.manifest_dir.join("manifest.wal");
if wal_path.exists() {
let file = std::fs::OpenOptions::new()
.write(true)
.truncate(true)
.open(&wal_path)?;
file.sync_all()?;
}
self.manifest_wal_file = None;
debug!(seq = self.manifest_seq, "catalog snapshot written");
Ok(())
}
fn maybe_snapshot(&mut self) -> Result<()> {
if self.changes_since_snapshot >= SNAPSHOT_INTERVAL {
self.write_snapshot()?;
self.changes_since_snapshot = 0;
}
Ok(())
}
}
const CATALOG_FORMAT_VERSION: u32 = 2;
#[derive(Debug, Serialize, Deserialize)]
struct CatalogSnapshot {
#[serde(default)]
format_version: u32,
segments: BTreeMap<ShardId, Vec<SegmentCatalogEntry>>,
schemas: HashMap<String, MeasurementSchema>,
#[serde(default)]
tombstones: TombstoneSet,
#[serde(default)]
wal_floor: u64,
#[serde(default)]
rollups: BTreeMap<String, RollupRecord>,
manifest_seq: u64,
next_segment_id: u64,
}
#[cfg(test)]
mod tests {
use super::*;
fn test_entry(id: u64, shard: i64, min_ts: i64, max_ts: i64) -> SegmentCatalogEntry {
SegmentCatalogEntry {
segment_id: SegmentId(id),
shard_id: ShardId(shard),
measurement: "cpu".to_string(),
path: PathBuf::from(format!("shard_{shard}/seg_{id}.csx")),
min_timestamp: min_ts,
max_timestamp: max_ts,
row_count: 1000,
series_count: 10,
byte_size: 4096,
row_group_count: 1,
column_count: 5,
column_stats: Vec::new(),
state: SegmentState::default(),
}
}
#[test]
fn add_and_get_segments() {
let dir = tempfile::tempdir().unwrap();
let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
catalog.add_segment(test_entry(2, 0, 300, 400)).unwrap();
catalog.add_segment(test_entry(3, 1, 100, 200)).unwrap();
assert_eq!(catalog.get_segments_for_shard(&ShardId(0)).len(), 2);
assert_eq!(catalog.get_segments_for_shard(&ShardId(1)).len(), 1);
assert_eq!(catalog.get_segments_for_shard(&ShardId(99)).len(), 0);
assert_eq!(catalog.segment_count(), 3);
}
#[test]
fn remove_segment() {
let dir = tempfile::tempdir().unwrap();
let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
catalog.add_segment(test_entry(2, 0, 300, 400)).unwrap();
let removed = catalog.remove_segment(SegmentId(1)).unwrap();
assert!(removed.is_some());
assert_eq!(removed.unwrap().segment_id, SegmentId(1));
assert_eq!(catalog.segment_count(), 1);
let removed = catalog.remove_segment(SegmentId(99)).unwrap();
assert!(removed.is_none());
}
#[test]
fn persist_and_reload() {
let dir = tempfile::tempdir().unwrap();
{
let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
catalog.add_segment(test_entry(2, 0, 300, 400)).unwrap();
catalog.add_segment(test_entry(3, 1, 500, 600)).unwrap();
}
let catalog = SegmentCatalog::open(dir.path()).unwrap();
assert_eq!(catalog.segment_count(), 3);
assert_eq!(catalog.get_segments_for_shard(&ShardId(0)).len(), 2);
assert_eq!(catalog.get_segments_for_shard(&ShardId(1)).len(), 1);
}
#[test]
fn snapshot_and_reload() {
let dir = tempfile::tempdir().unwrap();
{
let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
catalog.add_segment(test_entry(2, 0, 300, 400)).unwrap();
catalog.force_snapshot().unwrap();
catalog.add_segment(test_entry(3, 1, 500, 600)).unwrap();
}
let catalog = SegmentCatalog::open(dir.path()).unwrap();
assert_eq!(catalog.segment_count(), 3);
}
#[test]
fn schema_persistence() {
let dir = tempfile::tempdir().unwrap();
{
let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
let schema = MeasurementSchema::new("cpu");
catalog.set_schema(schema).unwrap();
}
let catalog = SegmentCatalog::open(dir.path()).unwrap();
assert!(catalog.get_schema("cpu").is_some());
assert_eq!(catalog.get_schema("cpu").unwrap().measurement(), "cpu");
}
#[test]
fn remove_schema() {
let dir = tempfile::tempdir().unwrap();
let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
catalog.set_schema(MeasurementSchema::new("cpu")).unwrap();
assert!(catalog.get_schema("cpu").is_some());
catalog.remove_schema("cpu").unwrap();
assert!(catalog.get_schema("cpu").is_none());
}
#[test]
fn next_segment_id_monotonic() {
let dir = tempfile::tempdir().unwrap();
let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
assert_eq!(catalog.next_segment_id(), SegmentId(1));
assert_eq!(catalog.next_segment_id(), SegmentId(2));
assert_eq!(catalog.next_segment_id(), SegmentId(3));
}
#[test]
fn next_segment_id_survives_reload() {
let dir = tempfile::tempdir().unwrap();
{
let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
catalog.add_segment(test_entry(5, 0, 100, 200)).unwrap();
catalog.add_segment(test_entry(10, 0, 300, 400)).unwrap();
}
let mut catalog = SegmentCatalog::open(dir.path()).unwrap();
let next = catalog.next_segment_id();
assert!(
next.0 >= 11,
"next_segment_id should be >= 11, got {}",
next.0
);
}
#[test]
fn add_remove_add_roundtrip() {
let dir = tempfile::tempdir().unwrap();
{
let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
catalog.remove_segment(SegmentId(1)).unwrap();
catalog.add_segment(test_entry(2, 0, 300, 400)).unwrap();
}
let catalog = SegmentCatalog::open(dir.path()).unwrap();
assert_eq!(catalog.segment_count(), 1);
assert_eq!(
catalog.get_segments_for_shard(&ShardId(0))[0].segment_id,
SegmentId(2)
);
}
#[test]
fn all_segments() {
let dir = tempfile::tempdir().unwrap();
let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
catalog.add_segment(test_entry(2, 1, 300, 400)).unwrap();
catalog.add_segment(test_entry(3, 2, 500, 600)).unwrap();
let all = catalog.all_segments();
assert_eq!(all.len(), 3);
}
#[test]
fn manifest_replay_tail_truncation_tolerated() {
let dir = tempfile::tempdir().unwrap();
{
let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
catalog.add_segment(test_entry(2, 0, 300, 400)).unwrap();
}
let wal_path = dir.path().join("manifest.wal");
let mut f = std::fs::OpenOptions::new()
.append(true)
.open(&wal_path)
.unwrap();
let fake_len: u32 = 128;
f.write_all(&fake_len.to_le_bytes()).unwrap();
f.write_all(&[0xDE, 0xAD]).unwrap(); drop(f);
let catalog = SegmentCatalog::open(dir.path()).unwrap();
assert_eq!(catalog.segment_count(), 2);
}
#[test]
fn manifest_replay_midstream_corruption_errors() {
let dir = tempfile::tempdir().unwrap();
{
let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
}
let wal_path = dir.path().join("manifest.wal");
let mut f = std::fs::OpenOptions::new()
.append(true)
.open(&wal_path)
.unwrap();
let corrupt_payload = b"CORRUPT_DATA_HERE_1234";
let corrupt_len = corrupt_payload.len() as u32;
let wrong_crc: u32 = 0xDEADBEEF;
f.write_all(&corrupt_len.to_le_bytes()).unwrap();
f.write_all(corrupt_payload).unwrap();
f.write_all(&wrong_crc.to_le_bytes()).unwrap();
let valid_entry = ManifestEntry::AddSegment(test_entry(3, 0, 500, 600));
let valid_payload = postcard::to_stdvec(&valid_entry).unwrap();
let valid_len = valid_payload.len() as u32;
let valid_crc = crc32c::crc32c(&valid_payload);
f.write_all(&valid_len.to_le_bytes()).unwrap();
f.write_all(&valid_payload).unwrap();
f.write_all(&valid_crc.to_le_bytes()).unwrap();
drop(f);
let result = SegmentCatalog::open(dir.path());
assert!(result.is_err(), "expected error for mid-stream corruption");
let err_msg = format!("{}", result.unwrap_err());
assert!(
err_msg.contains("mid-stream corruption"),
"error should mention mid-stream corruption, got: {err_msg}"
);
}
}