Skip to main content

chronix_engine/index/
catalog.rs

1//! Segment catalog with manifest persistence.
2//!
3//! The [`SegmentCatalog`] tracks metadata for all segments in the database,
4//! organized by shard. Changes are logged to a manifest WAL for crash-safe
5//! persistence, with periodic snapshots to bound recovery time.
6//!
7//! # Manifest format
8//!
9//! The manifest WAL uses a **length-prefixed postcard** binary format:
10//! each entry is `[u32-le length][postcard payload]`. This provides:
11//!
12//! - **Fast serialization**: postcard is far faster than JSON and
13//!   produces ~3x smaller output.
14//! - **Crash-safe framing**: length prefix + CRC32c integrity check
15//!   detects truncated or corrupt entries reliably.
16//! - **Bounded replay**: binary parsing is O(entries), not O(bytes).
17//!
18//! Snapshots use the same postcard format (single blob).
19
20use 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/// Per-column statistics stored in the catalog for pruning.
35///
36/// Contains the column name, data type, and min/max statistics so the
37/// pruning pipeline can eliminate segments without opening any file.
38#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
39pub struct CatalogColumnStats {
40    /// Column name (e.g. "temperature", "host").
41    pub name: String,
42    /// Column data type tag (from `crate::segment::metadata::data_types`).
43    pub data_type: u8,
44    /// Column role tag (0= timestamp, 1= tag, 2= field).
45    pub role: u8,
46    /// Digits after the decimal point, for a decimal column; `None`
47    /// otherwise.
48    ///
49    /// Here as well as in the segment because the schema repair at open
50    /// works from *this* summary and never opens a `.csx` file: without the
51    /// scale, a decimal column the manifest had lost could only be restored
52    /// as some other type, which is worse than not restoring it.
53    #[serde(default)]
54    pub decimal_scale: Option<u8>,
55    /// Min/max/null statistics.
56    pub stats: ColumnStats,
57}
58
59/// Metadata for a single segment in the catalog.
60#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
61pub struct SegmentCatalogEntry {
62    /// Unique segment identifier.
63    pub segment_id: SegmentId,
64    /// The shard this segment belongs to.
65    pub shard_id: ShardId,
66    /// Measurement name this segment contains data for.
67    pub measurement: String,
68    /// Filesystem path to the `.csx` file.
69    pub path: PathBuf,
70    /// Minimum timestamp in the segment (inclusive).
71    pub min_timestamp: Timestamp,
72    /// Maximum timestamp in the segment (inclusive).
73    pub max_timestamp: Timestamp,
74    /// Total number of rows in the segment.
75    pub row_count: u64,
76    /// Number of unique series in the segment.
77    pub series_count: u32,
78    /// File size in bytes.
79    pub byte_size: u64,
80    /// Number of row groups.
81    pub row_group_count: u32,
82    /// Number of columns.
83    pub column_count: u16,
84    /// Per-column statistics for predicate pushdown pruning.
85    #[serde(default)]
86    pub column_stats: Vec<CatalogColumnStats>,
87    /// Lifecycle state of this segment.
88    #[serde(default)]
89    pub state: SegmentState,
90}
91
92/// Manifest entry types for WAL-style persistence.
93#[derive(Debug, Clone, Serialize, Deserialize)]
94enum ManifestEntry {
95    /// A segment was added.
96    AddSegment(SegmentCatalogEntry),
97    /// A segment was removed.
98    RemoveSegment(u64),
99    /// A schema was registered or updated.
100    SetSchema(MeasurementSchema),
101    /// A schema was removed.
102    RemoveSchema(String),
103    /// A tombstone was recorded by a delete.
104    AddTombstone(Tombstone),
105    /// A tombstone was reclaimed — every segment it was issued against is gone.
106    RemoveTombstone(Tombstone),
107    /// Every WAL record with a sequence number at or below this is in a
108    /// segment. Replay starts after it.
109    SetWalFloor(u64),
110    /// Compaction merged `inputs` into `output`: every tombstone that named
111    /// an input now names the output too.
112    Compacted {
113        /// The input segment ids.
114        inputs: Vec<u64>,
115        /// The output segment id.
116        output: u64,
117    },
118    /// A rollup definition was created or replaced. The payload is the
119    /// facade's `RollupConfig`, opaque here — the catalog persists it, the
120    /// facade interprets it.
121    SetRollup {
122        /// The rollup's name, its identity.
123        name: String,
124        /// Postcard-encoded definition.
125        definition: Vec<u8>,
126    },
127    /// A rollup definition was deleted.
128    RemoveRollup(String),
129    /// A rollup's materialisation state changed: its watermark advanced, or
130    /// a write below the watermark invalidated some of its buckets.
131    SetRollupState {
132        /// The rollup's name.
133        name: String,
134        /// Postcard-encoded state.
135        state: Vec<u8>,
136    },
137}
138
139/// A rollup's persisted definition and state, as opaque payloads.
140#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
141pub struct RollupRecord {
142    /// Postcard-encoded definition, absent while only state is known.
143    #[serde(default)]
144    pub definition: Vec<u8>,
145    /// Postcard-encoded materialisation state.
146    #[serde(default)]
147    pub state: Vec<u8>,
148}
149
150/// Segment metadata catalog with manifest persistence.
151///
152/// Tracks all segment metadata organized by shard, along with measurement
153/// schemas. Changes are persisted to a manifest WAL file, with periodic
154/// snapshots to prevent unbounded manifest growth.
155///
156/// # Persistence
157///
158/// Changes are appended to `manifest.wal` as length-prefixed, CRC-32C
159/// framed postcard entries.
160/// Periodic snapshots written to `manifest.snapshot.bin`.
161/// On startup: load latest snapshot, replay WAL entries.
162///
163/// # Concurrency
164///
165/// The catalog currently uses a single `&mut self` borrow (i.e. external
166/// synchronisation via an `RwLock` or `Mutex`) for all shards.  This is
167/// acceptable because catalog mutations (segment add/remove, schema
168/// updates) are infrequent (flush/compaction cadence) and short-lived.
169///
170/// If catalog contention ever becomes measurable under very high shard
171/// counts, the internal `BTreeMap<ShardId, Vec<…>>` can be replaced with
172/// a `DashMap<ShardId, Vec<…>>` to allow per-shard concurrent access
173/// without changing the external API.
174#[derive(Debug)]
175pub struct SegmentCatalog {
176    /// Segments organized by shard.
177    segments: BTreeMap<ShardId, Vec<SegmentCatalogEntry>>,
178    /// Measurement schemas.
179    schemas: HashMap<String, MeasurementSchema>,
180    /// Tombstones recorded by deletes.
181    ///
182    /// These live in the catalog rather than in the data WAL, and that is the
183    /// fix for a delete that did not survive a restart. The data WAL is
184    /// truncated once the memtable it covers has been flushed, so a delete
185    /// logged before a flush was discarded by it — the tombstone existed only
186    /// in memory from then on, and the deleted rows came back at the next
187    /// open. The manifest is only ever rewritten by a snapshot that carries
188    /// its full contents forward.
189    tombstones: TombstoneSet,
190    /// The WAL floor: every data-WAL record with `sequence_no <= wal_floor`
191    /// has been flushed into a segment that this catalog registers.
192    ///
193    /// Recorded here rather than inferred from the WAL files, because the
194    /// active WAL file is never deleted by truncation. Without a floor every
195    /// restart replayed that file — up to 32 MB of already-flushed points —
196    /// into the memtable, and the next flush wrote them to disk a second
197    /// time. Dedup hid the duplicates from queries; the flash wear was real.
198    wal_floor: u64,
199    /// Rollup definitions and state, persisted with everything else that
200    /// a restart must not lose. They used to live in a hand-written JSON
201    /// file beside the data directory with no fsync and no CRC, and a
202    /// failure to parse it was logged and swallowed — leaving a database
203    /// that believed it had no rollups, and a retention pass that then
204    /// dropped the raw data those rollups existed to preserve.
205    rollups: BTreeMap<String, RollupRecord>,
206    /// Monotonically increasing catalog version.
207    manifest_seq: u64,
208    /// Directory for manifest files.
209    manifest_dir: PathBuf,
210    /// Number of changes since last snapshot.
211    changes_since_snapshot: u64,
212    /// Next segment ID to assign.
213    next_segment_id: u64,
214    /// Persistent WAL file handle (kept open for append).
215    manifest_wal_file: Option<std::fs::File>,
216    /// Number of appends written but not yet `sync_data()`d.
217    ///
218    /// Normally at most one: every append syncs. It grows only inside
219    /// [`in_one_sync`](Self::in_one_sync).
220    pending_sync: u64,
221    /// Depth of the [`in_one_sync`](Self::in_one_sync) nesting.
222    ///
223    /// Non-zero means "this transition is not finished; do not fsync yet".
224    defer_sync: u32,
225    /// How many times the manifest has actually been fsynced.
226    ///
227    /// Exported as `chronix_catalog_fsync_total`, beside the WAL's own
228    /// counter: on flash-backed storage the fsync rate *is* the wear rate,
229    /// and the catalog was paying one per row of bookkeeping. It is also what
230    /// makes the batching testable — a claim about a number of syscalls that
231    /// nothing counts is a claim that rots.
232    manifest_syncs: u64,
233}
234
235/// Snapshot interval: take a snapshot every N changes.
236const SNAPSHOT_INTERVAL: u64 = 1000;
237
238/// Sync on **every** manifest append, so no catalog mutation is lost to a
239/// power failure.
240///
241/// A single mutation — registering a segment, updating a schema — is
242/// infrequent enough that its fsync is free. What is not free is a mutation
243/// made of *many* appends: a compaction of four segments is six, and a delete
244/// producing a hundred tombstones is a hundred. Those are batched by
245/// [`SegmentCatalog::in_one_sync`], which is where the count belongs — a
246/// global batch size would defer the appends nobody is going to sync.
247const SYNC_BATCH_SIZE: u64 = 1;
248
249// Compile-time guarantee: SegmentCatalog is safe to share across threads.
250const _: () = {
251    const fn _assert_send<T: Send + Sync>() {}
252    _assert_send::<SegmentCatalog>();
253};
254
255impl SegmentCatalog {
256    /// Create a new empty catalog persisted in `manifest_dir`.
257    ///
258    /// # Errors
259    ///
260    /// Returns an error if the manifest directory cannot be created.
261    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    /// Open an existing catalog, loading from snapshot + replaying manifest WAL.
283    ///
284    /// # Errors
285    ///
286    /// Returns an error if the snapshot or manifest cannot be read/parsed.
287    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            // Reject snapshots from future versions we can't parse.
297            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        // Replay manifest WAL
343        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    /// Add a segment to the catalog.
352    ///
353    /// # Errors
354    ///
355    /// Returns an error if the manifest cannot be written.
356    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    /// Remove a segment from the catalog by its ID.
367    ///
368    /// Returns the removed entry if found.
369    ///
370    /// # Errors
371    ///
372    /// Returns an error if the manifest cannot be written.
373    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    /// Get all segments for a specific shard.
390    #[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    /// Mark a segment as soft-deleted.
396    ///
397    /// The segment remains in the catalog and is still queryable by code
398    /// that explicitly opts in to soft-deleted segments — but the default
399    /// query path (via `active_segments_for_measurement`) hides it.
400    /// A subsequent call to `remove_segment` will hard-delete it.
401    pub fn soft_delete_segment(&mut self, segment_id: SegmentId, now_ms: u64) -> Result<bool> {
402        // Find which measurement key and entry index contains this segment.
403        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        // Persist FIRST, then mutate in-memory state.
415        // If append_manifest fails, the in-memory state remains
416        // unchanged and the segment stays Active.
417        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        // Now safe to mutate in-memory state.
426        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    /// Get all *active* segments belonging to a specific measurement.
435    ///
436    /// Excludes segments that are soft-deleted (waiting for GC).
437    #[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    /// Get segments that are past the soft-delete grace period and can
447    /// be hard-deleted.
448    #[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    /// Get all segments across all shards.
468    #[must_use]
469    pub fn all_segments(&self) -> Vec<&SegmentCatalogEntry> {
470        self.segments.values().flat_map(|v| v.iter()).collect()
471    }
472
473    /// Get all segments belonging to a specific measurement.
474    #[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    /// Get the total number of segments.
484    /// The largest number of active segments any one `(shard, measurement)`
485    /// holds — the count a compaction pass can actually reduce, and so the
486    /// number write backpressure is keyed on.
487    #[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    /// Approximate heap bytes held by the catalog.
502    ///
503    /// One entry per segment, each carrying a measurement name, a path and its
504    /// per-column statistics — so this grows with segment count, not with row
505    /// count, and it is the term that a long-running gateway accumulates
506    /// between compactions. The schema registry and the tombstone set are
507    /// counted with it: all three live for the process's lifetime, which is
508    /// what makes them worth a number rather than an estimate.
509    #[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    /// Total number of segments the catalog holds, in any state.
547    #[must_use]
548    pub fn segment_count(&self) -> usize {
549        self.segments.values().map(Vec::len).sum()
550    }
551
552    /// Allocate the next segment ID.
553    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    /// Get the current manifest sequence number.
560    #[must_use]
561    pub fn manifest_seq(&self) -> u64 {
562        self.manifest_seq
563    }
564
565    /// Every persisted rollup, by name.
566    #[must_use]
567    pub fn rollups(&self) -> &BTreeMap<String, RollupRecord> {
568        &self.rollups
569    }
570
571    /// Persist a rollup definition, durably.
572    ///
573    /// # Errors
574    ///
575    /// Returns an error if the manifest cannot be written or synced.
576    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    /// Persist a rollup's materialisation state, durably.
588    ///
589    /// Called after the rollup's points are durable, never before: a
590    /// watermark that outlives the rows it claims is a permanent hole in
591    /// the tier, because nothing recomputes a bucket the watermark has
592    /// already passed.
593    ///
594    /// # Errors
595    ///
596    /// Returns an error if the manifest cannot be written or synced.
597    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    /// Delete a rollup definition and its state.
609    ///
610    /// # Errors
611    ///
612    /// Returns an error if the manifest cannot be written.
613    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    /// The tombstones recorded by deletes.
625    #[must_use]
626    pub fn tombstones(&self) -> &TombstoneSet {
627        &self.tombstones
628    }
629
630    /// Record the tombstones produced by a delete, durably.
631    ///
632    /// The manifest append is fsynced before this returns, so a delete that
633    /// has been acknowledged has been persisted — which is the property the
634    /// previous design lacked, because it kept tombstones only in memory and
635    /// in a data WAL that the next flush was free to truncate.
636    ///
637    /// # Errors
638    ///
639    /// Returns an error if the manifest cannot be written or synced.
640    pub fn record_tombstones(&mut self, tombstones: &[Tombstone]) -> Result<()> {
641        // One fsync for the delete, not one per tombstone: a delete over a
642        // measurement with a hundred series produces a hundred of them.
643        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    /// Register a compaction's output and retire its inputs, as one
655    /// durable step.
656    ///
657    /// Adds `output`, soft-deletes every input, and extends every tombstone
658    /// that named an input to the output. That last part is what closes the
659    /// window in which a delete issued *while* the merge ran was undone: the
660    /// merge applied the tombstones it snapshotted, the late tombstone named
661    /// only the inputs, and once GC removed them the tombstone was reclaimed
662    /// with its rows alive in the output.
663    ///
664    /// # Errors
665    ///
666    /// Returns an error if the manifest cannot be written.
667    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        // One fsync for the whole transition, not one per input segment.
676        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    /// Reclaim tombstones whose segments have all left the catalog.
693    ///
694    /// A segment leaves the catalog only by being rewritten — compaction
695    /// applies tombstones as it merges — or by being deleted outright, so once
696    /// none of a tombstone's segments remains, no stored row can still match
697    /// it. Returns the number reclaimed.
698    ///
699    /// # Errors
700    ///
701    /// Returns an error if the manifest cannot be written.
702    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        // A tombstone with no recorded segments predates nothing we can prove
711        // about, so it is kept. Every tombstone this engine writes records the
712        // segments it was issued against.
713        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    /// Register or update a measurement schema.
728    ///
729    /// # Errors
730    ///
731    /// Returns an error if the manifest cannot be written.
732    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    /// Remove a measurement schema.
742    ///
743    /// # Errors
744    ///
745    /// Returns an error if the manifest cannot be written.
746    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    /// The WAL floor: the highest data-WAL sequence number known to be
755    /// fully represented by the segments this catalog registers.
756    ///
757    /// `open()` replays only records above it.
758    #[must_use]
759    pub fn wal_floor(&self) -> u64 {
760        self.wal_floor
761    }
762
763    /// Raise the WAL floor to `sequence_no`.
764    ///
765    /// Persisted before it is applied, like every other catalog change. A
766    /// floor never moves backwards; a lower value is a no-op.
767    ///
768    /// # Errors
769    ///
770    /// Returns an error if the manifest append fails.
771    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    /// Look up a measurement schema.
782    #[must_use]
783    pub fn get_schema(&self, measurement: &str) -> Option<&MeasurementSchema> {
784        self.schemas.get(measurement)
785    }
786
787    /// Returns all schema names.
788    #[must_use]
789    pub fn schema_names(&self) -> Vec<String> {
790        self.schemas.keys().cloned().collect()
791    }
792
793    /// Returns an iterator over all `(name, schema)` pairs.
794    pub fn all_schemas(&self) -> impl Iterator<Item = (&String, &MeasurementSchema)> {
795        self.schemas.iter()
796    }
797
798    /// Force a snapshot of the current catalog state.
799    ///
800    /// # Errors
801    ///
802    /// Returns an error if the snapshot cannot be written.
803    pub fn force_snapshot(&mut self) -> Result<()> {
804        self.write_snapshot()?;
805        self.changes_since_snapshot = 0;
806        Ok(())
807    }
808
809    /// Append a manifest entry to the WAL file.
810    ///
811    /// Format: `[u32-le length][postcard payload][u32-le CRC32c]`
812    fn append_manifest(&mut self, entry: &ManifestEntry) -> Result<()> {
813        self.manifest_seq += 1;
814
815        // Lazily open the WAL file handle and keep it for future appends.
816        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        // Always flush to kernel buffers so data is ordered.
837        file.flush()?;
838
839        // Synced here unless a caller has declared that this append is one
840        // step of a larger transition (`in_one_sync`), in which case that
841        // caller's single `sync_manifest()` covers it. Data reaches the
842        // kernel either way.
843        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    /// How many times the manifest has been fsynced since this catalog was
856    /// opened.
857    ///
858    /// One per catalog *transition*, not per append: a compaction retiring
859    /// four segments is one, and so is a delete however many tombstones it
860    /// produces. Also exported as `chronix_catalog_fsync_total`.
861    #[must_use]
862    pub fn manifest_syncs(&self) -> u64 {
863        self.manifest_syncs
864    }
865
866    /// Run `body` as one durable manifest transition: the appends it makes are
867    /// written but fsynced **once**, when it returns.
868    ///
869    /// A compaction of four segments is six appends — the output, four
870    /// soft-deletes and the `Compacted` record — and a delete is one per
871    /// tombstone. On flash-backed storage the fsync rate *is* the wear rate,
872    /// which is why the WAL has a periodic policy.
873    ///
874    /// Everything `body` appended is durable when this returns, on the error
875    /// path as much as the success path, so a transition that failed half-way
876    /// is as persistent as it would be if each append had synced. A crash
877    /// *during* `body` leaves what it always could: the manifest is a replayed
878    /// log, so a partial transition means an input segment still live beside
879    /// its compaction output, which reads deduplicate and the next compaction
880    /// clears.
881    ///
882    /// **Only for a transition that ends in a sync.** The retention, GC and
883    /// cold-tier loops call `remove_segment` without one and rely on the
884    /// per-append fsync, which is why this is opt-in rather than a batch size.
885    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        // Before `out?`: a half-finished transition must be as durable as it
890        // was when each of its appends synced on its own.
891        self.sync_manifest()?;
892        out
893    }
894
895    /// Flush any pending manifest writes to durable storage.
896    ///
897    /// Call this after a batch of mutations to ensure all appended entries
898    /// survive a power failure.  This is a no-op if there are no pending
899    /// un-synced writes.
900    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    /// Replay manifest WAL entries from a file.
913    ///
914    /// Reads length-prefixed postcard records with CRC32c integrity.
915    /// Truncated tail records (common crash artefact) are skipped
916    /// with a warning. Mid-stream corruption returns an error.
917    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            // Read length header (4 bytes)
930            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            // Sanity check payload length
947            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            // Read payload + CRC
965            let total = payload_len + 4; // payload + CRC32c
966            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                // Check if there are more valid records after this
996                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                    // Check if remaining data could contain valid entries
1021                    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    /// Apply a manifest entry to the in-memory state (no persistence).
1050    fn apply_manifest_entry(&mut self, entry: ManifestEntry) {
1051        match entry {
1052            ManifestEntry::AddSegment(seg) => {
1053                // Track next_segment_id
1054                if seg.segment_id.0 >= self.next_segment_id {
1055                    self.next_segment_id = seg.segment_id.0 + 1;
1056                }
1057                // Dedup: remove any existing entry with the same segment_id
1058                // before pushing. This handles WAL replay of soft_delete_segment
1059                // which re-adds segments with updated state.
1060                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    /// Write a full snapshot and truncate the WAL.
1101    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        // Atomic snapshot write: write temp → fsync → rename → fsync parent.
1117        let snapshot_path = self.manifest_dir.join("manifest.snapshot.bin");
1118        let tmp_path = self.manifest_dir.join("manifest.snapshot.bin.tmp");
1119        // The temp file is removed on failure. The segment writer and the
1120        // local storage backend both do this; the catalog did not, so a
1121        // snapshot interrupted by `ENOSPC` left a partial `.tmp` behind —
1122        // consuming the space whose absence caused the failure, on the one
1123        // file the database cannot afford to lose.
1124        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        // Fsync parent directory to make the rename durable.
1139        // This MUST succeed before truncating the WAL; otherwise a crash
1140        // could leave us with the old snapshot and an empty WAL → data loss.
1141        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        // Truncate WAL and invalidate the persistent file handle so it
1150        // gets re-opened on the next append.
1151        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            // The truncation must be durable too. Replay is idempotent for
1158            // every entry kind, so a resurrected entry is harmless — but an
1159            // unsynced truncation is exactly the kind of "probably fine"
1160            // this catalog does not deal in.
1161            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    /// Take a snapshot if the change count exceeds the threshold.
1170    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
1179/// Current catalog snapshot format version — 1, the first that ships.
1180///
1181/// Unlike the segment header this is compared with `>`: a snapshot from a
1182/// *newer* Chronix is refused, an older one is read.
1183const CATALOG_FORMAT_VERSION: u32 = 1;
1184
1185/// Serializable snapshot of the catalog state.
1186#[derive(Debug, Serialize, Deserialize)]
1187struct CatalogSnapshot {
1188    /// Format version for forward compatibility.
1189    #[serde(default)]
1190    format_version: u32,
1191    segments: BTreeMap<ShardId, Vec<SegmentCatalogEntry>>,
1192    schemas: HashMap<String, MeasurementSchema>,
1193    /// Tombstones, as of this snapshot. A snapshot that omitted them would
1194    /// undo every delete the moment the manifest WAL was folded into it.
1195    #[serde(default)]
1196    tombstones: TombstoneSet,
1197    /// See [`SegmentCatalog::wal_floor`].
1198    #[serde(default)]
1199    wal_floor: u64,
1200    /// Rollup definitions and their materialisation state, by name. Opaque
1201    /// bytes: the catalog is where they are durable, the facade is where
1202    /// they mean something.
1203    #[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    /// A compaction is **one** fsync, whatever it retires.
1232    ///
1233    /// It used to be one per append: the output, one per input segment, and
1234    /// the `Compacted` record — six for a four-segment merge, and compaction
1235    /// runs every maintenance interval. On the flash the design partner
1236    /// writes to, the fsync rate is the wear rate.
1237    #[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        // …and it is durable: a reopen sees the output live and the inputs
1263        // soft-deleted, which is the property the per-append fsync bought.
1264        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    /// A delete is one fsync, not one per tombstone.
1275    #[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    /// A single mutation still syncs on its own — the batching is opt-in, and
1301    /// the retention, GC and cold-tier loops rely on that.
1302    #[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        // Remove non-existent
1342        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        // Create catalog and add segments
1351        {
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        // Reload from manifest
1359        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        // Create, populate, snapshot
1370        {
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            // Add more after snapshot
1376            catalog.add_segment(test_entry(3, 1, 500, 600)).unwrap();
1377        }
1378
1379        // Reload—should see all 3 from snapshot + WAL
1380        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        // next_segment_id should be 11 (max existing + 1)
1432        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        // Corrupt entries at the end (tail truncation from a crash) should be
1475        // skipped with a warning — not an error.
1476        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        // Append a truncated binary frame at the tail to simulate a crash mid-write.
1485        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        // Write a length header but no payload — simulates crash after partial write
1491        let fake_len: u32 = 128;
1492        f.write_all(&fake_len.to_le_bytes()).unwrap();
1493        f.write_all(&[0xDE, 0xAD]).unwrap(); // partial payload
1494        drop(f);
1495
1496        // Replay should succeed — tail corruption is tolerated.
1497        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        // A corrupt entry in the middle (valid entries follow it) is real
1504        // corruption and must cause an error.
1505        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        // Write a corrupt binary frame (valid length, bad CRC) followed by a valid frame.
1513        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        // Corrupt frame: valid-looking length, garbage payload, wrong CRC
1520        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        // Valid frame after the corrupt one
1528        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        // Replay should fail with a Manifest error.
1538        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}