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::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, SegmentFile, SegmentId, SegmentState, ShardId, Timestamp, Tombstone,
30    TombstoneSet,
31};
32
33use crate::index::error::{IndexError, Result};
34
35/// Per-column statistics stored in the catalog for pruning.
36///
37/// Contains the column name, data type, and min/max statistics so the
38/// pruning pipeline can eliminate segments without opening any file.
39#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
40pub struct CatalogColumnStats {
41    /// Column name (e.g. "temperature", "host").
42    pub name: String,
43    /// Column data type tag (from `crate::segment::metadata::data_types`).
44    pub data_type: u8,
45    /// Column role tag (0= timestamp, 1= tag, 2= field).
46    pub role: u8,
47    /// Digits after the decimal point, for a decimal column; `None`
48    /// otherwise.
49    ///
50    /// Here as well as in the segment because the schema repair at open
51    /// works from *this* summary and never opens a `.csx` file: without the
52    /// scale, a decimal column the manifest had lost could only be restored
53    /// as some other type, which is worse than not restoring it.
54    #[serde(default)]
55    pub decimal_scale: Option<u8>,
56    /// Min/max/null statistics.
57    pub stats: ColumnStats,
58}
59
60/// Metadata for a single segment in the catalog.
61#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
62pub struct SegmentCatalogEntry {
63    /// Unique segment identifier.
64    pub segment_id: SegmentId,
65    /// The shard this segment belongs to.
66    pub shard_id: ShardId,
67    /// Measurement name this segment contains data for.
68    pub measurement: String,
69    /// Where the `.csx` file lives, relative to the `segments/` directory.
70    ///
71    /// Relative, so the catalog describes *a* database rather than the
72    /// directory this one happens to sit in — see [`SegmentFile`].
73    pub file: SegmentFile,
74    /// Minimum timestamp in the segment (inclusive).
75    pub min_timestamp: Timestamp,
76    /// Maximum timestamp in the segment (inclusive).
77    pub max_timestamp: Timestamp,
78    /// Total number of rows in the segment.
79    pub row_count: u64,
80    /// Number of unique series in the segment.
81    pub series_count: u32,
82    /// File size in bytes.
83    pub byte_size: u64,
84    /// Number of row groups.
85    pub row_group_count: u32,
86    /// Number of columns.
87    pub column_count: u16,
88    /// Per-column statistics for predicate pushdown pruning.
89    #[serde(default)]
90    pub column_stats: Vec<CatalogColumnStats>,
91    /// Lifecycle state of this segment.
92    #[serde(default)]
93    pub state: SegmentState,
94}
95
96/// Manifest entry types for WAL-style persistence.
97#[derive(Debug, Clone, Serialize, Deserialize)]
98enum ManifestEntry {
99    /// A segment was added.
100    AddSegment(SegmentCatalogEntry),
101    /// A segment was removed.
102    RemoveSegment(u64),
103    /// A schema was registered or updated.
104    SetSchema(MeasurementSchema),
105    /// A schema was removed.
106    RemoveSchema(String),
107    /// A tombstone was recorded by a delete.
108    AddTombstone(Tombstone),
109    /// A tombstone was reclaimed — every segment it was issued against is gone.
110    RemoveTombstone(Tombstone),
111    /// Every WAL record with a sequence number at or below this is in a
112    /// segment. Replay starts after it.
113    SetWalFloor(u64),
114    /// Compaction merged `inputs` into `output`: every tombstone that named
115    /// an input now names the output too.
116    Compacted {
117        /// The input segment ids.
118        inputs: Vec<u64>,
119        /// The output segment id.
120        output: u64,
121    },
122    /// A rollup definition was created or replaced. The payload is the
123    /// facade's `RollupConfig`, opaque here — the catalog persists it, the
124    /// facade interprets it.
125    SetRollup {
126        /// The rollup's name, its identity.
127        name: String,
128        /// Postcard-encoded definition.
129        definition: Vec<u8>,
130    },
131    /// A rollup definition was deleted.
132    RemoveRollup(String),
133    /// A rollup's materialisation state changed: its watermark advanced, or
134    /// a write below the watermark invalidated some of its buckets.
135    SetRollupState {
136        /// The rollup's name.
137        name: String,
138        /// Postcard-encoded state.
139        state: Vec<u8>,
140    },
141    /// A measurement was soft-deleted: it is pending a hard delete at
142    /// `deadline_ms` unless cancelled first.
143    ///
144    /// Durable for the same reason a tombstone is: an in-memory-only
145    /// pending-drop map is undone by every restart, silently un-dropping a
146    /// measurement an operator was told was gone and forgetting the deadline
147    /// that was supposed to reclaim its disk.
148    SetPendingMeasurementDrop {
149        /// The measurement pending deletion.
150        measurement: String,
151        /// Unix-ms deadline after which the next GC pass hard-deletes it.
152        deadline_ms: u64,
153    },
154    /// A pending drop was cancelled — by a restore, or by the hard delete
155    /// that finally acted on it.
156    CancelPendingMeasurementDrop(String),
157}
158
159/// A rollup's persisted definition and state, as opaque payloads.
160#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
161pub struct RollupRecord {
162    /// Postcard-encoded definition, absent while only state is known.
163    #[serde(default)]
164    pub definition: Vec<u8>,
165    /// Postcard-encoded materialisation state.
166    #[serde(default)]
167    pub state: Vec<u8>,
168}
169
170/// Segment metadata catalog with manifest persistence.
171///
172/// Tracks all segment metadata organized by shard, along with measurement
173/// schemas. Changes are persisted to a manifest WAL file, with periodic
174/// snapshots to prevent unbounded manifest growth.
175///
176/// # Persistence
177///
178/// Changes are appended to `manifest.wal` as length-prefixed, CRC-32C
179/// framed postcard entries.
180/// Periodic snapshots written to `manifest.snapshot.bin`.
181/// On startup: load latest snapshot, replay WAL entries.
182///
183/// # Concurrency
184///
185/// The catalog currently uses a single `&mut self` borrow (i.e. external
186/// synchronisation via an `RwLock` or `Mutex`) for all shards.  This is
187/// acceptable because catalog mutations (segment add/remove, schema
188/// updates) are infrequent (flush/compaction cadence) and short-lived.
189///
190/// If catalog contention ever becomes measurable under very high shard
191/// counts, the internal `BTreeMap<ShardId, Vec<…>>` can be replaced with
192/// a `DashMap<ShardId, Vec<…>>` to allow per-shard concurrent access
193/// without changing the external API.
194#[derive(Debug)]
195pub struct SegmentCatalog {
196    /// Segments organized by shard.
197    segments: BTreeMap<ShardId, Vec<SegmentCatalogEntry>>,
198    /// Measurement schemas.
199    schemas: HashMap<String, MeasurementSchema>,
200    /// Tombstones recorded by deletes.
201    ///
202    /// These live in the catalog rather than in the data WAL, and that is the
203    /// fix for a delete that did not survive a restart. The data WAL is
204    /// truncated once the memtable it covers has been flushed, so a delete
205    /// logged before a flush was discarded by it — the tombstone existed only
206    /// in memory from then on, and the deleted rows came back at the next
207    /// open. The manifest is only ever rewritten by a snapshot that carries
208    /// its full contents forward.
209    tombstones: TombstoneSet,
210    /// The WAL floor: every data-WAL record with `sequence_no <= wal_floor`
211    /// has been flushed into a segment that this catalog registers.
212    ///
213    /// Recorded here rather than inferred from the WAL files, because the
214    /// active WAL file is never deleted by truncation. Without a floor every
215    /// restart replayed that file — up to 32 MB of already-flushed points —
216    /// into the memtable, and the next flush wrote them to disk a second
217    /// time. Dedup hid the duplicates from queries; the flash wear was real.
218    wal_floor: u64,
219    /// Rollup definitions and state, persisted with everything else that
220    /// a restart must not lose. They used to live in a hand-written JSON
221    /// file beside the data directory with no fsync and no CRC, and a
222    /// failure to parse it was logged and swallowed — leaving a database
223    /// that believed it had no rollups, and a retention pass that then
224    /// dropped the raw data those rollups existed to preserve.
225    rollups: BTreeMap<String, RollupRecord>,
226    /// Measurements pending a hard delete, by the unix-ms deadline a GC pass
227    /// acts on. See [`ManifestEntry::SetPendingMeasurementDrop`].
228    pending_measurement_drops: BTreeMap<String, u64>,
229    /// Monotonically increasing catalog version.
230    manifest_seq: u64,
231    /// Directory for manifest files.
232    manifest_dir: PathBuf,
233    /// Number of changes since last snapshot.
234    changes_since_snapshot: u64,
235    /// Next segment ID to assign.
236    next_segment_id: u64,
237    /// Persistent manifest-log handle (kept open for append).
238    ///
239    /// `dyn DurableFile` rather than `File` so a test can make the write
240    /// fail. A durability path is defined by what it does when the write
241    /// fails, and a filesystem will not fail on request.
242    manifest_wal_file: Option<Box<dyn crate::durable::DurableFile>>,
243    /// Bytes of `manifest.wal` known to hold whole records.
244    ///
245    /// The rewind point. An append that fails part-way truncates back to it,
246    /// so the file never holds a fragment with a valid record after it —
247    /// which replay reads as corruption, and used to answer by silently
248    /// discarding every transition that followed.
249    manifest_good_len: u64,
250    /// Number of appends written but not yet `sync_data()`d.
251    ///
252    /// Normally at most one: every append syncs. It grows only inside
253    /// [`in_one_sync`](Self::in_one_sync).
254    pending_sync: u64,
255    /// Depth of the [`in_one_sync`](Self::in_one_sync) nesting.
256    ///
257    /// Non-zero means "this transition is not finished; do not fsync yet".
258    defer_sync: u32,
259    /// How many times the manifest has actually been fsynced.
260    ///
261    /// Exported as `chronix_catalog_fsync_total`, beside the WAL's own
262    /// counter: on flash-backed storage the fsync rate *is* the wear rate,
263    /// and the catalog was paying one per row of bookkeeping. It is also what
264    /// makes the batching testable — a claim about a number of syscalls that
265    /// nothing counts is a claim that rots.
266    manifest_syncs: u64,
267}
268
269/// The largest a single manifest record may plausibly be.
270///
271/// A length header that claims more than this is corruption rather than a
272/// record: the biggest real entry is a schema or a batch of tombstones, and
273/// neither approaches it.
274const MAX_MANIFEST_RECORD_BYTES: usize = 64 * 1024 * 1024;
275
276/// What sits at an offset in the manifest log.
277enum RecordAt {
278    /// A whole record whose CRC checks out.
279    Valid {
280        /// Byte range of the postcard payload.
281        payload: std::ops::Range<usize>,
282        /// Offset the next record starts at.
283        next: usize,
284    },
285    /// Not a record, with the reason for the log.
286    Bad(String),
287}
288
289/// Snapshot interval: take a snapshot every N changes.
290const SNAPSHOT_INTERVAL: u64 = 1000;
291
292#[cfg(test)]
293thread_local! {
294    /// Bytes the manifest log may still accept on this thread before every
295    /// write returns `ENOSPC`.
296    ///
297    /// Thread-local rather than a field, so the budget reaches the file a
298    /// `SegmentCatalog` opens *lazily*, without a constructor argument every
299    /// caller in the tree would have to thread through for one test.
300    /// `i64::MAX` is a disk that never fills, which is what every test that
301    /// is not about `ENOSPC` gets.
302    static MANIFEST_DISK_BUDGET: std::sync::Arc<std::sync::atomic::AtomicI64> =
303        std::sync::Arc::new(std::sync::atomic::AtomicI64::new(i64::MAX));
304}
305
306/// Sync on **every** manifest append, so no catalog mutation is lost to a
307/// power failure.
308///
309/// A single mutation — registering a segment, updating a schema — is
310/// infrequent enough that its fsync is free. What is not free is a mutation
311/// made of *many* appends: a compaction of four segments is six, and a delete
312/// producing a hundred tombstones is a hundred. Those are batched by
313/// [`SegmentCatalog::in_one_sync`], which is where the count belongs — a
314/// global batch size would defer the appends nobody is going to sync.
315const SYNC_BATCH_SIZE: u64 = 1;
316
317// Compile-time guarantee: SegmentCatalog is safe to share across threads.
318const _: () = {
319    const fn _assert_send<T: Send + Sync>() {}
320    _assert_send::<SegmentCatalog>();
321};
322
323impl SegmentCatalog {
324    /// Create a new empty catalog persisted in `manifest_dir`.
325    ///
326    /// # Errors
327    ///
328    /// Returns an error if the manifest directory cannot be created.
329    pub fn new(manifest_dir: impl Into<PathBuf>) -> Result<Self> {
330        let manifest_dir = manifest_dir.into();
331        std::fs::create_dir_all(&manifest_dir)?;
332
333        Ok(Self {
334            segments: BTreeMap::new(),
335            schemas: HashMap::new(),
336            tombstones: TombstoneSet::new(),
337            wal_floor: 0,
338            rollups: BTreeMap::new(),
339            pending_measurement_drops: BTreeMap::new(),
340            manifest_seq: 0,
341            manifest_dir,
342            changes_since_snapshot: 0,
343            next_segment_id: 1,
344            manifest_wal_file: None,
345            manifest_good_len: 0,
346            pending_sync: 0,
347            defer_sync: 0,
348            manifest_syncs: 0,
349        })
350    }
351
352    /// Open an existing catalog, loading from snapshot + replaying manifest WAL.
353    ///
354    /// # Errors
355    ///
356    /// Returns an error if the snapshot or manifest cannot be read/parsed.
357    pub fn open(manifest_dir: impl Into<PathBuf>) -> Result<Self> {
358        let manifest_dir = manifest_dir.into();
359        std::fs::create_dir_all(&manifest_dir)?;
360
361        let bin_snapshot_path = manifest_dir.join("manifest.snapshot.bin");
362        let mut catalog = if bin_snapshot_path.exists() {
363            let data = std::fs::read(&bin_snapshot_path)?;
364            let snapshot: CatalogSnapshot = postcard::from_bytes(&data)
365                .map_err(|e| IndexError::BinarySerialization(e.to_string()))?;
366            // Equality, both directions. This used to be `>`, so an *older*
367            // snapshot was accepted by a reader with no code to interpret
368            // one — an asymmetry against the three sibling formats that
369            // nobody had decided (see `crate::format`).
370            if let Some(detail) = crate::format::check(
371                "this catalog snapshot",
372                u64::from(snapshot.format_version),
373                u64::from(CATALOG_FORMAT_VERSION),
374            ) {
375                return Err(IndexError::UnsupportedVersion { detail });
376            }
377            debug!(
378                seq = snapshot.manifest_seq,
379                segments = snapshot.segments.len(),
380                format_version = snapshot.format_version,
381                "loaded catalog snapshot (postcard)"
382            );
383            Self {
384                segments: snapshot.segments,
385                schemas: snapshot.schemas,
386                tombstones: snapshot.tombstones,
387                wal_floor: snapshot.wal_floor,
388                rollups: snapshot.rollups,
389                pending_measurement_drops: snapshot.pending_measurement_drops,
390                manifest_seq: snapshot.manifest_seq,
391                manifest_dir: manifest_dir.clone(),
392                changes_since_snapshot: 0,
393                next_segment_id: snapshot.next_segment_id,
394                manifest_wal_file: None,
395                manifest_good_len: 0,
396                pending_sync: 0,
397                defer_sync: 0,
398                manifest_syncs: 0,
399            }
400        } else {
401            Self {
402                segments: BTreeMap::new(),
403                schemas: HashMap::new(),
404                tombstones: TombstoneSet::new(),
405                wal_floor: 0,
406                rollups: BTreeMap::new(),
407                pending_measurement_drops: BTreeMap::new(),
408                manifest_seq: 0,
409                manifest_dir: manifest_dir.clone(),
410                changes_since_snapshot: 0,
411                next_segment_id: 1,
412                manifest_wal_file: None,
413                manifest_good_len: 0,
414                pending_sync: 0,
415                defer_sync: 0,
416                manifest_syncs: 0,
417            }
418        };
419
420        // Replay manifest WAL
421        let wal_path = manifest_dir.join("manifest.wal");
422        if wal_path.exists() {
423            catalog.replay_manifest(&wal_path)?;
424        }
425
426        Ok(catalog)
427    }
428
429    /// Add a segment to the catalog.
430    ///
431    /// # Errors
432    ///
433    /// Returns an error if the manifest cannot be written.
434    pub fn add_segment(&mut self, entry: SegmentCatalogEntry) -> Result<()> {
435        let manifest_entry = ManifestEntry::AddSegment(entry.clone());
436        self.append_manifest(&manifest_entry)?;
437
438        self.segments.entry(entry.shard_id).or_default().push(entry);
439
440        self.maybe_snapshot();
441        Ok(())
442    }
443
444    /// Remove a segment from the catalog by its ID.
445    ///
446    /// Returns the removed entry if found.
447    ///
448    /// # Errors
449    ///
450    /// Returns an error if the manifest cannot be written.
451    pub fn remove_segment(&mut self, segment_id: SegmentId) -> Result<Option<SegmentCatalogEntry>> {
452        let manifest_entry = ManifestEntry::RemoveSegment(segment_id.0);
453        self.append_manifest(&manifest_entry)?;
454
455        for entries in self.segments.values_mut() {
456            if let Some(pos) = entries.iter().position(|e| e.segment_id == segment_id) {
457                let removed = entries.remove(pos);
458                self.maybe_snapshot();
459                return Ok(Some(removed));
460            }
461        }
462
463        self.maybe_snapshot();
464        Ok(None)
465    }
466
467    /// Get all segments for a specific shard.
468    #[must_use]
469    pub fn get_segments_for_shard(&self, shard_id: &ShardId) -> &[SegmentCatalogEntry] {
470        self.segments.get(shard_id).map_or(&[], Vec::as_slice)
471    }
472
473    /// Mark a segment as soft-deleted.
474    ///
475    /// The segment remains in the catalog and is still queryable by code
476    /// that explicitly opts in to soft-deleted segments — but the default
477    /// query path (via `active_segments_for_measurement`) hides it.
478    /// A subsequent call to `remove_segment` will hard-delete it.
479    pub fn soft_delete_segment(&mut self, segment_id: SegmentId, now_ms: u64) -> Result<bool> {
480        // Find which measurement key and entry index contains this segment.
481        let location = self.segments.iter().find_map(|(measurement, entries)| {
482            entries
483                .iter()
484                .position(|e| e.segment_id == segment_id)
485                .map(|idx| (*measurement, idx))
486        });
487
488        let Some((measurement, idx)) = location else {
489            return Ok(false);
490        };
491
492        // Persist FIRST, then mutate in-memory state.
493        // If append_manifest fails, the in-memory state remains
494        // unchanged and the segment stays Active.
495        let entry = &self.segments[&measurement][idx];
496        let mut updated_entry = entry.clone();
497        updated_entry.state = SegmentState::SoftDeleted {
498            deleted_at_ms: now_ms,
499        };
500        let manifest_entry = ManifestEntry::AddSegment(updated_entry);
501        self.append_manifest(&manifest_entry)?;
502
503        // Now safe to mutate in-memory state.
504        if let Some(entries) = self.segments.get_mut(&measurement) {
505            entries[idx].state = SegmentState::SoftDeleted {
506                deleted_at_ms: now_ms,
507            };
508        }
509        Ok(true)
510    }
511
512    /// Get all *active* segments belonging to a specific measurement.
513    ///
514    /// Excludes segments that are soft-deleted (waiting for GC), and every
515    /// segment of a measurement pending a hard delete — a "drop" that leaves
516    /// its rows scannable for the whole grace period is not a drop. The
517    /// segments themselves are untouched, so a restore before the deadline
518    /// costs nothing and loses nothing.
519    #[must_use]
520    pub fn active_segments_for_measurement(&self, measurement: &str) -> Vec<&SegmentCatalogEntry> {
521        if self.pending_measurement_drops.contains_key(measurement) {
522            return Vec::new();
523        }
524        self.segments
525            .values()
526            .flat_map(|v| v.iter())
527            .filter(|e| e.measurement == measurement && e.state == SegmentState::Active)
528            .collect()
529    }
530
531    /// Segments that have been retired — taken out of every query — but
532    /// whose files are still on disk.
533    ///
534    /// A retirement unlinks the file in the same step that removes the
535    /// catalog entry; an entry reaches this state only when a running scan
536    /// had already been handed the path, so garbage collection is what
537    /// finishes the job once the reader has gone.
538    #[must_use]
539    pub fn retired_segments(&self) -> Vec<&SegmentCatalogEntry> {
540        self.segments
541            .values()
542            .flat_map(|v| v.iter())
543            .filter(|e| matches!(e.state, SegmentState::SoftDeleted { .. }))
544            .collect()
545    }
546
547    /// Get all segments across all shards.
548    #[must_use]
549    pub fn all_segments(&self) -> Vec<&SegmentCatalogEntry> {
550        self.segments.values().flat_map(|v| v.iter()).collect()
551    }
552
553    /// Get all segments belonging to a specific measurement.
554    #[must_use]
555    pub fn segments_for_measurement(&self, measurement: &str) -> Vec<&SegmentCatalogEntry> {
556        self.segments
557            .values()
558            .flat_map(|v| v.iter())
559            .filter(|e| e.measurement == measurement)
560            .collect()
561    }
562
563    /// Get the total number of segments.
564    /// The largest number of active segments any one `(shard, measurement)`
565    /// holds — the count a compaction pass can actually reduce, and so the
566    /// number write backpressure is keyed on.
567    #[must_use]
568    pub fn max_segments_per_shard_measurement(&self) -> usize {
569        let mut counts: std::collections::HashMap<(i64, &str), usize> =
570            std::collections::HashMap::new();
571        for e in self.all_segments() {
572            if e.state == chronix_core::SegmentState::Active {
573                *counts
574                    .entry((e.shard_id.0, e.measurement.as_str()))
575                    .or_default() += 1;
576            }
577        }
578        counts.into_values().max().unwrap_or(0)
579    }
580
581    /// Approximate heap bytes held by the catalog.
582    ///
583    /// One entry per segment, each carrying a measurement name, a path and its
584    /// per-column statistics — so this grows with segment count, not with row
585    /// count, and it is the term that a long-running gateway accumulates
586    /// between compactions. The schema registry and the tombstone set are
587    /// counted with it: all three live for the process's lifetime, which is
588    /// what makes them worth a number rather than an estimate.
589    #[must_use]
590    pub fn memory_bytes(&self) -> usize {
591        let entries: usize = self
592            .segments
593            .values()
594            .map(|v| {
595                v.capacity() * std::mem::size_of::<SegmentCatalogEntry>()
596                    + v.iter()
597                        .map(|e| {
598                            e.measurement.capacity()
599                                + e.file.as_relative().as_os_str().len()
600                                + e.column_stats.capacity()
601                                    * std::mem::size_of::<CatalogColumnStats>()
602                                + e.column_stats
603                                    .iter()
604                                    .map(|c| c.name.capacity())
605                                    .sum::<usize>()
606                        })
607                        .sum::<usize>()
608            })
609            .sum();
610
611        let schemas: usize = self
612            .schemas
613            .iter()
614            .map(|(name, ms)| {
615                name.capacity()
616                    + ms.columns()
617                        .iter()
618                        .map(|c| c.name.capacity() + std::mem::size_of_val(c))
619                        .sum::<usize>()
620            })
621            .sum();
622
623        entries + schemas + self.tombstones.len() * std::mem::size_of::<u64>() * 4
624    }
625    /// Shards holding at least one active segment.
626    ///
627    /// Derived rather than tracked: a `TimeIndex` was maintained beside the
628    /// catalog on every flush, compaction and retirement to answer this and
629    /// nothing else, and its own count drifted — a shard emptied by
630    /// per-measurement retention kept its entry and stayed counted.
631    #[must_use]
632    pub fn shard_count(&self) -> usize {
633        self.segments
634            .values()
635            .filter(|entries| entries.iter().any(|e| e.state == SegmentState::Active))
636            .count()
637    }
638
639    /// Total number of segments the catalog holds, in any state.
640    #[must_use]
641    pub fn segment_count(&self) -> usize {
642        self.segments.values().map(Vec::len).sum()
643    }
644
645    /// Allocate the next segment ID.
646    pub fn next_segment_id(&mut self) -> SegmentId {
647        let id = SegmentId(self.next_segment_id);
648        self.next_segment_id += 1;
649        id
650    }
651
652    /// Get the current manifest sequence number.
653    #[must_use]
654    pub fn manifest_seq(&self) -> u64 {
655        self.manifest_seq
656    }
657
658    /// Every persisted rollup, by name.
659    #[must_use]
660    pub fn rollups(&self) -> &BTreeMap<String, RollupRecord> {
661        &self.rollups
662    }
663
664    /// Persist a rollup definition, durably.
665    ///
666    /// # Errors
667    ///
668    /// Returns an error if the manifest cannot be written or synced.
669    pub fn set_rollup(&mut self, name: &str, definition: Vec<u8>) -> Result<()> {
670        self.append_manifest(&ManifestEntry::SetRollup {
671            name: name.to_string(),
672            definition: definition.clone(),
673        })?;
674        self.rollups.entry(name.to_string()).or_default().definition = definition;
675        self.sync_manifest()?;
676        self.maybe_snapshot();
677        Ok(())
678    }
679
680    /// Persist a rollup's materialisation state, durably.
681    ///
682    /// Called after the rollup's points are durable, never before: a
683    /// watermark that outlives the rows it claims is a permanent hole in
684    /// the tier, because nothing recomputes a bucket the watermark has
685    /// already passed.
686    ///
687    /// # Errors
688    ///
689    /// Returns an error if the manifest cannot be written or synced.
690    pub fn set_rollup_state(&mut self, name: &str, state: Vec<u8>) -> Result<()> {
691        self.append_manifest(&ManifestEntry::SetRollupState {
692            name: name.to_string(),
693            state: state.clone(),
694        })?;
695        self.rollups.entry(name.to_string()).or_default().state = state;
696        self.sync_manifest()?;
697        self.maybe_snapshot();
698        Ok(())
699    }
700
701    /// Delete a rollup definition and its state.
702    ///
703    /// # Errors
704    ///
705    /// Returns an error if the manifest cannot be written.
706    pub fn remove_rollup(&mut self, name: &str) -> Result<bool> {
707        if !self.rollups.contains_key(name) {
708            return Ok(false);
709        }
710        self.append_manifest(&ManifestEntry::RemoveRollup(name.to_string()))?;
711        self.rollups.remove(name);
712        self.sync_manifest()?;
713        self.maybe_snapshot();
714        Ok(true)
715    }
716
717    /// Every measurement pending a hard delete, and the unix-ms deadline
718    /// each was given.
719    #[must_use]
720    pub fn pending_measurement_drops(&self) -> &BTreeMap<String, u64> {
721        &self.pending_measurement_drops
722    }
723
724    /// Whether `measurement` is pending a hard delete.
725    #[must_use]
726    pub fn is_measurement_pending_drop(&self, measurement: &str) -> bool {
727        self.pending_measurement_drops.contains_key(measurement)
728    }
729
730    /// Mark `measurement` pending a hard delete at `deadline_ms`, durably.
731    ///
732    /// # Errors
733    ///
734    /// Returns an error if the manifest cannot be written or synced.
735    pub fn set_measurement_pending_drop(
736        &mut self,
737        measurement: &str,
738        deadline_ms: u64,
739    ) -> Result<()> {
740        self.append_manifest(&ManifestEntry::SetPendingMeasurementDrop {
741            measurement: measurement.to_string(),
742            deadline_ms,
743        })?;
744        self.pending_measurement_drops
745            .insert(measurement.to_string(), deadline_ms);
746        self.sync_manifest()?;
747        self.maybe_snapshot();
748        Ok(())
749    }
750
751    /// Cancel a pending drop \u2014 a restore, or the hard delete that finally
752    /// acted on it \u2014 durably.
753    ///
754    /// Returns whether `measurement` was pending.
755    ///
756    /// # Errors
757    ///
758    /// Returns an error if the manifest cannot be written or synced.
759    pub fn cancel_measurement_pending_drop(&mut self, measurement: &str) -> Result<bool> {
760        if !self.pending_measurement_drops.contains_key(measurement) {
761            return Ok(false);
762        }
763        self.append_manifest(&ManifestEntry::CancelPendingMeasurementDrop(
764            measurement.to_string(),
765        ))?;
766        self.pending_measurement_drops.remove(measurement);
767        self.sync_manifest()?;
768        self.maybe_snapshot();
769        Ok(true)
770    }
771
772    /// The tombstones recorded by deletes.
773    #[must_use]
774    pub fn tombstones(&self) -> &TombstoneSet {
775        &self.tombstones
776    }
777
778    /// Record the tombstones produced by a delete, durably.
779    ///
780    /// The manifest append is fsynced before this returns, so a delete that
781    /// has been acknowledged has been persisted — which is the property the
782    /// previous design lacked, because it kept tombstones only in memory and
783    /// in a data WAL that the next flush was free to truncate.
784    ///
785    /// # Errors
786    ///
787    /// Returns an error if the manifest cannot be written or synced.
788    pub fn record_tombstones(&mut self, tombstones: &[Tombstone]) -> Result<()> {
789        // One fsync for the delete, not one per tombstone: a delete over a
790        // measurement with a hundred series produces a hundred of them.
791        self.in_one_sync(|catalog| {
792            for tombstone in tombstones {
793                catalog.append_manifest(&ManifestEntry::AddTombstone(tombstone.clone()))?;
794                catalog.tombstones.insert(tombstone.clone());
795            }
796            Ok(())
797        })?;
798        self.maybe_snapshot();
799        Ok(())
800    }
801
802    /// Register a compaction's output and retire its inputs, as one
803    /// durable step.
804    ///
805    /// Adds `output`, soft-deletes every input, and extends every tombstone
806    /// that named an input to the output. That last part is what closes the
807    /// window in which a delete issued *while* the merge ran was undone: the
808    /// merge applied the tombstones it snapshotted, the late tombstone named
809    /// only the inputs, and once GC removed them the tombstone was reclaimed
810    /// with its rows alive in the output.
811    ///
812    /// # Errors
813    ///
814    /// Returns an error if the manifest cannot be written.
815    pub fn complete_compaction(
816        &mut self,
817        output: SegmentCatalogEntry,
818        inputs: &[SegmentId],
819        now_ms: u64,
820    ) -> Result<()> {
821        let output_id = output.segment_id.0;
822        let input_ids: Vec<u64> = inputs.iter().map(|id| id.0).collect();
823        // One fsync for the whole transition, not one per input segment.
824        self.in_one_sync(|catalog| {
825            catalog.add_segment(output)?;
826            for input in inputs {
827                catalog.soft_delete_segment(*input, now_ms)?;
828            }
829            catalog.append_manifest(&ManifestEntry::Compacted {
830                inputs: input_ids.clone(),
831                output: output_id,
832            })
833        })?;
834        self.tombstones
835            .extend_to_compaction_output(&input_ids, output_id);
836        self.maybe_snapshot();
837        Ok(())
838    }
839
840    /// Reclaim tombstones whose segments have all left the catalog.
841    ///
842    /// A segment leaves the catalog only by being rewritten — compaction
843    /// applies tombstones as it merges — or by being deleted outright, so once
844    /// none of a tombstone's segments remains, no stored row can still match
845    /// it. Returns the number reclaimed.
846    ///
847    /// # Errors
848    ///
849    /// Returns an error if the manifest cannot be written.
850    pub fn reclaim_tombstones(&mut self) -> Result<usize> {
851        let live: std::collections::HashSet<u64> = self
852            .segments
853            .values()
854            .flatten()
855            .map(|e| e.segment_id.0)
856            .collect();
857
858        // A tombstone with no recorded segments predates nothing we can prove
859        // about, so it is kept. Every tombstone this engine writes records the
860        // segments it was issued against.
861        let removed = self.tombstones.retain_tombstones(|t| {
862            t.segments.is_empty() || t.segments.iter().any(|id| live.contains(id))
863        });
864
865        for tombstone in &removed {
866            self.append_manifest(&ManifestEntry::RemoveTombstone(tombstone.clone()))?;
867        }
868        if !removed.is_empty() {
869            self.sync_manifest()?;
870            self.maybe_snapshot();
871        }
872        Ok(removed.len())
873    }
874
875    /// Register or update a measurement schema.
876    ///
877    /// # Errors
878    ///
879    /// Returns an error if the manifest cannot be written.
880    pub fn set_schema(&mut self, schema: MeasurementSchema) -> Result<()> {
881        let entry = ManifestEntry::SetSchema(schema.clone());
882        self.append_manifest(&entry)?;
883        self.schemas
884            .insert(schema.measurement().to_string(), schema);
885        self.maybe_snapshot();
886        Ok(())
887    }
888
889    /// Remove a measurement schema.
890    ///
891    /// # Errors
892    ///
893    /// Returns an error if the manifest cannot be written.
894    pub fn remove_schema(&mut self, measurement: &str) -> Result<Option<MeasurementSchema>> {
895        let entry = ManifestEntry::RemoveSchema(measurement.to_string());
896        self.append_manifest(&entry)?;
897        let removed = self.schemas.remove(measurement);
898        self.maybe_snapshot();
899        Ok(removed)
900    }
901
902    /// The WAL floor: the highest data-WAL sequence number known to be
903    /// fully represented by the segments this catalog registers.
904    ///
905    /// `open()` replays only records above it.
906    #[must_use]
907    pub fn wal_floor(&self) -> u64 {
908        self.wal_floor
909    }
910
911    /// Raise the WAL floor to `sequence_no`.
912    ///
913    /// Persisted before it is applied, like every other catalog change. A
914    /// floor never moves backwards; a lower value is a no-op.
915    ///
916    /// # Errors
917    ///
918    /// Returns an error if the manifest append fails.
919    pub fn set_wal_floor(&mut self, sequence_no: u64) -> Result<()> {
920        if sequence_no <= self.wal_floor {
921            return Ok(());
922        }
923        self.append_manifest(&ManifestEntry::SetWalFloor(sequence_no))?;
924        self.wal_floor = sequence_no;
925        self.maybe_snapshot();
926        Ok(())
927    }
928
929    /// Look up a measurement schema.
930    #[must_use]
931    pub fn get_schema(&self, measurement: &str) -> Option<&MeasurementSchema> {
932        self.schemas.get(measurement)
933    }
934
935    /// Returns all schema names.
936    #[must_use]
937    pub fn schema_names(&self) -> Vec<String> {
938        self.schemas.keys().cloned().collect()
939    }
940
941    /// Returns an iterator over all `(name, schema)` pairs.
942    pub fn all_schemas(&self) -> impl Iterator<Item = (&String, &MeasurementSchema)> {
943        self.schemas.iter()
944    }
945
946    /// Force a snapshot of the current catalog state.
947    ///
948    /// # Errors
949    ///
950    /// Returns an error if the snapshot cannot be written.
951    pub fn force_snapshot(&mut self) -> Result<()> {
952        self.write_snapshot()?;
953        self.changes_since_snapshot = 0;
954        Ok(())
955    }
956
957    /// Wrap the manifest file, so a test can make the write fail.
958    ///
959    /// The production build hands the `File` through untouched; the test
960    /// build routes it via a budget that reports `ENOSPC`, which is the
961    /// failure a flash-backed gateway actually meets. Mirrors the WAL's own
962    /// `sink`.
963    #[cfg(not(test))]
964    fn sink(file: std::fs::File) -> Box<dyn crate::durable::DurableFile> {
965        Box::new(file)
966    }
967
968    /// See the production `sink`. A budget of `i64::MAX` is a file that never
969    /// fills, which is what every test that is not about `ENOSPC` gets.
970    #[cfg(test)]
971    fn sink(file: std::fs::File) -> Box<dyn crate::durable::DurableFile> {
972        let budget = MANIFEST_DISK_BUDGET.with(Clone::clone);
973        Box::new(crate::durable::FullDiskFile::new(file, budget))
974    }
975
976    /// Append a manifest entry to the log.
977    ///
978    /// Format: `[u32-le length][postcard payload][u32-le CRC32c]`
979    ///
980    /// # A failed append leaves nothing behind
981    ///
982    /// Three `write_all` calls, and a short write inside any of them — an
983    /// `ENOSPC` at the boundary — leaves a fragment. That used to stay: the
984    /// next append, once a retention pass had freed space, wrote a valid
985    /// record *after* it, and replay then read the fragment as the end of the
986    /// log and **silently discarded every transition that followed**. The
987    /// caller had been told those transitions were durable, and `open()`'s
988    /// orphan sweep deletes the segment files the restored catalog no longer
989    /// names.
990    ///
991    /// So a failed append truncates back to the last whole record, the same
992    /// way `WalWriter::rewind_to_durable` does — the error the caller sees
993    /// and the bytes on the disk say the same thing. The catalog is the
994    /// durable record of what may be deleted; it is the last place a write
995    /// may half-succeed in silence.
996    fn append_manifest(&mut self, entry: &ManifestEntry) -> Result<()> {
997        self.manifest_seq += 1;
998
999        // Lazily open the WAL file handle and keep it for future appends.
1000        if self.manifest_wal_file.is_none() {
1001            let wal_path = self.manifest_dir.join("manifest.wal");
1002            let f = std::fs::OpenOptions::new()
1003                .create(true)
1004                .append(true)
1005                .open(&wal_path)?;
1006            self.manifest_good_len = f.metadata()?.len();
1007            self.manifest_wal_file = Some(Self::sink(f));
1008        }
1009        let file = self
1010            .manifest_wal_file
1011            .as_mut()
1012            .ok_or_else(|| IndexError::BinarySerialization("WAL file not opened".into()))?;
1013
1014        let payload = postcard::to_stdvec(entry)
1015            .map_err(|e| IndexError::BinarySerialization(e.to_string()))?;
1016        let len = payload.len() as u32;
1017        let crc = crc32c::crc32c(&payload);
1018
1019        let record_len = 4 + payload.len() as u64 + 4;
1020        let written = (|| -> std::io::Result<()> {
1021            file.write_all(&len.to_le_bytes())?;
1022            file.write_all(&payload)?;
1023            file.write_all(&crc.to_le_bytes())?;
1024            // Always flush to kernel buffers so data is ordered.
1025            file.flush()
1026        })();
1027
1028        if let Err(e) = written {
1029            // The record is not whole. Truncate back to the last one that
1030            // was, so the file never holds a fragment with a valid record
1031            // after it — see this function's own documentation for what that
1032            // used to cost. `manifest_seq` is put back too: a sequence
1033            // consumed by a record that does not exist makes the catalog's
1034            // version disagree with its log.
1035            self.manifest_seq -= 1;
1036            self.rewind_manifest();
1037            return Err(e.into());
1038        }
1039        self.manifest_good_len += record_len;
1040
1041        // Synced here unless a caller has declared that this append is one
1042        // step of a larger transition (`in_one_sync`), in which case that
1043        // caller's single `sync_manifest()` covers it. Data reaches the
1044        // kernel either way.
1045        self.pending_sync += 1;
1046        if self.defer_sync == 0 && self.pending_sync >= SYNC_BATCH_SIZE {
1047            file.sync_data()?;
1048            self.pending_sync = 0;
1049            self.manifest_syncs += 1;
1050            metrics::counter!("chronix_catalog_fsync_total").increment(1);
1051        }
1052
1053        self.changes_since_snapshot += 1;
1054        Ok(())
1055    }
1056
1057    /// Discard whatever a failed append left past the last whole record.
1058    ///
1059    /// The handle is dropped and reopened rather than reused: a write that
1060    /// failed leaves the file position where the kernel left it, and an
1061    /// append-mode handle would then write the *next* record after the
1062    /// fragment we are about to remove. Reopening also means a rewind that
1063    /// itself fails leaves no handle behind — the next append reopens, sees
1064    /// the real length, and tells the truth about where the log ends.
1065    fn rewind_manifest(&mut self) {
1066        let good = self.manifest_good_len;
1067        self.manifest_wal_file = None;
1068        self.pending_sync = 0;
1069        let path = self.manifest_dir.join("manifest.wal");
1070        match std::fs::OpenOptions::new().write(true).open(&path) {
1071            Ok(f) => {
1072                if let Err(e) = crate::durable::DurableFile::set_len(&f, good) {
1073                    warn!(error = %e, offset = good, "manifest rewind failed");
1074                } else if let Err(e) = crate::durable::DurableFile::sync_all(&f) {
1075                    warn!(error = %e, "manifest rewind sync failed");
1076                }
1077            }
1078            Err(e) => warn!(error = %e, "manifest rewind could not reopen the log"),
1079        }
1080    }
1081
1082    /// How many times the manifest has been fsynced since this catalog was
1083    /// opened.
1084    ///
1085    /// One per catalog *transition*, not per append: a compaction retiring
1086    /// four segments is one, and so is a delete however many tombstones it
1087    /// produces. Also exported as `chronix_catalog_fsync_total`.
1088    #[must_use]
1089    pub fn manifest_syncs(&self) -> u64 {
1090        self.manifest_syncs
1091    }
1092
1093    /// Run `body` as one durable manifest transition: the appends it makes are
1094    /// written but fsynced **once**, when it returns.
1095    ///
1096    /// A compaction of four segments is six appends — the output, four
1097    /// soft-deletes and the `Compacted` record — and a delete is one per
1098    /// tombstone. On flash-backed storage the fsync rate *is* the wear rate,
1099    /// which is why the WAL has a periodic policy.
1100    ///
1101    /// Everything `body` appended is durable when this returns, on the error
1102    /// path as much as the success path, so a transition that failed half-way
1103    /// is as persistent as it would be if each append had synced. A crash
1104    /// *during* `body` leaves what it always could: the manifest is a replayed
1105    /// log, so a partial transition means an input segment still live beside
1106    /// its compaction output, which reads deduplicate and the next compaction
1107    /// clears.
1108    ///
1109    /// **Only for a transition that ends in a sync**, and only where the
1110    /// caller does nothing irreversible until it returns: a file unlinked
1111    /// while the appends are still unsynced would survive a crash as a
1112    /// catalog entry naming a path that is gone.
1113    ///
1114    /// # Errors
1115    ///
1116    /// Returns `body`'s error, or the error of the fsync that follows it.
1117    pub fn in_one_sync<T>(&mut self, body: impl FnOnce(&mut Self) -> Result<T>) -> Result<T> {
1118        self.defer_sync += 1;
1119        let out = body(self);
1120        self.defer_sync -= 1;
1121        // Before `out?`: a half-finished transition must be as durable as it
1122        // was when each of its appends synced on its own.
1123        self.sync_manifest()?;
1124        out
1125    }
1126
1127    /// Flush any pending manifest writes to durable storage.
1128    ///
1129    /// Call this after a batch of mutations to ensure all appended entries
1130    /// survive a power failure.  This is a no-op if there are no pending
1131    /// un-synced writes.
1132    pub fn sync_manifest(&mut self) -> Result<()> {
1133        if self.pending_sync > 0 {
1134            if let Some(ref f) = self.manifest_wal_file {
1135                f.sync_data()?;
1136            }
1137            self.pending_sync = 0;
1138            self.manifest_syncs += 1;
1139            metrics::counter!("chronix_catalog_fsync_total").increment(1);
1140        }
1141        Ok(())
1142    }
1143
1144    /// Replay manifest log entries from a file.
1145    ///
1146    /// Reads length-prefixed postcard records with CRC32c integrity.
1147    ///
1148    /// # A bad record is the tail, or it is corruption
1149    ///
1150    /// The difference decides everything and it was decided by a *byte
1151    /// count*: "more than eight bytes remain, so this is mid-stream". A
1152    /// process that dies between two `write_all` calls leaves a fragment at
1153    /// the end of the log, which is ordinary and must be skipped; a fragment
1154    /// with **valid records after it** is corruption, and treating it as the
1155    /// tail silently discards every transition that follows. Those
1156    /// transitions were acknowledged, and the segment files a restored
1157    /// catalog no longer names are deleted by `open()`'s orphan sweep — so
1158    /// the quiet answer ends in deleted data, which is the one outcome a
1159    /// durable record of *what may be deleted* must not produce.
1160    ///
1161    /// So the question is asked directly: **is there a whole, CRC-valid
1162    /// record later in this file?** If there is, this is corruption and the
1163    /// catalog refuses to open, naming both offsets. If there is not, it is
1164    /// the tail, and it is skipped. Refusing is the right failure: pass 53
1165    /// made `open()` refuse a catalog naming a segment file that is not
1166    /// there, for the same reason — a catalog that is *partly* true reads as
1167    /// a catalog.
1168    fn replay_manifest(&mut self, wal_path: &Path) -> Result<()> {
1169        let buf = std::fs::read(wal_path)?;
1170
1171        let mut replayed = 0u64;
1172        let mut pos = 0usize;
1173
1174        while pos < buf.len() {
1175            match Self::decode_record(&buf, pos) {
1176                RecordAt::Valid { payload, next } => {
1177                    match postcard::from_bytes::<ManifestEntry>(&buf[payload]) {
1178                        Ok(entry) => {
1179                            self.apply_manifest_entry(entry);
1180                            self.manifest_seq += 1;
1181                            replayed += 1;
1182                            pos = next;
1183                        }
1184                        Err(e) => {
1185                            match Self::classify_bad_record(
1186                                &buf,
1187                                pos,
1188                                &format!("entry does not decode: {e}"),
1189                            ) {
1190                                Some(err) => return Err(err),
1191                                None => break,
1192                            }
1193                        }
1194                    }
1195                }
1196                RecordAt::Bad(reason) => match Self::classify_bad_record(&buf, pos, &reason) {
1197                    Some(err) => return Err(err),
1198                    None => break,
1199                },
1200            }
1201        }
1202
1203        if replayed > 0 {
1204            debug!(replayed, "replayed manifest entries");
1205        }
1206
1207        Ok(())
1208    }
1209
1210    /// Decide whether a bad record at `pos` is the log's tail or corruption.
1211    ///
1212    /// `Some` when a valid record follows, so this is corruption and the
1213    /// catalog must refuse; `None` when nothing does, which is the tail a
1214    /// crash between two writes leaves and is skipped.
1215    fn classify_bad_record(buf: &[u8], pos: usize, reason: &str) -> Option<IndexError> {
1216        if let Some(next) = Self::next_valid_record(buf, pos) {
1217            return Some(IndexError::Manifest {
1218                detail: format!(
1219                    "mid-stream corruption in the manifest at offset {pos} ({reason}); a \
1220                     valid record follows at offset {next}, so this is not a \
1221                     truncated tail — replaying past it would silently drop every \
1222                     transition between them. Restore from a backup, or verify the \
1223                     volume."
1224                ),
1225            });
1226        }
1227        warn!(
1228            offset = pos,
1229            reason, "manifest replay: incomplete record at the tail, skipping"
1230        );
1231        metrics::counter!("chronix_catalog_tail_records_skipped_total").increment(1);
1232        None
1233    }
1234
1235    /// The offset of the next whole, CRC-valid record after `pos`, if any.
1236    ///
1237    /// A byte-by-byte scan, which is affordable because it runs once, only on
1238    /// the corruption path, over a log bounded by the snapshot interval.
1239    fn next_valid_record(buf: &[u8], pos: usize) -> Option<usize> {
1240        (pos + 1..buf.len())
1241            .find(|&at| matches!(Self::decode_record(buf, at), RecordAt::Valid { .. }))
1242    }
1243
1244    /// Read one record at `at`.
1245    fn decode_record(buf: &[u8], at: usize) -> RecordAt {
1246        let Some(len_bytes) = buf.get(at..at + 4) else {
1247            return RecordAt::Bad("truncated length header".into());
1248        };
1249        let payload_len =
1250            u32::from_le_bytes([len_bytes[0], len_bytes[1], len_bytes[2], len_bytes[3]]) as usize;
1251        if payload_len == 0 || payload_len > MAX_MANIFEST_RECORD_BYTES {
1252            return RecordAt::Bad(format!("implausible record length {payload_len}"));
1253        }
1254        let payload_start = at + 4;
1255        let crc_start = payload_start + payload_len;
1256        let Some(crc_bytes) = buf.get(crc_start..crc_start + 4) else {
1257            return RecordAt::Bad("record extends past the end of the log".into());
1258        };
1259        let stored = u32::from_le_bytes([crc_bytes[0], crc_bytes[1], crc_bytes[2], crc_bytes[3]]);
1260        let payload = payload_start..crc_start;
1261        if crc32c::crc32c(&buf[payload.clone()]) != stored {
1262            return RecordAt::Bad("CRC mismatch".into());
1263        }
1264        RecordAt::Valid {
1265            payload,
1266            next: crc_start + 4,
1267        }
1268    }
1269
1270    /// Apply a manifest entry to the in-memory state (no persistence).
1271    fn apply_manifest_entry(&mut self, entry: ManifestEntry) {
1272        match entry {
1273            ManifestEntry::AddSegment(seg) => {
1274                // Track next_segment_id
1275                if seg.segment_id.0 >= self.next_segment_id {
1276                    self.next_segment_id = seg.segment_id.0 + 1;
1277                }
1278                // Dedup: remove any existing entry with the same segment_id
1279                // before pushing. This handles WAL replay of soft_delete_segment
1280                // which re-adds segments with updated state.
1281                let shard_entries = self.segments.entry(seg.shard_id).or_default();
1282                shard_entries.retain(|e| e.segment_id != seg.segment_id);
1283                shard_entries.push(seg);
1284            }
1285            ManifestEntry::RemoveSegment(id) => {
1286                for entries in self.segments.values_mut() {
1287                    entries.retain(|e| e.segment_id.0 != id);
1288                }
1289            }
1290            ManifestEntry::SetSchema(schema) => {
1291                self.schemas
1292                    .insert(schema.measurement().to_string(), schema);
1293            }
1294            ManifestEntry::RemoveSchema(name) => {
1295                self.schemas.remove(&name);
1296            }
1297            ManifestEntry::AddTombstone(t) => {
1298                self.tombstones.insert(t);
1299            }
1300            ManifestEntry::RemoveTombstone(t) => {
1301                self.tombstones.retain_tombstones(|existing| existing != &t);
1302            }
1303            ManifestEntry::SetWalFloor(seq) => {
1304                self.wal_floor = self.wal_floor.max(seq);
1305            }
1306            ManifestEntry::SetRollup { name, definition } => {
1307                self.rollups.entry(name).or_default().definition = definition;
1308            }
1309            ManifestEntry::RemoveRollup(name) => {
1310                self.rollups.remove(&name);
1311            }
1312            ManifestEntry::SetRollupState { name, state } => {
1313                self.rollups.entry(name).or_default().state = state;
1314            }
1315            ManifestEntry::Compacted { inputs, output } => {
1316                self.tombstones.extend_to_compaction_output(&inputs, output);
1317            }
1318            ManifestEntry::SetPendingMeasurementDrop {
1319                measurement,
1320                deadline_ms,
1321            } => {
1322                self.pending_measurement_drops
1323                    .insert(measurement, deadline_ms);
1324            }
1325            ManifestEntry::CancelPendingMeasurementDrop(measurement) => {
1326                self.pending_measurement_drops.remove(&measurement);
1327            }
1328        }
1329    }
1330
1331    /// The whole catalog, encoded — everything a reopen needs.
1332    fn snapshot_bytes(&self) -> Result<Vec<u8>> {
1333        let snapshot = CatalogSnapshot {
1334            format_version: CATALOG_FORMAT_VERSION,
1335            segments: self.segments.clone(),
1336            schemas: self.schemas.clone(),
1337            tombstones: self.tombstones.clone(),
1338            wal_floor: self.wal_floor,
1339            rollups: self.rollups.clone(),
1340            pending_measurement_drops: self.pending_measurement_drops.clone(),
1341            manifest_seq: self.manifest_seq,
1342            next_segment_id: self.next_segment_id,
1343        };
1344        postcard::to_stdvec(&snapshot).map_err(|e| IndexError::BinarySerialization(e.to_string()))
1345    }
1346
1347    /// Write this catalog, as it stands, as the whole catalog of a database
1348    /// rooted at `manifest_dir` — a directory that is *not* this one.
1349    ///
1350    /// This is what a backup captures instead of copying `catalog/` file by
1351    /// file. Copying was the defect: a snapshot landing mid-copy replaces
1352    /// `manifest.snapshot.bin` **and truncates `manifest.wal`**, so a reader
1353    /// walking the directory can pair the old snapshot with the emptied log
1354    /// and lose every transition since — silently, and reported as a
1355    /// successful backup. One encode of one consistent in-memory state cannot
1356    /// be torn, and the empty log beside it says there is nothing to replay.
1357    ///
1358    /// The caller must hold the catalog read lock for as long as it needs the
1359    /// segments this names to still be there.
1360    ///
1361    /// # Errors
1362    ///
1363    /// Returns an error if the directory cannot be created or written.
1364    pub fn write_snapshot_to(&self, manifest_dir: &Path) -> Result<()> {
1365        std::fs::create_dir_all(manifest_dir)?;
1366        let data = self.snapshot_bytes()?;
1367        let snapshot_path = manifest_dir.join("manifest.snapshot.bin");
1368        let tmp_path = manifest_dir.join("manifest.snapshot.bin.tmp");
1369        let write_tmp = (|| -> std::io::Result<()> {
1370            let mut file = std::fs::File::create(&tmp_path)?;
1371            file.write_all(&data)?;
1372            file.sync_all()
1373        })();
1374        if let Err(e) = write_tmp {
1375            let _ = std::fs::remove_file(&tmp_path);
1376            return Err(e.into());
1377        }
1378        if let Err(e) = std::fs::rename(&tmp_path, &snapshot_path) {
1379            let _ = std::fs::remove_file(&tmp_path);
1380            return Err(e.into());
1381        }
1382        // An empty log, written rather than left absent: a `manifest.wal`
1383        // carried over from an older backup in the same directory would be
1384        // replayed on top of this snapshot.
1385        let wal = std::fs::File::create(manifest_dir.join("manifest.wal"))?;
1386        wal.sync_all()?;
1387        let dir = std::fs::File::open(manifest_dir)?;
1388        dir.sync_all()?;
1389        Ok(())
1390    }
1391
1392    /// Write a full snapshot and truncate the WAL.
1393    fn write_snapshot(&mut self) -> Result<()> {
1394        let data = self.snapshot_bytes()?;
1395
1396        // Atomic snapshot write: write temp → fsync → rename → fsync parent.
1397        let snapshot_path = self.manifest_dir.join("manifest.snapshot.bin");
1398        let tmp_path = self.manifest_dir.join("manifest.snapshot.bin.tmp");
1399        // The temp file is removed on failure. The segment writer and the
1400        // local storage backend both do this; the catalog did not, so a
1401        // snapshot interrupted by `ENOSPC` left a partial `.tmp` behind —
1402        // consuming the space whose absence caused the failure, on the one
1403        // file the database cannot afford to lose.
1404        let write_tmp = (|| -> std::io::Result<()> {
1405            let mut file = std::fs::File::create(&tmp_path)?;
1406            file.write_all(&data)?;
1407            file.sync_all()
1408        })();
1409        if let Err(e) = write_tmp {
1410            let _ = std::fs::remove_file(&tmp_path);
1411            return Err(e.into());
1412        }
1413        if let Err(e) = std::fs::rename(&tmp_path, &snapshot_path) {
1414            let _ = std::fs::remove_file(&tmp_path);
1415            return Err(e.into());
1416        }
1417
1418        // Fsync parent directory to make the rename durable.
1419        // This MUST succeed before truncating the WAL; otherwise a crash
1420        // could leave us with the old snapshot and an empty WAL → data loss.
1421        let dir = std::fs::File::open(&self.manifest_dir).map_err(|e| {
1422            std::io::Error::new(
1423                e.kind(),
1424                format!("failed to open manifest dir for fsync: {e}"),
1425            )
1426        })?;
1427        dir.sync_all()?;
1428
1429        // Truncate WAL and invalidate the persistent file handle so it
1430        // gets re-opened on the next append.
1431        let wal_path = self.manifest_dir.join("manifest.wal");
1432        if wal_path.exists() {
1433            let file = std::fs::OpenOptions::new()
1434                .write(true)
1435                .truncate(true)
1436                .open(&wal_path)?;
1437            // The truncation must be durable too. Replay is idempotent for
1438            // every entry kind, so a resurrected entry is harmless — but an
1439            // unsynced truncation is exactly the kind of "probably fine"
1440            // this catalog does not deal in.
1441            file.sync_all()?;
1442        }
1443        self.manifest_wal_file = None;
1444        // The log is empty, so the rewind point is the start of it. Leaving
1445        // the old value would make the next failed append truncate *up*,
1446        // which `set_len` will happily do — extending the file with zeros
1447        // that replay reads as a zero-length record.
1448        self.manifest_good_len = 0;
1449
1450        debug!(seq = self.manifest_seq, "catalog snapshot written");
1451        Ok(())
1452    }
1453
1454    /// Take a snapshot if the change count exceeds the threshold.
1455    ///
1456    /// **A failed snapshot is not a failed transition**, and it must not be
1457    /// reported as one. The manifest append is the commit point; a snapshot
1458    /// only shortens the log that replay reads. Returning the snapshot's
1459    /// error from `add_segment` told the flush that registering the segment
1460    /// had failed — and the flush answers that by **deleting the segment
1461    /// files it just wrote**, while the catalog entry naming them is already
1462    /// durable. The next `open()` then refuses the database outright,
1463    /// because a catalog that names a file which is not there is one pass 53
1464    /// taught it not to trust.
1465    ///
1466    /// So it is logged and retried: `changes_since_snapshot` is left where it
1467    /// is, so the next mutation attempts it again, and a longer log costs
1468    /// replay time rather than data.
1469    fn maybe_snapshot(&mut self) {
1470        if self.changes_since_snapshot < SNAPSHOT_INTERVAL {
1471            return;
1472        }
1473        match self.write_snapshot() {
1474            Ok(()) => self.changes_since_snapshot = 0,
1475            Err(e) => {
1476                warn!(
1477                    error = %e,
1478                    changes = self.changes_since_snapshot,
1479                    "catalog snapshot failed; the transition is committed and the \
1480                     log keeps growing until one succeeds"
1481                );
1482                metrics::counter!("chronix_catalog_snapshot_failures_total").increment(1);
1483            }
1484        }
1485    }
1486}
1487
1488/// Current catalog snapshot format version — 1, the first that ships.
1489///
1490/// Unlike the segment header this is compared with `>`: a snapshot from a
1491/// *newer* Chronix is refused, an older one is read.
1492const CATALOG_FORMAT_VERSION: u32 = crate::format::CATALOG_FORMAT_VERSION;
1493
1494/// Serializable snapshot of the catalog state.
1495#[derive(Debug, Serialize, Deserialize)]
1496struct CatalogSnapshot {
1497    /// Format version for forward compatibility.
1498    #[serde(default)]
1499    format_version: u32,
1500    segments: BTreeMap<ShardId, Vec<SegmentCatalogEntry>>,
1501    schemas: HashMap<String, MeasurementSchema>,
1502    /// Tombstones, as of this snapshot. A snapshot that omitted them would
1503    /// undo every delete the moment the manifest WAL was folded into it.
1504    #[serde(default)]
1505    tombstones: TombstoneSet,
1506    /// See [`SegmentCatalog::wal_floor`].
1507    #[serde(default)]
1508    wal_floor: u64,
1509    /// Rollup definitions and their materialisation state, by name. Opaque
1510    /// bytes: the catalog is where they are durable, the facade is where
1511    /// they mean something.
1512    #[serde(default)]
1513    rollups: BTreeMap<String, RollupRecord>,
1514    /// See [`SegmentCatalog::pending_measurement_drops`].
1515    #[serde(default)]
1516    pending_measurement_drops: BTreeMap<String, u64>,
1517    manifest_seq: u64,
1518    next_segment_id: u64,
1519}
1520
1521#[cfg(test)]
1522mod tests {
1523    use super::*;
1524
1525    fn test_entry(id: u64, shard: i64, min_ts: i64, max_ts: i64) -> SegmentCatalogEntry {
1526        SegmentCatalogEntry {
1527            segment_id: SegmentId(id),
1528            shard_id: ShardId(shard),
1529            measurement: "cpu".to_string(),
1530            file: SegmentFile::new(ShardId(shard), &format!("seg_{id}.csx")).unwrap(),
1531            min_timestamp: min_ts,
1532            max_timestamp: max_ts,
1533            row_count: 1000,
1534            series_count: 10,
1535            byte_size: 4096,
1536            row_group_count: 1,
1537            column_count: 5,
1538            column_stats: Vec::new(),
1539            state: SegmentState::default(),
1540        }
1541    }
1542
1543    /// Set the manifest log's remaining byte budget for this thread.
1544    fn set_disk_budget(bytes: i64) -> std::sync::Arc<std::sync::atomic::AtomicI64> {
1545        MANIFEST_DISK_BUDGET.with(|b| {
1546            b.store(bytes, std::sync::atomic::Ordering::SeqCst);
1547            std::sync::Arc::clone(b)
1548        })
1549    }
1550
1551    /// **A failed manifest append must lose the transition it failed on, and
1552    /// nothing else.**
1553    ///
1554    /// The shape this reproduces is the one an embedded gateway on flash
1555    /// actually meets: the disk fills, an append fails part-way, a retention
1556    /// pass frees space, and the next append succeeds. The fragment the
1557    /// failure left is then *mid-stream*, and replay used to read it as the
1558    /// end of the log — returning `Ok` while **silently discarding every
1559    /// transition after it**. Those transitions had been acknowledged, and
1560    /// `open()`'s orphan sweep deletes the segment files a restored catalog
1561    /// no longer names, so the silence ended in deleted data.
1562    ///
1563    /// Driven through the real write path rather than by writing a fragment
1564    /// by hand: the question is what the *writer* leaves behind, and a
1565    /// hand-made fragment answers a question nobody asked.
1566    #[test]
1567    fn a_failed_manifest_append_loses_only_itself() {
1568        let dir = tempfile::tempdir().unwrap();
1569        let path = dir.path().join("catalog");
1570        let budget = set_disk_budget(i64::MAX);
1571
1572        {
1573            let mut cat = SegmentCatalog::new(&path).unwrap();
1574            cat.add_segment(test_entry(1, 0, 0, 100)).unwrap();
1575
1576            // Room for a few bytes of the next record and no more: a short
1577            // write inside `write_all`, which is what a real filesystem does
1578            // at the boundary.
1579            budget.store(6, std::sync::atomic::Ordering::SeqCst);
1580            let refused = cat.add_segment(test_entry(2, 0, 100, 200));
1581            assert!(refused.is_err(), "a full disk must refuse the append");
1582
1583            // Retention frees space, and the next transition lands.
1584            budget.store(i64::MAX, std::sync::atomic::Ordering::SeqCst);
1585            cat.add_segment(test_entry(3, 0, 200, 300))
1586                .expect("an append after the disk frees up must succeed");
1587        }
1588
1589        set_disk_budget(i64::MAX);
1590        let cat = SegmentCatalog::open(&path).expect("the catalog must still open");
1591        let ids: Vec<u64> = cat.all_segments().iter().map(|e| e.segment_id.0).collect();
1592        assert_eq!(
1593            ids,
1594            vec![1, 3],
1595            "the refused transition is gone and the two acknowledged ones are not"
1596        );
1597    }
1598
1599    /// **A failed snapshot must not be reported as a failed transition.**
1600    ///
1601    /// The snapshot only shortens the log replay reads; the append is the
1602    /// commit point. Returning the snapshot's error from `add_segment` told
1603    /// the flush that registration had failed — and the flush answers that by
1604    /// **deleting the segment files it just wrote**, while the catalog entry
1605    /// naming them is already durable. The next `open()` then refuses the
1606    /// database outright, because a catalog naming a file that is not there
1607    /// is one it does not trust.
1608    ///
1609    /// The snapshot is made to fail on its own, without touching the log: a
1610    /// *directory* where it wants to create `manifest.snapshot.bin.tmp`. A
1611    /// full disk would do it too, and would also stop the append — which is a
1612    /// different case, and mixing them is how a test ends up proving neither.
1613    #[test]
1614    fn a_failed_snapshot_does_not_fail_the_transition() {
1615        let dir = tempfile::tempdir().unwrap();
1616        let path = dir.path().join("catalog");
1617        set_disk_budget(i64::MAX);
1618
1619        let mut cat = SegmentCatalog::new(&path).unwrap();
1620        for i in 0..SNAPSHOT_INTERVAL - 1 {
1621            cat.add_segment(test_entry(i + 1, 0, 0, 100)).unwrap();
1622        }
1623
1624        // The next append takes the change count to the threshold, so the
1625        // snapshot runs — into this.
1626        std::fs::create_dir_all(path.join("manifest.snapshot.bin.tmp")).unwrap();
1627
1628        let last = SNAPSHOT_INTERVAL;
1629        cat.add_segment(test_entry(last, 0, 100, 200))
1630            .expect("the append committed; only the snapshot failed");
1631        assert_eq!(cat.segment_count() as u64, SNAPSHOT_INTERVAL);
1632
1633        // And it is durable: every transition replays, because the log was
1634        // never truncated by the snapshot that did not happen.
1635        drop(cat);
1636        std::fs::remove_dir_all(path.join("manifest.snapshot.bin.tmp")).unwrap();
1637        let reopened = SegmentCatalog::open(&path).expect("the catalog must open");
1638        assert_eq!(
1639            reopened.segment_count() as u64,
1640            SNAPSHOT_INTERVAL,
1641            "every acknowledged transition survives a failed snapshot"
1642        );
1643    }
1644
1645    /// The in-memory catalog and the log agree after a refusal.
1646    ///
1647    /// Write-ahead order already gave this — `append_manifest` runs before
1648    /// the in-memory mutation — so it is pinned rather than fixed: a later
1649    /// edit that mutates first would make the running process disagree with
1650    /// its own restart, which is the hardest class of bug to see.
1651    #[test]
1652    fn a_refused_append_leaves_the_in_memory_catalog_alone() {
1653        let dir = tempfile::tempdir().unwrap();
1654        let path = dir.path().join("catalog");
1655        let budget = set_disk_budget(i64::MAX);
1656
1657        let mut cat = SegmentCatalog::new(&path).unwrap();
1658        cat.add_segment(test_entry(1, 0, 0, 100)).unwrap();
1659        let before = cat.manifest_seq();
1660
1661        budget.store(6, std::sync::atomic::Ordering::SeqCst);
1662        assert!(cat.add_segment(test_entry(2, 0, 100, 200)).is_err());
1663        budget.store(i64::MAX, std::sync::atomic::Ordering::SeqCst);
1664
1665        assert_eq!(
1666            cat.all_segments().len(),
1667            1,
1668            "a refused append must not be visible in memory"
1669        );
1670        assert_eq!(
1671            cat.manifest_seq(),
1672            before,
1673            "a sequence consumed by a record that does not exist makes the \
1674             catalog's version disagree with its log"
1675        );
1676    }
1677
1678    /// A compaction is **one** fsync, whatever it retires.
1679    ///
1680    /// It used to be one per append: the output, one per input segment, and
1681    /// the `Compacted` record — six for a four-segment merge, and compaction
1682    /// runs every maintenance interval. On the flash the design partner
1683    /// writes to, the fsync rate is the wear rate.
1684    #[test]
1685    fn a_compaction_costs_one_manifest_fsync() {
1686        let dir = tempfile::tempdir().unwrap();
1687        let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1688
1689        let inputs: Vec<SegmentId> = (1..=4i64)
1690            .map(|i| {
1691                #[allow(clippy::cast_sign_loss)]
1692                let e = test_entry(i as u64, 0, i * 100, i * 100 + 50);
1693                let id = e.segment_id;
1694                catalog.add_segment(e).unwrap();
1695                id
1696            })
1697            .collect();
1698
1699        let before = catalog.manifest_syncs();
1700        catalog
1701            .complete_compaction(test_entry(99, 0, 100, 450), &inputs, 0)
1702            .unwrap();
1703        assert_eq!(
1704            catalog.manifest_syncs() - before,
1705            1,
1706            "one transition, one fsync — it was six"
1707        );
1708
1709        // …and it is durable: a reopen sees the output live and the inputs
1710        // soft-deleted, which is the property the per-append fsync bought.
1711        drop(catalog);
1712        let reopened = SegmentCatalog::open(dir.path()).unwrap();
1713        let live: Vec<u64> = reopened
1714            .active_segments_for_measurement("cpu")
1715            .iter()
1716            .map(|e| e.segment_id.0)
1717            .collect();
1718        assert_eq!(live, vec![99], "only the compaction output survives");
1719    }
1720
1721    /// A delete is one fsync, not one per tombstone.
1722    #[test]
1723    fn a_delete_costs_one_manifest_fsync() {
1724        let dir = tempfile::tempdir().unwrap();
1725        let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1726        let tombstones: Vec<Tombstone> = (0..25)
1727            .map(|i| Tombstone::ranged(format!("cpu,host=h{i}"), 0, 1_000))
1728            .collect();
1729
1730        let before = catalog.manifest_syncs();
1731        catalog.record_tombstones(&tombstones).unwrap();
1732        assert_eq!(
1733            catalog.manifest_syncs() - before,
1734            1,
1735            "25 tombstones, 1 fsync"
1736        );
1737
1738        drop(catalog);
1739        let reopened = SegmentCatalog::open(dir.path()).unwrap();
1740        assert_eq!(
1741            reopened.tombstones().len(),
1742            25,
1743            "every tombstone survived the reopen"
1744        );
1745    }
1746
1747    /// A single mutation still syncs on its own — the batching is opt-in, and
1748    /// the retention, GC and cold-tier loops rely on that.
1749    #[test]
1750    fn a_lone_append_still_syncs() {
1751        let dir = tempfile::tempdir().unwrap();
1752        let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1753        let before = catalog.manifest_syncs();
1754        catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
1755        assert_eq!(catalog.manifest_syncs() - before, 1);
1756        catalog.soft_delete_segment(SegmentId(1), 0).unwrap();
1757        assert_eq!(catalog.manifest_syncs() - before, 2);
1758    }
1759
1760    #[test]
1761    fn add_and_get_segments() {
1762        let dir = tempfile::tempdir().unwrap();
1763        let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1764
1765        catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
1766        catalog.add_segment(test_entry(2, 0, 300, 400)).unwrap();
1767        catalog.add_segment(test_entry(3, 1, 100, 200)).unwrap();
1768
1769        assert_eq!(catalog.get_segments_for_shard(&ShardId(0)).len(), 2);
1770        assert_eq!(catalog.get_segments_for_shard(&ShardId(1)).len(), 1);
1771        assert_eq!(catalog.get_segments_for_shard(&ShardId(99)).len(), 0);
1772        assert_eq!(catalog.segment_count(), 3);
1773    }
1774
1775    #[test]
1776    fn remove_segment() {
1777        let dir = tempfile::tempdir().unwrap();
1778        let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1779
1780        catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
1781        catalog.add_segment(test_entry(2, 0, 300, 400)).unwrap();
1782
1783        let removed = catalog.remove_segment(SegmentId(1)).unwrap();
1784        assert!(removed.is_some());
1785        assert_eq!(removed.unwrap().segment_id, SegmentId(1));
1786        assert_eq!(catalog.segment_count(), 1);
1787
1788        // Remove non-existent
1789        let removed = catalog.remove_segment(SegmentId(99)).unwrap();
1790        assert!(removed.is_none());
1791    }
1792
1793    #[test]
1794    fn persist_and_reload() {
1795        let dir = tempfile::tempdir().unwrap();
1796
1797        // Create catalog and add segments
1798        {
1799            let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1800            catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
1801            catalog.add_segment(test_entry(2, 0, 300, 400)).unwrap();
1802            catalog.add_segment(test_entry(3, 1, 500, 600)).unwrap();
1803        }
1804
1805        // Reload from manifest
1806        let catalog = SegmentCatalog::open(dir.path()).unwrap();
1807        assert_eq!(catalog.segment_count(), 3);
1808        assert_eq!(catalog.get_segments_for_shard(&ShardId(0)).len(), 2);
1809        assert_eq!(catalog.get_segments_for_shard(&ShardId(1)).len(), 1);
1810    }
1811
1812    #[test]
1813    fn snapshot_and_reload() {
1814        let dir = tempfile::tempdir().unwrap();
1815
1816        // Create, populate, snapshot
1817        {
1818            let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1819            catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
1820            catalog.add_segment(test_entry(2, 0, 300, 400)).unwrap();
1821            catalog.force_snapshot().unwrap();
1822            // Add more after snapshot
1823            catalog.add_segment(test_entry(3, 1, 500, 600)).unwrap();
1824        }
1825
1826        // Reload—should see all 3 from snapshot + WAL
1827        let catalog = SegmentCatalog::open(dir.path()).unwrap();
1828        assert_eq!(catalog.segment_count(), 3);
1829    }
1830
1831    #[test]
1832    fn schema_persistence() {
1833        let dir = tempfile::tempdir().unwrap();
1834
1835        {
1836            let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1837            let schema = MeasurementSchema::new("cpu");
1838            catalog.set_schema(schema).unwrap();
1839        }
1840
1841        let catalog = SegmentCatalog::open(dir.path()).unwrap();
1842        assert!(catalog.get_schema("cpu").is_some());
1843        assert_eq!(catalog.get_schema("cpu").unwrap().measurement(), "cpu");
1844    }
1845
1846    #[test]
1847    fn remove_schema() {
1848        let dir = tempfile::tempdir().unwrap();
1849        let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1850        catalog.set_schema(MeasurementSchema::new("cpu")).unwrap();
1851        assert!(catalog.get_schema("cpu").is_some());
1852
1853        catalog.remove_schema("cpu").unwrap();
1854        assert!(catalog.get_schema("cpu").is_none());
1855    }
1856
1857    #[test]
1858    fn next_segment_id_monotonic() {
1859        let dir = tempfile::tempdir().unwrap();
1860        let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1861
1862        assert_eq!(catalog.next_segment_id(), SegmentId(1));
1863        assert_eq!(catalog.next_segment_id(), SegmentId(2));
1864        assert_eq!(catalog.next_segment_id(), SegmentId(3));
1865    }
1866
1867    #[test]
1868    fn next_segment_id_survives_reload() {
1869        let dir = tempfile::tempdir().unwrap();
1870
1871        {
1872            let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1873            catalog.add_segment(test_entry(5, 0, 100, 200)).unwrap();
1874            catalog.add_segment(test_entry(10, 0, 300, 400)).unwrap();
1875        }
1876
1877        let mut catalog = SegmentCatalog::open(dir.path()).unwrap();
1878        // next_segment_id should be 11 (max existing + 1)
1879        let next = catalog.next_segment_id();
1880        assert!(
1881            next.0 >= 11,
1882            "next_segment_id should be >= 11, got {}",
1883            next.0
1884        );
1885    }
1886
1887    #[test]
1888    fn add_remove_add_roundtrip() {
1889        let dir = tempfile::tempdir().unwrap();
1890
1891        {
1892            let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1893            catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
1894            catalog.remove_segment(SegmentId(1)).unwrap();
1895            catalog.add_segment(test_entry(2, 0, 300, 400)).unwrap();
1896        }
1897
1898        let catalog = SegmentCatalog::open(dir.path()).unwrap();
1899        assert_eq!(catalog.segment_count(), 1);
1900        assert_eq!(
1901            catalog.get_segments_for_shard(&ShardId(0))[0].segment_id,
1902            SegmentId(2)
1903        );
1904    }
1905
1906    #[test]
1907    fn all_segments() {
1908        let dir = tempfile::tempdir().unwrap();
1909        let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1910
1911        catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
1912        catalog.add_segment(test_entry(2, 1, 300, 400)).unwrap();
1913        catalog.add_segment(test_entry(3, 2, 500, 600)).unwrap();
1914
1915        let all = catalog.all_segments();
1916        assert_eq!(all.len(), 3);
1917    }
1918
1919    #[test]
1920    fn manifest_replay_tail_truncation_tolerated() {
1921        // Corrupt entries at the end (tail truncation from a crash) should be
1922        // skipped with a warning — not an error.
1923        let dir = tempfile::tempdir().unwrap();
1924
1925        {
1926            let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1927            catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
1928            catalog.add_segment(test_entry(2, 0, 300, 400)).unwrap();
1929        }
1930
1931        // Append a truncated binary frame at the tail to simulate a crash mid-write.
1932        let wal_path = dir.path().join("manifest.wal");
1933        let mut f = std::fs::OpenOptions::new()
1934            .append(true)
1935            .open(&wal_path)
1936            .unwrap();
1937        // Write a length header but no payload — simulates crash after partial write
1938        let fake_len: u32 = 128;
1939        f.write_all(&fake_len.to_le_bytes()).unwrap();
1940        f.write_all(&[0xDE, 0xAD]).unwrap(); // partial payload
1941        drop(f);
1942
1943        // Replay should succeed — tail corruption is tolerated.
1944        let catalog = SegmentCatalog::open(dir.path()).unwrap();
1945        assert_eq!(catalog.segment_count(), 2);
1946    }
1947
1948    #[test]
1949    fn manifest_replay_midstream_corruption_errors() {
1950        // A corrupt entry in the middle (valid entries follow it) is real
1951        // corruption and must cause an error.
1952        let dir = tempfile::tempdir().unwrap();
1953
1954        {
1955            let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1956            catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
1957        }
1958
1959        // Write a corrupt binary frame (valid length, bad CRC) followed by a valid frame.
1960        let wal_path = dir.path().join("manifest.wal");
1961        let mut f = std::fs::OpenOptions::new()
1962            .append(true)
1963            .open(&wal_path)
1964            .unwrap();
1965
1966        // Corrupt frame: valid-looking length, garbage payload, wrong CRC
1967        let corrupt_payload = b"CORRUPT_DATA_HERE_1234";
1968        let corrupt_len = corrupt_payload.len() as u32;
1969        let wrong_crc: u32 = 0xDEADBEEF;
1970        f.write_all(&corrupt_len.to_le_bytes()).unwrap();
1971        f.write_all(corrupt_payload).unwrap();
1972        f.write_all(&wrong_crc.to_le_bytes()).unwrap();
1973
1974        // Valid frame after the corrupt one
1975        let valid_entry = ManifestEntry::AddSegment(test_entry(3, 0, 500, 600));
1976        let valid_payload = postcard::to_stdvec(&valid_entry).unwrap();
1977        let valid_len = valid_payload.len() as u32;
1978        let valid_crc = crc32c::crc32c(&valid_payload);
1979        f.write_all(&valid_len.to_le_bytes()).unwrap();
1980        f.write_all(&valid_payload).unwrap();
1981        f.write_all(&valid_crc.to_le_bytes()).unwrap();
1982        drop(f);
1983
1984        // Replay must refuse, and say enough to act on: *where* the damage
1985        // is and *where* the next good record is. A refusal an operator
1986        // cannot locate is a refusal they can only answer by restoring.
1987        let result = SegmentCatalog::open(dir.path());
1988        assert!(result.is_err(), "expected error for mid-stream corruption");
1989        let err_msg = format!("{}", result.unwrap_err());
1990        assert!(
1991            err_msg.contains("mid-stream corruption"),
1992            "error should mention mid-stream corruption, got: {err_msg}"
1993        );
1994        assert!(
1995            err_msg.contains("a valid record follows at offset"),
1996            "the error must name the offset that proves this is not a tail: {err_msg}"
1997        );
1998    }
1999}