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 #[serde(default)]
54 pub decimal_scale: Option<u8>,
55 pub stats: ColumnStats,
57}
58
59#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
61pub struct SegmentCatalogEntry {
62 pub segment_id: SegmentId,
64 pub shard_id: ShardId,
66 pub measurement: String,
68 pub path: PathBuf,
70 pub min_timestamp: Timestamp,
72 pub max_timestamp: Timestamp,
74 pub row_count: u64,
76 pub series_count: u32,
78 pub byte_size: u64,
80 pub row_group_count: u32,
82 pub column_count: u16,
84 #[serde(default)]
86 pub column_stats: Vec<CatalogColumnStats>,
87 #[serde(default)]
89 pub state: SegmentState,
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
94enum ManifestEntry {
95 AddSegment(SegmentCatalogEntry),
97 RemoveSegment(u64),
99 SetSchema(MeasurementSchema),
101 RemoveSchema(String),
103 AddTombstone(Tombstone),
105 RemoveTombstone(Tombstone),
107 SetWalFloor(u64),
110 Compacted {
113 inputs: Vec<u64>,
115 output: u64,
117 },
118 SetRollup {
122 name: String,
124 definition: Vec<u8>,
126 },
127 RemoveRollup(String),
129 SetRollupState {
132 name: String,
134 state: Vec<u8>,
136 },
137 SetPendingMeasurementDrop {
145 measurement: String,
147 deadline_ms: u64,
149 },
150 CancelPendingMeasurementDrop(String),
153}
154
155#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
157pub struct RollupRecord {
158 #[serde(default)]
160 pub definition: Vec<u8>,
161 #[serde(default)]
163 pub state: Vec<u8>,
164}
165
166#[derive(Debug)]
191pub struct SegmentCatalog {
192 segments: BTreeMap<ShardId, Vec<SegmentCatalogEntry>>,
194 schemas: HashMap<String, MeasurementSchema>,
196 tombstones: TombstoneSet,
206 wal_floor: u64,
215 rollups: BTreeMap<String, RollupRecord>,
222 pending_measurement_drops: BTreeMap<String, u64>,
225 manifest_seq: u64,
227 manifest_dir: PathBuf,
229 changes_since_snapshot: u64,
231 next_segment_id: u64,
233 manifest_wal_file: Option<std::fs::File>,
235 pending_sync: u64,
240 defer_sync: u32,
244 manifest_syncs: u64,
252}
253
254const SNAPSHOT_INTERVAL: u64 = 1000;
256
257const SYNC_BATCH_SIZE: u64 = 1;
267
268const _: () = {
270 const fn _assert_send<T: Send + Sync>() {}
271 _assert_send::<SegmentCatalog>();
272};
273
274impl SegmentCatalog {
275 pub fn new(manifest_dir: impl Into<PathBuf>) -> Result<Self> {
281 let manifest_dir = manifest_dir.into();
282 std::fs::create_dir_all(&manifest_dir)?;
283
284 Ok(Self {
285 segments: BTreeMap::new(),
286 schemas: HashMap::new(),
287 tombstones: TombstoneSet::new(),
288 wal_floor: 0,
289 rollups: BTreeMap::new(),
290 pending_measurement_drops: BTreeMap::new(),
291 manifest_seq: 0,
292 manifest_dir,
293 changes_since_snapshot: 0,
294 next_segment_id: 1,
295 manifest_wal_file: None,
296 pending_sync: 0,
297 defer_sync: 0,
298 manifest_syncs: 0,
299 })
300 }
301
302 pub fn open(manifest_dir: impl Into<PathBuf>) -> Result<Self> {
308 let manifest_dir = manifest_dir.into();
309 std::fs::create_dir_all(&manifest_dir)?;
310
311 let bin_snapshot_path = manifest_dir.join("manifest.snapshot.bin");
312 let mut catalog = if bin_snapshot_path.exists() {
313 let data = std::fs::read(&bin_snapshot_path)?;
314 let snapshot: CatalogSnapshot = postcard::from_bytes(&data)
315 .map_err(|e| IndexError::BinarySerialization(e.to_string()))?;
316 if snapshot.format_version > CATALOG_FORMAT_VERSION {
318 return Err(IndexError::BinarySerialization(format!(
319 "catalog snapshot format version {} is newer than supported version {CATALOG_FORMAT_VERSION}",
320 snapshot.format_version
321 )));
322 }
323 debug!(
324 seq = snapshot.manifest_seq,
325 segments = snapshot.segments.len(),
326 format_version = snapshot.format_version,
327 "loaded catalog snapshot (postcard)"
328 );
329 Self {
330 segments: snapshot.segments,
331 schemas: snapshot.schemas,
332 tombstones: snapshot.tombstones,
333 wal_floor: snapshot.wal_floor,
334 rollups: snapshot.rollups,
335 pending_measurement_drops: snapshot.pending_measurement_drops,
336 manifest_seq: snapshot.manifest_seq,
337 manifest_dir: manifest_dir.clone(),
338 changes_since_snapshot: 0,
339 next_segment_id: snapshot.next_segment_id,
340 manifest_wal_file: None,
341 pending_sync: 0,
342 defer_sync: 0,
343 manifest_syncs: 0,
344 }
345 } else {
346 Self {
347 segments: BTreeMap::new(),
348 schemas: HashMap::new(),
349 tombstones: TombstoneSet::new(),
350 wal_floor: 0,
351 rollups: BTreeMap::new(),
352 pending_measurement_drops: BTreeMap::new(),
353 manifest_seq: 0,
354 manifest_dir: manifest_dir.clone(),
355 changes_since_snapshot: 0,
356 next_segment_id: 1,
357 manifest_wal_file: None,
358 pending_sync: 0,
359 defer_sync: 0,
360 manifest_syncs: 0,
361 }
362 };
363
364 let wal_path = manifest_dir.join("manifest.wal");
366 if wal_path.exists() {
367 catalog.replay_manifest(&wal_path)?;
368 }
369
370 Ok(catalog)
371 }
372
373 pub fn add_segment(&mut self, entry: SegmentCatalogEntry) -> Result<()> {
379 let manifest_entry = ManifestEntry::AddSegment(entry.clone());
380 self.append_manifest(&manifest_entry)?;
381
382 self.segments.entry(entry.shard_id).or_default().push(entry);
383
384 self.maybe_snapshot()?;
385 Ok(())
386 }
387
388 pub fn remove_segment(&mut self, segment_id: SegmentId) -> Result<Option<SegmentCatalogEntry>> {
396 let manifest_entry = ManifestEntry::RemoveSegment(segment_id.0);
397 self.append_manifest(&manifest_entry)?;
398
399 for entries in self.segments.values_mut() {
400 if let Some(pos) = entries.iter().position(|e| e.segment_id == segment_id) {
401 let removed = entries.remove(pos);
402 self.maybe_snapshot()?;
403 return Ok(Some(removed));
404 }
405 }
406
407 self.maybe_snapshot()?;
408 Ok(None)
409 }
410
411 #[must_use]
413 pub fn get_segments_for_shard(&self, shard_id: &ShardId) -> &[SegmentCatalogEntry] {
414 self.segments.get(shard_id).map_or(&[], Vec::as_slice)
415 }
416
417 pub fn soft_delete_segment(&mut self, segment_id: SegmentId, now_ms: u64) -> Result<bool> {
424 let location = self.segments.iter().find_map(|(measurement, entries)| {
426 entries
427 .iter()
428 .position(|e| e.segment_id == segment_id)
429 .map(|idx| (*measurement, idx))
430 });
431
432 let Some((measurement, idx)) = location else {
433 return Ok(false);
434 };
435
436 let entry = &self.segments[&measurement][idx];
440 let mut updated_entry = entry.clone();
441 updated_entry.state = SegmentState::SoftDeleted {
442 deleted_at_ms: now_ms,
443 };
444 let manifest_entry = ManifestEntry::AddSegment(updated_entry);
445 self.append_manifest(&manifest_entry)?;
446
447 if let Some(entries) = self.segments.get_mut(&measurement) {
449 entries[idx].state = SegmentState::SoftDeleted {
450 deleted_at_ms: now_ms,
451 };
452 }
453 Ok(true)
454 }
455
456 #[must_use]
464 pub fn active_segments_for_measurement(&self, measurement: &str) -> Vec<&SegmentCatalogEntry> {
465 if self.pending_measurement_drops.contains_key(measurement) {
466 return Vec::new();
467 }
468 self.segments
469 .values()
470 .flat_map(|v| v.iter())
471 .filter(|e| e.measurement == measurement && e.state == SegmentState::Active)
472 .collect()
473 }
474
475 #[must_use]
478 pub fn expired_soft_deleted(
479 &self,
480 now_ms: u64,
481 grace_period_ms: u64,
482 ) -> Vec<&SegmentCatalogEntry> {
483 self.segments
484 .values()
485 .flat_map(|v| v.iter())
486 .filter(|e| {
487 if let SegmentState::SoftDeleted { deleted_at_ms } = e.state {
488 now_ms.saturating_sub(deleted_at_ms) >= grace_period_ms
489 } else {
490 false
491 }
492 })
493 .collect()
494 }
495
496 #[must_use]
498 pub fn all_segments(&self) -> Vec<&SegmentCatalogEntry> {
499 self.segments.values().flat_map(|v| v.iter()).collect()
500 }
501
502 #[must_use]
504 pub fn segments_for_measurement(&self, measurement: &str) -> Vec<&SegmentCatalogEntry> {
505 self.segments
506 .values()
507 .flat_map(|v| v.iter())
508 .filter(|e| e.measurement == measurement)
509 .collect()
510 }
511
512 #[must_use]
517 pub fn max_segments_per_shard_measurement(&self) -> usize {
518 let mut counts: std::collections::HashMap<(i64, &str), usize> =
519 std::collections::HashMap::new();
520 for e in self.all_segments() {
521 if e.state == chronix_core::SegmentState::Active {
522 *counts
523 .entry((e.shard_id.0, e.measurement.as_str()))
524 .or_default() += 1;
525 }
526 }
527 counts.into_values().max().unwrap_or(0)
528 }
529
530 #[must_use]
539 pub fn memory_bytes(&self) -> usize {
540 let entries: usize = self
541 .segments
542 .values()
543 .map(|v| {
544 v.capacity() * std::mem::size_of::<SegmentCatalogEntry>()
545 + v.iter()
546 .map(|e| {
547 e.measurement.capacity()
548 + e.path.as_os_str().len()
549 + e.column_stats.capacity()
550 * std::mem::size_of::<CatalogColumnStats>()
551 + e.column_stats
552 .iter()
553 .map(|c| c.name.capacity())
554 .sum::<usize>()
555 })
556 .sum::<usize>()
557 })
558 .sum();
559
560 let schemas: usize = self
561 .schemas
562 .iter()
563 .map(|(name, ms)| {
564 name.capacity()
565 + ms.columns()
566 .iter()
567 .map(|c| c.name.capacity() + std::mem::size_of_val(c))
568 .sum::<usize>()
569 })
570 .sum();
571
572 entries + schemas + self.tombstones.len() * std::mem::size_of::<u64>() * 4
573 }
574
575 #[must_use]
577 pub fn segment_count(&self) -> usize {
578 self.segments.values().map(Vec::len).sum()
579 }
580
581 pub fn next_segment_id(&mut self) -> SegmentId {
583 let id = SegmentId(self.next_segment_id);
584 self.next_segment_id += 1;
585 id
586 }
587
588 #[must_use]
590 pub fn manifest_seq(&self) -> u64 {
591 self.manifest_seq
592 }
593
594 #[must_use]
596 pub fn rollups(&self) -> &BTreeMap<String, RollupRecord> {
597 &self.rollups
598 }
599
600 pub fn set_rollup(&mut self, name: &str, definition: Vec<u8>) -> Result<()> {
606 self.append_manifest(&ManifestEntry::SetRollup {
607 name: name.to_string(),
608 definition: definition.clone(),
609 })?;
610 self.rollups.entry(name.to_string()).or_default().definition = definition;
611 self.sync_manifest()?;
612 self.maybe_snapshot()?;
613 Ok(())
614 }
615
616 pub fn set_rollup_state(&mut self, name: &str, state: Vec<u8>) -> Result<()> {
627 self.append_manifest(&ManifestEntry::SetRollupState {
628 name: name.to_string(),
629 state: state.clone(),
630 })?;
631 self.rollups.entry(name.to_string()).or_default().state = state;
632 self.sync_manifest()?;
633 self.maybe_snapshot()?;
634 Ok(())
635 }
636
637 pub fn remove_rollup(&mut self, name: &str) -> Result<bool> {
643 if !self.rollups.contains_key(name) {
644 return Ok(false);
645 }
646 self.append_manifest(&ManifestEntry::RemoveRollup(name.to_string()))?;
647 self.rollups.remove(name);
648 self.sync_manifest()?;
649 self.maybe_snapshot()?;
650 Ok(true)
651 }
652
653 #[must_use]
656 pub fn pending_measurement_drops(&self) -> &BTreeMap<String, u64> {
657 &self.pending_measurement_drops
658 }
659
660 #[must_use]
662 pub fn is_measurement_pending_drop(&self, measurement: &str) -> bool {
663 self.pending_measurement_drops.contains_key(measurement)
664 }
665
666 pub fn set_measurement_pending_drop(
672 &mut self,
673 measurement: &str,
674 deadline_ms: u64,
675 ) -> Result<()> {
676 self.append_manifest(&ManifestEntry::SetPendingMeasurementDrop {
677 measurement: measurement.to_string(),
678 deadline_ms,
679 })?;
680 self.pending_measurement_drops
681 .insert(measurement.to_string(), deadline_ms);
682 self.sync_manifest()?;
683 self.maybe_snapshot()?;
684 Ok(())
685 }
686
687 pub fn cancel_measurement_pending_drop(&mut self, measurement: &str) -> Result<bool> {
696 if !self.pending_measurement_drops.contains_key(measurement) {
697 return Ok(false);
698 }
699 self.append_manifest(&ManifestEntry::CancelPendingMeasurementDrop(
700 measurement.to_string(),
701 ))?;
702 self.pending_measurement_drops.remove(measurement);
703 self.sync_manifest()?;
704 self.maybe_snapshot()?;
705 Ok(true)
706 }
707
708 #[must_use]
710 pub fn tombstones(&self) -> &TombstoneSet {
711 &self.tombstones
712 }
713
714 pub fn record_tombstones(&mut self, tombstones: &[Tombstone]) -> Result<()> {
725 self.in_one_sync(|catalog| {
728 for tombstone in tombstones {
729 catalog.append_manifest(&ManifestEntry::AddTombstone(tombstone.clone()))?;
730 catalog.tombstones.insert(tombstone.clone());
731 }
732 Ok(())
733 })?;
734 self.maybe_snapshot()?;
735 Ok(())
736 }
737
738 pub fn complete_compaction(
752 &mut self,
753 output: SegmentCatalogEntry,
754 inputs: &[SegmentId],
755 now_ms: u64,
756 ) -> Result<()> {
757 let output_id = output.segment_id.0;
758 let input_ids: Vec<u64> = inputs.iter().map(|id| id.0).collect();
759 self.in_one_sync(|catalog| {
761 catalog.add_segment(output)?;
762 for input in inputs {
763 catalog.soft_delete_segment(*input, now_ms)?;
764 }
765 catalog.append_manifest(&ManifestEntry::Compacted {
766 inputs: input_ids.clone(),
767 output: output_id,
768 })
769 })?;
770 self.tombstones
771 .extend_to_compaction_output(&input_ids, output_id);
772 self.maybe_snapshot()?;
773 Ok(())
774 }
775
776 pub fn reclaim_tombstones(&mut self) -> Result<usize> {
787 let live: std::collections::HashSet<u64> = self
788 .segments
789 .values()
790 .flatten()
791 .map(|e| e.segment_id.0)
792 .collect();
793
794 let removed = self.tombstones.retain_tombstones(|t| {
798 t.segments.is_empty() || t.segments.iter().any(|id| live.contains(id))
799 });
800
801 for tombstone in &removed {
802 self.append_manifest(&ManifestEntry::RemoveTombstone(tombstone.clone()))?;
803 }
804 if !removed.is_empty() {
805 self.sync_manifest()?;
806 self.maybe_snapshot()?;
807 }
808 Ok(removed.len())
809 }
810
811 pub fn set_schema(&mut self, schema: MeasurementSchema) -> Result<()> {
817 let entry = ManifestEntry::SetSchema(schema.clone());
818 self.append_manifest(&entry)?;
819 self.schemas
820 .insert(schema.measurement().to_string(), schema);
821 self.maybe_snapshot()?;
822 Ok(())
823 }
824
825 pub fn remove_schema(&mut self, measurement: &str) -> Result<Option<MeasurementSchema>> {
831 let entry = ManifestEntry::RemoveSchema(measurement.to_string());
832 self.append_manifest(&entry)?;
833 let removed = self.schemas.remove(measurement);
834 self.maybe_snapshot()?;
835 Ok(removed)
836 }
837
838 #[must_use]
843 pub fn wal_floor(&self) -> u64 {
844 self.wal_floor
845 }
846
847 pub fn set_wal_floor(&mut self, sequence_no: u64) -> Result<()> {
856 if sequence_no <= self.wal_floor {
857 return Ok(());
858 }
859 self.append_manifest(&ManifestEntry::SetWalFloor(sequence_no))?;
860 self.wal_floor = sequence_no;
861 self.maybe_snapshot()?;
862 Ok(())
863 }
864
865 #[must_use]
867 pub fn get_schema(&self, measurement: &str) -> Option<&MeasurementSchema> {
868 self.schemas.get(measurement)
869 }
870
871 #[must_use]
873 pub fn schema_names(&self) -> Vec<String> {
874 self.schemas.keys().cloned().collect()
875 }
876
877 pub fn all_schemas(&self) -> impl Iterator<Item = (&String, &MeasurementSchema)> {
879 self.schemas.iter()
880 }
881
882 pub fn force_snapshot(&mut self) -> Result<()> {
888 self.write_snapshot()?;
889 self.changes_since_snapshot = 0;
890 Ok(())
891 }
892
893 fn append_manifest(&mut self, entry: &ManifestEntry) -> Result<()> {
897 self.manifest_seq += 1;
898
899 if self.manifest_wal_file.is_none() {
901 let wal_path = self.manifest_dir.join("manifest.wal");
902 let f = std::fs::OpenOptions::new()
903 .create(true)
904 .append(true)
905 .open(&wal_path)?;
906 self.manifest_wal_file = Some(f);
907 }
908 let file = self
909 .manifest_wal_file
910 .as_mut()
911 .ok_or_else(|| IndexError::BinarySerialization("WAL file not opened".into()))?;
912
913 let payload = postcard::to_stdvec(entry)
914 .map_err(|e| IndexError::BinarySerialization(e.to_string()))?;
915 let len = payload.len() as u32;
916 let crc = crc32c::crc32c(&payload);
917 file.write_all(&len.to_le_bytes())?;
918 file.write_all(&payload)?;
919 file.write_all(&crc.to_le_bytes())?;
920 file.flush()?;
922
923 self.pending_sync += 1;
928 if self.defer_sync == 0 && self.pending_sync >= SYNC_BATCH_SIZE {
929 file.sync_data()?;
930 self.pending_sync = 0;
931 self.manifest_syncs += 1;
932 metrics::counter!("chronix_catalog_fsync_total").increment(1);
933 }
934
935 self.changes_since_snapshot += 1;
936 Ok(())
937 }
938
939 #[must_use]
946 pub fn manifest_syncs(&self) -> u64 {
947 self.manifest_syncs
948 }
949
950 fn in_one_sync<T>(&mut self, body: impl FnOnce(&mut Self) -> Result<T>) -> Result<T> {
970 self.defer_sync += 1;
971 let out = body(self);
972 self.defer_sync -= 1;
973 self.sync_manifest()?;
976 out
977 }
978
979 pub fn sync_manifest(&mut self) -> Result<()> {
985 if self.pending_sync > 0 {
986 if let Some(ref f) = self.manifest_wal_file {
987 f.sync_data()?;
988 }
989 self.pending_sync = 0;
990 self.manifest_syncs += 1;
991 metrics::counter!("chronix_catalog_fsync_total").increment(1);
992 }
993 Ok(())
994 }
995
996 fn replay_manifest(&mut self, wal_path: &Path) -> Result<()> {
1002 let mut file = std::fs::File::open(wal_path)?;
1003 let file_len = file.metadata()?.len();
1004
1005 let mut replayed = 0u64;
1006 let mut tail_skipped = 0u64;
1007 let mut pos = 0u64;
1008
1009 loop {
1010 if pos >= file_len {
1011 break;
1012 }
1013 let mut len_buf = [0u8; 4];
1015 match file.read_exact(&mut len_buf) {
1016 Ok(()) => {}
1017 Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
1018 tail_skipped += 1;
1019 warn!(
1020 offset = pos,
1021 "manifest replay: truncated length header at tail"
1022 );
1023 break;
1024 }
1025 Err(e) => return Err(e.into()),
1026 }
1027 let payload_len = u32::from_le_bytes(len_buf) as usize;
1028 pos += 4;
1029
1030 if payload_len == 0 || payload_len > 64 * 1024 * 1024 {
1032 if pos + (payload_len as u64) + 4 > file_len {
1033 tail_skipped += 1;
1034 warn!(
1035 offset = pos - 4,
1036 payload_len, "manifest replay: truncated entry at tail"
1037 );
1038 break;
1039 }
1040 return Err(IndexError::Manifest {
1041 detail: format!(
1042 "invalid manifest entry length {payload_len} at offset {}",
1043 pos - 4
1044 ),
1045 });
1046 }
1047
1048 let total = payload_len + 4; if pos + total as u64 > file_len {
1051 tail_skipped += 1;
1052 warn!(
1053 offset = pos - 4,
1054 "manifest replay: truncated payload at tail"
1055 );
1056 break;
1057 }
1058
1059 let mut buf = vec![0u8; total];
1060 if let Err(e) = file.read_exact(&mut buf) {
1061 if e.kind() == std::io::ErrorKind::UnexpectedEof {
1062 tail_skipped += 1;
1063 warn!(offset = pos - 4, "manifest replay: truncated read at tail");
1064 break;
1065 }
1066 return Err(e.into());
1067 }
1068
1069 let payload = &buf[..payload_len];
1070 let stored_crc = u32::from_le_bytes([
1071 buf[payload_len],
1072 buf[payload_len + 1],
1073 buf[payload_len + 2],
1074 buf[payload_len + 3],
1075 ]);
1076 let computed_crc = crc32c::crc32c(payload);
1077
1078 if stored_crc != computed_crc {
1079 let remaining = file_len - pos - total as u64;
1081 if remaining > 8 {
1082 return Err(IndexError::Manifest {
1083 detail: format!(
1084 "CRC mismatch at offset {} (stored={stored_crc:#x}, computed={computed_crc:#x}) — mid-stream corruption",
1085 pos - 4,
1086 ),
1087 });
1088 }
1089 tail_skipped += 1;
1090 warn!(
1091 offset = pos - 4,
1092 "manifest replay: CRC mismatch at tail, skipping"
1093 );
1094 break;
1095 }
1096
1097 match postcard::from_bytes::<ManifestEntry>(payload) {
1098 Ok(entry) => {
1099 self.apply_manifest_entry(entry);
1100 self.manifest_seq += 1;
1101 replayed += 1;
1102 }
1103 Err(e) => {
1104 let remaining = file_len - pos - total as u64;
1106 if remaining > 8 {
1107 return Err(IndexError::Manifest {
1108 detail: format!("deserialization error at offset {}: {e}", pos - 4,),
1109 });
1110 }
1111 tail_skipped += 1;
1112 warn!(offset = pos - 4, error = %e, "manifest replay: skipping corrupt tail entry");
1113 break;
1114 }
1115 }
1116
1117 pos += total as u64;
1118 }
1119
1120 if tail_skipped > 0 {
1121 warn!(
1122 tail_skipped,
1123 "manifest replay: truncated tail entries skipped"
1124 );
1125 }
1126 if replayed > 0 {
1127 debug!(replayed, "replayed manifest entries");
1128 }
1129
1130 Ok(())
1131 }
1132
1133 fn apply_manifest_entry(&mut self, entry: ManifestEntry) {
1135 match entry {
1136 ManifestEntry::AddSegment(seg) => {
1137 if seg.segment_id.0 >= self.next_segment_id {
1139 self.next_segment_id = seg.segment_id.0 + 1;
1140 }
1141 let shard_entries = self.segments.entry(seg.shard_id).or_default();
1145 shard_entries.retain(|e| e.segment_id != seg.segment_id);
1146 shard_entries.push(seg);
1147 }
1148 ManifestEntry::RemoveSegment(id) => {
1149 for entries in self.segments.values_mut() {
1150 entries.retain(|e| e.segment_id.0 != id);
1151 }
1152 }
1153 ManifestEntry::SetSchema(schema) => {
1154 self.schemas
1155 .insert(schema.measurement().to_string(), schema);
1156 }
1157 ManifestEntry::RemoveSchema(name) => {
1158 self.schemas.remove(&name);
1159 }
1160 ManifestEntry::AddTombstone(t) => {
1161 self.tombstones.insert(t);
1162 }
1163 ManifestEntry::RemoveTombstone(t) => {
1164 self.tombstones.retain_tombstones(|existing| existing != &t);
1165 }
1166 ManifestEntry::SetWalFloor(seq) => {
1167 self.wal_floor = self.wal_floor.max(seq);
1168 }
1169 ManifestEntry::SetRollup { name, definition } => {
1170 self.rollups.entry(name).or_default().definition = definition;
1171 }
1172 ManifestEntry::RemoveRollup(name) => {
1173 self.rollups.remove(&name);
1174 }
1175 ManifestEntry::SetRollupState { name, state } => {
1176 self.rollups.entry(name).or_default().state = state;
1177 }
1178 ManifestEntry::Compacted { inputs, output } => {
1179 self.tombstones.extend_to_compaction_output(&inputs, output);
1180 }
1181 ManifestEntry::SetPendingMeasurementDrop {
1182 measurement,
1183 deadline_ms,
1184 } => {
1185 self.pending_measurement_drops
1186 .insert(measurement, deadline_ms);
1187 }
1188 ManifestEntry::CancelPendingMeasurementDrop(measurement) => {
1189 self.pending_measurement_drops.remove(&measurement);
1190 }
1191 }
1192 }
1193
1194 fn write_snapshot(&mut self) -> Result<()> {
1196 let snapshot = CatalogSnapshot {
1197 format_version: CATALOG_FORMAT_VERSION,
1198 segments: self.segments.clone(),
1199 schemas: self.schemas.clone(),
1200 tombstones: self.tombstones.clone(),
1201 wal_floor: self.wal_floor,
1202 rollups: self.rollups.clone(),
1203 pending_measurement_drops: self.pending_measurement_drops.clone(),
1204 manifest_seq: self.manifest_seq,
1205 next_segment_id: self.next_segment_id,
1206 };
1207
1208 let data = postcard::to_stdvec(&snapshot)
1209 .map_err(|e| IndexError::BinarySerialization(e.to_string()))?;
1210
1211 let snapshot_path = self.manifest_dir.join("manifest.snapshot.bin");
1213 let tmp_path = self.manifest_dir.join("manifest.snapshot.bin.tmp");
1214 let write_tmp = (|| -> std::io::Result<()> {
1220 let mut file = std::fs::File::create(&tmp_path)?;
1221 file.write_all(&data)?;
1222 file.sync_all()
1223 })();
1224 if let Err(e) = write_tmp {
1225 let _ = std::fs::remove_file(&tmp_path);
1226 return Err(e.into());
1227 }
1228 if let Err(e) = std::fs::rename(&tmp_path, &snapshot_path) {
1229 let _ = std::fs::remove_file(&tmp_path);
1230 return Err(e.into());
1231 }
1232
1233 let dir = std::fs::File::open(&self.manifest_dir).map_err(|e| {
1237 std::io::Error::new(
1238 e.kind(),
1239 format!("failed to open manifest dir for fsync: {e}"),
1240 )
1241 })?;
1242 dir.sync_all()?;
1243
1244 let wal_path = self.manifest_dir.join("manifest.wal");
1247 if wal_path.exists() {
1248 let file = std::fs::OpenOptions::new()
1249 .write(true)
1250 .truncate(true)
1251 .open(&wal_path)?;
1252 file.sync_all()?;
1257 }
1258 self.manifest_wal_file = None;
1259
1260 debug!(seq = self.manifest_seq, "catalog snapshot written");
1261 Ok(())
1262 }
1263
1264 fn maybe_snapshot(&mut self) -> Result<()> {
1266 if self.changes_since_snapshot >= SNAPSHOT_INTERVAL {
1267 self.write_snapshot()?;
1268 self.changes_since_snapshot = 0;
1269 }
1270 Ok(())
1271 }
1272}
1273
1274const CATALOG_FORMAT_VERSION: u32 = 1;
1279
1280#[derive(Debug, Serialize, Deserialize)]
1282struct CatalogSnapshot {
1283 #[serde(default)]
1285 format_version: u32,
1286 segments: BTreeMap<ShardId, Vec<SegmentCatalogEntry>>,
1287 schemas: HashMap<String, MeasurementSchema>,
1288 #[serde(default)]
1291 tombstones: TombstoneSet,
1292 #[serde(default)]
1294 wal_floor: u64,
1295 #[serde(default)]
1299 rollups: BTreeMap<String, RollupRecord>,
1300 #[serde(default)]
1302 pending_measurement_drops: BTreeMap<String, u64>,
1303 manifest_seq: u64,
1304 next_segment_id: u64,
1305}
1306
1307#[cfg(test)]
1308mod tests {
1309 use super::*;
1310
1311 fn test_entry(id: u64, shard: i64, min_ts: i64, max_ts: i64) -> SegmentCatalogEntry {
1312 SegmentCatalogEntry {
1313 segment_id: SegmentId(id),
1314 shard_id: ShardId(shard),
1315 measurement: "cpu".to_string(),
1316 path: PathBuf::from(format!("shard_{shard}/seg_{id}.csx")),
1317 min_timestamp: min_ts,
1318 max_timestamp: max_ts,
1319 row_count: 1000,
1320 series_count: 10,
1321 byte_size: 4096,
1322 row_group_count: 1,
1323 column_count: 5,
1324 column_stats: Vec::new(),
1325 state: SegmentState::default(),
1326 }
1327 }
1328
1329 #[test]
1336 fn a_compaction_costs_one_manifest_fsync() {
1337 let dir = tempfile::tempdir().unwrap();
1338 let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1339
1340 let inputs: Vec<SegmentId> = (1..=4i64)
1341 .map(|i| {
1342 #[allow(clippy::cast_sign_loss)]
1343 let e = test_entry(i as u64, 0, i * 100, i * 100 + 50);
1344 let id = e.segment_id;
1345 catalog.add_segment(e).unwrap();
1346 id
1347 })
1348 .collect();
1349
1350 let before = catalog.manifest_syncs();
1351 catalog
1352 .complete_compaction(test_entry(99, 0, 100, 450), &inputs, 0)
1353 .unwrap();
1354 assert_eq!(
1355 catalog.manifest_syncs() - before,
1356 1,
1357 "one transition, one fsync — it was six"
1358 );
1359
1360 drop(catalog);
1363 let reopened = SegmentCatalog::open(dir.path()).unwrap();
1364 let live: Vec<u64> = reopened
1365 .active_segments_for_measurement("cpu")
1366 .iter()
1367 .map(|e| e.segment_id.0)
1368 .collect();
1369 assert_eq!(live, vec![99], "only the compaction output survives");
1370 }
1371
1372 #[test]
1374 fn a_delete_costs_one_manifest_fsync() {
1375 let dir = tempfile::tempdir().unwrap();
1376 let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1377 let tombstones: Vec<Tombstone> = (0..25)
1378 .map(|i| Tombstone::ranged(format!("cpu,host=h{i}"), 0, 1_000))
1379 .collect();
1380
1381 let before = catalog.manifest_syncs();
1382 catalog.record_tombstones(&tombstones).unwrap();
1383 assert_eq!(
1384 catalog.manifest_syncs() - before,
1385 1,
1386 "25 tombstones, 1 fsync"
1387 );
1388
1389 drop(catalog);
1390 let reopened = SegmentCatalog::open(dir.path()).unwrap();
1391 assert_eq!(
1392 reopened.tombstones().len(),
1393 25,
1394 "every tombstone survived the reopen"
1395 );
1396 }
1397
1398 #[test]
1401 fn a_lone_append_still_syncs() {
1402 let dir = tempfile::tempdir().unwrap();
1403 let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1404 let before = catalog.manifest_syncs();
1405 catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
1406 assert_eq!(catalog.manifest_syncs() - before, 1);
1407 catalog.soft_delete_segment(SegmentId(1), 0).unwrap();
1408 assert_eq!(catalog.manifest_syncs() - before, 2);
1409 }
1410
1411 #[test]
1412 fn add_and_get_segments() {
1413 let dir = tempfile::tempdir().unwrap();
1414 let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1415
1416 catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
1417 catalog.add_segment(test_entry(2, 0, 300, 400)).unwrap();
1418 catalog.add_segment(test_entry(3, 1, 100, 200)).unwrap();
1419
1420 assert_eq!(catalog.get_segments_for_shard(&ShardId(0)).len(), 2);
1421 assert_eq!(catalog.get_segments_for_shard(&ShardId(1)).len(), 1);
1422 assert_eq!(catalog.get_segments_for_shard(&ShardId(99)).len(), 0);
1423 assert_eq!(catalog.segment_count(), 3);
1424 }
1425
1426 #[test]
1427 fn remove_segment() {
1428 let dir = tempfile::tempdir().unwrap();
1429 let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1430
1431 catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
1432 catalog.add_segment(test_entry(2, 0, 300, 400)).unwrap();
1433
1434 let removed = catalog.remove_segment(SegmentId(1)).unwrap();
1435 assert!(removed.is_some());
1436 assert_eq!(removed.unwrap().segment_id, SegmentId(1));
1437 assert_eq!(catalog.segment_count(), 1);
1438
1439 let removed = catalog.remove_segment(SegmentId(99)).unwrap();
1441 assert!(removed.is_none());
1442 }
1443
1444 #[test]
1445 fn persist_and_reload() {
1446 let dir = tempfile::tempdir().unwrap();
1447
1448 {
1450 let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1451 catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
1452 catalog.add_segment(test_entry(2, 0, 300, 400)).unwrap();
1453 catalog.add_segment(test_entry(3, 1, 500, 600)).unwrap();
1454 }
1455
1456 let catalog = SegmentCatalog::open(dir.path()).unwrap();
1458 assert_eq!(catalog.segment_count(), 3);
1459 assert_eq!(catalog.get_segments_for_shard(&ShardId(0)).len(), 2);
1460 assert_eq!(catalog.get_segments_for_shard(&ShardId(1)).len(), 1);
1461 }
1462
1463 #[test]
1464 fn snapshot_and_reload() {
1465 let dir = tempfile::tempdir().unwrap();
1466
1467 {
1469 let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1470 catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
1471 catalog.add_segment(test_entry(2, 0, 300, 400)).unwrap();
1472 catalog.force_snapshot().unwrap();
1473 catalog.add_segment(test_entry(3, 1, 500, 600)).unwrap();
1475 }
1476
1477 let catalog = SegmentCatalog::open(dir.path()).unwrap();
1479 assert_eq!(catalog.segment_count(), 3);
1480 }
1481
1482 #[test]
1483 fn schema_persistence() {
1484 let dir = tempfile::tempdir().unwrap();
1485
1486 {
1487 let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1488 let schema = MeasurementSchema::new("cpu");
1489 catalog.set_schema(schema).unwrap();
1490 }
1491
1492 let catalog = SegmentCatalog::open(dir.path()).unwrap();
1493 assert!(catalog.get_schema("cpu").is_some());
1494 assert_eq!(catalog.get_schema("cpu").unwrap().measurement(), "cpu");
1495 }
1496
1497 #[test]
1498 fn remove_schema() {
1499 let dir = tempfile::tempdir().unwrap();
1500 let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1501 catalog.set_schema(MeasurementSchema::new("cpu")).unwrap();
1502 assert!(catalog.get_schema("cpu").is_some());
1503
1504 catalog.remove_schema("cpu").unwrap();
1505 assert!(catalog.get_schema("cpu").is_none());
1506 }
1507
1508 #[test]
1509 fn next_segment_id_monotonic() {
1510 let dir = tempfile::tempdir().unwrap();
1511 let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1512
1513 assert_eq!(catalog.next_segment_id(), SegmentId(1));
1514 assert_eq!(catalog.next_segment_id(), SegmentId(2));
1515 assert_eq!(catalog.next_segment_id(), SegmentId(3));
1516 }
1517
1518 #[test]
1519 fn next_segment_id_survives_reload() {
1520 let dir = tempfile::tempdir().unwrap();
1521
1522 {
1523 let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1524 catalog.add_segment(test_entry(5, 0, 100, 200)).unwrap();
1525 catalog.add_segment(test_entry(10, 0, 300, 400)).unwrap();
1526 }
1527
1528 let mut catalog = SegmentCatalog::open(dir.path()).unwrap();
1529 let next = catalog.next_segment_id();
1531 assert!(
1532 next.0 >= 11,
1533 "next_segment_id should be >= 11, got {}",
1534 next.0
1535 );
1536 }
1537
1538 #[test]
1539 fn add_remove_add_roundtrip() {
1540 let dir = tempfile::tempdir().unwrap();
1541
1542 {
1543 let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1544 catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
1545 catalog.remove_segment(SegmentId(1)).unwrap();
1546 catalog.add_segment(test_entry(2, 0, 300, 400)).unwrap();
1547 }
1548
1549 let catalog = SegmentCatalog::open(dir.path()).unwrap();
1550 assert_eq!(catalog.segment_count(), 1);
1551 assert_eq!(
1552 catalog.get_segments_for_shard(&ShardId(0))[0].segment_id,
1553 SegmentId(2)
1554 );
1555 }
1556
1557 #[test]
1558 fn all_segments() {
1559 let dir = tempfile::tempdir().unwrap();
1560 let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1561
1562 catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
1563 catalog.add_segment(test_entry(2, 1, 300, 400)).unwrap();
1564 catalog.add_segment(test_entry(3, 2, 500, 600)).unwrap();
1565
1566 let all = catalog.all_segments();
1567 assert_eq!(all.len(), 3);
1568 }
1569
1570 #[test]
1571 fn manifest_replay_tail_truncation_tolerated() {
1572 let dir = tempfile::tempdir().unwrap();
1575
1576 {
1577 let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1578 catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
1579 catalog.add_segment(test_entry(2, 0, 300, 400)).unwrap();
1580 }
1581
1582 let wal_path = dir.path().join("manifest.wal");
1584 let mut f = std::fs::OpenOptions::new()
1585 .append(true)
1586 .open(&wal_path)
1587 .unwrap();
1588 let fake_len: u32 = 128;
1590 f.write_all(&fake_len.to_le_bytes()).unwrap();
1591 f.write_all(&[0xDE, 0xAD]).unwrap(); drop(f);
1593
1594 let catalog = SegmentCatalog::open(dir.path()).unwrap();
1596 assert_eq!(catalog.segment_count(), 2);
1597 }
1598
1599 #[test]
1600 fn manifest_replay_midstream_corruption_errors() {
1601 let dir = tempfile::tempdir().unwrap();
1604
1605 {
1606 let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1607 catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
1608 }
1609
1610 let wal_path = dir.path().join("manifest.wal");
1612 let mut f = std::fs::OpenOptions::new()
1613 .append(true)
1614 .open(&wal_path)
1615 .unwrap();
1616
1617 let corrupt_payload = b"CORRUPT_DATA_HERE_1234";
1619 let corrupt_len = corrupt_payload.len() as u32;
1620 let wrong_crc: u32 = 0xDEADBEEF;
1621 f.write_all(&corrupt_len.to_le_bytes()).unwrap();
1622 f.write_all(corrupt_payload).unwrap();
1623 f.write_all(&wrong_crc.to_le_bytes()).unwrap();
1624
1625 let valid_entry = ManifestEntry::AddSegment(test_entry(3, 0, 500, 600));
1627 let valid_payload = postcard::to_stdvec(&valid_entry).unwrap();
1628 let valid_len = valid_payload.len() as u32;
1629 let valid_crc = crc32c::crc32c(&valid_payload);
1630 f.write_all(&valid_len.to_le_bytes()).unwrap();
1631 f.write_all(&valid_payload).unwrap();
1632 f.write_all(&valid_crc.to_le_bytes()).unwrap();
1633 drop(f);
1634
1635 let result = SegmentCatalog::open(dir.path());
1637 assert!(result.is_err(), "expected error for mid-stream corruption");
1638 let err_msg = format!("{}", result.unwrap_err());
1639 assert!(
1640 err_msg.contains("mid-stream corruption"),
1641 "error should mention mid-stream corruption, got: {err_msg}"
1642 );
1643 }
1644}