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 since last `sync_data()`. When this reaches
208    /// [`SYNC_BATCH_SIZE`], an automatic fsync is issued. Callers can
209    /// also call [`sync_manifest()`](Self::sync_manifest) explicitly.
210    pending_sync: u64,
211}
212
213/// Snapshot interval: take a snapshot every N changes.
214const SNAPSHOT_INTERVAL: u64 = 1000;
215
216/// Sync on every manifest append to guarantee zero
217/// catalog mutations are lost on power failure. Catalog mutations
218/// (segment register, soft-delete, schema update) are infrequent
219/// enough that the extra fsync cost is negligible.
220const SYNC_BATCH_SIZE: u64 = 1;
221
222// Compile-time guarantee: SegmentCatalog is safe to share across threads.
223const _: () = {
224    const fn _assert_send<T: Send + Sync>() {}
225    _assert_send::<SegmentCatalog>();
226};
227
228impl SegmentCatalog {
229    /// Create a new empty catalog persisted in `manifest_dir`.
230    ///
231    /// # Errors
232    ///
233    /// Returns an error if the manifest directory cannot be created.
234    pub fn new(manifest_dir: impl Into<PathBuf>) -> Result<Self> {
235        let manifest_dir = manifest_dir.into();
236        std::fs::create_dir_all(&manifest_dir)?;
237
238        Ok(Self {
239            segments: BTreeMap::new(),
240            schemas: HashMap::new(),
241            tombstones: TombstoneSet::new(),
242            wal_floor: 0,
243            rollups: BTreeMap::new(),
244            manifest_seq: 0,
245            manifest_dir,
246            changes_since_snapshot: 0,
247            next_segment_id: 1,
248            manifest_wal_file: None,
249            pending_sync: 0,
250        })
251    }
252
253    /// Open an existing catalog, loading from snapshot + replaying manifest WAL.
254    ///
255    /// # Errors
256    ///
257    /// Returns an error if the snapshot or manifest cannot be read/parsed.
258    pub fn open(manifest_dir: impl Into<PathBuf>) -> Result<Self> {
259        let manifest_dir = manifest_dir.into();
260        std::fs::create_dir_all(&manifest_dir)?;
261
262        let bin_snapshot_path = manifest_dir.join("manifest.snapshot.bin");
263        let mut catalog = if bin_snapshot_path.exists() {
264            let data = std::fs::read(&bin_snapshot_path)?;
265            let snapshot: CatalogSnapshot = postcard::from_bytes(&data)
266                .map_err(|e| IndexError::BinarySerialization(e.to_string()))?;
267            // Reject snapshots from future versions we can't parse.
268            if snapshot.format_version > CATALOG_FORMAT_VERSION {
269                return Err(IndexError::BinarySerialization(format!(
270                    "catalog snapshot format version {} is newer than supported version {CATALOG_FORMAT_VERSION}",
271                    snapshot.format_version
272                )));
273            }
274            debug!(
275                seq = snapshot.manifest_seq,
276                segments = snapshot.segments.len(),
277                format_version = snapshot.format_version,
278                "loaded catalog snapshot (postcard)"
279            );
280            Self {
281                segments: snapshot.segments,
282                schemas: snapshot.schemas,
283                tombstones: snapshot.tombstones,
284                wal_floor: snapshot.wal_floor,
285                rollups: snapshot.rollups,
286                manifest_seq: snapshot.manifest_seq,
287                manifest_dir: manifest_dir.clone(),
288                changes_since_snapshot: 0,
289                next_segment_id: snapshot.next_segment_id,
290                manifest_wal_file: None,
291                pending_sync: 0,
292            }
293        } else {
294            Self {
295                segments: BTreeMap::new(),
296                schemas: HashMap::new(),
297                tombstones: TombstoneSet::new(),
298                wal_floor: 0,
299                rollups: BTreeMap::new(),
300                manifest_seq: 0,
301                manifest_dir: manifest_dir.clone(),
302                changes_since_snapshot: 0,
303                next_segment_id: 1,
304                manifest_wal_file: None,
305                pending_sync: 0,
306            }
307        };
308
309        // Replay manifest WAL
310        let wal_path = manifest_dir.join("manifest.wal");
311        if wal_path.exists() {
312            catalog.replay_manifest(&wal_path)?;
313        }
314
315        Ok(catalog)
316    }
317
318    /// Add a segment to the catalog.
319    ///
320    /// # Errors
321    ///
322    /// Returns an error if the manifest cannot be written.
323    pub fn add_segment(&mut self, entry: SegmentCatalogEntry) -> Result<()> {
324        let manifest_entry = ManifestEntry::AddSegment(entry.clone());
325        self.append_manifest(&manifest_entry)?;
326
327        self.segments.entry(entry.shard_id).or_default().push(entry);
328
329        self.maybe_snapshot()?;
330        Ok(())
331    }
332
333    /// Remove a segment from the catalog by its ID.
334    ///
335    /// Returns the removed entry if found.
336    ///
337    /// # Errors
338    ///
339    /// Returns an error if the manifest cannot be written.
340    pub fn remove_segment(&mut self, segment_id: SegmentId) -> Result<Option<SegmentCatalogEntry>> {
341        let manifest_entry = ManifestEntry::RemoveSegment(segment_id.0);
342        self.append_manifest(&manifest_entry)?;
343
344        for entries in self.segments.values_mut() {
345            if let Some(pos) = entries.iter().position(|e| e.segment_id == segment_id) {
346                let removed = entries.remove(pos);
347                self.maybe_snapshot()?;
348                return Ok(Some(removed));
349            }
350        }
351
352        self.maybe_snapshot()?;
353        Ok(None)
354    }
355
356    /// Get all segments for a specific shard.
357    #[must_use]
358    pub fn get_segments_for_shard(&self, shard_id: &ShardId) -> &[SegmentCatalogEntry] {
359        self.segments.get(shard_id).map_or(&[], Vec::as_slice)
360    }
361
362    /// Mark a segment as soft-deleted.
363    ///
364    /// The segment remains in the catalog and is still queryable by code
365    /// that explicitly opts in to soft-deleted segments — but the default
366    /// query path (via `active_segments_for_measurement`) hides it.
367    /// A subsequent call to `remove_segment` will hard-delete it.
368    pub fn soft_delete_segment(&mut self, segment_id: SegmentId, now_ms: u64) -> Result<bool> {
369        // Find which measurement key and entry index contains this segment.
370        let location = self.segments.iter().find_map(|(measurement, entries)| {
371            entries
372                .iter()
373                .position(|e| e.segment_id == segment_id)
374                .map(|idx| (*measurement, idx))
375        });
376
377        let Some((measurement, idx)) = location else {
378            return Ok(false);
379        };
380
381        // Persist FIRST, then mutate in-memory state.
382        // If append_manifest fails, the in-memory state remains
383        // unchanged and the segment stays Active.
384        let entry = &self.segments[&measurement][idx];
385        let mut updated_entry = entry.clone();
386        updated_entry.state = SegmentState::SoftDeleted {
387            deleted_at_ms: now_ms,
388        };
389        let manifest_entry = ManifestEntry::AddSegment(updated_entry);
390        self.append_manifest(&manifest_entry)?;
391
392        // Now safe to mutate in-memory state.
393        if let Some(entries) = self.segments.get_mut(&measurement) {
394            entries[idx].state = SegmentState::SoftDeleted {
395                deleted_at_ms: now_ms,
396            };
397        }
398        Ok(true)
399    }
400
401    /// Get all *active* segments belonging to a specific measurement.
402    ///
403    /// Excludes segments that are soft-deleted (waiting for GC).
404    #[must_use]
405    pub fn active_segments_for_measurement(&self, measurement: &str) -> Vec<&SegmentCatalogEntry> {
406        self.segments
407            .values()
408            .flat_map(|v| v.iter())
409            .filter(|e| e.measurement == measurement && e.state == SegmentState::Active)
410            .collect()
411    }
412
413    /// Get segments that are past the soft-delete grace period and can
414    /// be hard-deleted.
415    #[must_use]
416    pub fn expired_soft_deleted(
417        &self,
418        now_ms: u64,
419        grace_period_ms: u64,
420    ) -> Vec<&SegmentCatalogEntry> {
421        self.segments
422            .values()
423            .flat_map(|v| v.iter())
424            .filter(|e| {
425                if let SegmentState::SoftDeleted { deleted_at_ms } = e.state {
426                    now_ms.saturating_sub(deleted_at_ms) >= grace_period_ms
427                } else {
428                    false
429                }
430            })
431            .collect()
432    }
433
434    /// Get all segments across all shards.
435    #[must_use]
436    pub fn all_segments(&self) -> Vec<&SegmentCatalogEntry> {
437        self.segments.values().flat_map(|v| v.iter()).collect()
438    }
439
440    /// Get all segments belonging to a specific measurement.
441    #[must_use]
442    pub fn segments_for_measurement(&self, measurement: &str) -> Vec<&SegmentCatalogEntry> {
443        self.segments
444            .values()
445            .flat_map(|v| v.iter())
446            .filter(|e| e.measurement == measurement)
447            .collect()
448    }
449
450    /// Get the total number of segments.
451    /// The largest number of active segments any one `(shard, measurement)`
452    /// holds — the count a compaction pass can actually reduce, and so the
453    /// number write backpressure is keyed on.
454    #[must_use]
455    pub fn max_segments_per_shard_measurement(&self) -> usize {
456        let mut counts: std::collections::HashMap<(i64, &str), usize> =
457            std::collections::HashMap::new();
458        for e in self.all_segments() {
459            if e.state == chronix_core::SegmentState::Active {
460                *counts
461                    .entry((e.shard_id.0, e.measurement.as_str()))
462                    .or_default() += 1;
463            }
464        }
465        counts.into_values().max().unwrap_or(0)
466    }
467
468    /// Approximate heap bytes held by the catalog.
469    ///
470    /// One entry per segment, each carrying a measurement name, a path and its
471    /// per-column statistics — so this grows with segment count, not with row
472    /// count, and it is the term that a long-running gateway accumulates
473    /// between compactions. The schema registry and the tombstone set are
474    /// counted with it: all three live for the process's lifetime, which is
475    /// what makes them worth a number rather than an estimate.
476    #[must_use]
477    pub fn memory_bytes(&self) -> usize {
478        let entries: usize = self
479            .segments
480            .values()
481            .map(|v| {
482                v.capacity() * std::mem::size_of::<SegmentCatalogEntry>()
483                    + v.iter()
484                        .map(|e| {
485                            e.measurement.capacity()
486                                + e.path.as_os_str().len()
487                                + e.column_stats.capacity()
488                                    * std::mem::size_of::<CatalogColumnStats>()
489                                + e.column_stats
490                                    .iter()
491                                    .map(|c| c.name.capacity())
492                                    .sum::<usize>()
493                        })
494                        .sum::<usize>()
495            })
496            .sum();
497
498        let schemas: usize = self
499            .schemas
500            .iter()
501            .map(|(name, ms)| {
502                name.capacity()
503                    + ms.columns()
504                        .iter()
505                        .map(|c| c.name.capacity() + std::mem::size_of_val(c))
506                        .sum::<usize>()
507            })
508            .sum();
509
510        entries + schemas + self.tombstones.len() * std::mem::size_of::<u64>() * 4
511    }
512
513    /// Total number of segments the catalog holds, in any state.
514    #[must_use]
515    pub fn segment_count(&self) -> usize {
516        self.segments.values().map(Vec::len).sum()
517    }
518
519    /// Allocate the next segment ID.
520    pub fn next_segment_id(&mut self) -> SegmentId {
521        let id = SegmentId(self.next_segment_id);
522        self.next_segment_id += 1;
523        id
524    }
525
526    /// Get the current manifest sequence number.
527    #[must_use]
528    pub fn manifest_seq(&self) -> u64 {
529        self.manifest_seq
530    }
531
532    /// Every persisted rollup, by name.
533    #[must_use]
534    pub fn rollups(&self) -> &BTreeMap<String, RollupRecord> {
535        &self.rollups
536    }
537
538    /// Persist a rollup definition, durably.
539    ///
540    /// # Errors
541    ///
542    /// Returns an error if the manifest cannot be written or synced.
543    pub fn set_rollup(&mut self, name: &str, definition: Vec<u8>) -> Result<()> {
544        self.append_manifest(&ManifestEntry::SetRollup {
545            name: name.to_string(),
546            definition: definition.clone(),
547        })?;
548        self.rollups.entry(name.to_string()).or_default().definition = definition;
549        self.sync_manifest()?;
550        self.maybe_snapshot()?;
551        Ok(())
552    }
553
554    /// Persist a rollup's materialisation state, durably.
555    ///
556    /// Called after the rollup's points are durable, never before: a
557    /// watermark that outlives the rows it claims is a permanent hole in
558    /// the tier, because nothing recomputes a bucket the watermark has
559    /// already passed.
560    ///
561    /// # Errors
562    ///
563    /// Returns an error if the manifest cannot be written or synced.
564    pub fn set_rollup_state(&mut self, name: &str, state: Vec<u8>) -> Result<()> {
565        self.append_manifest(&ManifestEntry::SetRollupState {
566            name: name.to_string(),
567            state: state.clone(),
568        })?;
569        self.rollups.entry(name.to_string()).or_default().state = state;
570        self.sync_manifest()?;
571        self.maybe_snapshot()?;
572        Ok(())
573    }
574
575    /// Delete a rollup definition and its state.
576    ///
577    /// # Errors
578    ///
579    /// Returns an error if the manifest cannot be written.
580    pub fn remove_rollup(&mut self, name: &str) -> Result<bool> {
581        if !self.rollups.contains_key(name) {
582            return Ok(false);
583        }
584        self.append_manifest(&ManifestEntry::RemoveRollup(name.to_string()))?;
585        self.rollups.remove(name);
586        self.sync_manifest()?;
587        self.maybe_snapshot()?;
588        Ok(true)
589    }
590
591    /// The tombstones recorded by deletes.
592    #[must_use]
593    pub fn tombstones(&self) -> &TombstoneSet {
594        &self.tombstones
595    }
596
597    /// Record the tombstones produced by a delete, durably.
598    ///
599    /// The manifest append is fsynced before this returns, so a delete that
600    /// has been acknowledged has been persisted — which is the property the
601    /// previous design lacked, because it kept tombstones only in memory and
602    /// in a data WAL that the next flush was free to truncate.
603    ///
604    /// # Errors
605    ///
606    /// Returns an error if the manifest cannot be written or synced.
607    pub fn record_tombstones(&mut self, tombstones: &[Tombstone]) -> Result<()> {
608        for tombstone in tombstones {
609            self.append_manifest(&ManifestEntry::AddTombstone(tombstone.clone()))?;
610            self.tombstones.insert(tombstone.clone());
611        }
612        self.sync_manifest()?;
613        self.maybe_snapshot()?;
614        Ok(())
615    }
616
617    /// Register a compaction's output and retire its inputs, as one
618    /// durable step.
619    ///
620    /// Adds `output`, soft-deletes every input, and extends every tombstone
621    /// that named an input to the output. That last part is what closes the
622    /// window in which a delete issued *while* the merge ran was undone: the
623    /// merge applied the tombstones it snapshotted, the late tombstone named
624    /// only the inputs, and once GC removed them the tombstone was reclaimed
625    /// with its rows alive in the output.
626    ///
627    /// # Errors
628    ///
629    /// Returns an error if the manifest cannot be written.
630    pub fn complete_compaction(
631        &mut self,
632        output: SegmentCatalogEntry,
633        inputs: &[SegmentId],
634        now_ms: u64,
635    ) -> Result<()> {
636        let output_id = output.segment_id.0;
637        self.add_segment(output)?;
638        for input in inputs {
639            self.soft_delete_segment(*input, now_ms)?;
640        }
641        let inputs: Vec<u64> = inputs.iter().map(|id| id.0).collect();
642        self.append_manifest(&ManifestEntry::Compacted {
643            inputs: inputs.clone(),
644            output: output_id,
645        })?;
646        self.tombstones
647            .extend_to_compaction_output(&inputs, output_id);
648        self.sync_manifest()?;
649        self.maybe_snapshot()?;
650        Ok(())
651    }
652
653    /// Reclaim tombstones whose segments have all left the catalog.
654    ///
655    /// A segment leaves the catalog only by being rewritten — compaction
656    /// applies tombstones as it merges — or by being deleted outright, so once
657    /// none of a tombstone's segments remains, no stored row can still match
658    /// it. Returns the number reclaimed.
659    ///
660    /// # Errors
661    ///
662    /// Returns an error if the manifest cannot be written.
663    pub fn reclaim_tombstones(&mut self) -> Result<usize> {
664        let live: std::collections::HashSet<u64> = self
665            .segments
666            .values()
667            .flatten()
668            .map(|e| e.segment_id.0)
669            .collect();
670
671        // A tombstone with no recorded segments predates nothing we can prove
672        // about, so it is kept. Every tombstone this engine writes records the
673        // segments it was issued against.
674        let removed = self.tombstones.retain_tombstones(|t| {
675            t.segments.is_empty() || t.segments.iter().any(|id| live.contains(id))
676        });
677
678        for tombstone in &removed {
679            self.append_manifest(&ManifestEntry::RemoveTombstone(tombstone.clone()))?;
680        }
681        if !removed.is_empty() {
682            self.sync_manifest()?;
683            self.maybe_snapshot()?;
684        }
685        Ok(removed.len())
686    }
687
688    /// Register or update a measurement schema.
689    ///
690    /// # Errors
691    ///
692    /// Returns an error if the manifest cannot be written.
693    pub fn set_schema(&mut self, schema: MeasurementSchema) -> Result<()> {
694        let entry = ManifestEntry::SetSchema(schema.clone());
695        self.append_manifest(&entry)?;
696        self.schemas
697            .insert(schema.measurement().to_string(), schema);
698        self.maybe_snapshot()?;
699        Ok(())
700    }
701
702    /// Remove a measurement schema.
703    ///
704    /// # Errors
705    ///
706    /// Returns an error if the manifest cannot be written.
707    pub fn remove_schema(&mut self, measurement: &str) -> Result<Option<MeasurementSchema>> {
708        let entry = ManifestEntry::RemoveSchema(measurement.to_string());
709        self.append_manifest(&entry)?;
710        let removed = self.schemas.remove(measurement);
711        self.maybe_snapshot()?;
712        Ok(removed)
713    }
714
715    /// The WAL floor: the highest data-WAL sequence number known to be
716    /// fully represented by the segments this catalog registers.
717    ///
718    /// `open()` replays only records above it.
719    #[must_use]
720    pub fn wal_floor(&self) -> u64 {
721        self.wal_floor
722    }
723
724    /// Raise the WAL floor to `sequence_no`.
725    ///
726    /// Persisted before it is applied, like every other catalog change. A
727    /// floor never moves backwards; a lower value is a no-op.
728    ///
729    /// # Errors
730    ///
731    /// Returns an error if the manifest append fails.
732    pub fn set_wal_floor(&mut self, sequence_no: u64) -> Result<()> {
733        if sequence_no <= self.wal_floor {
734            return Ok(());
735        }
736        self.append_manifest(&ManifestEntry::SetWalFloor(sequence_no))?;
737        self.wal_floor = sequence_no;
738        self.maybe_snapshot()?;
739        Ok(())
740    }
741
742    /// Look up a measurement schema.
743    #[must_use]
744    pub fn get_schema(&self, measurement: &str) -> Option<&MeasurementSchema> {
745        self.schemas.get(measurement)
746    }
747
748    /// Returns all schema names.
749    #[must_use]
750    pub fn schema_names(&self) -> Vec<String> {
751        self.schemas.keys().cloned().collect()
752    }
753
754    /// Returns an iterator over all `(name, schema)` pairs.
755    pub fn all_schemas(&self) -> impl Iterator<Item = (&String, &MeasurementSchema)> {
756        self.schemas.iter()
757    }
758
759    /// Force a snapshot of the current catalog state.
760    ///
761    /// # Errors
762    ///
763    /// Returns an error if the snapshot cannot be written.
764    pub fn force_snapshot(&mut self) -> Result<()> {
765        self.write_snapshot()?;
766        self.changes_since_snapshot = 0;
767        Ok(())
768    }
769
770    /// Append a manifest entry to the WAL file.
771    ///
772    /// Format: `[u32-le length][postcard payload][u32-le CRC32c]`
773    fn append_manifest(&mut self, entry: &ManifestEntry) -> Result<()> {
774        self.manifest_seq += 1;
775
776        // Lazily open the WAL file handle and keep it for future appends.
777        if self.manifest_wal_file.is_none() {
778            let wal_path = self.manifest_dir.join("manifest.wal");
779            let f = std::fs::OpenOptions::new()
780                .create(true)
781                .append(true)
782                .open(&wal_path)?;
783            self.manifest_wal_file = Some(f);
784        }
785        let file = self
786            .manifest_wal_file
787            .as_mut()
788            .ok_or_else(|| IndexError::BinarySerialization("WAL file not opened".into()))?;
789
790        let payload = postcard::to_stdvec(entry)
791            .map_err(|e| IndexError::BinarySerialization(e.to_string()))?;
792        let len = payload.len() as u32;
793        let crc = crc32c::crc32c(&payload);
794        file.write_all(&len.to_le_bytes())?;
795        file.write_all(&payload)?;
796        file.write_all(&crc.to_le_bytes())?;
797        // Always flush to kernel buffers so data is ordered.
798        file.flush()?;
799
800        // Batch sync — only issue sync_data() every SYNC_BATCH_SIZE
801        // appends to reduce fsync syscall overhead during compaction/flush
802        // bursts.  Data is still flushed to kernel buffers on every write.
803        self.pending_sync += 1;
804        if self.pending_sync >= SYNC_BATCH_SIZE {
805            file.sync_data()?;
806            self.pending_sync = 0;
807        }
808
809        self.changes_since_snapshot += 1;
810        Ok(())
811    }
812
813    /// Flush any pending manifest writes to durable storage.
814    ///
815    /// Call this after a batch of mutations to ensure all appended entries
816    /// survive a power failure.  This is a no-op if there are no pending
817    /// un-synced writes.
818    pub fn sync_manifest(&mut self) -> Result<()> {
819        if self.pending_sync > 0 {
820            if let Some(ref f) = self.manifest_wal_file {
821                f.sync_data()?;
822            }
823            self.pending_sync = 0;
824        }
825        Ok(())
826    }
827
828    /// Replay manifest WAL entries from a file.
829    ///
830    /// Reads length-prefixed postcard records with CRC32c integrity.
831    /// Truncated tail records (common crash artefact) are skipped
832    /// with a warning. Mid-stream corruption returns an error.
833    fn replay_manifest(&mut self, wal_path: &Path) -> Result<()> {
834        let mut file = std::fs::File::open(wal_path)?;
835        let file_len = file.metadata()?.len();
836
837        let mut replayed = 0u64;
838        let mut tail_skipped = 0u64;
839        let mut pos = 0u64;
840
841        loop {
842            if pos >= file_len {
843                break;
844            }
845            // Read length header (4 bytes)
846            let mut len_buf = [0u8; 4];
847            match file.read_exact(&mut len_buf) {
848                Ok(()) => {}
849                Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
850                    tail_skipped += 1;
851                    warn!(
852                        offset = pos,
853                        "manifest replay: truncated length header at tail"
854                    );
855                    break;
856                }
857                Err(e) => return Err(e.into()),
858            }
859            let payload_len = u32::from_le_bytes(len_buf) as usize;
860            pos += 4;
861
862            // Sanity check payload length
863            if payload_len == 0 || payload_len > 64 * 1024 * 1024 {
864                if pos + (payload_len as u64) + 4 > file_len {
865                    tail_skipped += 1;
866                    warn!(
867                        offset = pos - 4,
868                        payload_len, "manifest replay: truncated entry at tail"
869                    );
870                    break;
871                }
872                return Err(IndexError::Manifest {
873                    detail: format!(
874                        "invalid manifest entry length {payload_len} at offset {}",
875                        pos - 4
876                    ),
877                });
878            }
879
880            // Read payload + CRC
881            let total = payload_len + 4; // payload + CRC32c
882            if pos + total as u64 > file_len {
883                tail_skipped += 1;
884                warn!(
885                    offset = pos - 4,
886                    "manifest replay: truncated payload at tail"
887                );
888                break;
889            }
890
891            let mut buf = vec![0u8; total];
892            if let Err(e) = file.read_exact(&mut buf) {
893                if e.kind() == std::io::ErrorKind::UnexpectedEof {
894                    tail_skipped += 1;
895                    warn!(offset = pos - 4, "manifest replay: truncated read at tail");
896                    break;
897                }
898                return Err(e.into());
899            }
900
901            let payload = &buf[..payload_len];
902            let stored_crc = u32::from_le_bytes([
903                buf[payload_len],
904                buf[payload_len + 1],
905                buf[payload_len + 2],
906                buf[payload_len + 3],
907            ]);
908            let computed_crc = crc32c::crc32c(payload);
909
910            if stored_crc != computed_crc {
911                // Check if there are more valid records after this
912                let remaining = file_len - pos - total as u64;
913                if remaining > 8 {
914                    return Err(IndexError::Manifest {
915                        detail: format!(
916                            "CRC mismatch at offset {} (stored={stored_crc:#x}, computed={computed_crc:#x}) — mid-stream corruption",
917                            pos - 4,
918                        ),
919                    });
920                }
921                tail_skipped += 1;
922                warn!(
923                    offset = pos - 4,
924                    "manifest replay: CRC mismatch at tail, skipping"
925                );
926                break;
927            }
928
929            match postcard::from_bytes::<ManifestEntry>(payload) {
930                Ok(entry) => {
931                    self.apply_manifest_entry(entry);
932                    self.manifest_seq += 1;
933                    replayed += 1;
934                }
935                Err(e) => {
936                    // Check if remaining data could contain valid entries
937                    let remaining = file_len - pos - total as u64;
938                    if remaining > 8 {
939                        return Err(IndexError::Manifest {
940                            detail: format!("deserialization error at offset {}: {e}", pos - 4,),
941                        });
942                    }
943                    tail_skipped += 1;
944                    warn!(offset = pos - 4, error = %e, "manifest replay: skipping corrupt tail entry");
945                    break;
946                }
947            }
948
949            pos += total as u64;
950        }
951
952        if tail_skipped > 0 {
953            warn!(
954                tail_skipped,
955                "manifest replay: truncated tail entries skipped"
956            );
957        }
958        if replayed > 0 {
959            debug!(replayed, "replayed manifest entries");
960        }
961
962        Ok(())
963    }
964
965    /// Apply a manifest entry to the in-memory state (no persistence).
966    fn apply_manifest_entry(&mut self, entry: ManifestEntry) {
967        match entry {
968            ManifestEntry::AddSegment(seg) => {
969                // Track next_segment_id
970                if seg.segment_id.0 >= self.next_segment_id {
971                    self.next_segment_id = seg.segment_id.0 + 1;
972                }
973                // Dedup: remove any existing entry with the same segment_id
974                // before pushing. This handles WAL replay of soft_delete_segment
975                // which re-adds segments with updated state.
976                let shard_entries = self.segments.entry(seg.shard_id).or_default();
977                shard_entries.retain(|e| e.segment_id != seg.segment_id);
978                shard_entries.push(seg);
979            }
980            ManifestEntry::RemoveSegment(id) => {
981                for entries in self.segments.values_mut() {
982                    entries.retain(|e| e.segment_id.0 != id);
983                }
984            }
985            ManifestEntry::SetSchema(schema) => {
986                self.schemas
987                    .insert(schema.measurement().to_string(), schema);
988            }
989            ManifestEntry::RemoveSchema(name) => {
990                self.schemas.remove(&name);
991            }
992            ManifestEntry::AddTombstone(t) => {
993                self.tombstones.insert(t);
994            }
995            ManifestEntry::RemoveTombstone(t) => {
996                self.tombstones.retain_tombstones(|existing| existing != &t);
997            }
998            ManifestEntry::SetWalFloor(seq) => {
999                self.wal_floor = self.wal_floor.max(seq);
1000            }
1001            ManifestEntry::SetRollup { name, definition } => {
1002                self.rollups.entry(name).or_default().definition = definition;
1003            }
1004            ManifestEntry::RemoveRollup(name) => {
1005                self.rollups.remove(&name);
1006            }
1007            ManifestEntry::SetRollupState { name, state } => {
1008                self.rollups.entry(name).or_default().state = state;
1009            }
1010            ManifestEntry::Compacted { inputs, output } => {
1011                self.tombstones.extend_to_compaction_output(&inputs, output);
1012            }
1013        }
1014    }
1015
1016    /// Write a full snapshot and truncate the WAL.
1017    fn write_snapshot(&mut self) -> Result<()> {
1018        let snapshot = CatalogSnapshot {
1019            format_version: CATALOG_FORMAT_VERSION,
1020            segments: self.segments.clone(),
1021            schemas: self.schemas.clone(),
1022            tombstones: self.tombstones.clone(),
1023            wal_floor: self.wal_floor,
1024            rollups: self.rollups.clone(),
1025            manifest_seq: self.manifest_seq,
1026            next_segment_id: self.next_segment_id,
1027        };
1028
1029        let data = postcard::to_stdvec(&snapshot)
1030            .map_err(|e| IndexError::BinarySerialization(e.to_string()))?;
1031
1032        // Atomic snapshot write: write temp → fsync → rename → fsync parent.
1033        let snapshot_path = self.manifest_dir.join("manifest.snapshot.bin");
1034        let tmp_path = self.manifest_dir.join("manifest.snapshot.bin.tmp");
1035        // The temp file is removed on failure. The segment writer and the
1036        // local storage backend both do this; the catalog did not, so a
1037        // snapshot interrupted by `ENOSPC` left a partial `.tmp` behind —
1038        // consuming the space whose absence caused the failure, on the one
1039        // file the database cannot afford to lose.
1040        let write_tmp = (|| -> std::io::Result<()> {
1041            let mut file = std::fs::File::create(&tmp_path)?;
1042            file.write_all(&data)?;
1043            file.sync_all()
1044        })();
1045        if let Err(e) = write_tmp {
1046            let _ = std::fs::remove_file(&tmp_path);
1047            return Err(e.into());
1048        }
1049        if let Err(e) = std::fs::rename(&tmp_path, &snapshot_path) {
1050            let _ = std::fs::remove_file(&tmp_path);
1051            return Err(e.into());
1052        }
1053
1054        // Fsync parent directory to make the rename durable.
1055        // This MUST succeed before truncating the WAL; otherwise a crash
1056        // could leave us with the old snapshot and an empty WAL → data loss.
1057        let dir = std::fs::File::open(&self.manifest_dir).map_err(|e| {
1058            std::io::Error::new(
1059                e.kind(),
1060                format!("failed to open manifest dir for fsync: {e}"),
1061            )
1062        })?;
1063        dir.sync_all()?;
1064
1065        // Truncate WAL and invalidate the persistent file handle so it
1066        // gets re-opened on the next append.
1067        let wal_path = self.manifest_dir.join("manifest.wal");
1068        if wal_path.exists() {
1069            let file = std::fs::OpenOptions::new()
1070                .write(true)
1071                .truncate(true)
1072                .open(&wal_path)?;
1073            // The truncation must be durable too. Replay is idempotent for
1074            // every entry kind, so a resurrected entry is harmless — but an
1075            // unsynced truncation is exactly the kind of "probably fine"
1076            // this catalog does not deal in.
1077            file.sync_all()?;
1078        }
1079        self.manifest_wal_file = None;
1080
1081        debug!(seq = self.manifest_seq, "catalog snapshot written");
1082        Ok(())
1083    }
1084
1085    /// Take a snapshot if the change count exceeds the threshold.
1086    fn maybe_snapshot(&mut self) -> Result<()> {
1087        if self.changes_since_snapshot >= SNAPSHOT_INTERVAL {
1088            self.write_snapshot()?;
1089            self.changes_since_snapshot = 0;
1090        }
1091        Ok(())
1092    }
1093}
1094
1095/// Current catalog snapshot format version.
1096const CATALOG_FORMAT_VERSION: u32 = 2;
1097
1098/// Serializable snapshot of the catalog state.
1099#[derive(Debug, Serialize, Deserialize)]
1100struct CatalogSnapshot {
1101    /// Format version for forward compatibility.
1102    #[serde(default)]
1103    format_version: u32,
1104    segments: BTreeMap<ShardId, Vec<SegmentCatalogEntry>>,
1105    schemas: HashMap<String, MeasurementSchema>,
1106    /// Tombstones, as of this snapshot. A snapshot that omitted them would
1107    /// undo every delete the moment the manifest WAL was folded into it.
1108    #[serde(default)]
1109    tombstones: TombstoneSet,
1110    /// See [`SegmentCatalog::wal_floor`].
1111    #[serde(default)]
1112    wal_floor: u64,
1113    /// Rollup definitions and their materialisation state, by name. Opaque
1114    /// bytes: the catalog is where they are durable, the facade is where
1115    /// they mean something.
1116    #[serde(default)]
1117    rollups: BTreeMap<String, RollupRecord>,
1118    manifest_seq: u64,
1119    next_segment_id: u64,
1120}
1121
1122#[cfg(test)]
1123mod tests {
1124    use super::*;
1125
1126    fn test_entry(id: u64, shard: i64, min_ts: i64, max_ts: i64) -> SegmentCatalogEntry {
1127        SegmentCatalogEntry {
1128            segment_id: SegmentId(id),
1129            shard_id: ShardId(shard),
1130            measurement: "cpu".to_string(),
1131            path: PathBuf::from(format!("shard_{shard}/seg_{id}.csx")),
1132            min_timestamp: min_ts,
1133            max_timestamp: max_ts,
1134            row_count: 1000,
1135            series_count: 10,
1136            byte_size: 4096,
1137            row_group_count: 1,
1138            column_count: 5,
1139            column_stats: Vec::new(),
1140            state: SegmentState::default(),
1141        }
1142    }
1143
1144    #[test]
1145    fn add_and_get_segments() {
1146        let dir = tempfile::tempdir().unwrap();
1147        let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1148
1149        catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
1150        catalog.add_segment(test_entry(2, 0, 300, 400)).unwrap();
1151        catalog.add_segment(test_entry(3, 1, 100, 200)).unwrap();
1152
1153        assert_eq!(catalog.get_segments_for_shard(&ShardId(0)).len(), 2);
1154        assert_eq!(catalog.get_segments_for_shard(&ShardId(1)).len(), 1);
1155        assert_eq!(catalog.get_segments_for_shard(&ShardId(99)).len(), 0);
1156        assert_eq!(catalog.segment_count(), 3);
1157    }
1158
1159    #[test]
1160    fn remove_segment() {
1161        let dir = tempfile::tempdir().unwrap();
1162        let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1163
1164        catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
1165        catalog.add_segment(test_entry(2, 0, 300, 400)).unwrap();
1166
1167        let removed = catalog.remove_segment(SegmentId(1)).unwrap();
1168        assert!(removed.is_some());
1169        assert_eq!(removed.unwrap().segment_id, SegmentId(1));
1170        assert_eq!(catalog.segment_count(), 1);
1171
1172        // Remove non-existent
1173        let removed = catalog.remove_segment(SegmentId(99)).unwrap();
1174        assert!(removed.is_none());
1175    }
1176
1177    #[test]
1178    fn persist_and_reload() {
1179        let dir = tempfile::tempdir().unwrap();
1180
1181        // Create catalog and add segments
1182        {
1183            let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1184            catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
1185            catalog.add_segment(test_entry(2, 0, 300, 400)).unwrap();
1186            catalog.add_segment(test_entry(3, 1, 500, 600)).unwrap();
1187        }
1188
1189        // Reload from manifest
1190        let catalog = SegmentCatalog::open(dir.path()).unwrap();
1191        assert_eq!(catalog.segment_count(), 3);
1192        assert_eq!(catalog.get_segments_for_shard(&ShardId(0)).len(), 2);
1193        assert_eq!(catalog.get_segments_for_shard(&ShardId(1)).len(), 1);
1194    }
1195
1196    #[test]
1197    fn snapshot_and_reload() {
1198        let dir = tempfile::tempdir().unwrap();
1199
1200        // Create, populate, snapshot
1201        {
1202            let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1203            catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
1204            catalog.add_segment(test_entry(2, 0, 300, 400)).unwrap();
1205            catalog.force_snapshot().unwrap();
1206            // Add more after snapshot
1207            catalog.add_segment(test_entry(3, 1, 500, 600)).unwrap();
1208        }
1209
1210        // Reload—should see all 3 from snapshot + WAL
1211        let catalog = SegmentCatalog::open(dir.path()).unwrap();
1212        assert_eq!(catalog.segment_count(), 3);
1213    }
1214
1215    #[test]
1216    fn schema_persistence() {
1217        let dir = tempfile::tempdir().unwrap();
1218
1219        {
1220            let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1221            let schema = MeasurementSchema::new("cpu");
1222            catalog.set_schema(schema).unwrap();
1223        }
1224
1225        let catalog = SegmentCatalog::open(dir.path()).unwrap();
1226        assert!(catalog.get_schema("cpu").is_some());
1227        assert_eq!(catalog.get_schema("cpu").unwrap().measurement(), "cpu");
1228    }
1229
1230    #[test]
1231    fn remove_schema() {
1232        let dir = tempfile::tempdir().unwrap();
1233        let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1234        catalog.set_schema(MeasurementSchema::new("cpu")).unwrap();
1235        assert!(catalog.get_schema("cpu").is_some());
1236
1237        catalog.remove_schema("cpu").unwrap();
1238        assert!(catalog.get_schema("cpu").is_none());
1239    }
1240
1241    #[test]
1242    fn next_segment_id_monotonic() {
1243        let dir = tempfile::tempdir().unwrap();
1244        let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1245
1246        assert_eq!(catalog.next_segment_id(), SegmentId(1));
1247        assert_eq!(catalog.next_segment_id(), SegmentId(2));
1248        assert_eq!(catalog.next_segment_id(), SegmentId(3));
1249    }
1250
1251    #[test]
1252    fn next_segment_id_survives_reload() {
1253        let dir = tempfile::tempdir().unwrap();
1254
1255        {
1256            let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1257            catalog.add_segment(test_entry(5, 0, 100, 200)).unwrap();
1258            catalog.add_segment(test_entry(10, 0, 300, 400)).unwrap();
1259        }
1260
1261        let mut catalog = SegmentCatalog::open(dir.path()).unwrap();
1262        // next_segment_id should be 11 (max existing + 1)
1263        let next = catalog.next_segment_id();
1264        assert!(
1265            next.0 >= 11,
1266            "next_segment_id should be >= 11, got {}",
1267            next.0
1268        );
1269    }
1270
1271    #[test]
1272    fn add_remove_add_roundtrip() {
1273        let dir = tempfile::tempdir().unwrap();
1274
1275        {
1276            let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1277            catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
1278            catalog.remove_segment(SegmentId(1)).unwrap();
1279            catalog.add_segment(test_entry(2, 0, 300, 400)).unwrap();
1280        }
1281
1282        let catalog = SegmentCatalog::open(dir.path()).unwrap();
1283        assert_eq!(catalog.segment_count(), 1);
1284        assert_eq!(
1285            catalog.get_segments_for_shard(&ShardId(0))[0].segment_id,
1286            SegmentId(2)
1287        );
1288    }
1289
1290    #[test]
1291    fn all_segments() {
1292        let dir = tempfile::tempdir().unwrap();
1293        let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1294
1295        catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
1296        catalog.add_segment(test_entry(2, 1, 300, 400)).unwrap();
1297        catalog.add_segment(test_entry(3, 2, 500, 600)).unwrap();
1298
1299        let all = catalog.all_segments();
1300        assert_eq!(all.len(), 3);
1301    }
1302
1303    #[test]
1304    fn manifest_replay_tail_truncation_tolerated() {
1305        // Corrupt entries at the end (tail truncation from a crash) should be
1306        // skipped with a warning — not an error.
1307        let dir = tempfile::tempdir().unwrap();
1308
1309        {
1310            let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1311            catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
1312            catalog.add_segment(test_entry(2, 0, 300, 400)).unwrap();
1313        }
1314
1315        // Append a truncated binary frame at the tail to simulate a crash mid-write.
1316        let wal_path = dir.path().join("manifest.wal");
1317        let mut f = std::fs::OpenOptions::new()
1318            .append(true)
1319            .open(&wal_path)
1320            .unwrap();
1321        // Write a length header but no payload — simulates crash after partial write
1322        let fake_len: u32 = 128;
1323        f.write_all(&fake_len.to_le_bytes()).unwrap();
1324        f.write_all(&[0xDE, 0xAD]).unwrap(); // partial payload
1325        drop(f);
1326
1327        // Replay should succeed — tail corruption is tolerated.
1328        let catalog = SegmentCatalog::open(dir.path()).unwrap();
1329        assert_eq!(catalog.segment_count(), 2);
1330    }
1331
1332    #[test]
1333    fn manifest_replay_midstream_corruption_errors() {
1334        // A corrupt entry in the middle (valid entries follow it) is real
1335        // corruption and must cause an error.
1336        let dir = tempfile::tempdir().unwrap();
1337
1338        {
1339            let mut catalog = SegmentCatalog::new(dir.path()).unwrap();
1340            catalog.add_segment(test_entry(1, 0, 100, 200)).unwrap();
1341        }
1342
1343        // Write a corrupt binary frame (valid length, bad CRC) followed by a valid frame.
1344        let wal_path = dir.path().join("manifest.wal");
1345        let mut f = std::fs::OpenOptions::new()
1346            .append(true)
1347            .open(&wal_path)
1348            .unwrap();
1349
1350        // Corrupt frame: valid-looking length, garbage payload, wrong CRC
1351        let corrupt_payload = b"CORRUPT_DATA_HERE_1234";
1352        let corrupt_len = corrupt_payload.len() as u32;
1353        let wrong_crc: u32 = 0xDEADBEEF;
1354        f.write_all(&corrupt_len.to_le_bytes()).unwrap();
1355        f.write_all(corrupt_payload).unwrap();
1356        f.write_all(&wrong_crc.to_le_bytes()).unwrap();
1357
1358        // Valid frame after the corrupt one
1359        let valid_entry = ManifestEntry::AddSegment(test_entry(3, 0, 500, 600));
1360        let valid_payload = postcard::to_stdvec(&valid_entry).unwrap();
1361        let valid_len = valid_payload.len() as u32;
1362        let valid_crc = crc32c::crc32c(&valid_payload);
1363        f.write_all(&valid_len.to_le_bytes()).unwrap();
1364        f.write_all(&valid_payload).unwrap();
1365        f.write_all(&valid_crc.to_le_bytes()).unwrap();
1366        drop(f);
1367
1368        // Replay should fail with a Manifest error.
1369        let result = SegmentCatalog::open(dir.path());
1370        assert!(result.is_err(), "expected error for mid-stream corruption");
1371        let err_msg = format!("{}", result.unwrap_err());
1372        assert!(
1373            err_msg.contains("mid-stream corruption"),
1374            "error should mention mid-stream corruption, got: {err_msg}"
1375        );
1376    }
1377}