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}
138
139#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
141pub struct RollupRecord {
142 #[serde(default)]
144 pub definition: Vec<u8>,
145 #[serde(default)]
147 pub state: Vec<u8>,
148}
149
150#[derive(Debug)]
175pub struct SegmentCatalog {
176 segments: BTreeMap<ShardId, Vec<SegmentCatalogEntry>>,
178 schemas: HashMap<String, MeasurementSchema>,
180 tombstones: TombstoneSet,
190 wal_floor: u64,
199 rollups: BTreeMap<String, RollupRecord>,
206 manifest_seq: u64,
208 manifest_dir: PathBuf,
210 changes_since_snapshot: u64,
212 next_segment_id: u64,
214 manifest_wal_file: Option<std::fs::File>,
216 pending_sync: u64,
221 defer_sync: u32,
225 manifest_syncs: u64,
233}
234
235const SNAPSHOT_INTERVAL: u64 = 1000;
237
238const SYNC_BATCH_SIZE: u64 = 1;
248
249const _: () = {
251 const fn _assert_send<T: Send + Sync>() {}
252 _assert_send::<SegmentCatalog>();
253};
254
255impl SegmentCatalog {
256 pub fn new(manifest_dir: impl Into<PathBuf>) -> Result<Self> {
262 let manifest_dir = manifest_dir.into();
263 std::fs::create_dir_all(&manifest_dir)?;
264
265 Ok(Self {
266 segments: BTreeMap::new(),
267 schemas: HashMap::new(),
268 tombstones: TombstoneSet::new(),
269 wal_floor: 0,
270 rollups: BTreeMap::new(),
271 manifest_seq: 0,
272 manifest_dir,
273 changes_since_snapshot: 0,
274 next_segment_id: 1,
275 manifest_wal_file: None,
276 pending_sync: 0,
277 defer_sync: 0,
278 manifest_syncs: 0,
279 })
280 }
281
282 pub fn open(manifest_dir: impl Into<PathBuf>) -> Result<Self> {
288 let manifest_dir = manifest_dir.into();
289 std::fs::create_dir_all(&manifest_dir)?;
290
291 let bin_snapshot_path = manifest_dir.join("manifest.snapshot.bin");
292 let mut catalog = if bin_snapshot_path.exists() {
293 let data = std::fs::read(&bin_snapshot_path)?;
294 let snapshot: CatalogSnapshot = postcard::from_bytes(&data)
295 .map_err(|e| IndexError::BinarySerialization(e.to_string()))?;
296 if snapshot.format_version > CATALOG_FORMAT_VERSION {
298 return Err(IndexError::BinarySerialization(format!(
299 "catalog snapshot format version {} is newer than supported version {CATALOG_FORMAT_VERSION}",
300 snapshot.format_version
301 )));
302 }
303 debug!(
304 seq = snapshot.manifest_seq,
305 segments = snapshot.segments.len(),
306 format_version = snapshot.format_version,
307 "loaded catalog snapshot (postcard)"
308 );
309 Self {
310 segments: snapshot.segments,
311 schemas: snapshot.schemas,
312 tombstones: snapshot.tombstones,
313 wal_floor: snapshot.wal_floor,
314 rollups: snapshot.rollups,
315 manifest_seq: snapshot.manifest_seq,
316 manifest_dir: manifest_dir.clone(),
317 changes_since_snapshot: 0,
318 next_segment_id: snapshot.next_segment_id,
319 manifest_wal_file: None,
320 pending_sync: 0,
321 defer_sync: 0,
322 manifest_syncs: 0,
323 }
324 } else {
325 Self {
326 segments: BTreeMap::new(),
327 schemas: HashMap::new(),
328 tombstones: TombstoneSet::new(),
329 wal_floor: 0,
330 rollups: BTreeMap::new(),
331 manifest_seq: 0,
332 manifest_dir: manifest_dir.clone(),
333 changes_since_snapshot: 0,
334 next_segment_id: 1,
335 manifest_wal_file: None,
336 pending_sync: 0,
337 defer_sync: 0,
338 manifest_syncs: 0,
339 }
340 };
341
342 let wal_path = manifest_dir.join("manifest.wal");
344 if wal_path.exists() {
345 catalog.replay_manifest(&wal_path)?;
346 }
347
348 Ok(catalog)
349 }
350
351 pub fn add_segment(&mut self, entry: SegmentCatalogEntry) -> Result<()> {
357 let manifest_entry = ManifestEntry::AddSegment(entry.clone());
358 self.append_manifest(&manifest_entry)?;
359
360 self.segments.entry(entry.shard_id).or_default().push(entry);
361
362 self.maybe_snapshot()?;
363 Ok(())
364 }
365
366 pub fn remove_segment(&mut self, segment_id: SegmentId) -> Result<Option<SegmentCatalogEntry>> {
374 let manifest_entry = ManifestEntry::RemoveSegment(segment_id.0);
375 self.append_manifest(&manifest_entry)?;
376
377 for entries in self.segments.values_mut() {
378 if let Some(pos) = entries.iter().position(|e| e.segment_id == segment_id) {
379 let removed = entries.remove(pos);
380 self.maybe_snapshot()?;
381 return Ok(Some(removed));
382 }
383 }
384
385 self.maybe_snapshot()?;
386 Ok(None)
387 }
388
389 #[must_use]
391 pub fn get_segments_for_shard(&self, shard_id: &ShardId) -> &[SegmentCatalogEntry] {
392 self.segments.get(shard_id).map_or(&[], Vec::as_slice)
393 }
394
395 pub fn soft_delete_segment(&mut self, segment_id: SegmentId, now_ms: u64) -> Result<bool> {
402 let location = self.segments.iter().find_map(|(measurement, entries)| {
404 entries
405 .iter()
406 .position(|e| e.segment_id == segment_id)
407 .map(|idx| (*measurement, idx))
408 });
409
410 let Some((measurement, idx)) = location else {
411 return Ok(false);
412 };
413
414 let entry = &self.segments[&measurement][idx];
418 let mut updated_entry = entry.clone();
419 updated_entry.state = SegmentState::SoftDeleted {
420 deleted_at_ms: now_ms,
421 };
422 let manifest_entry = ManifestEntry::AddSegment(updated_entry);
423 self.append_manifest(&manifest_entry)?;
424
425 if let Some(entries) = self.segments.get_mut(&measurement) {
427 entries[idx].state = SegmentState::SoftDeleted {
428 deleted_at_ms: now_ms,
429 };
430 }
431 Ok(true)
432 }
433
434 #[must_use]
438 pub fn active_segments_for_measurement(&self, measurement: &str) -> Vec<&SegmentCatalogEntry> {
439 self.segments
440 .values()
441 .flat_map(|v| v.iter())
442 .filter(|e| e.measurement == measurement && e.state == SegmentState::Active)
443 .collect()
444 }
445
446 #[must_use]
449 pub fn expired_soft_deleted(
450 &self,
451 now_ms: u64,
452 grace_period_ms: u64,
453 ) -> Vec<&SegmentCatalogEntry> {
454 self.segments
455 .values()
456 .flat_map(|v| v.iter())
457 .filter(|e| {
458 if let SegmentState::SoftDeleted { deleted_at_ms } = e.state {
459 now_ms.saturating_sub(deleted_at_ms) >= grace_period_ms
460 } else {
461 false
462 }
463 })
464 .collect()
465 }
466
467 #[must_use]
469 pub fn all_segments(&self) -> Vec<&SegmentCatalogEntry> {
470 self.segments.values().flat_map(|v| v.iter()).collect()
471 }
472
473 #[must_use]
475 pub fn segments_for_measurement(&self, measurement: &str) -> Vec<&SegmentCatalogEntry> {
476 self.segments
477 .values()
478 .flat_map(|v| v.iter())
479 .filter(|e| e.measurement == measurement)
480 .collect()
481 }
482
483 #[must_use]
488 pub fn max_segments_per_shard_measurement(&self) -> usize {
489 let mut counts: std::collections::HashMap<(i64, &str), usize> =
490 std::collections::HashMap::new();
491 for e in self.all_segments() {
492 if e.state == chronix_core::SegmentState::Active {
493 *counts
494 .entry((e.shard_id.0, e.measurement.as_str()))
495 .or_default() += 1;
496 }
497 }
498 counts.into_values().max().unwrap_or(0)
499 }
500
501 #[must_use]
510 pub fn memory_bytes(&self) -> usize {
511 let entries: usize = self
512 .segments
513 .values()
514 .map(|v| {
515 v.capacity() * std::mem::size_of::<SegmentCatalogEntry>()
516 + v.iter()
517 .map(|e| {
518 e.measurement.capacity()
519 + e.path.as_os_str().len()
520 + e.column_stats.capacity()
521 * std::mem::size_of::<CatalogColumnStats>()
522 + e.column_stats
523 .iter()
524 .map(|c| c.name.capacity())
525 .sum::<usize>()
526 })
527 .sum::<usize>()
528 })
529 .sum();
530
531 let schemas: usize = self
532 .schemas
533 .iter()
534 .map(|(name, ms)| {
535 name.capacity()
536 + ms.columns()
537 .iter()
538 .map(|c| c.name.capacity() + std::mem::size_of_val(c))
539 .sum::<usize>()
540 })
541 .sum();
542
543 entries + schemas + self.tombstones.len() * std::mem::size_of::<u64>() * 4
544 }
545
546 #[must_use]
548 pub fn segment_count(&self) -> usize {
549 self.segments.values().map(Vec::len).sum()
550 }
551
552 pub fn next_segment_id(&mut self) -> SegmentId {
554 let id = SegmentId(self.next_segment_id);
555 self.next_segment_id += 1;
556 id
557 }
558
559 #[must_use]
561 pub fn manifest_seq(&self) -> u64 {
562 self.manifest_seq
563 }
564
565 #[must_use]
567 pub fn rollups(&self) -> &BTreeMap<String, RollupRecord> {
568 &self.rollups
569 }
570
571 pub fn set_rollup(&mut self, name: &str, definition: Vec<u8>) -> Result<()> {
577 self.append_manifest(&ManifestEntry::SetRollup {
578 name: name.to_string(),
579 definition: definition.clone(),
580 })?;
581 self.rollups.entry(name.to_string()).or_default().definition = definition;
582 self.sync_manifest()?;
583 self.maybe_snapshot()?;
584 Ok(())
585 }
586
587 pub fn set_rollup_state(&mut self, name: &str, state: Vec<u8>) -> Result<()> {
598 self.append_manifest(&ManifestEntry::SetRollupState {
599 name: name.to_string(),
600 state: state.clone(),
601 })?;
602 self.rollups.entry(name.to_string()).or_default().state = state;
603 self.sync_manifest()?;
604 self.maybe_snapshot()?;
605 Ok(())
606 }
607
608 pub fn remove_rollup(&mut self, name: &str) -> Result<bool> {
614 if !self.rollups.contains_key(name) {
615 return Ok(false);
616 }
617 self.append_manifest(&ManifestEntry::RemoveRollup(name.to_string()))?;
618 self.rollups.remove(name);
619 self.sync_manifest()?;
620 self.maybe_snapshot()?;
621 Ok(true)
622 }
623
624 #[must_use]
626 pub fn tombstones(&self) -> &TombstoneSet {
627 &self.tombstones
628 }
629
630 pub fn record_tombstones(&mut self, tombstones: &[Tombstone]) -> Result<()> {
641 self.in_one_sync(|catalog| {
644 for tombstone in tombstones {
645 catalog.append_manifest(&ManifestEntry::AddTombstone(tombstone.clone()))?;
646 catalog.tombstones.insert(tombstone.clone());
647 }
648 Ok(())
649 })?;
650 self.maybe_snapshot()?;
651 Ok(())
652 }
653
654 pub fn complete_compaction(
668 &mut self,
669 output: SegmentCatalogEntry,
670 inputs: &[SegmentId],
671 now_ms: u64,
672 ) -> Result<()> {
673 let output_id = output.segment_id.0;
674 let input_ids: Vec<u64> = inputs.iter().map(|id| id.0).collect();
675 self.in_one_sync(|catalog| {
677 catalog.add_segment(output)?;
678 for input in inputs {
679 catalog.soft_delete_segment(*input, now_ms)?;
680 }
681 catalog.append_manifest(&ManifestEntry::Compacted {
682 inputs: input_ids.clone(),
683 output: output_id,
684 })
685 })?;
686 self.tombstones
687 .extend_to_compaction_output(&input_ids, output_id);
688 self.maybe_snapshot()?;
689 Ok(())
690 }
691
692 pub fn reclaim_tombstones(&mut self) -> Result<usize> {
703 let live: std::collections::HashSet<u64> = self
704 .segments
705 .values()
706 .flatten()
707 .map(|e| e.segment_id.0)
708 .collect();
709
710 let removed = self.tombstones.retain_tombstones(|t| {
714 t.segments.is_empty() || t.segments.iter().any(|id| live.contains(id))
715 });
716
717 for tombstone in &removed {
718 self.append_manifest(&ManifestEntry::RemoveTombstone(tombstone.clone()))?;
719 }
720 if !removed.is_empty() {
721 self.sync_manifest()?;
722 self.maybe_snapshot()?;
723 }
724 Ok(removed.len())
725 }
726
727 pub fn set_schema(&mut self, schema: MeasurementSchema) -> Result<()> {
733 let entry = ManifestEntry::SetSchema(schema.clone());
734 self.append_manifest(&entry)?;
735 self.schemas
736 .insert(schema.measurement().to_string(), schema);
737 self.maybe_snapshot()?;
738 Ok(())
739 }
740
741 pub fn remove_schema(&mut self, measurement: &str) -> Result<Option<MeasurementSchema>> {
747 let entry = ManifestEntry::RemoveSchema(measurement.to_string());
748 self.append_manifest(&entry)?;
749 let removed = self.schemas.remove(measurement);
750 self.maybe_snapshot()?;
751 Ok(removed)
752 }
753
754 #[must_use]
759 pub fn wal_floor(&self) -> u64 {
760 self.wal_floor
761 }
762
763 pub fn set_wal_floor(&mut self, sequence_no: u64) -> Result<()> {
772 if sequence_no <= self.wal_floor {
773 return Ok(());
774 }
775 self.append_manifest(&ManifestEntry::SetWalFloor(sequence_no))?;
776 self.wal_floor = sequence_no;
777 self.maybe_snapshot()?;
778 Ok(())
779 }
780
781 #[must_use]
783 pub fn get_schema(&self, measurement: &str) -> Option<&MeasurementSchema> {
784 self.schemas.get(measurement)
785 }
786
787 #[must_use]
789 pub fn schema_names(&self) -> Vec<String> {
790 self.schemas.keys().cloned().collect()
791 }
792
793 pub fn all_schemas(&self) -> impl Iterator<Item = (&String, &MeasurementSchema)> {
795 self.schemas.iter()
796 }
797
798 pub fn force_snapshot(&mut self) -> Result<()> {
804 self.write_snapshot()?;
805 self.changes_since_snapshot = 0;
806 Ok(())
807 }
808
809 fn append_manifest(&mut self, entry: &ManifestEntry) -> Result<()> {
813 self.manifest_seq += 1;
814
815 if self.manifest_wal_file.is_none() {
817 let wal_path = self.manifest_dir.join("manifest.wal");
818 let f = std::fs::OpenOptions::new()
819 .create(true)
820 .append(true)
821 .open(&wal_path)?;
822 self.manifest_wal_file = Some(f);
823 }
824 let file = self
825 .manifest_wal_file
826 .as_mut()
827 .ok_or_else(|| IndexError::BinarySerialization("WAL file not opened".into()))?;
828
829 let payload = postcard::to_stdvec(entry)
830 .map_err(|e| IndexError::BinarySerialization(e.to_string()))?;
831 let len = payload.len() as u32;
832 let crc = crc32c::crc32c(&payload);
833 file.write_all(&len.to_le_bytes())?;
834 file.write_all(&payload)?;
835 file.write_all(&crc.to_le_bytes())?;
836 file.flush()?;
838
839 self.pending_sync += 1;
844 if self.defer_sync == 0 && self.pending_sync >= SYNC_BATCH_SIZE {
845 file.sync_data()?;
846 self.pending_sync = 0;
847 self.manifest_syncs += 1;
848 metrics::counter!("chronix_catalog_fsync_total").increment(1);
849 }
850
851 self.changes_since_snapshot += 1;
852 Ok(())
853 }
854
855 #[must_use]
862 pub fn manifest_syncs(&self) -> u64 {
863 self.manifest_syncs
864 }
865
866 fn in_one_sync<T>(&mut self, body: impl FnOnce(&mut Self) -> Result<T>) -> Result<T> {
886 self.defer_sync += 1;
887 let out = body(self);
888 self.defer_sync -= 1;
889 self.sync_manifest()?;
892 out
893 }
894
895 pub fn sync_manifest(&mut self) -> Result<()> {
901 if self.pending_sync > 0 {
902 if let Some(ref f) = self.manifest_wal_file {
903 f.sync_data()?;
904 }
905 self.pending_sync = 0;
906 self.manifest_syncs += 1;
907 metrics::counter!("chronix_catalog_fsync_total").increment(1);
908 }
909 Ok(())
910 }
911
912 fn replay_manifest(&mut self, wal_path: &Path) -> Result<()> {
918 let mut file = std::fs::File::open(wal_path)?;
919 let file_len = file.metadata()?.len();
920
921 let mut replayed = 0u64;
922 let mut tail_skipped = 0u64;
923 let mut pos = 0u64;
924
925 loop {
926 if pos >= file_len {
927 break;
928 }
929 let mut len_buf = [0u8; 4];
931 match file.read_exact(&mut len_buf) {
932 Ok(()) => {}
933 Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
934 tail_skipped += 1;
935 warn!(
936 offset = pos,
937 "manifest replay: truncated length header at tail"
938 );
939 break;
940 }
941 Err(e) => return Err(e.into()),
942 }
943 let payload_len = u32::from_le_bytes(len_buf) as usize;
944 pos += 4;
945
946 if payload_len == 0 || payload_len > 64 * 1024 * 1024 {
948 if pos + (payload_len as u64) + 4 > file_len {
949 tail_skipped += 1;
950 warn!(
951 offset = pos - 4,
952 payload_len, "manifest replay: truncated entry at tail"
953 );
954 break;
955 }
956 return Err(IndexError::Manifest {
957 detail: format!(
958 "invalid manifest entry length {payload_len} at offset {}",
959 pos - 4
960 ),
961 });
962 }
963
964 let total = payload_len + 4; if pos + total as u64 > file_len {
967 tail_skipped += 1;
968 warn!(
969 offset = pos - 4,
970 "manifest replay: truncated payload at tail"
971 );
972 break;
973 }
974
975 let mut buf = vec![0u8; total];
976 if let Err(e) = file.read_exact(&mut buf) {
977 if e.kind() == std::io::ErrorKind::UnexpectedEof {
978 tail_skipped += 1;
979 warn!(offset = pos - 4, "manifest replay: truncated read at tail");
980 break;
981 }
982 return Err(e.into());
983 }
984
985 let payload = &buf[..payload_len];
986 let stored_crc = u32::from_le_bytes([
987 buf[payload_len],
988 buf[payload_len + 1],
989 buf[payload_len + 2],
990 buf[payload_len + 3],
991 ]);
992 let computed_crc = crc32c::crc32c(payload);
993
994 if stored_crc != computed_crc {
995 let remaining = file_len - pos - total as u64;
997 if remaining > 8 {
998 return Err(IndexError::Manifest {
999 detail: format!(
1000 "CRC mismatch at offset {} (stored={stored_crc:#x}, computed={computed_crc:#x}) — mid-stream corruption",
1001 pos - 4,
1002 ),
1003 });
1004 }
1005 tail_skipped += 1;
1006 warn!(
1007 offset = pos - 4,
1008 "manifest replay: CRC mismatch at tail, skipping"
1009 );
1010 break;
1011 }
1012
1013 match postcard::from_bytes::<ManifestEntry>(payload) {
1014 Ok(entry) => {
1015 self.apply_manifest_entry(entry);
1016 self.manifest_seq += 1;
1017 replayed += 1;
1018 }
1019 Err(e) => {
1020 let remaining = file_len - pos - total as u64;
1022 if remaining > 8 {
1023 return Err(IndexError::Manifest {
1024 detail: format!("deserialization error at offset {}: {e}", pos - 4,),
1025 });
1026 }
1027 tail_skipped += 1;
1028 warn!(offset = pos - 4, error = %e, "manifest replay: skipping corrupt tail entry");
1029 break;
1030 }
1031 }
1032
1033 pos += total as u64;
1034 }
1035
1036 if tail_skipped > 0 {
1037 warn!(
1038 tail_skipped,
1039 "manifest replay: truncated tail entries skipped"
1040 );
1041 }
1042 if replayed > 0 {
1043 debug!(replayed, "replayed manifest entries");
1044 }
1045
1046 Ok(())
1047 }
1048
1049 fn apply_manifest_entry(&mut self, entry: ManifestEntry) {
1051 match entry {
1052 ManifestEntry::AddSegment(seg) => {
1053 if seg.segment_id.0 >= self.next_segment_id {
1055 self.next_segment_id = seg.segment_id.0 + 1;
1056 }
1057 let shard_entries = self.segments.entry(seg.shard_id).or_default();
1061 shard_entries.retain(|e| e.segment_id != seg.segment_id);
1062 shard_entries.push(seg);
1063 }
1064 ManifestEntry::RemoveSegment(id) => {
1065 for entries in self.segments.values_mut() {
1066 entries.retain(|e| e.segment_id.0 != id);
1067 }
1068 }
1069 ManifestEntry::SetSchema(schema) => {
1070 self.schemas
1071 .insert(schema.measurement().to_string(), schema);
1072 }
1073 ManifestEntry::RemoveSchema(name) => {
1074 self.schemas.remove(&name);
1075 }
1076 ManifestEntry::AddTombstone(t) => {
1077 self.tombstones.insert(t);
1078 }
1079 ManifestEntry::RemoveTombstone(t) => {
1080 self.tombstones.retain_tombstones(|existing| existing != &t);
1081 }
1082 ManifestEntry::SetWalFloor(seq) => {
1083 self.wal_floor = self.wal_floor.max(seq);
1084 }
1085 ManifestEntry::SetRollup { name, definition } => {
1086 self.rollups.entry(name).or_default().definition = definition;
1087 }
1088 ManifestEntry::RemoveRollup(name) => {
1089 self.rollups.remove(&name);
1090 }
1091 ManifestEntry::SetRollupState { name, state } => {
1092 self.rollups.entry(name).or_default().state = state;
1093 }
1094 ManifestEntry::Compacted { inputs, output } => {
1095 self.tombstones.extend_to_compaction_output(&inputs, output);
1096 }
1097 }
1098 }
1099
1100 fn write_snapshot(&mut self) -> Result<()> {
1102 let snapshot = CatalogSnapshot {
1103 format_version: CATALOG_FORMAT_VERSION,
1104 segments: self.segments.clone(),
1105 schemas: self.schemas.clone(),
1106 tombstones: self.tombstones.clone(),
1107 wal_floor: self.wal_floor,
1108 rollups: self.rollups.clone(),
1109 manifest_seq: self.manifest_seq,
1110 next_segment_id: self.next_segment_id,
1111 };
1112
1113 let data = postcard::to_stdvec(&snapshot)
1114 .map_err(|e| IndexError::BinarySerialization(e.to_string()))?;
1115
1116 let snapshot_path = self.manifest_dir.join("manifest.snapshot.bin");
1118 let tmp_path = self.manifest_dir.join("manifest.snapshot.bin.tmp");
1119 let write_tmp = (|| -> std::io::Result<()> {
1125 let mut file = std::fs::File::create(&tmp_path)?;
1126 file.write_all(&data)?;
1127 file.sync_all()
1128 })();
1129 if let Err(e) = write_tmp {
1130 let _ = std::fs::remove_file(&tmp_path);
1131 return Err(e.into());
1132 }
1133 if let Err(e) = std::fs::rename(&tmp_path, &snapshot_path) {
1134 let _ = std::fs::remove_file(&tmp_path);
1135 return Err(e.into());
1136 }
1137
1138 let dir = std::fs::File::open(&self.manifest_dir).map_err(|e| {
1142 std::io::Error::new(
1143 e.kind(),
1144 format!("failed to open manifest dir for fsync: {e}"),
1145 )
1146 })?;
1147 dir.sync_all()?;
1148
1149 let wal_path = self.manifest_dir.join("manifest.wal");
1152 if wal_path.exists() {
1153 let file = std::fs::OpenOptions::new()
1154 .write(true)
1155 .truncate(true)
1156 .open(&wal_path)?;
1157 file.sync_all()?;
1162 }
1163 self.manifest_wal_file = None;
1164
1165 debug!(seq = self.manifest_seq, "catalog snapshot written");
1166 Ok(())
1167 }
1168
1169 fn maybe_snapshot(&mut self) -> Result<()> {
1171 if self.changes_since_snapshot >= SNAPSHOT_INTERVAL {
1172 self.write_snapshot()?;
1173 self.changes_since_snapshot = 0;
1174 }
1175 Ok(())
1176 }
1177}
1178
1179const CATALOG_FORMAT_VERSION: u32 = 1;
1184
1185#[derive(Debug, Serialize, Deserialize)]
1187struct CatalogSnapshot {
1188 #[serde(default)]
1190 format_version: u32,
1191 segments: BTreeMap<ShardId, Vec<SegmentCatalogEntry>>,
1192 schemas: HashMap<String, MeasurementSchema>,
1193 #[serde(default)]
1196 tombstones: TombstoneSet,
1197 #[serde(default)]
1199 wal_floor: u64,
1200 #[serde(default)]
1204 rollups: BTreeMap<String, RollupRecord>,
1205 manifest_seq: u64,
1206 next_segment_id: u64,
1207}
1208
1209#[cfg(test)]
1210mod tests {
1211 use super::*;
1212
1213 fn test_entry(id: u64, shard: i64, min_ts: i64, max_ts: i64) -> SegmentCatalogEntry {
1214 SegmentCatalogEntry {
1215 segment_id: SegmentId(id),
1216 shard_id: ShardId(shard),
1217 measurement: "cpu".to_string(),
1218 path: PathBuf::from(format!("shard_{shard}/seg_{id}.csx")),
1219 min_timestamp: min_ts,
1220 max_timestamp: max_ts,
1221 row_count: 1000,
1222 series_count: 10,
1223 byte_size: 4096,
1224 row_group_count: 1,
1225 column_count: 5,
1226 column_stats: Vec::new(),
1227 state: SegmentState::default(),
1228 }
1229 }
1230
1231 #[test]
1238 fn a_compaction_costs_one_manifest_fsync() {
1239 let dir = tempfile::tempdir().unwrap();
1240 let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1241
1242 let inputs: Vec<SegmentId> = (1..=4i64)
1243 .map(|i| {
1244 #[allow(clippy::cast_sign_loss)]
1245 let e = test_entry(i as u64, 0, i * 100, i * 100 + 50);
1246 let id = e.segment_id;
1247 catalog.add_segment(e).unwrap();
1248 id
1249 })
1250 .collect();
1251
1252 let before = catalog.manifest_syncs();
1253 catalog
1254 .complete_compaction(test_entry(99, 0, 100, 450), &inputs, 0)
1255 .unwrap();
1256 assert_eq!(
1257 catalog.manifest_syncs() - before,
1258 1,
1259 "one transition, one fsync — it was six"
1260 );
1261
1262 drop(catalog);
1265 let reopened = SegmentCatalog::open(dir.path()).unwrap();
1266 let live: Vec<u64> = reopened
1267 .active_segments_for_measurement("cpu")
1268 .iter()
1269 .map(|e| e.segment_id.0)
1270 .collect();
1271 assert_eq!(live, vec![99], "only the compaction output survives");
1272 }
1273
1274 #[test]
1276 fn a_delete_costs_one_manifest_fsync() {
1277 let dir = tempfile::tempdir().unwrap();
1278 let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1279 let tombstones: Vec<Tombstone> = (0..25)
1280 .map(|i| Tombstone::ranged(format!("cpu,host=h{i}"), 0, 1_000))
1281 .collect();
1282
1283 let before = catalog.manifest_syncs();
1284 catalog.record_tombstones(&tombstones).unwrap();
1285 assert_eq!(
1286 catalog.manifest_syncs() - before,
1287 1,
1288 "25 tombstones, 1 fsync"
1289 );
1290
1291 drop(catalog);
1292 let reopened = SegmentCatalog::open(dir.path()).unwrap();
1293 assert_eq!(
1294 reopened.tombstones().len(),
1295 25,
1296 "every tombstone survived the reopen"
1297 );
1298 }
1299
1300 #[test]
1303 fn a_lone_append_still_syncs() {
1304 let dir = tempfile::tempdir().unwrap();
1305 let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1306 let before = catalog.manifest_syncs();
1307 catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
1308 assert_eq!(catalog.manifest_syncs() - before, 1);
1309 catalog.soft_delete_segment(SegmentId(1), 0).unwrap();
1310 assert_eq!(catalog.manifest_syncs() - before, 2);
1311 }
1312
1313 #[test]
1314 fn add_and_get_segments() {
1315 let dir = tempfile::tempdir().unwrap();
1316 let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1317
1318 catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
1319 catalog.add_segment(test_entry(2, 0, 300, 400)).unwrap();
1320 catalog.add_segment(test_entry(3, 1, 100, 200)).unwrap();
1321
1322 assert_eq!(catalog.get_segments_for_shard(&ShardId(0)).len(), 2);
1323 assert_eq!(catalog.get_segments_for_shard(&ShardId(1)).len(), 1);
1324 assert_eq!(catalog.get_segments_for_shard(&ShardId(99)).len(), 0);
1325 assert_eq!(catalog.segment_count(), 3);
1326 }
1327
1328 #[test]
1329 fn remove_segment() {
1330 let dir = tempfile::tempdir().unwrap();
1331 let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1332
1333 catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
1334 catalog.add_segment(test_entry(2, 0, 300, 400)).unwrap();
1335
1336 let removed = catalog.remove_segment(SegmentId(1)).unwrap();
1337 assert!(removed.is_some());
1338 assert_eq!(removed.unwrap().segment_id, SegmentId(1));
1339 assert_eq!(catalog.segment_count(), 1);
1340
1341 let removed = catalog.remove_segment(SegmentId(99)).unwrap();
1343 assert!(removed.is_none());
1344 }
1345
1346 #[test]
1347 fn persist_and_reload() {
1348 let dir = tempfile::tempdir().unwrap();
1349
1350 {
1352 let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1353 catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
1354 catalog.add_segment(test_entry(2, 0, 300, 400)).unwrap();
1355 catalog.add_segment(test_entry(3, 1, 500, 600)).unwrap();
1356 }
1357
1358 let catalog = SegmentCatalog::open(dir.path()).unwrap();
1360 assert_eq!(catalog.segment_count(), 3);
1361 assert_eq!(catalog.get_segments_for_shard(&ShardId(0)).len(), 2);
1362 assert_eq!(catalog.get_segments_for_shard(&ShardId(1)).len(), 1);
1363 }
1364
1365 #[test]
1366 fn snapshot_and_reload() {
1367 let dir = tempfile::tempdir().unwrap();
1368
1369 {
1371 let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1372 catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
1373 catalog.add_segment(test_entry(2, 0, 300, 400)).unwrap();
1374 catalog.force_snapshot().unwrap();
1375 catalog.add_segment(test_entry(3, 1, 500, 600)).unwrap();
1377 }
1378
1379 let catalog = SegmentCatalog::open(dir.path()).unwrap();
1381 assert_eq!(catalog.segment_count(), 3);
1382 }
1383
1384 #[test]
1385 fn schema_persistence() {
1386 let dir = tempfile::tempdir().unwrap();
1387
1388 {
1389 let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1390 let schema = MeasurementSchema::new("cpu");
1391 catalog.set_schema(schema).unwrap();
1392 }
1393
1394 let catalog = SegmentCatalog::open(dir.path()).unwrap();
1395 assert!(catalog.get_schema("cpu").is_some());
1396 assert_eq!(catalog.get_schema("cpu").unwrap().measurement(), "cpu");
1397 }
1398
1399 #[test]
1400 fn remove_schema() {
1401 let dir = tempfile::tempdir().unwrap();
1402 let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1403 catalog.set_schema(MeasurementSchema::new("cpu")).unwrap();
1404 assert!(catalog.get_schema("cpu").is_some());
1405
1406 catalog.remove_schema("cpu").unwrap();
1407 assert!(catalog.get_schema("cpu").is_none());
1408 }
1409
1410 #[test]
1411 fn next_segment_id_monotonic() {
1412 let dir = tempfile::tempdir().unwrap();
1413 let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1414
1415 assert_eq!(catalog.next_segment_id(), SegmentId(1));
1416 assert_eq!(catalog.next_segment_id(), SegmentId(2));
1417 assert_eq!(catalog.next_segment_id(), SegmentId(3));
1418 }
1419
1420 #[test]
1421 fn next_segment_id_survives_reload() {
1422 let dir = tempfile::tempdir().unwrap();
1423
1424 {
1425 let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1426 catalog.add_segment(test_entry(5, 0, 100, 200)).unwrap();
1427 catalog.add_segment(test_entry(10, 0, 300, 400)).unwrap();
1428 }
1429
1430 let mut catalog = SegmentCatalog::open(dir.path()).unwrap();
1431 let next = catalog.next_segment_id();
1433 assert!(
1434 next.0 >= 11,
1435 "next_segment_id should be >= 11, got {}",
1436 next.0
1437 );
1438 }
1439
1440 #[test]
1441 fn add_remove_add_roundtrip() {
1442 let dir = tempfile::tempdir().unwrap();
1443
1444 {
1445 let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1446 catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
1447 catalog.remove_segment(SegmentId(1)).unwrap();
1448 catalog.add_segment(test_entry(2, 0, 300, 400)).unwrap();
1449 }
1450
1451 let catalog = SegmentCatalog::open(dir.path()).unwrap();
1452 assert_eq!(catalog.segment_count(), 1);
1453 assert_eq!(
1454 catalog.get_segments_for_shard(&ShardId(0))[0].segment_id,
1455 SegmentId(2)
1456 );
1457 }
1458
1459 #[test]
1460 fn all_segments() {
1461 let dir = tempfile::tempdir().unwrap();
1462 let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1463
1464 catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
1465 catalog.add_segment(test_entry(2, 1, 300, 400)).unwrap();
1466 catalog.add_segment(test_entry(3, 2, 500, 600)).unwrap();
1467
1468 let all = catalog.all_segments();
1469 assert_eq!(all.len(), 3);
1470 }
1471
1472 #[test]
1473 fn manifest_replay_tail_truncation_tolerated() {
1474 let dir = tempfile::tempdir().unwrap();
1477
1478 {
1479 let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1480 catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
1481 catalog.add_segment(test_entry(2, 0, 300, 400)).unwrap();
1482 }
1483
1484 let wal_path = dir.path().join("manifest.wal");
1486 let mut f = std::fs::OpenOptions::new()
1487 .append(true)
1488 .open(&wal_path)
1489 .unwrap();
1490 let fake_len: u32 = 128;
1492 f.write_all(&fake_len.to_le_bytes()).unwrap();
1493 f.write_all(&[0xDE, 0xAD]).unwrap(); drop(f);
1495
1496 let catalog = SegmentCatalog::open(dir.path()).unwrap();
1498 assert_eq!(catalog.segment_count(), 2);
1499 }
1500
1501 #[test]
1502 fn manifest_replay_midstream_corruption_errors() {
1503 let dir = tempfile::tempdir().unwrap();
1506
1507 {
1508 let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1509 catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
1510 }
1511
1512 let wal_path = dir.path().join("manifest.wal");
1514 let mut f = std::fs::OpenOptions::new()
1515 .append(true)
1516 .open(&wal_path)
1517 .unwrap();
1518
1519 let corrupt_payload = b"CORRUPT_DATA_HERE_1234";
1521 let corrupt_len = corrupt_payload.len() as u32;
1522 let wrong_crc: u32 = 0xDEADBEEF;
1523 f.write_all(&corrupt_len.to_le_bytes()).unwrap();
1524 f.write_all(corrupt_payload).unwrap();
1525 f.write_all(&wrong_crc.to_le_bytes()).unwrap();
1526
1527 let valid_entry = ManifestEntry::AddSegment(test_entry(3, 0, 500, 600));
1529 let valid_payload = postcard::to_stdvec(&valid_entry).unwrap();
1530 let valid_len = valid_payload.len() as u32;
1531 let valid_crc = crc32c::crc32c(&valid_payload);
1532 f.write_all(&valid_len.to_le_bytes()).unwrap();
1533 f.write_all(&valid_payload).unwrap();
1534 f.write_all(&valid_crc.to_le_bytes()).unwrap();
1535 drop(f);
1536
1537 let result = SegmentCatalog::open(dir.path());
1539 assert!(result.is_err(), "expected error for mid-stream corruption");
1540 let err_msg = format!("{}", result.unwrap_err());
1541 assert!(
1542 err_msg.contains("mid-stream corruption"),
1543 "error should mention mid-stream corruption, got: {err_msg}"
1544 );
1545 }
1546}