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,
211}
212
213const SNAPSHOT_INTERVAL: u64 = 1000;
215
216const SYNC_BATCH_SIZE: u64 = 1;
221
222const _: () = {
224 const fn _assert_send<T: Send + Sync>() {}
225 _assert_send::<SegmentCatalog>();
226};
227
228impl SegmentCatalog {
229 pub fn new(manifest_dir: impl Into<PathBuf>) -> Result<Self> {
235 let manifest_dir = manifest_dir.into();
236 std::fs::create_dir_all(&manifest_dir)?;
237
238 Ok(Self {
239 segments: BTreeMap::new(),
240 schemas: HashMap::new(),
241 tombstones: TombstoneSet::new(),
242 wal_floor: 0,
243 rollups: BTreeMap::new(),
244 manifest_seq: 0,
245 manifest_dir,
246 changes_since_snapshot: 0,
247 next_segment_id: 1,
248 manifest_wal_file: None,
249 pending_sync: 0,
250 })
251 }
252
253 pub fn open(manifest_dir: impl Into<PathBuf>) -> Result<Self> {
259 let manifest_dir = manifest_dir.into();
260 std::fs::create_dir_all(&manifest_dir)?;
261
262 let bin_snapshot_path = manifest_dir.join("manifest.snapshot.bin");
263 let mut catalog = if bin_snapshot_path.exists() {
264 let data = std::fs::read(&bin_snapshot_path)?;
265 let snapshot: CatalogSnapshot = postcard::from_bytes(&data)
266 .map_err(|e| IndexError::BinarySerialization(e.to_string()))?;
267 if snapshot.format_version > CATALOG_FORMAT_VERSION {
269 return Err(IndexError::BinarySerialization(format!(
270 "catalog snapshot format version {} is newer than supported version {CATALOG_FORMAT_VERSION}",
271 snapshot.format_version
272 )));
273 }
274 debug!(
275 seq = snapshot.manifest_seq,
276 segments = snapshot.segments.len(),
277 format_version = snapshot.format_version,
278 "loaded catalog snapshot (postcard)"
279 );
280 Self {
281 segments: snapshot.segments,
282 schemas: snapshot.schemas,
283 tombstones: snapshot.tombstones,
284 wal_floor: snapshot.wal_floor,
285 rollups: snapshot.rollups,
286 manifest_seq: snapshot.manifest_seq,
287 manifest_dir: manifest_dir.clone(),
288 changes_since_snapshot: 0,
289 next_segment_id: snapshot.next_segment_id,
290 manifest_wal_file: None,
291 pending_sync: 0,
292 }
293 } else {
294 Self {
295 segments: BTreeMap::new(),
296 schemas: HashMap::new(),
297 tombstones: TombstoneSet::new(),
298 wal_floor: 0,
299 rollups: BTreeMap::new(),
300 manifest_seq: 0,
301 manifest_dir: manifest_dir.clone(),
302 changes_since_snapshot: 0,
303 next_segment_id: 1,
304 manifest_wal_file: None,
305 pending_sync: 0,
306 }
307 };
308
309 let wal_path = manifest_dir.join("manifest.wal");
311 if wal_path.exists() {
312 catalog.replay_manifest(&wal_path)?;
313 }
314
315 Ok(catalog)
316 }
317
318 pub fn add_segment(&mut self, entry: SegmentCatalogEntry) -> Result<()> {
324 let manifest_entry = ManifestEntry::AddSegment(entry.clone());
325 self.append_manifest(&manifest_entry)?;
326
327 self.segments.entry(entry.shard_id).or_default().push(entry);
328
329 self.maybe_snapshot()?;
330 Ok(())
331 }
332
333 pub fn remove_segment(&mut self, segment_id: SegmentId) -> Result<Option<SegmentCatalogEntry>> {
341 let manifest_entry = ManifestEntry::RemoveSegment(segment_id.0);
342 self.append_manifest(&manifest_entry)?;
343
344 for entries in self.segments.values_mut() {
345 if let Some(pos) = entries.iter().position(|e| e.segment_id == segment_id) {
346 let removed = entries.remove(pos);
347 self.maybe_snapshot()?;
348 return Ok(Some(removed));
349 }
350 }
351
352 self.maybe_snapshot()?;
353 Ok(None)
354 }
355
356 #[must_use]
358 pub fn get_segments_for_shard(&self, shard_id: &ShardId) -> &[SegmentCatalogEntry] {
359 self.segments.get(shard_id).map_or(&[], Vec::as_slice)
360 }
361
362 pub fn soft_delete_segment(&mut self, segment_id: SegmentId, now_ms: u64) -> Result<bool> {
369 let location = self.segments.iter().find_map(|(measurement, entries)| {
371 entries
372 .iter()
373 .position(|e| e.segment_id == segment_id)
374 .map(|idx| (*measurement, idx))
375 });
376
377 let Some((measurement, idx)) = location else {
378 return Ok(false);
379 };
380
381 let entry = &self.segments[&measurement][idx];
385 let mut updated_entry = entry.clone();
386 updated_entry.state = SegmentState::SoftDeleted {
387 deleted_at_ms: now_ms,
388 };
389 let manifest_entry = ManifestEntry::AddSegment(updated_entry);
390 self.append_manifest(&manifest_entry)?;
391
392 if let Some(entries) = self.segments.get_mut(&measurement) {
394 entries[idx].state = SegmentState::SoftDeleted {
395 deleted_at_ms: now_ms,
396 };
397 }
398 Ok(true)
399 }
400
401 #[must_use]
405 pub fn active_segments_for_measurement(&self, measurement: &str) -> Vec<&SegmentCatalogEntry> {
406 self.segments
407 .values()
408 .flat_map(|v| v.iter())
409 .filter(|e| e.measurement == measurement && e.state == SegmentState::Active)
410 .collect()
411 }
412
413 #[must_use]
416 pub fn expired_soft_deleted(
417 &self,
418 now_ms: u64,
419 grace_period_ms: u64,
420 ) -> Vec<&SegmentCatalogEntry> {
421 self.segments
422 .values()
423 .flat_map(|v| v.iter())
424 .filter(|e| {
425 if let SegmentState::SoftDeleted { deleted_at_ms } = e.state {
426 now_ms.saturating_sub(deleted_at_ms) >= grace_period_ms
427 } else {
428 false
429 }
430 })
431 .collect()
432 }
433
434 #[must_use]
436 pub fn all_segments(&self) -> Vec<&SegmentCatalogEntry> {
437 self.segments.values().flat_map(|v| v.iter()).collect()
438 }
439
440 #[must_use]
442 pub fn segments_for_measurement(&self, measurement: &str) -> Vec<&SegmentCatalogEntry> {
443 self.segments
444 .values()
445 .flat_map(|v| v.iter())
446 .filter(|e| e.measurement == measurement)
447 .collect()
448 }
449
450 #[must_use]
455 pub fn max_segments_per_shard_measurement(&self) -> usize {
456 let mut counts: std::collections::HashMap<(i64, &str), usize> =
457 std::collections::HashMap::new();
458 for e in self.all_segments() {
459 if e.state == chronix_core::SegmentState::Active {
460 *counts
461 .entry((e.shard_id.0, e.measurement.as_str()))
462 .or_default() += 1;
463 }
464 }
465 counts.into_values().max().unwrap_or(0)
466 }
467
468 #[must_use]
477 pub fn memory_bytes(&self) -> usize {
478 let entries: usize = self
479 .segments
480 .values()
481 .map(|v| {
482 v.capacity() * std::mem::size_of::<SegmentCatalogEntry>()
483 + v.iter()
484 .map(|e| {
485 e.measurement.capacity()
486 + e.path.as_os_str().len()
487 + e.column_stats.capacity()
488 * std::mem::size_of::<CatalogColumnStats>()
489 + e.column_stats
490 .iter()
491 .map(|c| c.name.capacity())
492 .sum::<usize>()
493 })
494 .sum::<usize>()
495 })
496 .sum();
497
498 let schemas: usize = self
499 .schemas
500 .iter()
501 .map(|(name, ms)| {
502 name.capacity()
503 + ms.columns()
504 .iter()
505 .map(|c| c.name.capacity() + std::mem::size_of_val(c))
506 .sum::<usize>()
507 })
508 .sum();
509
510 entries + schemas + self.tombstones.len() * std::mem::size_of::<u64>() * 4
511 }
512
513 #[must_use]
515 pub fn segment_count(&self) -> usize {
516 self.segments.values().map(Vec::len).sum()
517 }
518
519 pub fn next_segment_id(&mut self) -> SegmentId {
521 let id = SegmentId(self.next_segment_id);
522 self.next_segment_id += 1;
523 id
524 }
525
526 #[must_use]
528 pub fn manifest_seq(&self) -> u64 {
529 self.manifest_seq
530 }
531
532 #[must_use]
534 pub fn rollups(&self) -> &BTreeMap<String, RollupRecord> {
535 &self.rollups
536 }
537
538 pub fn set_rollup(&mut self, name: &str, definition: Vec<u8>) -> Result<()> {
544 self.append_manifest(&ManifestEntry::SetRollup {
545 name: name.to_string(),
546 definition: definition.clone(),
547 })?;
548 self.rollups.entry(name.to_string()).or_default().definition = definition;
549 self.sync_manifest()?;
550 self.maybe_snapshot()?;
551 Ok(())
552 }
553
554 pub fn set_rollup_state(&mut self, name: &str, state: Vec<u8>) -> Result<()> {
565 self.append_manifest(&ManifestEntry::SetRollupState {
566 name: name.to_string(),
567 state: state.clone(),
568 })?;
569 self.rollups.entry(name.to_string()).or_default().state = state;
570 self.sync_manifest()?;
571 self.maybe_snapshot()?;
572 Ok(())
573 }
574
575 pub fn remove_rollup(&mut self, name: &str) -> Result<bool> {
581 if !self.rollups.contains_key(name) {
582 return Ok(false);
583 }
584 self.append_manifest(&ManifestEntry::RemoveRollup(name.to_string()))?;
585 self.rollups.remove(name);
586 self.sync_manifest()?;
587 self.maybe_snapshot()?;
588 Ok(true)
589 }
590
591 #[must_use]
593 pub fn tombstones(&self) -> &TombstoneSet {
594 &self.tombstones
595 }
596
597 pub fn record_tombstones(&mut self, tombstones: &[Tombstone]) -> Result<()> {
608 for tombstone in tombstones {
609 self.append_manifest(&ManifestEntry::AddTombstone(tombstone.clone()))?;
610 self.tombstones.insert(tombstone.clone());
611 }
612 self.sync_manifest()?;
613 self.maybe_snapshot()?;
614 Ok(())
615 }
616
617 pub fn complete_compaction(
631 &mut self,
632 output: SegmentCatalogEntry,
633 inputs: &[SegmentId],
634 now_ms: u64,
635 ) -> Result<()> {
636 let output_id = output.segment_id.0;
637 self.add_segment(output)?;
638 for input in inputs {
639 self.soft_delete_segment(*input, now_ms)?;
640 }
641 let inputs: Vec<u64> = inputs.iter().map(|id| id.0).collect();
642 self.append_manifest(&ManifestEntry::Compacted {
643 inputs: inputs.clone(),
644 output: output_id,
645 })?;
646 self.tombstones
647 .extend_to_compaction_output(&inputs, output_id);
648 self.sync_manifest()?;
649 self.maybe_snapshot()?;
650 Ok(())
651 }
652
653 pub fn reclaim_tombstones(&mut self) -> Result<usize> {
664 let live: std::collections::HashSet<u64> = self
665 .segments
666 .values()
667 .flatten()
668 .map(|e| e.segment_id.0)
669 .collect();
670
671 let removed = self.tombstones.retain_tombstones(|t| {
675 t.segments.is_empty() || t.segments.iter().any(|id| live.contains(id))
676 });
677
678 for tombstone in &removed {
679 self.append_manifest(&ManifestEntry::RemoveTombstone(tombstone.clone()))?;
680 }
681 if !removed.is_empty() {
682 self.sync_manifest()?;
683 self.maybe_snapshot()?;
684 }
685 Ok(removed.len())
686 }
687
688 pub fn set_schema(&mut self, schema: MeasurementSchema) -> Result<()> {
694 let entry = ManifestEntry::SetSchema(schema.clone());
695 self.append_manifest(&entry)?;
696 self.schemas
697 .insert(schema.measurement().to_string(), schema);
698 self.maybe_snapshot()?;
699 Ok(())
700 }
701
702 pub fn remove_schema(&mut self, measurement: &str) -> Result<Option<MeasurementSchema>> {
708 let entry = ManifestEntry::RemoveSchema(measurement.to_string());
709 self.append_manifest(&entry)?;
710 let removed = self.schemas.remove(measurement);
711 self.maybe_snapshot()?;
712 Ok(removed)
713 }
714
715 #[must_use]
720 pub fn wal_floor(&self) -> u64 {
721 self.wal_floor
722 }
723
724 pub fn set_wal_floor(&mut self, sequence_no: u64) -> Result<()> {
733 if sequence_no <= self.wal_floor {
734 return Ok(());
735 }
736 self.append_manifest(&ManifestEntry::SetWalFloor(sequence_no))?;
737 self.wal_floor = sequence_no;
738 self.maybe_snapshot()?;
739 Ok(())
740 }
741
742 #[must_use]
744 pub fn get_schema(&self, measurement: &str) -> Option<&MeasurementSchema> {
745 self.schemas.get(measurement)
746 }
747
748 #[must_use]
750 pub fn schema_names(&self) -> Vec<String> {
751 self.schemas.keys().cloned().collect()
752 }
753
754 pub fn all_schemas(&self) -> impl Iterator<Item = (&String, &MeasurementSchema)> {
756 self.schemas.iter()
757 }
758
759 pub fn force_snapshot(&mut self) -> Result<()> {
765 self.write_snapshot()?;
766 self.changes_since_snapshot = 0;
767 Ok(())
768 }
769
770 fn append_manifest(&mut self, entry: &ManifestEntry) -> Result<()> {
774 self.manifest_seq += 1;
775
776 if self.manifest_wal_file.is_none() {
778 let wal_path = self.manifest_dir.join("manifest.wal");
779 let f = std::fs::OpenOptions::new()
780 .create(true)
781 .append(true)
782 .open(&wal_path)?;
783 self.manifest_wal_file = Some(f);
784 }
785 let file = self
786 .manifest_wal_file
787 .as_mut()
788 .ok_or_else(|| IndexError::BinarySerialization("WAL file not opened".into()))?;
789
790 let payload = postcard::to_stdvec(entry)
791 .map_err(|e| IndexError::BinarySerialization(e.to_string()))?;
792 let len = payload.len() as u32;
793 let crc = crc32c::crc32c(&payload);
794 file.write_all(&len.to_le_bytes())?;
795 file.write_all(&payload)?;
796 file.write_all(&crc.to_le_bytes())?;
797 file.flush()?;
799
800 self.pending_sync += 1;
804 if self.pending_sync >= SYNC_BATCH_SIZE {
805 file.sync_data()?;
806 self.pending_sync = 0;
807 }
808
809 self.changes_since_snapshot += 1;
810 Ok(())
811 }
812
813 pub fn sync_manifest(&mut self) -> Result<()> {
819 if self.pending_sync > 0 {
820 if let Some(ref f) = self.manifest_wal_file {
821 f.sync_data()?;
822 }
823 self.pending_sync = 0;
824 }
825 Ok(())
826 }
827
828 fn replay_manifest(&mut self, wal_path: &Path) -> Result<()> {
834 let mut file = std::fs::File::open(wal_path)?;
835 let file_len = file.metadata()?.len();
836
837 let mut replayed = 0u64;
838 let mut tail_skipped = 0u64;
839 let mut pos = 0u64;
840
841 loop {
842 if pos >= file_len {
843 break;
844 }
845 let mut len_buf = [0u8; 4];
847 match file.read_exact(&mut len_buf) {
848 Ok(()) => {}
849 Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
850 tail_skipped += 1;
851 warn!(
852 offset = pos,
853 "manifest replay: truncated length header at tail"
854 );
855 break;
856 }
857 Err(e) => return Err(e.into()),
858 }
859 let payload_len = u32::from_le_bytes(len_buf) as usize;
860 pos += 4;
861
862 if payload_len == 0 || payload_len > 64 * 1024 * 1024 {
864 if pos + (payload_len as u64) + 4 > file_len {
865 tail_skipped += 1;
866 warn!(
867 offset = pos - 4,
868 payload_len, "manifest replay: truncated entry at tail"
869 );
870 break;
871 }
872 return Err(IndexError::Manifest {
873 detail: format!(
874 "invalid manifest entry length {payload_len} at offset {}",
875 pos - 4
876 ),
877 });
878 }
879
880 let total = payload_len + 4; if pos + total as u64 > file_len {
883 tail_skipped += 1;
884 warn!(
885 offset = pos - 4,
886 "manifest replay: truncated payload at tail"
887 );
888 break;
889 }
890
891 let mut buf = vec![0u8; total];
892 if let Err(e) = file.read_exact(&mut buf) {
893 if e.kind() == std::io::ErrorKind::UnexpectedEof {
894 tail_skipped += 1;
895 warn!(offset = pos - 4, "manifest replay: truncated read at tail");
896 break;
897 }
898 return Err(e.into());
899 }
900
901 let payload = &buf[..payload_len];
902 let stored_crc = u32::from_le_bytes([
903 buf[payload_len],
904 buf[payload_len + 1],
905 buf[payload_len + 2],
906 buf[payload_len + 3],
907 ]);
908 let computed_crc = crc32c::crc32c(payload);
909
910 if stored_crc != computed_crc {
911 let remaining = file_len - pos - total as u64;
913 if remaining > 8 {
914 return Err(IndexError::Manifest {
915 detail: format!(
916 "CRC mismatch at offset {} (stored={stored_crc:#x}, computed={computed_crc:#x}) — mid-stream corruption",
917 pos - 4,
918 ),
919 });
920 }
921 tail_skipped += 1;
922 warn!(
923 offset = pos - 4,
924 "manifest replay: CRC mismatch at tail, skipping"
925 );
926 break;
927 }
928
929 match postcard::from_bytes::<ManifestEntry>(payload) {
930 Ok(entry) => {
931 self.apply_manifest_entry(entry);
932 self.manifest_seq += 1;
933 replayed += 1;
934 }
935 Err(e) => {
936 let remaining = file_len - pos - total as u64;
938 if remaining > 8 {
939 return Err(IndexError::Manifest {
940 detail: format!("deserialization error at offset {}: {e}", pos - 4,),
941 });
942 }
943 tail_skipped += 1;
944 warn!(offset = pos - 4, error = %e, "manifest replay: skipping corrupt tail entry");
945 break;
946 }
947 }
948
949 pos += total as u64;
950 }
951
952 if tail_skipped > 0 {
953 warn!(
954 tail_skipped,
955 "manifest replay: truncated tail entries skipped"
956 );
957 }
958 if replayed > 0 {
959 debug!(replayed, "replayed manifest entries");
960 }
961
962 Ok(())
963 }
964
965 fn apply_manifest_entry(&mut self, entry: ManifestEntry) {
967 match entry {
968 ManifestEntry::AddSegment(seg) => {
969 if seg.segment_id.0 >= self.next_segment_id {
971 self.next_segment_id = seg.segment_id.0 + 1;
972 }
973 let shard_entries = self.segments.entry(seg.shard_id).or_default();
977 shard_entries.retain(|e| e.segment_id != seg.segment_id);
978 shard_entries.push(seg);
979 }
980 ManifestEntry::RemoveSegment(id) => {
981 for entries in self.segments.values_mut() {
982 entries.retain(|e| e.segment_id.0 != id);
983 }
984 }
985 ManifestEntry::SetSchema(schema) => {
986 self.schemas
987 .insert(schema.measurement().to_string(), schema);
988 }
989 ManifestEntry::RemoveSchema(name) => {
990 self.schemas.remove(&name);
991 }
992 ManifestEntry::AddTombstone(t) => {
993 self.tombstones.insert(t);
994 }
995 ManifestEntry::RemoveTombstone(t) => {
996 self.tombstones.retain_tombstones(|existing| existing != &t);
997 }
998 ManifestEntry::SetWalFloor(seq) => {
999 self.wal_floor = self.wal_floor.max(seq);
1000 }
1001 ManifestEntry::SetRollup { name, definition } => {
1002 self.rollups.entry(name).or_default().definition = definition;
1003 }
1004 ManifestEntry::RemoveRollup(name) => {
1005 self.rollups.remove(&name);
1006 }
1007 ManifestEntry::SetRollupState { name, state } => {
1008 self.rollups.entry(name).or_default().state = state;
1009 }
1010 ManifestEntry::Compacted { inputs, output } => {
1011 self.tombstones.extend_to_compaction_output(&inputs, output);
1012 }
1013 }
1014 }
1015
1016 fn write_snapshot(&mut self) -> Result<()> {
1018 let snapshot = CatalogSnapshot {
1019 format_version: CATALOG_FORMAT_VERSION,
1020 segments: self.segments.clone(),
1021 schemas: self.schemas.clone(),
1022 tombstones: self.tombstones.clone(),
1023 wal_floor: self.wal_floor,
1024 rollups: self.rollups.clone(),
1025 manifest_seq: self.manifest_seq,
1026 next_segment_id: self.next_segment_id,
1027 };
1028
1029 let data = postcard::to_stdvec(&snapshot)
1030 .map_err(|e| IndexError::BinarySerialization(e.to_string()))?;
1031
1032 let snapshot_path = self.manifest_dir.join("manifest.snapshot.bin");
1034 let tmp_path = self.manifest_dir.join("manifest.snapshot.bin.tmp");
1035 let write_tmp = (|| -> std::io::Result<()> {
1041 let mut file = std::fs::File::create(&tmp_path)?;
1042 file.write_all(&data)?;
1043 file.sync_all()
1044 })();
1045 if let Err(e) = write_tmp {
1046 let _ = std::fs::remove_file(&tmp_path);
1047 return Err(e.into());
1048 }
1049 if let Err(e) = std::fs::rename(&tmp_path, &snapshot_path) {
1050 let _ = std::fs::remove_file(&tmp_path);
1051 return Err(e.into());
1052 }
1053
1054 let dir = std::fs::File::open(&self.manifest_dir).map_err(|e| {
1058 std::io::Error::new(
1059 e.kind(),
1060 format!("failed to open manifest dir for fsync: {e}"),
1061 )
1062 })?;
1063 dir.sync_all()?;
1064
1065 let wal_path = self.manifest_dir.join("manifest.wal");
1068 if wal_path.exists() {
1069 let file = std::fs::OpenOptions::new()
1070 .write(true)
1071 .truncate(true)
1072 .open(&wal_path)?;
1073 file.sync_all()?;
1078 }
1079 self.manifest_wal_file = None;
1080
1081 debug!(seq = self.manifest_seq, "catalog snapshot written");
1082 Ok(())
1083 }
1084
1085 fn maybe_snapshot(&mut self) -> Result<()> {
1087 if self.changes_since_snapshot >= SNAPSHOT_INTERVAL {
1088 self.write_snapshot()?;
1089 self.changes_since_snapshot = 0;
1090 }
1091 Ok(())
1092 }
1093}
1094
1095const CATALOG_FORMAT_VERSION: u32 = 2;
1097
1098#[derive(Debug, Serialize, Deserialize)]
1100struct CatalogSnapshot {
1101 #[serde(default)]
1103 format_version: u32,
1104 segments: BTreeMap<ShardId, Vec<SegmentCatalogEntry>>,
1105 schemas: HashMap<String, MeasurementSchema>,
1106 #[serde(default)]
1109 tombstones: TombstoneSet,
1110 #[serde(default)]
1112 wal_floor: u64,
1113 #[serde(default)]
1117 rollups: BTreeMap<String, RollupRecord>,
1118 manifest_seq: u64,
1119 next_segment_id: u64,
1120}
1121
1122#[cfg(test)]
1123mod tests {
1124 use super::*;
1125
1126 fn test_entry(id: u64, shard: i64, min_ts: i64, max_ts: i64) -> SegmentCatalogEntry {
1127 SegmentCatalogEntry {
1128 segment_id: SegmentId(id),
1129 shard_id: ShardId(shard),
1130 measurement: "cpu".to_string(),
1131 path: PathBuf::from(format!("shard_{shard}/seg_{id}.csx")),
1132 min_timestamp: min_ts,
1133 max_timestamp: max_ts,
1134 row_count: 1000,
1135 series_count: 10,
1136 byte_size: 4096,
1137 row_group_count: 1,
1138 column_count: 5,
1139 column_stats: Vec::new(),
1140 state: SegmentState::default(),
1141 }
1142 }
1143
1144 #[test]
1145 fn add_and_get_segments() {
1146 let dir = tempfile::tempdir().unwrap();
1147 let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1148
1149 catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
1150 catalog.add_segment(test_entry(2, 0, 300, 400)).unwrap();
1151 catalog.add_segment(test_entry(3, 1, 100, 200)).unwrap();
1152
1153 assert_eq!(catalog.get_segments_for_shard(&ShardId(0)).len(), 2);
1154 assert_eq!(catalog.get_segments_for_shard(&ShardId(1)).len(), 1);
1155 assert_eq!(catalog.get_segments_for_shard(&ShardId(99)).len(), 0);
1156 assert_eq!(catalog.segment_count(), 3);
1157 }
1158
1159 #[test]
1160 fn remove_segment() {
1161 let dir = tempfile::tempdir().unwrap();
1162 let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1163
1164 catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
1165 catalog.add_segment(test_entry(2, 0, 300, 400)).unwrap();
1166
1167 let removed = catalog.remove_segment(SegmentId(1)).unwrap();
1168 assert!(removed.is_some());
1169 assert_eq!(removed.unwrap().segment_id, SegmentId(1));
1170 assert_eq!(catalog.segment_count(), 1);
1171
1172 let removed = catalog.remove_segment(SegmentId(99)).unwrap();
1174 assert!(removed.is_none());
1175 }
1176
1177 #[test]
1178 fn persist_and_reload() {
1179 let dir = tempfile::tempdir().unwrap();
1180
1181 {
1183 let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1184 catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
1185 catalog.add_segment(test_entry(2, 0, 300, 400)).unwrap();
1186 catalog.add_segment(test_entry(3, 1, 500, 600)).unwrap();
1187 }
1188
1189 let catalog = SegmentCatalog::open(dir.path()).unwrap();
1191 assert_eq!(catalog.segment_count(), 3);
1192 assert_eq!(catalog.get_segments_for_shard(&ShardId(0)).len(), 2);
1193 assert_eq!(catalog.get_segments_for_shard(&ShardId(1)).len(), 1);
1194 }
1195
1196 #[test]
1197 fn snapshot_and_reload() {
1198 let dir = tempfile::tempdir().unwrap();
1199
1200 {
1202 let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1203 catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
1204 catalog.add_segment(test_entry(2, 0, 300, 400)).unwrap();
1205 catalog.force_snapshot().unwrap();
1206 catalog.add_segment(test_entry(3, 1, 500, 600)).unwrap();
1208 }
1209
1210 let catalog = SegmentCatalog::open(dir.path()).unwrap();
1212 assert_eq!(catalog.segment_count(), 3);
1213 }
1214
1215 #[test]
1216 fn schema_persistence() {
1217 let dir = tempfile::tempdir().unwrap();
1218
1219 {
1220 let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1221 let schema = MeasurementSchema::new("cpu");
1222 catalog.set_schema(schema).unwrap();
1223 }
1224
1225 let catalog = SegmentCatalog::open(dir.path()).unwrap();
1226 assert!(catalog.get_schema("cpu").is_some());
1227 assert_eq!(catalog.get_schema("cpu").unwrap().measurement(), "cpu");
1228 }
1229
1230 #[test]
1231 fn remove_schema() {
1232 let dir = tempfile::tempdir().unwrap();
1233 let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1234 catalog.set_schema(MeasurementSchema::new("cpu")).unwrap();
1235 assert!(catalog.get_schema("cpu").is_some());
1236
1237 catalog.remove_schema("cpu").unwrap();
1238 assert!(catalog.get_schema("cpu").is_none());
1239 }
1240
1241 #[test]
1242 fn next_segment_id_monotonic() {
1243 let dir = tempfile::tempdir().unwrap();
1244 let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1245
1246 assert_eq!(catalog.next_segment_id(), SegmentId(1));
1247 assert_eq!(catalog.next_segment_id(), SegmentId(2));
1248 assert_eq!(catalog.next_segment_id(), SegmentId(3));
1249 }
1250
1251 #[test]
1252 fn next_segment_id_survives_reload() {
1253 let dir = tempfile::tempdir().unwrap();
1254
1255 {
1256 let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1257 catalog.add_segment(test_entry(5, 0, 100, 200)).unwrap();
1258 catalog.add_segment(test_entry(10, 0, 300, 400)).unwrap();
1259 }
1260
1261 let mut catalog = SegmentCatalog::open(dir.path()).unwrap();
1262 let next = catalog.next_segment_id();
1264 assert!(
1265 next.0 >= 11,
1266 "next_segment_id should be >= 11, got {}",
1267 next.0
1268 );
1269 }
1270
1271 #[test]
1272 fn add_remove_add_roundtrip() {
1273 let dir = tempfile::tempdir().unwrap();
1274
1275 {
1276 let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1277 catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
1278 catalog.remove_segment(SegmentId(1)).unwrap();
1279 catalog.add_segment(test_entry(2, 0, 300, 400)).unwrap();
1280 }
1281
1282 let catalog = SegmentCatalog::open(dir.path()).unwrap();
1283 assert_eq!(catalog.segment_count(), 1);
1284 assert_eq!(
1285 catalog.get_segments_for_shard(&ShardId(0))[0].segment_id,
1286 SegmentId(2)
1287 );
1288 }
1289
1290 #[test]
1291 fn all_segments() {
1292 let dir = tempfile::tempdir().unwrap();
1293 let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1294
1295 catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
1296 catalog.add_segment(test_entry(2, 1, 300, 400)).unwrap();
1297 catalog.add_segment(test_entry(3, 2, 500, 600)).unwrap();
1298
1299 let all = catalog.all_segments();
1300 assert_eq!(all.len(), 3);
1301 }
1302
1303 #[test]
1304 fn manifest_replay_tail_truncation_tolerated() {
1305 let dir = tempfile::tempdir().unwrap();
1308
1309 {
1310 let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1311 catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
1312 catalog.add_segment(test_entry(2, 0, 300, 400)).unwrap();
1313 }
1314
1315 let wal_path = dir.path().join("manifest.wal");
1317 let mut f = std::fs::OpenOptions::new()
1318 .append(true)
1319 .open(&wal_path)
1320 .unwrap();
1321 let fake_len: u32 = 128;
1323 f.write_all(&fake_len.to_le_bytes()).unwrap();
1324 f.write_all(&[0xDE, 0xAD]).unwrap(); drop(f);
1326
1327 let catalog = SegmentCatalog::open(dir.path()).unwrap();
1329 assert_eq!(catalog.segment_count(), 2);
1330 }
1331
1332 #[test]
1333 fn manifest_replay_midstream_corruption_errors() {
1334 let dir = tempfile::tempdir().unwrap();
1337
1338 {
1339 let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1340 catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
1341 }
1342
1343 let wal_path = dir.path().join("manifest.wal");
1345 let mut f = std::fs::OpenOptions::new()
1346 .append(true)
1347 .open(&wal_path)
1348 .unwrap();
1349
1350 let corrupt_payload = b"CORRUPT_DATA_HERE_1234";
1352 let corrupt_len = corrupt_payload.len() as u32;
1353 let wrong_crc: u32 = 0xDEADBEEF;
1354 f.write_all(&corrupt_len.to_le_bytes()).unwrap();
1355 f.write_all(corrupt_payload).unwrap();
1356 f.write_all(&wrong_crc.to_le_bytes()).unwrap();
1357
1358 let valid_entry = ManifestEntry::AddSegment(test_entry(3, 0, 500, 600));
1360 let valid_payload = postcard::to_stdvec(&valid_entry).unwrap();
1361 let valid_len = valid_payload.len() as u32;
1362 let valid_crc = crc32c::crc32c(&valid_payload);
1363 f.write_all(&valid_len.to_le_bytes()).unwrap();
1364 f.write_all(&valid_payload).unwrap();
1365 f.write_all(&valid_crc.to_le_bytes()).unwrap();
1366 drop(f);
1367
1368 let result = SegmentCatalog::open(dir.path());
1370 assert!(result.is_err(), "expected error for mid-stream corruption");
1371 let err_msg = format!("{}", result.unwrap_err());
1372 assert!(
1373 err_msg.contains("mid-stream corruption"),
1374 "error should mention mid-stream corruption, got: {err_msg}"
1375 );
1376 }
1377}