1use std::collections::{BTreeMap, HashMap};
21use std::io::{Read, Write};
22use std::path::{Path, PathBuf};
23
24use serde::{Deserialize, Serialize};
25use tracing::{debug, warn};
26
27use crate::segment::stats::ColumnStats;
28use chronix_core::{
29 MeasurementSchema, SegmentId, SegmentState, ShardId, Timestamp, Tombstone, TombstoneSet,
30};
31
32use crate::index::error::{IndexError, Result};
33
34#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
39pub struct CatalogColumnStats {
40 pub name: String,
42 pub data_type: u8,
44 pub role: u8,
46 pub stats: ColumnStats,
48}
49
50#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
52pub struct SegmentCatalogEntry {
53 pub segment_id: SegmentId,
55 pub shard_id: ShardId,
57 pub measurement: String,
59 pub path: PathBuf,
61 pub min_timestamp: Timestamp,
63 pub max_timestamp: Timestamp,
65 pub row_count: u64,
67 pub series_count: u32,
69 pub byte_size: u64,
71 pub row_group_count: u32,
73 pub column_count: u16,
75 #[serde(default)]
77 pub column_stats: Vec<CatalogColumnStats>,
78 #[serde(default)]
80 pub state: SegmentState,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
85enum ManifestEntry {
86 AddSegment(SegmentCatalogEntry),
88 RemoveSegment(u64),
90 SetSchema(MeasurementSchema),
92 RemoveSchema(String),
94 AddTombstone(Tombstone),
96 RemoveTombstone(Tombstone),
98 SetWalFloor(u64),
101 Compacted {
104 inputs: Vec<u64>,
106 output: u64,
108 },
109 SetRollup {
113 name: String,
115 definition: Vec<u8>,
117 },
118 RemoveRollup(String),
120 SetRollupState {
123 name: String,
125 state: Vec<u8>,
127 },
128}
129
130#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
132pub struct RollupRecord {
133 #[serde(default)]
135 pub definition: Vec<u8>,
136 #[serde(default)]
138 pub state: Vec<u8>,
139}
140
141#[derive(Debug)]
166pub struct SegmentCatalog {
167 segments: BTreeMap<ShardId, Vec<SegmentCatalogEntry>>,
169 schemas: HashMap<String, MeasurementSchema>,
171 tombstones: TombstoneSet,
181 wal_floor: u64,
190 rollups: BTreeMap<String, RollupRecord>,
197 manifest_seq: u64,
199 manifest_dir: PathBuf,
201 changes_since_snapshot: u64,
203 next_segment_id: u64,
205 manifest_wal_file: Option<std::fs::File>,
207 pending_sync: u64,
212 defer_sync: u32,
216 manifest_syncs: u64,
224}
225
226const SNAPSHOT_INTERVAL: u64 = 1000;
228
229const SYNC_BATCH_SIZE: u64 = 1;
239
240const _: () = {
242 const fn _assert_send<T: Send + Sync>() {}
243 _assert_send::<SegmentCatalog>();
244};
245
246impl SegmentCatalog {
247 pub fn new(manifest_dir: impl Into<PathBuf>) -> Result<Self> {
253 let manifest_dir = manifest_dir.into();
254 std::fs::create_dir_all(&manifest_dir)?;
255
256 Ok(Self {
257 segments: BTreeMap::new(),
258 schemas: HashMap::new(),
259 tombstones: TombstoneSet::new(),
260 wal_floor: 0,
261 rollups: BTreeMap::new(),
262 manifest_seq: 0,
263 manifest_dir,
264 changes_since_snapshot: 0,
265 next_segment_id: 1,
266 manifest_wal_file: None,
267 pending_sync: 0,
268 defer_sync: 0,
269 manifest_syncs: 0,
270 })
271 }
272
273 pub fn open(manifest_dir: impl Into<PathBuf>) -> Result<Self> {
279 let manifest_dir = manifest_dir.into();
280 std::fs::create_dir_all(&manifest_dir)?;
281
282 let bin_snapshot_path = manifest_dir.join("manifest.snapshot.bin");
283 let mut catalog = if bin_snapshot_path.exists() {
284 let data = std::fs::read(&bin_snapshot_path)?;
285 let snapshot: CatalogSnapshot = postcard::from_bytes(&data)
286 .map_err(|e| IndexError::BinarySerialization(e.to_string()))?;
287 if snapshot.format_version > CATALOG_FORMAT_VERSION {
289 return Err(IndexError::BinarySerialization(format!(
290 "catalog snapshot format version {} is newer than supported version {CATALOG_FORMAT_VERSION}",
291 snapshot.format_version
292 )));
293 }
294 debug!(
295 seq = snapshot.manifest_seq,
296 segments = snapshot.segments.len(),
297 format_version = snapshot.format_version,
298 "loaded catalog snapshot (postcard)"
299 );
300 Self {
301 segments: snapshot.segments,
302 schemas: snapshot.schemas,
303 tombstones: snapshot.tombstones,
304 wal_floor: snapshot.wal_floor,
305 rollups: snapshot.rollups,
306 manifest_seq: snapshot.manifest_seq,
307 manifest_dir: manifest_dir.clone(),
308 changes_since_snapshot: 0,
309 next_segment_id: snapshot.next_segment_id,
310 manifest_wal_file: None,
311 pending_sync: 0,
312 defer_sync: 0,
313 manifest_syncs: 0,
314 }
315 } else {
316 Self {
317 segments: BTreeMap::new(),
318 schemas: HashMap::new(),
319 tombstones: TombstoneSet::new(),
320 wal_floor: 0,
321 rollups: BTreeMap::new(),
322 manifest_seq: 0,
323 manifest_dir: manifest_dir.clone(),
324 changes_since_snapshot: 0,
325 next_segment_id: 1,
326 manifest_wal_file: None,
327 pending_sync: 0,
328 defer_sync: 0,
329 manifest_syncs: 0,
330 }
331 };
332
333 let wal_path = manifest_dir.join("manifest.wal");
335 if wal_path.exists() {
336 catalog.replay_manifest(&wal_path)?;
337 }
338
339 Ok(catalog)
340 }
341
342 pub fn add_segment(&mut self, entry: SegmentCatalogEntry) -> Result<()> {
348 let manifest_entry = ManifestEntry::AddSegment(entry.clone());
349 self.append_manifest(&manifest_entry)?;
350
351 self.segments.entry(entry.shard_id).or_default().push(entry);
352
353 self.maybe_snapshot()?;
354 Ok(())
355 }
356
357 pub fn remove_segment(&mut self, segment_id: SegmentId) -> Result<Option<SegmentCatalogEntry>> {
365 let manifest_entry = ManifestEntry::RemoveSegment(segment_id.0);
366 self.append_manifest(&manifest_entry)?;
367
368 for entries in self.segments.values_mut() {
369 if let Some(pos) = entries.iter().position(|e| e.segment_id == segment_id) {
370 let removed = entries.remove(pos);
371 self.maybe_snapshot()?;
372 return Ok(Some(removed));
373 }
374 }
375
376 self.maybe_snapshot()?;
377 Ok(None)
378 }
379
380 #[must_use]
382 pub fn get_segments_for_shard(&self, shard_id: &ShardId) -> &[SegmentCatalogEntry] {
383 self.segments.get(shard_id).map_or(&[], Vec::as_slice)
384 }
385
386 pub fn soft_delete_segment(&mut self, segment_id: SegmentId, now_ms: u64) -> Result<bool> {
393 let location = self.segments.iter().find_map(|(measurement, entries)| {
395 entries
396 .iter()
397 .position(|e| e.segment_id == segment_id)
398 .map(|idx| (*measurement, idx))
399 });
400
401 let Some((measurement, idx)) = location else {
402 return Ok(false);
403 };
404
405 let entry = &self.segments[&measurement][idx];
409 let mut updated_entry = entry.clone();
410 updated_entry.state = SegmentState::SoftDeleted {
411 deleted_at_ms: now_ms,
412 };
413 let manifest_entry = ManifestEntry::AddSegment(updated_entry);
414 self.append_manifest(&manifest_entry)?;
415
416 if let Some(entries) = self.segments.get_mut(&measurement) {
418 entries[idx].state = SegmentState::SoftDeleted {
419 deleted_at_ms: now_ms,
420 };
421 }
422 Ok(true)
423 }
424
425 #[must_use]
429 pub fn active_segments_for_measurement(&self, measurement: &str) -> Vec<&SegmentCatalogEntry> {
430 self.segments
431 .values()
432 .flat_map(|v| v.iter())
433 .filter(|e| e.measurement == measurement && e.state == SegmentState::Active)
434 .collect()
435 }
436
437 #[must_use]
440 pub fn expired_soft_deleted(
441 &self,
442 now_ms: u64,
443 grace_period_ms: u64,
444 ) -> Vec<&SegmentCatalogEntry> {
445 self.segments
446 .values()
447 .flat_map(|v| v.iter())
448 .filter(|e| {
449 if let SegmentState::SoftDeleted { deleted_at_ms } = e.state {
450 now_ms.saturating_sub(deleted_at_ms) >= grace_period_ms
451 } else {
452 false
453 }
454 })
455 .collect()
456 }
457
458 #[must_use]
460 pub fn all_segments(&self) -> Vec<&SegmentCatalogEntry> {
461 self.segments.values().flat_map(|v| v.iter()).collect()
462 }
463
464 #[must_use]
466 pub fn segments_for_measurement(&self, measurement: &str) -> Vec<&SegmentCatalogEntry> {
467 self.segments
468 .values()
469 .flat_map(|v| v.iter())
470 .filter(|e| e.measurement == measurement)
471 .collect()
472 }
473
474 #[must_use]
479 pub fn max_segments_per_shard_measurement(&self) -> usize {
480 let mut counts: std::collections::HashMap<(i64, &str), usize> =
481 std::collections::HashMap::new();
482 for e in self.all_segments() {
483 if e.state == chronix_core::SegmentState::Active {
484 *counts
485 .entry((e.shard_id.0, e.measurement.as_str()))
486 .or_default() += 1;
487 }
488 }
489 counts.into_values().max().unwrap_or(0)
490 }
491
492 #[must_use]
501 pub fn memory_bytes(&self) -> usize {
502 let entries: usize = self
503 .segments
504 .values()
505 .map(|v| {
506 v.capacity() * std::mem::size_of::<SegmentCatalogEntry>()
507 + v.iter()
508 .map(|e| {
509 e.measurement.capacity()
510 + e.path.as_os_str().len()
511 + e.column_stats.capacity()
512 * std::mem::size_of::<CatalogColumnStats>()
513 + e.column_stats
514 .iter()
515 .map(|c| c.name.capacity())
516 .sum::<usize>()
517 })
518 .sum::<usize>()
519 })
520 .sum();
521
522 let schemas: usize = self
523 .schemas
524 .iter()
525 .map(|(name, ms)| {
526 name.capacity()
527 + ms.columns()
528 .iter()
529 .map(|c| c.name.capacity() + std::mem::size_of_val(c))
530 .sum::<usize>()
531 })
532 .sum();
533
534 entries + schemas + self.tombstones.len() * std::mem::size_of::<u64>() * 4
535 }
536
537 #[must_use]
539 pub fn segment_count(&self) -> usize {
540 self.segments.values().map(Vec::len).sum()
541 }
542
543 pub fn next_segment_id(&mut self) -> SegmentId {
545 let id = SegmentId(self.next_segment_id);
546 self.next_segment_id += 1;
547 id
548 }
549
550 #[must_use]
552 pub fn manifest_seq(&self) -> u64 {
553 self.manifest_seq
554 }
555
556 #[must_use]
558 pub fn rollups(&self) -> &BTreeMap<String, RollupRecord> {
559 &self.rollups
560 }
561
562 pub fn set_rollup(&mut self, name: &str, definition: Vec<u8>) -> Result<()> {
568 self.append_manifest(&ManifestEntry::SetRollup {
569 name: name.to_string(),
570 definition: definition.clone(),
571 })?;
572 self.rollups.entry(name.to_string()).or_default().definition = definition;
573 self.sync_manifest()?;
574 self.maybe_snapshot()?;
575 Ok(())
576 }
577
578 pub fn set_rollup_state(&mut self, name: &str, state: Vec<u8>) -> Result<()> {
589 self.append_manifest(&ManifestEntry::SetRollupState {
590 name: name.to_string(),
591 state: state.clone(),
592 })?;
593 self.rollups.entry(name.to_string()).or_default().state = state;
594 self.sync_manifest()?;
595 self.maybe_snapshot()?;
596 Ok(())
597 }
598
599 pub fn remove_rollup(&mut self, name: &str) -> Result<bool> {
605 if !self.rollups.contains_key(name) {
606 return Ok(false);
607 }
608 self.append_manifest(&ManifestEntry::RemoveRollup(name.to_string()))?;
609 self.rollups.remove(name);
610 self.sync_manifest()?;
611 self.maybe_snapshot()?;
612 Ok(true)
613 }
614
615 #[must_use]
617 pub fn tombstones(&self) -> &TombstoneSet {
618 &self.tombstones
619 }
620
621 pub fn record_tombstones(&mut self, tombstones: &[Tombstone]) -> Result<()> {
632 self.in_one_sync(|catalog| {
635 for tombstone in tombstones {
636 catalog.append_manifest(&ManifestEntry::AddTombstone(tombstone.clone()))?;
637 catalog.tombstones.insert(tombstone.clone());
638 }
639 Ok(())
640 })?;
641 self.maybe_snapshot()?;
642 Ok(())
643 }
644
645 pub fn complete_compaction(
659 &mut self,
660 output: SegmentCatalogEntry,
661 inputs: &[SegmentId],
662 now_ms: u64,
663 ) -> Result<()> {
664 let output_id = output.segment_id.0;
665 let input_ids: Vec<u64> = inputs.iter().map(|id| id.0).collect();
666 self.in_one_sync(|catalog| {
668 catalog.add_segment(output)?;
669 for input in inputs {
670 catalog.soft_delete_segment(*input, now_ms)?;
671 }
672 catalog.append_manifest(&ManifestEntry::Compacted {
673 inputs: input_ids.clone(),
674 output: output_id,
675 })
676 })?;
677 self.tombstones
678 .extend_to_compaction_output(&input_ids, output_id);
679 self.maybe_snapshot()?;
680 Ok(())
681 }
682
683 pub fn reclaim_tombstones(&mut self) -> Result<usize> {
694 let live: std::collections::HashSet<u64> = self
695 .segments
696 .values()
697 .flatten()
698 .map(|e| e.segment_id.0)
699 .collect();
700
701 let removed = self.tombstones.retain_tombstones(|t| {
705 t.segments.is_empty() || t.segments.iter().any(|id| live.contains(id))
706 });
707
708 for tombstone in &removed {
709 self.append_manifest(&ManifestEntry::RemoveTombstone(tombstone.clone()))?;
710 }
711 if !removed.is_empty() {
712 self.sync_manifest()?;
713 self.maybe_snapshot()?;
714 }
715 Ok(removed.len())
716 }
717
718 pub fn set_schema(&mut self, schema: MeasurementSchema) -> Result<()> {
724 let entry = ManifestEntry::SetSchema(schema.clone());
725 self.append_manifest(&entry)?;
726 self.schemas
727 .insert(schema.measurement().to_string(), schema);
728 self.maybe_snapshot()?;
729 Ok(())
730 }
731
732 pub fn remove_schema(&mut self, measurement: &str) -> Result<Option<MeasurementSchema>> {
738 let entry = ManifestEntry::RemoveSchema(measurement.to_string());
739 self.append_manifest(&entry)?;
740 let removed = self.schemas.remove(measurement);
741 self.maybe_snapshot()?;
742 Ok(removed)
743 }
744
745 #[must_use]
750 pub fn wal_floor(&self) -> u64 {
751 self.wal_floor
752 }
753
754 pub fn set_wal_floor(&mut self, sequence_no: u64) -> Result<()> {
763 if sequence_no <= self.wal_floor {
764 return Ok(());
765 }
766 self.append_manifest(&ManifestEntry::SetWalFloor(sequence_no))?;
767 self.wal_floor = sequence_no;
768 self.maybe_snapshot()?;
769 Ok(())
770 }
771
772 #[must_use]
774 pub fn get_schema(&self, measurement: &str) -> Option<&MeasurementSchema> {
775 self.schemas.get(measurement)
776 }
777
778 #[must_use]
780 pub fn schema_names(&self) -> Vec<String> {
781 self.schemas.keys().cloned().collect()
782 }
783
784 pub fn all_schemas(&self) -> impl Iterator<Item = (&String, &MeasurementSchema)> {
786 self.schemas.iter()
787 }
788
789 pub fn force_snapshot(&mut self) -> Result<()> {
795 self.write_snapshot()?;
796 self.changes_since_snapshot = 0;
797 Ok(())
798 }
799
800 fn append_manifest(&mut self, entry: &ManifestEntry) -> Result<()> {
804 self.manifest_seq += 1;
805
806 if self.manifest_wal_file.is_none() {
808 let wal_path = self.manifest_dir.join("manifest.wal");
809 let f = std::fs::OpenOptions::new()
810 .create(true)
811 .append(true)
812 .open(&wal_path)?;
813 self.manifest_wal_file = Some(f);
814 }
815 let file = self
816 .manifest_wal_file
817 .as_mut()
818 .ok_or_else(|| IndexError::BinarySerialization("WAL file not opened".into()))?;
819
820 let payload = postcard::to_stdvec(entry)
821 .map_err(|e| IndexError::BinarySerialization(e.to_string()))?;
822 let len = payload.len() as u32;
823 let crc = crc32c::crc32c(&payload);
824 file.write_all(&len.to_le_bytes())?;
825 file.write_all(&payload)?;
826 file.write_all(&crc.to_le_bytes())?;
827 file.flush()?;
829
830 self.pending_sync += 1;
835 if self.defer_sync == 0 && self.pending_sync >= SYNC_BATCH_SIZE {
836 file.sync_data()?;
837 self.pending_sync = 0;
838 self.manifest_syncs += 1;
839 metrics::counter!("chronix_catalog_fsync_total").increment(1);
840 }
841
842 self.changes_since_snapshot += 1;
843 Ok(())
844 }
845
846 #[must_use]
853 pub fn manifest_syncs(&self) -> u64 {
854 self.manifest_syncs
855 }
856
857 fn in_one_sync<T>(&mut self, body: impl FnOnce(&mut Self) -> Result<T>) -> Result<T> {
877 self.defer_sync += 1;
878 let out = body(self);
879 self.defer_sync -= 1;
880 self.sync_manifest()?;
883 out
884 }
885
886 pub fn sync_manifest(&mut self) -> Result<()> {
892 if self.pending_sync > 0 {
893 if let Some(ref f) = self.manifest_wal_file {
894 f.sync_data()?;
895 }
896 self.pending_sync = 0;
897 self.manifest_syncs += 1;
898 metrics::counter!("chronix_catalog_fsync_total").increment(1);
899 }
900 Ok(())
901 }
902
903 fn replay_manifest(&mut self, wal_path: &Path) -> Result<()> {
909 let mut file = std::fs::File::open(wal_path)?;
910 let file_len = file.metadata()?.len();
911
912 let mut replayed = 0u64;
913 let mut tail_skipped = 0u64;
914 let mut pos = 0u64;
915
916 loop {
917 if pos >= file_len {
918 break;
919 }
920 let mut len_buf = [0u8; 4];
922 match file.read_exact(&mut len_buf) {
923 Ok(()) => {}
924 Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
925 tail_skipped += 1;
926 warn!(
927 offset = pos,
928 "manifest replay: truncated length header at tail"
929 );
930 break;
931 }
932 Err(e) => return Err(e.into()),
933 }
934 let payload_len = u32::from_le_bytes(len_buf) as usize;
935 pos += 4;
936
937 if payload_len == 0 || payload_len > 64 * 1024 * 1024 {
939 if pos + (payload_len as u64) + 4 > file_len {
940 tail_skipped += 1;
941 warn!(
942 offset = pos - 4,
943 payload_len, "manifest replay: truncated entry at tail"
944 );
945 break;
946 }
947 return Err(IndexError::Manifest {
948 detail: format!(
949 "invalid manifest entry length {payload_len} at offset {}",
950 pos - 4
951 ),
952 });
953 }
954
955 let total = payload_len + 4; if pos + total as u64 > file_len {
958 tail_skipped += 1;
959 warn!(
960 offset = pos - 4,
961 "manifest replay: truncated payload at tail"
962 );
963 break;
964 }
965
966 let mut buf = vec![0u8; total];
967 if let Err(e) = file.read_exact(&mut buf) {
968 if e.kind() == std::io::ErrorKind::UnexpectedEof {
969 tail_skipped += 1;
970 warn!(offset = pos - 4, "manifest replay: truncated read at tail");
971 break;
972 }
973 return Err(e.into());
974 }
975
976 let payload = &buf[..payload_len];
977 let stored_crc = u32::from_le_bytes([
978 buf[payload_len],
979 buf[payload_len + 1],
980 buf[payload_len + 2],
981 buf[payload_len + 3],
982 ]);
983 let computed_crc = crc32c::crc32c(payload);
984
985 if stored_crc != computed_crc {
986 let remaining = file_len - pos - total as u64;
988 if remaining > 8 {
989 return Err(IndexError::Manifest {
990 detail: format!(
991 "CRC mismatch at offset {} (stored={stored_crc:#x}, computed={computed_crc:#x}) — mid-stream corruption",
992 pos - 4,
993 ),
994 });
995 }
996 tail_skipped += 1;
997 warn!(
998 offset = pos - 4,
999 "manifest replay: CRC mismatch at tail, skipping"
1000 );
1001 break;
1002 }
1003
1004 match postcard::from_bytes::<ManifestEntry>(payload) {
1005 Ok(entry) => {
1006 self.apply_manifest_entry(entry);
1007 self.manifest_seq += 1;
1008 replayed += 1;
1009 }
1010 Err(e) => {
1011 let remaining = file_len - pos - total as u64;
1013 if remaining > 8 {
1014 return Err(IndexError::Manifest {
1015 detail: format!("deserialization error at offset {}: {e}", pos - 4,),
1016 });
1017 }
1018 tail_skipped += 1;
1019 warn!(offset = pos - 4, error = %e, "manifest replay: skipping corrupt tail entry");
1020 break;
1021 }
1022 }
1023
1024 pos += total as u64;
1025 }
1026
1027 if tail_skipped > 0 {
1028 warn!(
1029 tail_skipped,
1030 "manifest replay: truncated tail entries skipped"
1031 );
1032 }
1033 if replayed > 0 {
1034 debug!(replayed, "replayed manifest entries");
1035 }
1036
1037 Ok(())
1038 }
1039
1040 fn apply_manifest_entry(&mut self, entry: ManifestEntry) {
1042 match entry {
1043 ManifestEntry::AddSegment(seg) => {
1044 if seg.segment_id.0 >= self.next_segment_id {
1046 self.next_segment_id = seg.segment_id.0 + 1;
1047 }
1048 let shard_entries = self.segments.entry(seg.shard_id).or_default();
1052 shard_entries.retain(|e| e.segment_id != seg.segment_id);
1053 shard_entries.push(seg);
1054 }
1055 ManifestEntry::RemoveSegment(id) => {
1056 for entries in self.segments.values_mut() {
1057 entries.retain(|e| e.segment_id.0 != id);
1058 }
1059 }
1060 ManifestEntry::SetSchema(schema) => {
1061 self.schemas
1062 .insert(schema.measurement().to_string(), schema);
1063 }
1064 ManifestEntry::RemoveSchema(name) => {
1065 self.schemas.remove(&name);
1066 }
1067 ManifestEntry::AddTombstone(t) => {
1068 self.tombstones.insert(t);
1069 }
1070 ManifestEntry::RemoveTombstone(t) => {
1071 self.tombstones.retain_tombstones(|existing| existing != &t);
1072 }
1073 ManifestEntry::SetWalFloor(seq) => {
1074 self.wal_floor = self.wal_floor.max(seq);
1075 }
1076 ManifestEntry::SetRollup { name, definition } => {
1077 self.rollups.entry(name).or_default().definition = definition;
1078 }
1079 ManifestEntry::RemoveRollup(name) => {
1080 self.rollups.remove(&name);
1081 }
1082 ManifestEntry::SetRollupState { name, state } => {
1083 self.rollups.entry(name).or_default().state = state;
1084 }
1085 ManifestEntry::Compacted { inputs, output } => {
1086 self.tombstones.extend_to_compaction_output(&inputs, output);
1087 }
1088 }
1089 }
1090
1091 fn write_snapshot(&mut self) -> Result<()> {
1093 let snapshot = CatalogSnapshot {
1094 format_version: CATALOG_FORMAT_VERSION,
1095 segments: self.segments.clone(),
1096 schemas: self.schemas.clone(),
1097 tombstones: self.tombstones.clone(),
1098 wal_floor: self.wal_floor,
1099 rollups: self.rollups.clone(),
1100 manifest_seq: self.manifest_seq,
1101 next_segment_id: self.next_segment_id,
1102 };
1103
1104 let data = postcard::to_stdvec(&snapshot)
1105 .map_err(|e| IndexError::BinarySerialization(e.to_string()))?;
1106
1107 let snapshot_path = self.manifest_dir.join("manifest.snapshot.bin");
1109 let tmp_path = self.manifest_dir.join("manifest.snapshot.bin.tmp");
1110 let write_tmp = (|| -> std::io::Result<()> {
1116 let mut file = std::fs::File::create(&tmp_path)?;
1117 file.write_all(&data)?;
1118 file.sync_all()
1119 })();
1120 if let Err(e) = write_tmp {
1121 let _ = std::fs::remove_file(&tmp_path);
1122 return Err(e.into());
1123 }
1124 if let Err(e) = std::fs::rename(&tmp_path, &snapshot_path) {
1125 let _ = std::fs::remove_file(&tmp_path);
1126 return Err(e.into());
1127 }
1128
1129 let dir = std::fs::File::open(&self.manifest_dir).map_err(|e| {
1133 std::io::Error::new(
1134 e.kind(),
1135 format!("failed to open manifest dir for fsync: {e}"),
1136 )
1137 })?;
1138 dir.sync_all()?;
1139
1140 let wal_path = self.manifest_dir.join("manifest.wal");
1143 if wal_path.exists() {
1144 let file = std::fs::OpenOptions::new()
1145 .write(true)
1146 .truncate(true)
1147 .open(&wal_path)?;
1148 file.sync_all()?;
1153 }
1154 self.manifest_wal_file = None;
1155
1156 debug!(seq = self.manifest_seq, "catalog snapshot written");
1157 Ok(())
1158 }
1159
1160 fn maybe_snapshot(&mut self) -> Result<()> {
1162 if self.changes_since_snapshot >= SNAPSHOT_INTERVAL {
1163 self.write_snapshot()?;
1164 self.changes_since_snapshot = 0;
1165 }
1166 Ok(())
1167 }
1168}
1169
1170const CATALOG_FORMAT_VERSION: u32 = 2;
1172
1173#[derive(Debug, Serialize, Deserialize)]
1175struct CatalogSnapshot {
1176 #[serde(default)]
1178 format_version: u32,
1179 segments: BTreeMap<ShardId, Vec<SegmentCatalogEntry>>,
1180 schemas: HashMap<String, MeasurementSchema>,
1181 #[serde(default)]
1184 tombstones: TombstoneSet,
1185 #[serde(default)]
1187 wal_floor: u64,
1188 #[serde(default)]
1192 rollups: BTreeMap<String, RollupRecord>,
1193 manifest_seq: u64,
1194 next_segment_id: u64,
1195}
1196
1197#[cfg(test)]
1198mod tests {
1199 use super::*;
1200
1201 fn test_entry(id: u64, shard: i64, min_ts: i64, max_ts: i64) -> SegmentCatalogEntry {
1202 SegmentCatalogEntry {
1203 segment_id: SegmentId(id),
1204 shard_id: ShardId(shard),
1205 measurement: "cpu".to_string(),
1206 path: PathBuf::from(format!("shard_{shard}/seg_{id}.csx")),
1207 min_timestamp: min_ts,
1208 max_timestamp: max_ts,
1209 row_count: 1000,
1210 series_count: 10,
1211 byte_size: 4096,
1212 row_group_count: 1,
1213 column_count: 5,
1214 column_stats: Vec::new(),
1215 state: SegmentState::default(),
1216 }
1217 }
1218
1219 #[test]
1226 fn a_compaction_costs_one_manifest_fsync() {
1227 let dir = tempfile::tempdir().unwrap();
1228 let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1229
1230 let inputs: Vec<SegmentId> = (1..=4i64)
1231 .map(|i| {
1232 #[allow(clippy::cast_sign_loss)]
1233 let e = test_entry(i as u64, 0, i * 100, i * 100 + 50);
1234 let id = e.segment_id;
1235 catalog.add_segment(e).unwrap();
1236 id
1237 })
1238 .collect();
1239
1240 let before = catalog.manifest_syncs();
1241 catalog
1242 .complete_compaction(test_entry(99, 0, 100, 450), &inputs, 0)
1243 .unwrap();
1244 assert_eq!(
1245 catalog.manifest_syncs() - before,
1246 1,
1247 "one transition, one fsync — it was six"
1248 );
1249
1250 drop(catalog);
1253 let reopened = SegmentCatalog::open(dir.path()).unwrap();
1254 let live: Vec<u64> = reopened
1255 .active_segments_for_measurement("cpu")
1256 .iter()
1257 .map(|e| e.segment_id.0)
1258 .collect();
1259 assert_eq!(live, vec![99], "only the compaction output survives");
1260 }
1261
1262 #[test]
1264 fn a_delete_costs_one_manifest_fsync() {
1265 let dir = tempfile::tempdir().unwrap();
1266 let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1267 let tombstones: Vec<Tombstone> = (0..25)
1268 .map(|i| Tombstone::ranged(format!("cpu,host=h{i}"), 0, 1_000))
1269 .collect();
1270
1271 let before = catalog.manifest_syncs();
1272 catalog.record_tombstones(&tombstones).unwrap();
1273 assert_eq!(
1274 catalog.manifest_syncs() - before,
1275 1,
1276 "25 tombstones, 1 fsync"
1277 );
1278
1279 drop(catalog);
1280 let reopened = SegmentCatalog::open(dir.path()).unwrap();
1281 assert_eq!(
1282 reopened.tombstones().len(),
1283 25,
1284 "every tombstone survived the reopen"
1285 );
1286 }
1287
1288 #[test]
1291 fn a_lone_append_still_syncs() {
1292 let dir = tempfile::tempdir().unwrap();
1293 let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1294 let before = catalog.manifest_syncs();
1295 catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
1296 assert_eq!(catalog.manifest_syncs() - before, 1);
1297 catalog.soft_delete_segment(SegmentId(1), 0).unwrap();
1298 assert_eq!(catalog.manifest_syncs() - before, 2);
1299 }
1300
1301 #[test]
1302 fn add_and_get_segments() {
1303 let dir = tempfile::tempdir().unwrap();
1304 let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1305
1306 catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
1307 catalog.add_segment(test_entry(2, 0, 300, 400)).unwrap();
1308 catalog.add_segment(test_entry(3, 1, 100, 200)).unwrap();
1309
1310 assert_eq!(catalog.get_segments_for_shard(&ShardId(0)).len(), 2);
1311 assert_eq!(catalog.get_segments_for_shard(&ShardId(1)).len(), 1);
1312 assert_eq!(catalog.get_segments_for_shard(&ShardId(99)).len(), 0);
1313 assert_eq!(catalog.segment_count(), 3);
1314 }
1315
1316 #[test]
1317 fn remove_segment() {
1318 let dir = tempfile::tempdir().unwrap();
1319 let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1320
1321 catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
1322 catalog.add_segment(test_entry(2, 0, 300, 400)).unwrap();
1323
1324 let removed = catalog.remove_segment(SegmentId(1)).unwrap();
1325 assert!(removed.is_some());
1326 assert_eq!(removed.unwrap().segment_id, SegmentId(1));
1327 assert_eq!(catalog.segment_count(), 1);
1328
1329 let removed = catalog.remove_segment(SegmentId(99)).unwrap();
1331 assert!(removed.is_none());
1332 }
1333
1334 #[test]
1335 fn persist_and_reload() {
1336 let dir = tempfile::tempdir().unwrap();
1337
1338 {
1340 let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1341 catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
1342 catalog.add_segment(test_entry(2, 0, 300, 400)).unwrap();
1343 catalog.add_segment(test_entry(3, 1, 500, 600)).unwrap();
1344 }
1345
1346 let catalog = SegmentCatalog::open(dir.path()).unwrap();
1348 assert_eq!(catalog.segment_count(), 3);
1349 assert_eq!(catalog.get_segments_for_shard(&ShardId(0)).len(), 2);
1350 assert_eq!(catalog.get_segments_for_shard(&ShardId(1)).len(), 1);
1351 }
1352
1353 #[test]
1354 fn snapshot_and_reload() {
1355 let dir = tempfile::tempdir().unwrap();
1356
1357 {
1359 let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1360 catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
1361 catalog.add_segment(test_entry(2, 0, 300, 400)).unwrap();
1362 catalog.force_snapshot().unwrap();
1363 catalog.add_segment(test_entry(3, 1, 500, 600)).unwrap();
1365 }
1366
1367 let catalog = SegmentCatalog::open(dir.path()).unwrap();
1369 assert_eq!(catalog.segment_count(), 3);
1370 }
1371
1372 #[test]
1373 fn schema_persistence() {
1374 let dir = tempfile::tempdir().unwrap();
1375
1376 {
1377 let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1378 let schema = MeasurementSchema::new("cpu");
1379 catalog.set_schema(schema).unwrap();
1380 }
1381
1382 let catalog = SegmentCatalog::open(dir.path()).unwrap();
1383 assert!(catalog.get_schema("cpu").is_some());
1384 assert_eq!(catalog.get_schema("cpu").unwrap().measurement(), "cpu");
1385 }
1386
1387 #[test]
1388 fn remove_schema() {
1389 let dir = tempfile::tempdir().unwrap();
1390 let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1391 catalog.set_schema(MeasurementSchema::new("cpu")).unwrap();
1392 assert!(catalog.get_schema("cpu").is_some());
1393
1394 catalog.remove_schema("cpu").unwrap();
1395 assert!(catalog.get_schema("cpu").is_none());
1396 }
1397
1398 #[test]
1399 fn next_segment_id_monotonic() {
1400 let dir = tempfile::tempdir().unwrap();
1401 let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1402
1403 assert_eq!(catalog.next_segment_id(), SegmentId(1));
1404 assert_eq!(catalog.next_segment_id(), SegmentId(2));
1405 assert_eq!(catalog.next_segment_id(), SegmentId(3));
1406 }
1407
1408 #[test]
1409 fn next_segment_id_survives_reload() {
1410 let dir = tempfile::tempdir().unwrap();
1411
1412 {
1413 let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1414 catalog.add_segment(test_entry(5, 0, 100, 200)).unwrap();
1415 catalog.add_segment(test_entry(10, 0, 300, 400)).unwrap();
1416 }
1417
1418 let mut catalog = SegmentCatalog::open(dir.path()).unwrap();
1419 let next = catalog.next_segment_id();
1421 assert!(
1422 next.0 >= 11,
1423 "next_segment_id should be >= 11, got {}",
1424 next.0
1425 );
1426 }
1427
1428 #[test]
1429 fn add_remove_add_roundtrip() {
1430 let dir = tempfile::tempdir().unwrap();
1431
1432 {
1433 let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1434 catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
1435 catalog.remove_segment(SegmentId(1)).unwrap();
1436 catalog.add_segment(test_entry(2, 0, 300, 400)).unwrap();
1437 }
1438
1439 let catalog = SegmentCatalog::open(dir.path()).unwrap();
1440 assert_eq!(catalog.segment_count(), 1);
1441 assert_eq!(
1442 catalog.get_segments_for_shard(&ShardId(0))[0].segment_id,
1443 SegmentId(2)
1444 );
1445 }
1446
1447 #[test]
1448 fn all_segments() {
1449 let dir = tempfile::tempdir().unwrap();
1450 let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1451
1452 catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
1453 catalog.add_segment(test_entry(2, 1, 300, 400)).unwrap();
1454 catalog.add_segment(test_entry(3, 2, 500, 600)).unwrap();
1455
1456 let all = catalog.all_segments();
1457 assert_eq!(all.len(), 3);
1458 }
1459
1460 #[test]
1461 fn manifest_replay_tail_truncation_tolerated() {
1462 let dir = tempfile::tempdir().unwrap();
1465
1466 {
1467 let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1468 catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
1469 catalog.add_segment(test_entry(2, 0, 300, 400)).unwrap();
1470 }
1471
1472 let wal_path = dir.path().join("manifest.wal");
1474 let mut f = std::fs::OpenOptions::new()
1475 .append(true)
1476 .open(&wal_path)
1477 .unwrap();
1478 let fake_len: u32 = 128;
1480 f.write_all(&fake_len.to_le_bytes()).unwrap();
1481 f.write_all(&[0xDE, 0xAD]).unwrap(); drop(f);
1483
1484 let catalog = SegmentCatalog::open(dir.path()).unwrap();
1486 assert_eq!(catalog.segment_count(), 2);
1487 }
1488
1489 #[test]
1490 fn manifest_replay_midstream_corruption_errors() {
1491 let dir = tempfile::tempdir().unwrap();
1494
1495 {
1496 let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1497 catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
1498 }
1499
1500 let wal_path = dir.path().join("manifest.wal");
1502 let mut f = std::fs::OpenOptions::new()
1503 .append(true)
1504 .open(&wal_path)
1505 .unwrap();
1506
1507 let corrupt_payload = b"CORRUPT_DATA_HERE_1234";
1509 let corrupt_len = corrupt_payload.len() as u32;
1510 let wrong_crc: u32 = 0xDEADBEEF;
1511 f.write_all(&corrupt_len.to_le_bytes()).unwrap();
1512 f.write_all(corrupt_payload).unwrap();
1513 f.write_all(&wrong_crc.to_le_bytes()).unwrap();
1514
1515 let valid_entry = ManifestEntry::AddSegment(test_entry(3, 0, 500, 600));
1517 let valid_payload = postcard::to_stdvec(&valid_entry).unwrap();
1518 let valid_len = valid_payload.len() as u32;
1519 let valid_crc = crc32c::crc32c(&valid_payload);
1520 f.write_all(&valid_len.to_le_bytes()).unwrap();
1521 f.write_all(&valid_payload).unwrap();
1522 f.write_all(&valid_crc.to_le_bytes()).unwrap();
1523 drop(f);
1524
1525 let result = SegmentCatalog::open(dir.path());
1527 assert!(result.is_err(), "expected error for mid-stream corruption");
1528 let err_msg = format!("{}", result.unwrap_err());
1529 assert!(
1530 err_msg.contains("mid-stream corruption"),
1531 "error should mention mid-stream corruption, got: {err_msg}"
1532 );
1533 }
1534}