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