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