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    /// A measurement was soft-deleted: it is pending a hard delete at
138    /// `deadline_ms` unless cancelled first.
139    ///
140    /// Durable for the same reason a tombstone is: an in-memory-only
141    /// pending-drop map is undone by every restart, silently un-dropping a
142    /// measurement an operator was told was gone and forgetting the deadline
143    /// that was supposed to reclaim its disk.
144    SetPendingMeasurementDrop {
145        /// The measurement pending deletion.
146        measurement: String,
147        /// Unix-ms deadline after which the next GC pass hard-deletes it.
148        deadline_ms: u64,
149    },
150    /// A pending drop was cancelled — by a restore, or by the hard delete
151    /// that finally acted on it.
152    CancelPendingMeasurementDrop(String),
153}
154
155/// A rollup's persisted definition and state, as opaque payloads.
156#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
157pub struct RollupRecord {
158    /// Postcard-encoded definition, absent while only state is known.
159    #[serde(default)]
160    pub definition: Vec<u8>,
161    /// Postcard-encoded materialisation state.
162    #[serde(default)]
163    pub state: Vec<u8>,
164}
165
166/// Segment metadata catalog with manifest persistence.
167///
168/// Tracks all segment metadata organized by shard, along with measurement
169/// schemas. Changes are persisted to a manifest WAL file, with periodic
170/// snapshots to prevent unbounded manifest growth.
171///
172/// # Persistence
173///
174/// Changes are appended to `manifest.wal` as length-prefixed, CRC-32C
175/// framed postcard entries.
176/// Periodic snapshots written to `manifest.snapshot.bin`.
177/// On startup: load latest snapshot, replay WAL entries.
178///
179/// # Concurrency
180///
181/// The catalog currently uses a single `&mut self` borrow (i.e. external
182/// synchronisation via an `RwLock` or `Mutex`) for all shards.  This is
183/// acceptable because catalog mutations (segment add/remove, schema
184/// updates) are infrequent (flush/compaction cadence) and short-lived.
185///
186/// If catalog contention ever becomes measurable under very high shard
187/// counts, the internal `BTreeMap<ShardId, Vec<…>>` can be replaced with
188/// a `DashMap<ShardId, Vec<…>>` to allow per-shard concurrent access
189/// without changing the external API.
190#[derive(Debug)]
191pub struct SegmentCatalog {
192    /// Segments organized by shard.
193    segments: BTreeMap<ShardId, Vec<SegmentCatalogEntry>>,
194    /// Measurement schemas.
195    schemas: HashMap<String, MeasurementSchema>,
196    /// Tombstones recorded by deletes.
197    ///
198    /// These live in the catalog rather than in the data WAL, and that is the
199    /// fix for a delete that did not survive a restart. The data WAL is
200    /// truncated once the memtable it covers has been flushed, so a delete
201    /// logged before a flush was discarded by it — the tombstone existed only
202    /// in memory from then on, and the deleted rows came back at the next
203    /// open. The manifest is only ever rewritten by a snapshot that carries
204    /// its full contents forward.
205    tombstones: TombstoneSet,
206    /// The WAL floor: every data-WAL record with `sequence_no <= wal_floor`
207    /// has been flushed into a segment that this catalog registers.
208    ///
209    /// Recorded here rather than inferred from the WAL files, because the
210    /// active WAL file is never deleted by truncation. Without a floor every
211    /// restart replayed that file — up to 32 MB of already-flushed points —
212    /// into the memtable, and the next flush wrote them to disk a second
213    /// time. Dedup hid the duplicates from queries; the flash wear was real.
214    wal_floor: u64,
215    /// Rollup definitions and state, persisted with everything else that
216    /// a restart must not lose. They used to live in a hand-written JSON
217    /// file beside the data directory with no fsync and no CRC, and a
218    /// failure to parse it was logged and swallowed — leaving a database
219    /// that believed it had no rollups, and a retention pass that then
220    /// dropped the raw data those rollups existed to preserve.
221    rollups: BTreeMap<String, RollupRecord>,
222    /// Measurements pending a hard delete, by the unix-ms deadline a GC pass
223    /// acts on. See [`ManifestEntry::SetPendingMeasurementDrop`].
224    pending_measurement_drops: BTreeMap<String, u64>,
225    /// Monotonically increasing catalog version.
226    manifest_seq: u64,
227    /// Directory for manifest files.
228    manifest_dir: PathBuf,
229    /// Number of changes since last snapshot.
230    changes_since_snapshot: u64,
231    /// Next segment ID to assign.
232    next_segment_id: u64,
233    /// Persistent WAL file handle (kept open for append).
234    manifest_wal_file: Option<std::fs::File>,
235    /// Number of appends written but not yet `sync_data()`d.
236    ///
237    /// Normally at most one: every append syncs. It grows only inside
238    /// [`in_one_sync`](Self::in_one_sync).
239    pending_sync: u64,
240    /// Depth of the [`in_one_sync`](Self::in_one_sync) nesting.
241    ///
242    /// Non-zero means "this transition is not finished; do not fsync yet".
243    defer_sync: u32,
244    /// How many times the manifest has actually been fsynced.
245    ///
246    /// Exported as `chronix_catalog_fsync_total`, beside the WAL's own
247    /// counter: on flash-backed storage the fsync rate *is* the wear rate,
248    /// and the catalog was paying one per row of bookkeeping. It is also what
249    /// makes the batching testable — a claim about a number of syscalls that
250    /// nothing counts is a claim that rots.
251    manifest_syncs: u64,
252}
253
254/// Snapshot interval: take a snapshot every N changes.
255const SNAPSHOT_INTERVAL: u64 = 1000;
256
257/// Sync on **every** manifest append, so no catalog mutation is lost to a
258/// power failure.
259///
260/// A single mutation — registering a segment, updating a schema — is
261/// infrequent enough that its fsync is free. What is not free is a mutation
262/// made of *many* appends: a compaction of four segments is six, and a delete
263/// producing a hundred tombstones is a hundred. Those are batched by
264/// [`SegmentCatalog::in_one_sync`], which is where the count belongs — a
265/// global batch size would defer the appends nobody is going to sync.
266const SYNC_BATCH_SIZE: u64 = 1;
267
268// Compile-time guarantee: SegmentCatalog is safe to share across threads.
269const _: () = {
270    const fn _assert_send<T: Send + Sync>() {}
271    _assert_send::<SegmentCatalog>();
272};
273
274impl SegmentCatalog {
275    /// Create a new empty catalog persisted in `manifest_dir`.
276    ///
277    /// # Errors
278    ///
279    /// Returns an error if the manifest directory cannot be created.
280    pub fn new(manifest_dir: impl Into<PathBuf>) -> Result<Self> {
281        let manifest_dir = manifest_dir.into();
282        std::fs::create_dir_all(&manifest_dir)?;
283
284        Ok(Self {
285            segments: BTreeMap::new(),
286            schemas: HashMap::new(),
287            tombstones: TombstoneSet::new(),
288            wal_floor: 0,
289            rollups: BTreeMap::new(),
290            pending_measurement_drops: BTreeMap::new(),
291            manifest_seq: 0,
292            manifest_dir,
293            changes_since_snapshot: 0,
294            next_segment_id: 1,
295            manifest_wal_file: None,
296            pending_sync: 0,
297            defer_sync: 0,
298            manifest_syncs: 0,
299        })
300    }
301
302    /// Open an existing catalog, loading from snapshot + replaying manifest WAL.
303    ///
304    /// # Errors
305    ///
306    /// Returns an error if the snapshot or manifest cannot be read/parsed.
307    pub fn open(manifest_dir: impl Into<PathBuf>) -> Result<Self> {
308        let manifest_dir = manifest_dir.into();
309        std::fs::create_dir_all(&manifest_dir)?;
310
311        let bin_snapshot_path = manifest_dir.join("manifest.snapshot.bin");
312        let mut catalog = if bin_snapshot_path.exists() {
313            let data = std::fs::read(&bin_snapshot_path)?;
314            let snapshot: CatalogSnapshot = postcard::from_bytes(&data)
315                .map_err(|e| IndexError::BinarySerialization(e.to_string()))?;
316            // Reject snapshots from future versions we can't parse.
317            if snapshot.format_version > CATALOG_FORMAT_VERSION {
318                return Err(IndexError::BinarySerialization(format!(
319                    "catalog snapshot format version {} is newer than supported version {CATALOG_FORMAT_VERSION}",
320                    snapshot.format_version
321                )));
322            }
323            debug!(
324                seq = snapshot.manifest_seq,
325                segments = snapshot.segments.len(),
326                format_version = snapshot.format_version,
327                "loaded catalog snapshot (postcard)"
328            );
329            Self {
330                segments: snapshot.segments,
331                schemas: snapshot.schemas,
332                tombstones: snapshot.tombstones,
333                wal_floor: snapshot.wal_floor,
334                rollups: snapshot.rollups,
335                pending_measurement_drops: snapshot.pending_measurement_drops,
336                manifest_seq: snapshot.manifest_seq,
337                manifest_dir: manifest_dir.clone(),
338                changes_since_snapshot: 0,
339                next_segment_id: snapshot.next_segment_id,
340                manifest_wal_file: None,
341                pending_sync: 0,
342                defer_sync: 0,
343                manifest_syncs: 0,
344            }
345        } else {
346            Self {
347                segments: BTreeMap::new(),
348                schemas: HashMap::new(),
349                tombstones: TombstoneSet::new(),
350                wal_floor: 0,
351                rollups: BTreeMap::new(),
352                pending_measurement_drops: BTreeMap::new(),
353                manifest_seq: 0,
354                manifest_dir: manifest_dir.clone(),
355                changes_since_snapshot: 0,
356                next_segment_id: 1,
357                manifest_wal_file: None,
358                pending_sync: 0,
359                defer_sync: 0,
360                manifest_syncs: 0,
361            }
362        };
363
364        // Replay manifest WAL
365        let wal_path = manifest_dir.join("manifest.wal");
366        if wal_path.exists() {
367            catalog.replay_manifest(&wal_path)?;
368        }
369
370        Ok(catalog)
371    }
372
373    /// Add a segment to the catalog.
374    ///
375    /// # Errors
376    ///
377    /// Returns an error if the manifest cannot be written.
378    pub fn add_segment(&mut self, entry: SegmentCatalogEntry) -> Result<()> {
379        let manifest_entry = ManifestEntry::AddSegment(entry.clone());
380        self.append_manifest(&manifest_entry)?;
381
382        self.segments.entry(entry.shard_id).or_default().push(entry);
383
384        self.maybe_snapshot()?;
385        Ok(())
386    }
387
388    /// Remove a segment from the catalog by its ID.
389    ///
390    /// Returns the removed entry if found.
391    ///
392    /// # Errors
393    ///
394    /// Returns an error if the manifest cannot be written.
395    pub fn remove_segment(&mut self, segment_id: SegmentId) -> Result<Option<SegmentCatalogEntry>> {
396        let manifest_entry = ManifestEntry::RemoveSegment(segment_id.0);
397        self.append_manifest(&manifest_entry)?;
398
399        for entries in self.segments.values_mut() {
400            if let Some(pos) = entries.iter().position(|e| e.segment_id == segment_id) {
401                let removed = entries.remove(pos);
402                self.maybe_snapshot()?;
403                return Ok(Some(removed));
404            }
405        }
406
407        self.maybe_snapshot()?;
408        Ok(None)
409    }
410
411    /// Get all segments for a specific shard.
412    #[must_use]
413    pub fn get_segments_for_shard(&self, shard_id: &ShardId) -> &[SegmentCatalogEntry] {
414        self.segments.get(shard_id).map_or(&[], Vec::as_slice)
415    }
416
417    /// Mark a segment as soft-deleted.
418    ///
419    /// The segment remains in the catalog and is still queryable by code
420    /// that explicitly opts in to soft-deleted segments — but the default
421    /// query path (via `active_segments_for_measurement`) hides it.
422    /// A subsequent call to `remove_segment` will hard-delete it.
423    pub fn soft_delete_segment(&mut self, segment_id: SegmentId, now_ms: u64) -> Result<bool> {
424        // Find which measurement key and entry index contains this segment.
425        let location = self.segments.iter().find_map(|(measurement, entries)| {
426            entries
427                .iter()
428                .position(|e| e.segment_id == segment_id)
429                .map(|idx| (*measurement, idx))
430        });
431
432        let Some((measurement, idx)) = location else {
433            return Ok(false);
434        };
435
436        // Persist FIRST, then mutate in-memory state.
437        // If append_manifest fails, the in-memory state remains
438        // unchanged and the segment stays Active.
439        let entry = &self.segments[&measurement][idx];
440        let mut updated_entry = entry.clone();
441        updated_entry.state = SegmentState::SoftDeleted {
442            deleted_at_ms: now_ms,
443        };
444        let manifest_entry = ManifestEntry::AddSegment(updated_entry);
445        self.append_manifest(&manifest_entry)?;
446
447        // Now safe to mutate in-memory state.
448        if let Some(entries) = self.segments.get_mut(&measurement) {
449            entries[idx].state = SegmentState::SoftDeleted {
450                deleted_at_ms: now_ms,
451            };
452        }
453        Ok(true)
454    }
455
456    /// Get all *active* segments belonging to a specific measurement.
457    ///
458    /// Excludes segments that are soft-deleted (waiting for GC), and every
459    /// segment of a measurement pending a hard delete — a "drop" that leaves
460    /// its rows scannable for the whole grace period is not a drop. The
461    /// segments themselves are untouched, so a restore before the deadline
462    /// costs nothing and loses nothing.
463    #[must_use]
464    pub fn active_segments_for_measurement(&self, measurement: &str) -> Vec<&SegmentCatalogEntry> {
465        if self.pending_measurement_drops.contains_key(measurement) {
466            return Vec::new();
467        }
468        self.segments
469            .values()
470            .flat_map(|v| v.iter())
471            .filter(|e| e.measurement == measurement && e.state == SegmentState::Active)
472            .collect()
473    }
474
475    /// Get segments that are past the soft-delete grace period and can
476    /// be hard-deleted.
477    #[must_use]
478    pub fn expired_soft_deleted(
479        &self,
480        now_ms: u64,
481        grace_period_ms: u64,
482    ) -> Vec<&SegmentCatalogEntry> {
483        self.segments
484            .values()
485            .flat_map(|v| v.iter())
486            .filter(|e| {
487                if let SegmentState::SoftDeleted { deleted_at_ms } = e.state {
488                    now_ms.saturating_sub(deleted_at_ms) >= grace_period_ms
489                } else {
490                    false
491                }
492            })
493            .collect()
494    }
495
496    /// Get all segments across all shards.
497    #[must_use]
498    pub fn all_segments(&self) -> Vec<&SegmentCatalogEntry> {
499        self.segments.values().flat_map(|v| v.iter()).collect()
500    }
501
502    /// Get all segments belonging to a specific measurement.
503    #[must_use]
504    pub fn segments_for_measurement(&self, measurement: &str) -> Vec<&SegmentCatalogEntry> {
505        self.segments
506            .values()
507            .flat_map(|v| v.iter())
508            .filter(|e| e.measurement == measurement)
509            .collect()
510    }
511
512    /// Get the total number of segments.
513    /// The largest number of active segments any one `(shard, measurement)`
514    /// holds — the count a compaction pass can actually reduce, and so the
515    /// number write backpressure is keyed on.
516    #[must_use]
517    pub fn max_segments_per_shard_measurement(&self) -> usize {
518        let mut counts: std::collections::HashMap<(i64, &str), usize> =
519            std::collections::HashMap::new();
520        for e in self.all_segments() {
521            if e.state == chronix_core::SegmentState::Active {
522                *counts
523                    .entry((e.shard_id.0, e.measurement.as_str()))
524                    .or_default() += 1;
525            }
526        }
527        counts.into_values().max().unwrap_or(0)
528    }
529
530    /// Approximate heap bytes held by the catalog.
531    ///
532    /// One entry per segment, each carrying a measurement name, a path and its
533    /// per-column statistics — so this grows with segment count, not with row
534    /// count, and it is the term that a long-running gateway accumulates
535    /// between compactions. The schema registry and the tombstone set are
536    /// counted with it: all three live for the process's lifetime, which is
537    /// what makes them worth a number rather than an estimate.
538    #[must_use]
539    pub fn memory_bytes(&self) -> usize {
540        let entries: usize = self
541            .segments
542            .values()
543            .map(|v| {
544                v.capacity() * std::mem::size_of::<SegmentCatalogEntry>()
545                    + v.iter()
546                        .map(|e| {
547                            e.measurement.capacity()
548                                + e.path.as_os_str().len()
549                                + e.column_stats.capacity()
550                                    * std::mem::size_of::<CatalogColumnStats>()
551                                + e.column_stats
552                                    .iter()
553                                    .map(|c| c.name.capacity())
554                                    .sum::<usize>()
555                        })
556                        .sum::<usize>()
557            })
558            .sum();
559
560        let schemas: usize = self
561            .schemas
562            .iter()
563            .map(|(name, ms)| {
564                name.capacity()
565                    + ms.columns()
566                        .iter()
567                        .map(|c| c.name.capacity() + std::mem::size_of_val(c))
568                        .sum::<usize>()
569            })
570            .sum();
571
572        entries + schemas + self.tombstones.len() * std::mem::size_of::<u64>() * 4
573    }
574
575    /// Total number of segments the catalog holds, in any state.
576    #[must_use]
577    pub fn segment_count(&self) -> usize {
578        self.segments.values().map(Vec::len).sum()
579    }
580
581    /// Allocate the next segment ID.
582    pub fn next_segment_id(&mut self) -> SegmentId {
583        let id = SegmentId(self.next_segment_id);
584        self.next_segment_id += 1;
585        id
586    }
587
588    /// Get the current manifest sequence number.
589    #[must_use]
590    pub fn manifest_seq(&self) -> u64 {
591        self.manifest_seq
592    }
593
594    /// Every persisted rollup, by name.
595    #[must_use]
596    pub fn rollups(&self) -> &BTreeMap<String, RollupRecord> {
597        &self.rollups
598    }
599
600    /// Persist a rollup definition, durably.
601    ///
602    /// # Errors
603    ///
604    /// Returns an error if the manifest cannot be written or synced.
605    pub fn set_rollup(&mut self, name: &str, definition: Vec<u8>) -> Result<()> {
606        self.append_manifest(&ManifestEntry::SetRollup {
607            name: name.to_string(),
608            definition: definition.clone(),
609        })?;
610        self.rollups.entry(name.to_string()).or_default().definition = definition;
611        self.sync_manifest()?;
612        self.maybe_snapshot()?;
613        Ok(())
614    }
615
616    /// Persist a rollup's materialisation state, durably.
617    ///
618    /// Called after the rollup's points are durable, never before: a
619    /// watermark that outlives the rows it claims is a permanent hole in
620    /// the tier, because nothing recomputes a bucket the watermark has
621    /// already passed.
622    ///
623    /// # Errors
624    ///
625    /// Returns an error if the manifest cannot be written or synced.
626    pub fn set_rollup_state(&mut self, name: &str, state: Vec<u8>) -> Result<()> {
627        self.append_manifest(&ManifestEntry::SetRollupState {
628            name: name.to_string(),
629            state: state.clone(),
630        })?;
631        self.rollups.entry(name.to_string()).or_default().state = state;
632        self.sync_manifest()?;
633        self.maybe_snapshot()?;
634        Ok(())
635    }
636
637    /// Delete a rollup definition and its state.
638    ///
639    /// # Errors
640    ///
641    /// Returns an error if the manifest cannot be written.
642    pub fn remove_rollup(&mut self, name: &str) -> Result<bool> {
643        if !self.rollups.contains_key(name) {
644            return Ok(false);
645        }
646        self.append_manifest(&ManifestEntry::RemoveRollup(name.to_string()))?;
647        self.rollups.remove(name);
648        self.sync_manifest()?;
649        self.maybe_snapshot()?;
650        Ok(true)
651    }
652
653    /// Every measurement pending a hard delete, and the unix-ms deadline
654    /// each was given.
655    #[must_use]
656    pub fn pending_measurement_drops(&self) -> &BTreeMap<String, u64> {
657        &self.pending_measurement_drops
658    }
659
660    /// Whether `measurement` is pending a hard delete.
661    #[must_use]
662    pub fn is_measurement_pending_drop(&self, measurement: &str) -> bool {
663        self.pending_measurement_drops.contains_key(measurement)
664    }
665
666    /// Mark `measurement` pending a hard delete at `deadline_ms`, durably.
667    ///
668    /// # Errors
669    ///
670    /// Returns an error if the manifest cannot be written or synced.
671    pub fn set_measurement_pending_drop(
672        &mut self,
673        measurement: &str,
674        deadline_ms: u64,
675    ) -> Result<()> {
676        self.append_manifest(&ManifestEntry::SetPendingMeasurementDrop {
677            measurement: measurement.to_string(),
678            deadline_ms,
679        })?;
680        self.pending_measurement_drops
681            .insert(measurement.to_string(), deadline_ms);
682        self.sync_manifest()?;
683        self.maybe_snapshot()?;
684        Ok(())
685    }
686
687    /// Cancel a pending drop \u2014 a restore, or the hard delete that finally
688    /// acted on it \u2014 durably.
689    ///
690    /// Returns whether `measurement` was pending.
691    ///
692    /// # Errors
693    ///
694    /// Returns an error if the manifest cannot be written or synced.
695    pub fn cancel_measurement_pending_drop(&mut self, measurement: &str) -> Result<bool> {
696        if !self.pending_measurement_drops.contains_key(measurement) {
697            return Ok(false);
698        }
699        self.append_manifest(&ManifestEntry::CancelPendingMeasurementDrop(
700            measurement.to_string(),
701        ))?;
702        self.pending_measurement_drops.remove(measurement);
703        self.sync_manifest()?;
704        self.maybe_snapshot()?;
705        Ok(true)
706    }
707
708    /// The tombstones recorded by deletes.
709    #[must_use]
710    pub fn tombstones(&self) -> &TombstoneSet {
711        &self.tombstones
712    }
713
714    /// Record the tombstones produced by a delete, durably.
715    ///
716    /// The manifest append is fsynced before this returns, so a delete that
717    /// has been acknowledged has been persisted — which is the property the
718    /// previous design lacked, because it kept tombstones only in memory and
719    /// in a data WAL that the next flush was free to truncate.
720    ///
721    /// # Errors
722    ///
723    /// Returns an error if the manifest cannot be written or synced.
724    pub fn record_tombstones(&mut self, tombstones: &[Tombstone]) -> Result<()> {
725        // One fsync for the delete, not one per tombstone: a delete over a
726        // measurement with a hundred series produces a hundred of them.
727        self.in_one_sync(|catalog| {
728            for tombstone in tombstones {
729                catalog.append_manifest(&ManifestEntry::AddTombstone(tombstone.clone()))?;
730                catalog.tombstones.insert(tombstone.clone());
731            }
732            Ok(())
733        })?;
734        self.maybe_snapshot()?;
735        Ok(())
736    }
737
738    /// Register a compaction's output and retire its inputs, as one
739    /// durable step.
740    ///
741    /// Adds `output`, soft-deletes every input, and extends every tombstone
742    /// that named an input to the output. That last part is what closes the
743    /// window in which a delete issued *while* the merge ran was undone: the
744    /// merge applied the tombstones it snapshotted, the late tombstone named
745    /// only the inputs, and once GC removed them the tombstone was reclaimed
746    /// with its rows alive in the output.
747    ///
748    /// # Errors
749    ///
750    /// Returns an error if the manifest cannot be written.
751    pub fn complete_compaction(
752        &mut self,
753        output: SegmentCatalogEntry,
754        inputs: &[SegmentId],
755        now_ms: u64,
756    ) -> Result<()> {
757        let output_id = output.segment_id.0;
758        let input_ids: Vec<u64> = inputs.iter().map(|id| id.0).collect();
759        // One fsync for the whole transition, not one per input segment.
760        self.in_one_sync(|catalog| {
761            catalog.add_segment(output)?;
762            for input in inputs {
763                catalog.soft_delete_segment(*input, now_ms)?;
764            }
765            catalog.append_manifest(&ManifestEntry::Compacted {
766                inputs: input_ids.clone(),
767                output: output_id,
768            })
769        })?;
770        self.tombstones
771            .extend_to_compaction_output(&input_ids, output_id);
772        self.maybe_snapshot()?;
773        Ok(())
774    }
775
776    /// Reclaim tombstones whose segments have all left the catalog.
777    ///
778    /// A segment leaves the catalog only by being rewritten — compaction
779    /// applies tombstones as it merges — or by being deleted outright, so once
780    /// none of a tombstone's segments remains, no stored row can still match
781    /// it. Returns the number reclaimed.
782    ///
783    /// # Errors
784    ///
785    /// Returns an error if the manifest cannot be written.
786    pub fn reclaim_tombstones(&mut self) -> Result<usize> {
787        let live: std::collections::HashSet<u64> = self
788            .segments
789            .values()
790            .flatten()
791            .map(|e| e.segment_id.0)
792            .collect();
793
794        // A tombstone with no recorded segments predates nothing we can prove
795        // about, so it is kept. Every tombstone this engine writes records the
796        // segments it was issued against.
797        let removed = self.tombstones.retain_tombstones(|t| {
798            t.segments.is_empty() || t.segments.iter().any(|id| live.contains(id))
799        });
800
801        for tombstone in &removed {
802            self.append_manifest(&ManifestEntry::RemoveTombstone(tombstone.clone()))?;
803        }
804        if !removed.is_empty() {
805            self.sync_manifest()?;
806            self.maybe_snapshot()?;
807        }
808        Ok(removed.len())
809    }
810
811    /// Register or update a measurement schema.
812    ///
813    /// # Errors
814    ///
815    /// Returns an error if the manifest cannot be written.
816    pub fn set_schema(&mut self, schema: MeasurementSchema) -> Result<()> {
817        let entry = ManifestEntry::SetSchema(schema.clone());
818        self.append_manifest(&entry)?;
819        self.schemas
820            .insert(schema.measurement().to_string(), schema);
821        self.maybe_snapshot()?;
822        Ok(())
823    }
824
825    /// Remove a measurement schema.
826    ///
827    /// # Errors
828    ///
829    /// Returns an error if the manifest cannot be written.
830    pub fn remove_schema(&mut self, measurement: &str) -> Result<Option<MeasurementSchema>> {
831        let entry = ManifestEntry::RemoveSchema(measurement.to_string());
832        self.append_manifest(&entry)?;
833        let removed = self.schemas.remove(measurement);
834        self.maybe_snapshot()?;
835        Ok(removed)
836    }
837
838    /// The WAL floor: the highest data-WAL sequence number known to be
839    /// fully represented by the segments this catalog registers.
840    ///
841    /// `open()` replays only records above it.
842    #[must_use]
843    pub fn wal_floor(&self) -> u64 {
844        self.wal_floor
845    }
846
847    /// Raise the WAL floor to `sequence_no`.
848    ///
849    /// Persisted before it is applied, like every other catalog change. A
850    /// floor never moves backwards; a lower value is a no-op.
851    ///
852    /// # Errors
853    ///
854    /// Returns an error if the manifest append fails.
855    pub fn set_wal_floor(&mut self, sequence_no: u64) -> Result<()> {
856        if sequence_no <= self.wal_floor {
857            return Ok(());
858        }
859        self.append_manifest(&ManifestEntry::SetWalFloor(sequence_no))?;
860        self.wal_floor = sequence_no;
861        self.maybe_snapshot()?;
862        Ok(())
863    }
864
865    /// Look up a measurement schema.
866    #[must_use]
867    pub fn get_schema(&self, measurement: &str) -> Option<&MeasurementSchema> {
868        self.schemas.get(measurement)
869    }
870
871    /// Returns all schema names.
872    #[must_use]
873    pub fn schema_names(&self) -> Vec<String> {
874        self.schemas.keys().cloned().collect()
875    }
876
877    /// Returns an iterator over all `(name, schema)` pairs.
878    pub fn all_schemas(&self) -> impl Iterator<Item = (&String, &MeasurementSchema)> {
879        self.schemas.iter()
880    }
881
882    /// Force a snapshot of the current catalog state.
883    ///
884    /// # Errors
885    ///
886    /// Returns an error if the snapshot cannot be written.
887    pub fn force_snapshot(&mut self) -> Result<()> {
888        self.write_snapshot()?;
889        self.changes_since_snapshot = 0;
890        Ok(())
891    }
892
893    /// Append a manifest entry to the WAL file.
894    ///
895    /// Format: `[u32-le length][postcard payload][u32-le CRC32c]`
896    fn append_manifest(&mut self, entry: &ManifestEntry) -> Result<()> {
897        self.manifest_seq += 1;
898
899        // Lazily open the WAL file handle and keep it for future appends.
900        if self.manifest_wal_file.is_none() {
901            let wal_path = self.manifest_dir.join("manifest.wal");
902            let f = std::fs::OpenOptions::new()
903                .create(true)
904                .append(true)
905                .open(&wal_path)?;
906            self.manifest_wal_file = Some(f);
907        }
908        let file = self
909            .manifest_wal_file
910            .as_mut()
911            .ok_or_else(|| IndexError::BinarySerialization("WAL file not opened".into()))?;
912
913        let payload = postcard::to_stdvec(entry)
914            .map_err(|e| IndexError::BinarySerialization(e.to_string()))?;
915        let len = payload.len() as u32;
916        let crc = crc32c::crc32c(&payload);
917        file.write_all(&len.to_le_bytes())?;
918        file.write_all(&payload)?;
919        file.write_all(&crc.to_le_bytes())?;
920        // Always flush to kernel buffers so data is ordered.
921        file.flush()?;
922
923        // Synced here unless a caller has declared that this append is one
924        // step of a larger transition (`in_one_sync`), in which case that
925        // caller's single `sync_manifest()` covers it. Data reaches the
926        // kernel either way.
927        self.pending_sync += 1;
928        if self.defer_sync == 0 && self.pending_sync >= SYNC_BATCH_SIZE {
929            file.sync_data()?;
930            self.pending_sync = 0;
931            self.manifest_syncs += 1;
932            metrics::counter!("chronix_catalog_fsync_total").increment(1);
933        }
934
935        self.changes_since_snapshot += 1;
936        Ok(())
937    }
938
939    /// How many times the manifest has been fsynced since this catalog was
940    /// opened.
941    ///
942    /// One per catalog *transition*, not per append: a compaction retiring
943    /// four segments is one, and so is a delete however many tombstones it
944    /// produces. Also exported as `chronix_catalog_fsync_total`.
945    #[must_use]
946    pub fn manifest_syncs(&self) -> u64 {
947        self.manifest_syncs
948    }
949
950    /// Run `body` as one durable manifest transition: the appends it makes are
951    /// written but fsynced **once**, when it returns.
952    ///
953    /// A compaction of four segments is six appends — the output, four
954    /// soft-deletes and the `Compacted` record — and a delete is one per
955    /// tombstone. On flash-backed storage the fsync rate *is* the wear rate,
956    /// which is why the WAL has a periodic policy.
957    ///
958    /// Everything `body` appended is durable when this returns, on the error
959    /// path as much as the success path, so a transition that failed half-way
960    /// is as persistent as it would be if each append had synced. A crash
961    /// *during* `body` leaves what it always could: the manifest is a replayed
962    /// log, so a partial transition means an input segment still live beside
963    /// its compaction output, which reads deduplicate and the next compaction
964    /// clears.
965    ///
966    /// **Only for a transition that ends in a sync.** The retention, GC and
967    /// cold-tier loops call `remove_segment` without one and rely on the
968    /// per-append fsync, which is why this is opt-in rather than a batch size.
969    fn in_one_sync<T>(&mut self, body: impl FnOnce(&mut Self) -> Result<T>) -> Result<T> {
970        self.defer_sync += 1;
971        let out = body(self);
972        self.defer_sync -= 1;
973        // Before `out?`: a half-finished transition must be as durable as it
974        // was when each of its appends synced on its own.
975        self.sync_manifest()?;
976        out
977    }
978
979    /// Flush any pending manifest writes to durable storage.
980    ///
981    /// Call this after a batch of mutations to ensure all appended entries
982    /// survive a power failure.  This is a no-op if there are no pending
983    /// un-synced writes.
984    pub fn sync_manifest(&mut self) -> Result<()> {
985        if self.pending_sync > 0 {
986            if let Some(ref f) = self.manifest_wal_file {
987                f.sync_data()?;
988            }
989            self.pending_sync = 0;
990            self.manifest_syncs += 1;
991            metrics::counter!("chronix_catalog_fsync_total").increment(1);
992        }
993        Ok(())
994    }
995
996    /// Replay manifest WAL entries from a file.
997    ///
998    /// Reads length-prefixed postcard records with CRC32c integrity.
999    /// Truncated tail records (common crash artefact) are skipped
1000    /// with a warning. Mid-stream corruption returns an error.
1001    fn replay_manifest(&mut self, wal_path: &Path) -> Result<()> {
1002        let mut file = std::fs::File::open(wal_path)?;
1003        let file_len = file.metadata()?.len();
1004
1005        let mut replayed = 0u64;
1006        let mut tail_skipped = 0u64;
1007        let mut pos = 0u64;
1008
1009        loop {
1010            if pos >= file_len {
1011                break;
1012            }
1013            // Read length header (4 bytes)
1014            let mut len_buf = [0u8; 4];
1015            match file.read_exact(&mut len_buf) {
1016                Ok(()) => {}
1017                Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
1018                    tail_skipped += 1;
1019                    warn!(
1020                        offset = pos,
1021                        "manifest replay: truncated length header at tail"
1022                    );
1023                    break;
1024                }
1025                Err(e) => return Err(e.into()),
1026            }
1027            let payload_len = u32::from_le_bytes(len_buf) as usize;
1028            pos += 4;
1029
1030            // Sanity check payload length
1031            if payload_len == 0 || payload_len > 64 * 1024 * 1024 {
1032                if pos + (payload_len as u64) + 4 > file_len {
1033                    tail_skipped += 1;
1034                    warn!(
1035                        offset = pos - 4,
1036                        payload_len, "manifest replay: truncated entry at tail"
1037                    );
1038                    break;
1039                }
1040                return Err(IndexError::Manifest {
1041                    detail: format!(
1042                        "invalid manifest entry length {payload_len} at offset {}",
1043                        pos - 4
1044                    ),
1045                });
1046            }
1047
1048            // Read payload + CRC
1049            let total = payload_len + 4; // payload + CRC32c
1050            if pos + total as u64 > file_len {
1051                tail_skipped += 1;
1052                warn!(
1053                    offset = pos - 4,
1054                    "manifest replay: truncated payload at tail"
1055                );
1056                break;
1057            }
1058
1059            let mut buf = vec![0u8; total];
1060            if let Err(e) = file.read_exact(&mut buf) {
1061                if e.kind() == std::io::ErrorKind::UnexpectedEof {
1062                    tail_skipped += 1;
1063                    warn!(offset = pos - 4, "manifest replay: truncated read at tail");
1064                    break;
1065                }
1066                return Err(e.into());
1067            }
1068
1069            let payload = &buf[..payload_len];
1070            let stored_crc = u32::from_le_bytes([
1071                buf[payload_len],
1072                buf[payload_len + 1],
1073                buf[payload_len + 2],
1074                buf[payload_len + 3],
1075            ]);
1076            let computed_crc = crc32c::crc32c(payload);
1077
1078            if stored_crc != computed_crc {
1079                // Check if there are more valid records after this
1080                let remaining = file_len - pos - total as u64;
1081                if remaining > 8 {
1082                    return Err(IndexError::Manifest {
1083                        detail: format!(
1084                            "CRC mismatch at offset {} (stored={stored_crc:#x}, computed={computed_crc:#x}) — mid-stream corruption",
1085                            pos - 4,
1086                        ),
1087                    });
1088                }
1089                tail_skipped += 1;
1090                warn!(
1091                    offset = pos - 4,
1092                    "manifest replay: CRC mismatch at tail, skipping"
1093                );
1094                break;
1095            }
1096
1097            match postcard::from_bytes::<ManifestEntry>(payload) {
1098                Ok(entry) => {
1099                    self.apply_manifest_entry(entry);
1100                    self.manifest_seq += 1;
1101                    replayed += 1;
1102                }
1103                Err(e) => {
1104                    // Check if remaining data could contain valid entries
1105                    let remaining = file_len - pos - total as u64;
1106                    if remaining > 8 {
1107                        return Err(IndexError::Manifest {
1108                            detail: format!("deserialization error at offset {}: {e}", pos - 4,),
1109                        });
1110                    }
1111                    tail_skipped += 1;
1112                    warn!(offset = pos - 4, error = %e, "manifest replay: skipping corrupt tail entry");
1113                    break;
1114                }
1115            }
1116
1117            pos += total as u64;
1118        }
1119
1120        if tail_skipped > 0 {
1121            warn!(
1122                tail_skipped,
1123                "manifest replay: truncated tail entries skipped"
1124            );
1125        }
1126        if replayed > 0 {
1127            debug!(replayed, "replayed manifest entries");
1128        }
1129
1130        Ok(())
1131    }
1132
1133    /// Apply a manifest entry to the in-memory state (no persistence).
1134    fn apply_manifest_entry(&mut self, entry: ManifestEntry) {
1135        match entry {
1136            ManifestEntry::AddSegment(seg) => {
1137                // Track next_segment_id
1138                if seg.segment_id.0 >= self.next_segment_id {
1139                    self.next_segment_id = seg.segment_id.0 + 1;
1140                }
1141                // Dedup: remove any existing entry with the same segment_id
1142                // before pushing. This handles WAL replay of soft_delete_segment
1143                // which re-adds segments with updated state.
1144                let shard_entries = self.segments.entry(seg.shard_id).or_default();
1145                shard_entries.retain(|e| e.segment_id != seg.segment_id);
1146                shard_entries.push(seg);
1147            }
1148            ManifestEntry::RemoveSegment(id) => {
1149                for entries in self.segments.values_mut() {
1150                    entries.retain(|e| e.segment_id.0 != id);
1151                }
1152            }
1153            ManifestEntry::SetSchema(schema) => {
1154                self.schemas
1155                    .insert(schema.measurement().to_string(), schema);
1156            }
1157            ManifestEntry::RemoveSchema(name) => {
1158                self.schemas.remove(&name);
1159            }
1160            ManifestEntry::AddTombstone(t) => {
1161                self.tombstones.insert(t);
1162            }
1163            ManifestEntry::RemoveTombstone(t) => {
1164                self.tombstones.retain_tombstones(|existing| existing != &t);
1165            }
1166            ManifestEntry::SetWalFloor(seq) => {
1167                self.wal_floor = self.wal_floor.max(seq);
1168            }
1169            ManifestEntry::SetRollup { name, definition } => {
1170                self.rollups.entry(name).or_default().definition = definition;
1171            }
1172            ManifestEntry::RemoveRollup(name) => {
1173                self.rollups.remove(&name);
1174            }
1175            ManifestEntry::SetRollupState { name, state } => {
1176                self.rollups.entry(name).or_default().state = state;
1177            }
1178            ManifestEntry::Compacted { inputs, output } => {
1179                self.tombstones.extend_to_compaction_output(&inputs, output);
1180            }
1181            ManifestEntry::SetPendingMeasurementDrop {
1182                measurement,
1183                deadline_ms,
1184            } => {
1185                self.pending_measurement_drops
1186                    .insert(measurement, deadline_ms);
1187            }
1188            ManifestEntry::CancelPendingMeasurementDrop(measurement) => {
1189                self.pending_measurement_drops.remove(&measurement);
1190            }
1191        }
1192    }
1193
1194    /// Write a full snapshot and truncate the WAL.
1195    fn write_snapshot(&mut self) -> Result<()> {
1196        let snapshot = CatalogSnapshot {
1197            format_version: CATALOG_FORMAT_VERSION,
1198            segments: self.segments.clone(),
1199            schemas: self.schemas.clone(),
1200            tombstones: self.tombstones.clone(),
1201            wal_floor: self.wal_floor,
1202            rollups: self.rollups.clone(),
1203            pending_measurement_drops: self.pending_measurement_drops.clone(),
1204            manifest_seq: self.manifest_seq,
1205            next_segment_id: self.next_segment_id,
1206        };
1207
1208        let data = postcard::to_stdvec(&snapshot)
1209            .map_err(|e| IndexError::BinarySerialization(e.to_string()))?;
1210
1211        // Atomic snapshot write: write temp → fsync → rename → fsync parent.
1212        let snapshot_path = self.manifest_dir.join("manifest.snapshot.bin");
1213        let tmp_path = self.manifest_dir.join("manifest.snapshot.bin.tmp");
1214        // The temp file is removed on failure. The segment writer and the
1215        // local storage backend both do this; the catalog did not, so a
1216        // snapshot interrupted by `ENOSPC` left a partial `.tmp` behind —
1217        // consuming the space whose absence caused the failure, on the one
1218        // file the database cannot afford to lose.
1219        let write_tmp = (|| -> std::io::Result<()> {
1220            let mut file = std::fs::File::create(&tmp_path)?;
1221            file.write_all(&data)?;
1222            file.sync_all()
1223        })();
1224        if let Err(e) = write_tmp {
1225            let _ = std::fs::remove_file(&tmp_path);
1226            return Err(e.into());
1227        }
1228        if let Err(e) = std::fs::rename(&tmp_path, &snapshot_path) {
1229            let _ = std::fs::remove_file(&tmp_path);
1230            return Err(e.into());
1231        }
1232
1233        // Fsync parent directory to make the rename durable.
1234        // This MUST succeed before truncating the WAL; otherwise a crash
1235        // could leave us with the old snapshot and an empty WAL → data loss.
1236        let dir = std::fs::File::open(&self.manifest_dir).map_err(|e| {
1237            std::io::Error::new(
1238                e.kind(),
1239                format!("failed to open manifest dir for fsync: {e}"),
1240            )
1241        })?;
1242        dir.sync_all()?;
1243
1244        // Truncate WAL and invalidate the persistent file handle so it
1245        // gets re-opened on the next append.
1246        let wal_path = self.manifest_dir.join("manifest.wal");
1247        if wal_path.exists() {
1248            let file = std::fs::OpenOptions::new()
1249                .write(true)
1250                .truncate(true)
1251                .open(&wal_path)?;
1252            // The truncation must be durable too. Replay is idempotent for
1253            // every entry kind, so a resurrected entry is harmless — but an
1254            // unsynced truncation is exactly the kind of "probably fine"
1255            // this catalog does not deal in.
1256            file.sync_all()?;
1257        }
1258        self.manifest_wal_file = None;
1259
1260        debug!(seq = self.manifest_seq, "catalog snapshot written");
1261        Ok(())
1262    }
1263
1264    /// Take a snapshot if the change count exceeds the threshold.
1265    fn maybe_snapshot(&mut self) -> Result<()> {
1266        if self.changes_since_snapshot >= SNAPSHOT_INTERVAL {
1267            self.write_snapshot()?;
1268            self.changes_since_snapshot = 0;
1269        }
1270        Ok(())
1271    }
1272}
1273
1274/// Current catalog snapshot format version — 1, the first that ships.
1275///
1276/// Unlike the segment header this is compared with `>`: a snapshot from a
1277/// *newer* Chronix is refused, an older one is read.
1278const CATALOG_FORMAT_VERSION: u32 = 1;
1279
1280/// Serializable snapshot of the catalog state.
1281#[derive(Debug, Serialize, Deserialize)]
1282struct CatalogSnapshot {
1283    /// Format version for forward compatibility.
1284    #[serde(default)]
1285    format_version: u32,
1286    segments: BTreeMap<ShardId, Vec<SegmentCatalogEntry>>,
1287    schemas: HashMap<String, MeasurementSchema>,
1288    /// Tombstones, as of this snapshot. A snapshot that omitted them would
1289    /// undo every delete the moment the manifest WAL was folded into it.
1290    #[serde(default)]
1291    tombstones: TombstoneSet,
1292    /// See [`SegmentCatalog::wal_floor`].
1293    #[serde(default)]
1294    wal_floor: u64,
1295    /// Rollup definitions and their materialisation state, by name. Opaque
1296    /// bytes: the catalog is where they are durable, the facade is where
1297    /// they mean something.
1298    #[serde(default)]
1299    rollups: BTreeMap<String, RollupRecord>,
1300    /// See [`SegmentCatalog::pending_measurement_drops`].
1301    #[serde(default)]
1302    pending_measurement_drops: BTreeMap<String, u64>,
1303    manifest_seq: u64,
1304    next_segment_id: u64,
1305}
1306
1307#[cfg(test)]
1308mod tests {
1309    use super::*;
1310
1311    fn test_entry(id: u64, shard: i64, min_ts: i64, max_ts: i64) -> SegmentCatalogEntry {
1312        SegmentCatalogEntry {
1313            segment_id: SegmentId(id),
1314            shard_id: ShardId(shard),
1315            measurement: "cpu".to_string(),
1316            path: PathBuf::from(format!("shard_{shard}/seg_{id}.csx")),
1317            min_timestamp: min_ts,
1318            max_timestamp: max_ts,
1319            row_count: 1000,
1320            series_count: 10,
1321            byte_size: 4096,
1322            row_group_count: 1,
1323            column_count: 5,
1324            column_stats: Vec::new(),
1325            state: SegmentState::default(),
1326        }
1327    }
1328
1329    /// A compaction is **one** fsync, whatever it retires.
1330    ///
1331    /// It used to be one per append: the output, one per input segment, and
1332    /// the `Compacted` record — six for a four-segment merge, and compaction
1333    /// runs every maintenance interval. On the flash the design partner
1334    /// writes to, the fsync rate is the wear rate.
1335    #[test]
1336    fn a_compaction_costs_one_manifest_fsync() {
1337        let dir = tempfile::tempdir().unwrap();
1338        let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1339
1340        let inputs: Vec<SegmentId> = (1..=4i64)
1341            .map(|i| {
1342                #[allow(clippy::cast_sign_loss)]
1343                let e = test_entry(i as u64, 0, i * 100, i * 100 + 50);
1344                let id = e.segment_id;
1345                catalog.add_segment(e).unwrap();
1346                id
1347            })
1348            .collect();
1349
1350        let before = catalog.manifest_syncs();
1351        catalog
1352            .complete_compaction(test_entry(99, 0, 100, 450), &inputs, 0)
1353            .unwrap();
1354        assert_eq!(
1355            catalog.manifest_syncs() - before,
1356            1,
1357            "one transition, one fsync — it was six"
1358        );
1359
1360        // …and it is durable: a reopen sees the output live and the inputs
1361        // soft-deleted, which is the property the per-append fsync bought.
1362        drop(catalog);
1363        let reopened = SegmentCatalog::open(dir.path()).unwrap();
1364        let live: Vec<u64> = reopened
1365            .active_segments_for_measurement("cpu")
1366            .iter()
1367            .map(|e| e.segment_id.0)
1368            .collect();
1369        assert_eq!(live, vec![99], "only the compaction output survives");
1370    }
1371
1372    /// A delete is one fsync, not one per tombstone.
1373    #[test]
1374    fn a_delete_costs_one_manifest_fsync() {
1375        let dir = tempfile::tempdir().unwrap();
1376        let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1377        let tombstones: Vec<Tombstone> = (0..25)
1378            .map(|i| Tombstone::ranged(format!("cpu,host=h{i}"), 0, 1_000))
1379            .collect();
1380
1381        let before = catalog.manifest_syncs();
1382        catalog.record_tombstones(&tombstones).unwrap();
1383        assert_eq!(
1384            catalog.manifest_syncs() - before,
1385            1,
1386            "25 tombstones, 1 fsync"
1387        );
1388
1389        drop(catalog);
1390        let reopened = SegmentCatalog::open(dir.path()).unwrap();
1391        assert_eq!(
1392            reopened.tombstones().len(),
1393            25,
1394            "every tombstone survived the reopen"
1395        );
1396    }
1397
1398    /// A single mutation still syncs on its own — the batching is opt-in, and
1399    /// the retention, GC and cold-tier loops rely on that.
1400    #[test]
1401    fn a_lone_append_still_syncs() {
1402        let dir = tempfile::tempdir().unwrap();
1403        let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1404        let before = catalog.manifest_syncs();
1405        catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
1406        assert_eq!(catalog.manifest_syncs() - before, 1);
1407        catalog.soft_delete_segment(SegmentId(1), 0).unwrap();
1408        assert_eq!(catalog.manifest_syncs() - before, 2);
1409    }
1410
1411    #[test]
1412    fn add_and_get_segments() {
1413        let dir = tempfile::tempdir().unwrap();
1414        let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1415
1416        catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
1417        catalog.add_segment(test_entry(2, 0, 300, 400)).unwrap();
1418        catalog.add_segment(test_entry(3, 1, 100, 200)).unwrap();
1419
1420        assert_eq!(catalog.get_segments_for_shard(&ShardId(0)).len(), 2);
1421        assert_eq!(catalog.get_segments_for_shard(&ShardId(1)).len(), 1);
1422        assert_eq!(catalog.get_segments_for_shard(&ShardId(99)).len(), 0);
1423        assert_eq!(catalog.segment_count(), 3);
1424    }
1425
1426    #[test]
1427    fn remove_segment() {
1428        let dir = tempfile::tempdir().unwrap();
1429        let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1430
1431        catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
1432        catalog.add_segment(test_entry(2, 0, 300, 400)).unwrap();
1433
1434        let removed = catalog.remove_segment(SegmentId(1)).unwrap();
1435        assert!(removed.is_some());
1436        assert_eq!(removed.unwrap().segment_id, SegmentId(1));
1437        assert_eq!(catalog.segment_count(), 1);
1438
1439        // Remove non-existent
1440        let removed = catalog.remove_segment(SegmentId(99)).unwrap();
1441        assert!(removed.is_none());
1442    }
1443
1444    #[test]
1445    fn persist_and_reload() {
1446        let dir = tempfile::tempdir().unwrap();
1447
1448        // Create catalog and add segments
1449        {
1450            let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1451            catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
1452            catalog.add_segment(test_entry(2, 0, 300, 400)).unwrap();
1453            catalog.add_segment(test_entry(3, 1, 500, 600)).unwrap();
1454        }
1455
1456        // Reload from manifest
1457        let catalog = SegmentCatalog::open(dir.path()).unwrap();
1458        assert_eq!(catalog.segment_count(), 3);
1459        assert_eq!(catalog.get_segments_for_shard(&ShardId(0)).len(), 2);
1460        assert_eq!(catalog.get_segments_for_shard(&ShardId(1)).len(), 1);
1461    }
1462
1463    #[test]
1464    fn snapshot_and_reload() {
1465        let dir = tempfile::tempdir().unwrap();
1466
1467        // Create, populate, snapshot
1468        {
1469            let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1470            catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
1471            catalog.add_segment(test_entry(2, 0, 300, 400)).unwrap();
1472            catalog.force_snapshot().unwrap();
1473            // Add more after snapshot
1474            catalog.add_segment(test_entry(3, 1, 500, 600)).unwrap();
1475        }
1476
1477        // Reload—should see all 3 from snapshot + WAL
1478        let catalog = SegmentCatalog::open(dir.path()).unwrap();
1479        assert_eq!(catalog.segment_count(), 3);
1480    }
1481
1482    #[test]
1483    fn schema_persistence() {
1484        let dir = tempfile::tempdir().unwrap();
1485
1486        {
1487            let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1488            let schema = MeasurementSchema::new("cpu");
1489            catalog.set_schema(schema).unwrap();
1490        }
1491
1492        let catalog = SegmentCatalog::open(dir.path()).unwrap();
1493        assert!(catalog.get_schema("cpu").is_some());
1494        assert_eq!(catalog.get_schema("cpu").unwrap().measurement(), "cpu");
1495    }
1496
1497    #[test]
1498    fn remove_schema() {
1499        let dir = tempfile::tempdir().unwrap();
1500        let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1501        catalog.set_schema(MeasurementSchema::new("cpu")).unwrap();
1502        assert!(catalog.get_schema("cpu").is_some());
1503
1504        catalog.remove_schema("cpu").unwrap();
1505        assert!(catalog.get_schema("cpu").is_none());
1506    }
1507
1508    #[test]
1509    fn next_segment_id_monotonic() {
1510        let dir = tempfile::tempdir().unwrap();
1511        let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1512
1513        assert_eq!(catalog.next_segment_id(), SegmentId(1));
1514        assert_eq!(catalog.next_segment_id(), SegmentId(2));
1515        assert_eq!(catalog.next_segment_id(), SegmentId(3));
1516    }
1517
1518    #[test]
1519    fn next_segment_id_survives_reload() {
1520        let dir = tempfile::tempdir().unwrap();
1521
1522        {
1523            let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1524            catalog.add_segment(test_entry(5, 0, 100, 200)).unwrap();
1525            catalog.add_segment(test_entry(10, 0, 300, 400)).unwrap();
1526        }
1527
1528        let mut catalog = SegmentCatalog::open(dir.path()).unwrap();
1529        // next_segment_id should be 11 (max existing + 1)
1530        let next = catalog.next_segment_id();
1531        assert!(
1532            next.0 >= 11,
1533            "next_segment_id should be >= 11, got {}",
1534            next.0
1535        );
1536    }
1537
1538    #[test]
1539    fn add_remove_add_roundtrip() {
1540        let dir = tempfile::tempdir().unwrap();
1541
1542        {
1543            let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1544            catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
1545            catalog.remove_segment(SegmentId(1)).unwrap();
1546            catalog.add_segment(test_entry(2, 0, 300, 400)).unwrap();
1547        }
1548
1549        let catalog = SegmentCatalog::open(dir.path()).unwrap();
1550        assert_eq!(catalog.segment_count(), 1);
1551        assert_eq!(
1552            catalog.get_segments_for_shard(&ShardId(0))[0].segment_id,
1553            SegmentId(2)
1554        );
1555    }
1556
1557    #[test]
1558    fn all_segments() {
1559        let dir = tempfile::tempdir().unwrap();
1560        let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1561
1562        catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
1563        catalog.add_segment(test_entry(2, 1, 300, 400)).unwrap();
1564        catalog.add_segment(test_entry(3, 2, 500, 600)).unwrap();
1565
1566        let all = catalog.all_segments();
1567        assert_eq!(all.len(), 3);
1568    }
1569
1570    #[test]
1571    fn manifest_replay_tail_truncation_tolerated() {
1572        // Corrupt entries at the end (tail truncation from a crash) should be
1573        // skipped with a warning — not an error.
1574        let dir = tempfile::tempdir().unwrap();
1575
1576        {
1577            let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1578            catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
1579            catalog.add_segment(test_entry(2, 0, 300, 400)).unwrap();
1580        }
1581
1582        // Append a truncated binary frame at the tail to simulate a crash mid-write.
1583        let wal_path = dir.path().join("manifest.wal");
1584        let mut f = std::fs::OpenOptions::new()
1585            .append(true)
1586            .open(&wal_path)
1587            .unwrap();
1588        // Write a length header but no payload — simulates crash after partial write
1589        let fake_len: u32 = 128;
1590        f.write_all(&fake_len.to_le_bytes()).unwrap();
1591        f.write_all(&[0xDE, 0xAD]).unwrap(); // partial payload
1592        drop(f);
1593
1594        // Replay should succeed — tail corruption is tolerated.
1595        let catalog = SegmentCatalog::open(dir.path()).unwrap();
1596        assert_eq!(catalog.segment_count(), 2);
1597    }
1598
1599    #[test]
1600    fn manifest_replay_midstream_corruption_errors() {
1601        // A corrupt entry in the middle (valid entries follow it) is real
1602        // corruption and must cause an error.
1603        let dir = tempfile::tempdir().unwrap();
1604
1605        {
1606            let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1607            catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
1608        }
1609
1610        // Write a corrupt binary frame (valid length, bad CRC) followed by a valid frame.
1611        let wal_path = dir.path().join("manifest.wal");
1612        let mut f = std::fs::OpenOptions::new()
1613            .append(true)
1614            .open(&wal_path)
1615            .unwrap();
1616
1617        // Corrupt frame: valid-looking length, garbage payload, wrong CRC
1618        let corrupt_payload = b"CORRUPT_DATA_HERE_1234";
1619        let corrupt_len = corrupt_payload.len() as u32;
1620        let wrong_crc: u32 = 0xDEADBEEF;
1621        f.write_all(&corrupt_len.to_le_bytes()).unwrap();
1622        f.write_all(corrupt_payload).unwrap();
1623        f.write_all(&wrong_crc.to_le_bytes()).unwrap();
1624
1625        // Valid frame after the corrupt one
1626        let valid_entry = ManifestEntry::AddSegment(test_entry(3, 0, 500, 600));
1627        let valid_payload = postcard::to_stdvec(&valid_entry).unwrap();
1628        let valid_len = valid_payload.len() as u32;
1629        let valid_crc = crc32c::crc32c(&valid_payload);
1630        f.write_all(&valid_len.to_le_bytes()).unwrap();
1631        f.write_all(&valid_payload).unwrap();
1632        f.write_all(&valid_crc.to_le_bytes()).unwrap();
1633        drop(f);
1634
1635        // Replay should fail with a Manifest error.
1636        let result = SegmentCatalog::open(dir.path());
1637        assert!(result.is_err(), "expected error for mid-stream corruption");
1638        let err_msg = format!("{}", result.unwrap_err());
1639        assert!(
1640            err_msg.contains("mid-stream corruption"),
1641            "error should mention mid-stream corruption, got: {err_msg}"
1642        );
1643    }
1644}