Skip to main content

chronix_engine/index/
catalog.rs

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