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