Skip to main content

chronix_core/
config.rs

1//! Database configuration types with builder pattern.
2//!
3//! All configuration is loadable from TOML files, environment variables, and
4//! programmatic builders. The builder validates constraints before construction.
5
6use std::collections::{HashMap, HashSet};
7use std::fmt;
8use std::path::{Path, PathBuf};
9use std::time::Duration;
10
11use serde::{Deserialize, Serialize};
12
13use crate::error::ConfigError;
14
15/// WAL fsync policy — controls the durability vs. performance trade-off.
16#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
17pub enum FsyncPolicy {
18    /// Call `fsync()` after every single write. Maximum durability, lowest throughput.
19    PerWrite,
20    /// Call `fsync()` after each batch commit. Balances durability and throughput (default).
21    #[default]
22    PerBatch,
23    /// Call `fsync()` periodically at the given interval. Highest throughput, risk of data loss
24    /// on crash up to one interval.
25    Periodic(Duration),
26}
27
28impl fmt::Display for FsyncPolicy {
29    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30        match self {
31            Self::PerWrite => write!(f, "per-write"),
32            Self::PerBatch => write!(f, "per-batch"),
33            Self::Periodic(d) => write!(f, "periodic({d:?})"),
34        }
35    }
36}
37
38/// Compression codec for segment files.
39#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
40pub enum CompressionCodec {
41    /// LZ4 — fast compression, moderate ratio (default).
42    #[default]
43    Lz4,
44    /// Zstandard — slower compression, higher ratio.
45    Zstd,
46    /// No compression.
47    None,
48}
49
50impl fmt::Display for CompressionCodec {
51    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52        match self {
53            Self::Lz4 => write!(f, "lz4"),
54            Self::Zstd => write!(f, "zstd"),
55            Self::None => write!(f, "none"),
56        }
57    }
58}
59
60/// Float encoding algorithm.
61#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
62pub enum FloatEncoding {
63    /// Chimp — best compression ratio for time-series floats (default).
64    #[default]
65    Chimp,
66    /// Gorilla (Facebook) — good compression, wide compatibility.
67    Gorilla,
68    /// Plain IEEE 754 — no compression.
69    Plain,
70}
71
72impl fmt::Display for FloatEncoding {
73    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74        match self {
75            Self::Chimp => write!(f, "chimp"),
76            Self::Gorilla => write!(f, "gorilla"),
77            Self::Plain => write!(f, "plain"),
78        }
79    }
80}
81
82/// WAL configuration.
83#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
84#[serde(deny_unknown_fields)]
85pub struct WalConfig {
86    /// Fsync policy: `PerWrite`, `PerBatch`, or `Periodic(Duration)`.
87    pub fsync_policy: FsyncPolicy,
88    /// Maximum WAL file size before rotation (default: 32 MB).
89    pub max_file_size: usize,
90    /// Maximum number of unflushed WAL files before backpressure (default: 4).
91    pub max_unflushed_wals: usize,
92    /// Enable LZ4 compression for WAL payloads (default: true).
93    /// Reduces WAL size significantly with minimal CPU cost.
94    #[serde(default = "default_compress_wal")]
95    pub compress: bool,
96    /// Group-commit sync timeout in seconds (default: 30).
97    /// Controls how long a waiter blocks before promoting itself to sync leader.
98    #[serde(default = "default_group_sync_timeout_secs")]
99    pub group_sync_timeout_secs: u64,
100}
101
102/// Default CDC event-bus capacity, in events.
103const fn default_cdc_capacity() -> usize {
104    65_536
105}
106
107fn default_maintenance_interval() -> Duration {
108    Duration::from_secs(30)
109}
110
111fn default_compress_wal() -> bool {
112    true
113}
114
115fn default_group_sync_timeout_secs() -> u64 {
116    30
117}
118
119impl Default for WalConfig {
120    fn default() -> Self {
121        Self {
122            fsync_policy: FsyncPolicy::default(),
123            max_file_size: 32 * 1024 * 1024, // 32 MB
124            max_unflushed_wals: 4,
125            compress: true,
126            group_sync_timeout_secs: 30,
127        }
128    }
129}
130
131// ─── Analytics Configuration ────────────────────────────────────────
132
133/// Analytics engine configuration (forecasting, anomaly detection, preprocessing).
134///
135/// Configurable via TOML under the `[analytics]` section.
136#[derive(Debug, Clone, Serialize, Deserialize)]
137#[serde(deny_unknown_fields)]
138pub struct AnalyticsConfig {
139    /// Default forecast model name (one of: ses, holt, `holt_winters`, arima, sarima, `linear_regression`).
140    #[serde(default = "default_forecast_model")]
141    pub default_forecast_model: String,
142    /// Default anomaly detection method (one of: zscore, `modified_zscore`, iqr, `forecast_residual`, `moving_average`, `dynamic_threshold`).
143    #[serde(default = "default_anomaly_method")]
144    pub default_anomaly_method: String,
145    /// Default confidence level for prediction intervals.
146    #[serde(default = "default_confidence")]
147    pub default_confidence_level: f64,
148    /// Maximum forecast horizon in data points.
149    #[serde(default = "default_max_horizon")]
150    pub max_forecast_horizon: usize,
151    /// Maximum number of training points fed to a model.
152    #[serde(default = "default_max_training")]
153    pub max_training_points: usize,
154    /// Default anomaly detection threshold (standard deviations).
155    #[serde(default = "default_anomaly_threshold")]
156    pub default_anomaly_threshold: f64,
157}
158
159fn default_forecast_model() -> String {
160    "ses".into()
161}
162fn default_anomaly_method() -> String {
163    "zscore".into()
164}
165fn default_confidence() -> f64 {
166    0.95
167}
168fn default_max_horizon() -> usize {
169    8760
170}
171fn default_max_training() -> usize {
172    1_000_000
173}
174fn default_anomaly_threshold() -> f64 {
175    3.0
176}
177fn default_max_query_result_bytes() -> usize {
178    256 * 1024 * 1024 // 256 MB
179}
180fn default_per_query_memory_limit() -> usize {
181    256 * 1024 * 1024 // 256 MiB
182}
183fn default_query_timeout() -> Duration {
184    Duration::from_secs(60)
185}
186fn default_future_write_tolerance() -> Duration {
187    Duration::from_secs(3600)
188}
189
190impl Default for AnalyticsConfig {
191    fn default() -> Self {
192        Self {
193            default_forecast_model: default_forecast_model(),
194            default_anomaly_method: default_anomaly_method(),
195            default_confidence_level: default_confidence(),
196            max_forecast_horizon: default_max_horizon(),
197            max_training_points: default_max_training(),
198            default_anomaly_threshold: default_anomaly_threshold(),
199        }
200    }
201}
202
203// ─── Per-measurement Analytics Overrides ────────────────────────────
204
205/// Default Zstd compression level.
206const fn default_zstd_level() -> i32 {
207    3
208}
209
210/// Top-level database configuration.
211///
212/// Constructed via the builder pattern: `ChronixConfig::builder()`.
213/// Every field defaults, so a TOML file may set only what it changes.
214///
215/// Without this, `from_toml` required **all twenty-five** settings to be
216/// present and answered a file that set only `data_dir` with `TOML parse
217/// error at line 1, column 1` — no field named, no hint. There is no such
218/// thing as a config file a person writes that lists every setting, so the
219/// loader could not load anything anybody would write.
220///
221/// `deny_unknown_fields` stays: a key that is *misspelled* must still be an
222/// error, because a silently ignored setting is the failure this whole file
223/// has been bitten by.
224#[derive(Debug, Clone, Serialize, Deserialize)]
225#[serde(deny_unknown_fields, default)]
226pub struct ChronixConfig {
227    /// Path to the data directory.
228    pub data_dir: PathBuf,
229    /// WAL configuration.
230    pub wal: WalConfig,
231    /// Memtable flush threshold in bytes (default: 64 MB).
232    pub memtable_flush_threshold: usize,
233    /// Maximum total memtable memory across active + frozen (default: 256 MB).
234    pub max_memtable_memory: usize,
235    /// Time-shard duration (default: 1 hour).
236    pub shard_duration: Duration,
237    /// Out-of-order tolerance in shard units (default: 2).
238    pub ooo_shard_tolerance: u32,
239    /// Retention period, enforced by the background compaction pass.
240    ///
241    /// `None` — the default — keeps data forever. A database that deletes
242    /// data by default is a database that deletes data nobody asked it to;
243    /// retention is opt-in, exactly as it is in InfluxDB and SQLite-shaped
244    /// stores, and unlike a scrape server.
245    pub retention: Option<Duration>,
246    /// Per-measurement retention overrides.
247    pub measurement_retention: HashMap<String, Duration>,
248    /// Compression codec for segments (default: LZ4).
249    pub compression: CompressionCodec,
250    /// Zstd compression level for new segments (1–22, default: 3).
251    ///
252    /// Only meaningful when `compression` is `Zstd`. Higher is smaller and
253    /// slower; cold-tier re-compression uses 9.
254    #[serde(default = "default_zstd_level")]
255    pub zstd_level: i32,
256    /// Train a Zstd dictionary from the first row group of each segment
257    /// (default: false).
258    ///
259    /// The writer collects the first row group's encoded blocks as training
260    /// samples and, if there is enough of them (≥ 8 blocks, ≥ 16 KiB),
261    /// compresses every later row group against the trained dictionary,
262    /// storing it in the segment metadata for the reader. It helps most where
263    /// blocks are small and repetitive, and costs a training pass per segment.
264    ///
265    /// Only meaningful when `compression` is `Zstd`.
266    #[serde(default)]
267    pub zstd_dict_training: bool,
268    /// Float encoding algorithm (default: Chimp).
269    pub float_encoding: FloatEncoding,
270    /// Segment cache size in bytes (default: 512 MB).
271    pub segment_cache_size: usize,
272    /// CDC event-bus capacity — events buffered before the oldest are dropped
273    /// for a slow subscriber (default: 65 536).
274    ///
275    /// The ring costs `capacity` × ~112 bytes and is allocated on the first
276    /// subscription, so a deployment that never reads the CDC stream pays
277    /// nothing. The default is ~7 MiB, sized for a server;
278    /// [`ChronixConfig::small`] sets 4 096.
279    #[serde(default = "default_cdc_capacity")]
280    pub cdc_capacity: usize,
281    /// Enable Last Value Cache (default: false).
282    pub enable_last_value_cache: bool,
283    /// Per-measurement LVC opt-in. When `Some`, only listed measurements
284    /// participate in the LVC. When `None`, all measurements use the LVC
285    /// (provided `enable_last_value_cache` is `true`).
286    pub lvc_measurements: Option<HashSet<String>>,
287    /// Maximum concurrent compaction tasks (default: 2).
288    pub compaction_concurrency: usize,
289    /// How often the built-in maintenance thread compacts, materialises
290    /// rollups, collects garbage and enforces retention (default: 30 s).
291    ///
292    /// Flushes are not on this schedule — the thread flushes as soon as a
293    /// memtable crosses its threshold. `Duration::ZERO` disables the
294    /// periodic passes (flush-on-demand stays), for a caller that runs
295    /// `compact()` and `enforce_retention()` on its own schedule.
296    #[serde(default = "default_maintenance_interval")]
297    pub maintenance_interval: Duration,
298    /// Maximum number of distinct series in the database (default: 1M).
299    pub max_series_cardinality: usize,
300    /// Maximum query result size in bytes (default: 256 MB, 0 = unlimited).
301    /// Queries whose intermediate batches exceed this are aborted early.
302    #[serde(default = "default_max_query_result_bytes")]
303    pub max_query_result_bytes: usize,
304    /// Per-query memory budget in bytes (default: 256 MiB, 0 = unlimited).
305    ///
306    /// Each query execution creates a `MemoryTracker` with this
307    /// budget.  Intermediate allocations (collected `RecordBatch`es)
308    /// are charged against the tracker; if the budget is exceeded the
309    /// query fails with `QueryMemoryExceeded` instead of causing OOM.
310    #[serde(default = "default_per_query_memory_limit")]
311    pub per_query_memory_limit: usize,
312    /// Query execution timeout (default: 60s, `Duration::ZERO` = no timeout).
313    ///
314    /// Queries that exceed this duration are aborted with a
315    /// `QueryTimeout` error to prevent runaway scans.
316    #[serde(default = "default_query_timeout")]
317    pub query_timeout: Duration,
318    /// Analytics engine configuration.
319    #[serde(default)]
320    pub analytics: AnalyticsConfig,
321    /// Soft-delete TTL for dropped measurements.
322    ///
323    /// When set, `drop_measurement` marks the measurement as "pending
324    /// deletion" with a deadline instead of immediately removing its
325    /// segments. A background GC pass hard-deletes measurements that
326    /// have been pending longer than this TTL, allowing accidental drops
327    /// to be recovered within the grace window.
328    ///
329    /// Default: `None` (immediate hard-delete, original behaviour).
330    #[serde(default)]
331    pub soft_delete_ttl: Option<Duration>,
332    /// How far ahead of the wall clock a point's timestamp may be
333    /// (default: 1 hour).
334    ///
335    /// A point beyond this is rejected before admission, on every write
336    /// path including `backfill`. The out-of-order window is anchored on
337    /// the newest *admitted* timestamp, so without this bound one point
338    /// from a device with a broken clock would move the window years into
339    /// the future and every real write after it would be rejected until
340    /// the next restart.
341    #[serde(default = "default_future_write_tolerance")]
342    pub future_write_tolerance: Duration,
343}
344
345impl Default for ChronixConfig {
346    fn default() -> Self {
347        // Through the builder, so the defaults have one definition rather
348        // than two that can disagree. `data_dir` matches the server's own
349        // default, which is what an operator who omits it expects.
350        ChronixConfigBuilder::default()
351            .data_dir("./chronix-data")
352            .build()
353            .expect("the builder's own defaults must be valid")
354    }
355}
356
357impl ChronixConfig {
358    /// Returns a builder with sensible defaults.
359    /// A configuration preset for small-footprint deployments — IoT
360    /// gateways and edge devices with 512 MB–1 GB of RAM and flash (eMMC/SD)
361    /// storage.
362    ///
363    /// Compared to the default configuration:
364    ///
365    /// - **Memory budget ≈ 48 MB** — 8 MB memtable flush threshold,
366    ///   24 MB total memtable budget, 16 MB segment cache.
367    /// - **Flash-friendly WAL** — `Periodic(5s)` fsync coalesces syncs to
368    ///   reduce write amplification and flash wear; up to ~5 s of the most
369    ///   recent writes may be lost on power failure. Choose
370    ///   [`FsyncPolicy::PerBatch`] instead if that is not acceptable.
371    /// - **Single compaction worker** — bounds background CPU and I/O.
372    ///
373    /// All fields remain overridable after construction.
374    #[must_use]
375    pub fn small(data_dir: impl Into<PathBuf>) -> Self {
376        Self::builder()
377            .data_dir(data_dir)
378            .memtable_flush_threshold(8 * 1024 * 1024) // 8 MB
379            .max_memtable_memory(24 * 1024 * 1024) // 24 MB
380            .segment_cache_size(16 * 1024 * 1024) // 16 MB
381            // 4 096 events ≈ 450 KiB, and at the design partner's 50 points/s
382            // still eighty seconds of tolerance for a slow subscriber. The
383            // 65 536 default is a server's number.
384            .cdc_capacity(4_096)
385            .compaction_concurrency(1)
386            .maintenance_interval(Duration::from_secs(60))
387            .wal_fsync_policy(FsyncPolicy::Periodic(Duration::from_secs(5)))
388            .wal_max_file_size(8 * 1024 * 1024) // 8 MB — bounded replay on tiny devices
389            .build()
390            .expect("BUG: the small-footprint preset must always validate")
391    }
392
393    /// Returns a builder with sensible defaults.
394    #[must_use]
395    pub fn builder() -> ChronixConfigBuilder {
396        ChronixConfigBuilder::default()
397    }
398
399    /// Load configuration from a TOML file, then apply builder overrides.
400    ///
401    /// # Errors
402    ///
403    /// Returns [`ConfigError`] if the file cannot be read, parsed, or fails
404    /// validation.
405    pub fn from_toml(path: &Path) -> Result<Self, ConfigError> {
406        let content = std::fs::read_to_string(path)?;
407        let config: Self = toml::from_str(&content)?;
408        config.validate()?;
409        Ok(config)
410    }
411
412    /// Validate configuration invariants.
413    ///
414    /// Called by `from_toml` and [`ChronixConfigBuilder::build`] to enforce
415    /// structural invariants on the final configuration.
416    /// # Errors
417    ///
418    /// Returns [`ConfigError`] when any field is out of range or
419    /// internally inconsistent (see the individual checks).
420    pub fn validate(&self) -> Result<(), ConfigError> {
421        if self.memtable_flush_threshold == 0 {
422            return Err(ConfigError::Validation {
423                message: "memtable_flush_threshold must be > 0".into(),
424            });
425        }
426        if self.max_memtable_memory == 0 {
427            return Err(ConfigError::Validation {
428                message: "max_memtable_memory must be > 0".into(),
429            });
430        }
431        if self.max_memtable_memory < self.memtable_flush_threshold {
432            return Err(ConfigError::Validation {
433                message: format!(
434                    "max_memtable_memory ({}) must be >= memtable_flush_threshold ({})",
435                    self.max_memtable_memory, self.memtable_flush_threshold
436                ),
437            });
438        }
439        if self.shard_duration.is_zero() {
440            return Err(ConfigError::Validation {
441                message: "shard_duration must be > 0".into(),
442            });
443        }
444        if let Some(retention) = &self.retention {
445            if *retention < self.shard_duration {
446                return Err(ConfigError::Validation {
447                    message: format!(
448                        "retention ({retention:?}) must be >= shard_duration ({:?})",
449                        self.shard_duration
450                    ),
451                });
452            }
453        }
454        if self.compaction_concurrency == 0 {
455            return Err(ConfigError::Validation {
456                message: "compaction_concurrency must be > 0".into(),
457            });
458        }
459        if self.max_series_cardinality == 0 {
460            return Err(ConfigError::Validation {
461                message: "max_series_cardinality must be > 0".into(),
462            });
463        }
464        if self.wal.max_file_size == 0 {
465            return Err(ConfigError::Validation {
466                message: "WAL max_file_size must be > 0".into(),
467            });
468        }
469        if self.wal.max_unflushed_wals == 0 {
470            return Err(ConfigError::Validation {
471                message: "WAL max_unflushed_wals must be > 0".into(),
472            });
473        }
474
475        // Validate compaction and analytics settings at startup
476        // to surface misconfigurations early.
477        if self.analytics.max_forecast_horizon == 0 {
478            return Err(ConfigError::Validation {
479                message: "analytics.max_forecast_horizon must be > 0".into(),
480            });
481        }
482        if self.analytics.max_training_points == 0 {
483            return Err(ConfigError::Validation {
484                message: "analytics.max_training_points must be > 0".into(),
485            });
486        }
487        Ok(())
488    }
489}
490
491/// Builder for [`ChronixConfig`] with validation on `build()`.
492#[derive(Debug, Clone)]
493pub struct ChronixConfigBuilder {
494    data_dir: Option<PathBuf>,
495    wal: WalConfig,
496    memtable_flush_threshold: usize,
497    max_memtable_memory: usize,
498    shard_duration: Duration,
499    ooo_shard_tolerance: u32,
500    retention: Option<Duration>,
501    measurement_retention: HashMap<String, Duration>,
502    compression: CompressionCodec,
503    zstd_level: i32,
504    zstd_dict_training: bool,
505    float_encoding: FloatEncoding,
506    segment_cache_size: usize,
507    cdc_capacity: usize,
508    enable_last_value_cache: bool,
509    lvc_measurements: Option<HashSet<String>>,
510    compaction_concurrency: usize,
511    maintenance_interval: Duration,
512    max_series_cardinality: usize,
513    max_query_result_bytes: usize,
514    per_query_memory_limit: usize,
515    query_timeout: Duration,
516    analytics: AnalyticsConfig,
517    soft_delete_ttl: Option<Duration>,
518    future_write_tolerance: Duration,
519}
520
521impl Default for ChronixConfigBuilder {
522    fn default() -> Self {
523        Self {
524            data_dir: None,
525            wal: WalConfig::default(),
526            memtable_flush_threshold: 64 * 1024 * 1024, // 64 MB
527            max_memtable_memory: 256 * 1024 * 1024,     // 256 MB
528            shard_duration: Duration::from_secs(3600),  // 1 hour
529            ooo_shard_tolerance: 2,
530            retention: None,
531            measurement_retention: HashMap::new(),
532            compression: CompressionCodec::default(),
533            zstd_level: default_zstd_level(),
534            zstd_dict_training: false,
535            float_encoding: FloatEncoding::default(),
536            segment_cache_size: 512 * 1024 * 1024, // 512 MB
537            cdc_capacity: default_cdc_capacity(),
538            enable_last_value_cache: false,
539            lvc_measurements: None,
540            compaction_concurrency: 2,
541            maintenance_interval: default_maintenance_interval(),
542            max_series_cardinality: 1_000_000,
543            max_query_result_bytes: default_max_query_result_bytes(),
544            per_query_memory_limit: default_per_query_memory_limit(),
545            query_timeout: default_query_timeout(),
546            analytics: AnalyticsConfig::default(),
547            soft_delete_ttl: None,
548            future_write_tolerance: default_future_write_tolerance(),
549        }
550    }
551}
552
553impl ChronixConfigBuilder {
554    /// Set the data directory path (required).
555    #[must_use]
556    pub fn data_dir(mut self, path: impl Into<PathBuf>) -> Self {
557        self.data_dir = Some(path.into());
558        self
559    }
560
561    /// Set the WAL fsync policy.
562    #[must_use]
563    pub fn wal_fsync_policy(mut self, policy: FsyncPolicy) -> Self {
564        self.wal.fsync_policy = policy;
565        self
566    }
567
568    /// Set the maximum WAL file size.
569    #[must_use]
570    pub fn wal_max_file_size(mut self, size: usize) -> Self {
571        self.wal.max_file_size = size;
572        self
573    }
574
575    /// Set the maximum number of unflushed WAL files.
576    #[must_use]
577    pub fn wal_max_unflushed(mut self, count: usize) -> Self {
578        self.wal.max_unflushed_wals = count;
579        self
580    }
581
582    /// Set the memtable flush threshold in bytes.
583    #[must_use]
584    pub fn memtable_flush_threshold(mut self, size: usize) -> Self {
585        self.memtable_flush_threshold = size;
586        self
587    }
588
589    /// Set the maximum total memtable memory.
590    #[must_use]
591    pub fn max_memtable_memory(mut self, size: usize) -> Self {
592        self.max_memtable_memory = size;
593        self
594    }
595
596    /// Set the time-shard duration.
597    #[must_use]
598    pub fn shard_duration(mut self, duration: Duration) -> Self {
599        self.shard_duration = duration;
600        self
601    }
602
603    /// Set the retention period (None for infinite).
604    #[must_use]
605    pub fn retention(mut self, duration: Option<Duration>) -> Self {
606        self.retention = duration;
607        self
608    }
609
610    /// Set the compression codec.
611    #[must_use]
612    pub fn compression(mut self, codec: CompressionCodec) -> Self {
613        self.compression = codec;
614        self
615    }
616
617    /// Set the Zstd compression level for new segments (1–22).
618    #[must_use]
619    pub fn zstd_level(mut self, level: i32) -> Self {
620        self.zstd_level = level;
621        self
622    }
623
624    /// Enable Zstd dictionary training for new segments.
625    #[must_use]
626    pub fn zstd_dict_training(mut self, enabled: bool) -> Self {
627        self.zstd_dict_training = enabled;
628        self
629    }
630
631    /// Set the float encoding algorithm.
632    #[must_use]
633    pub fn float_encoding(mut self, encoding: FloatEncoding) -> Self {
634        self.float_encoding = encoding;
635        self
636    }
637
638    /// Set the out-of-order shard tolerance (in shard units).
639    #[must_use]
640    pub fn ooo_shard_tolerance(mut self, tolerance: u32) -> Self {
641        self.ooo_shard_tolerance = tolerance;
642        self
643    }
644
645    /// Add a per-measurement retention override.
646    #[must_use]
647    pub fn measurement_retention(
648        mut self,
649        measurement: impl Into<String>,
650        duration: Duration,
651    ) -> Self {
652        self.measurement_retention
653            .insert(measurement.into(), duration);
654        self
655    }
656
657    /// Set the maximum concurrent compaction tasks.
658    #[must_use]
659    pub fn compaction_concurrency(mut self, concurrency: usize) -> Self {
660        self.compaction_concurrency = concurrency;
661        self
662    }
663
664    /// Set how often the built-in maintenance thread compacts, materialises
665    /// rollups and enforces retention. `Duration::ZERO` disables the
666    /// periodic passes.
667    #[must_use]
668    pub fn maintenance_interval(mut self, interval: Duration) -> Self {
669        self.maintenance_interval = interval;
670        self
671    }
672
673    /// Enable or disable the Last Value Cache.
674    #[must_use]
675    pub fn enable_last_value_cache(mut self, enable: bool) -> Self {
676        self.enable_last_value_cache = enable;
677        self
678    }
679
680    /// Restrict the Last Value Cache to specific measurements.
681    ///
682    /// When set, only the listed measurements participate in the LVC.
683    /// Requires `enable_last_value_cache(true)` to have any effect.
684    #[must_use]
685    pub fn lvc_measurements(mut self, measurements: HashSet<String>) -> Self {
686        self.lvc_measurements = Some(measurements);
687        self
688    }
689
690    /// CDC event-bus capacity, in events.
691    ///
692    /// The ring is allocated on the first subscription; a deployment that
693    /// never reads the CDC stream pays nothing for this.
694    #[must_use]
695    pub fn cdc_capacity(mut self, events: usize) -> Self {
696        self.cdc_capacity = events;
697        self
698    }
699
700    /// Set the segment cache size in bytes.
701    #[must_use]
702    pub fn segment_cache_size(mut self, size: usize) -> Self {
703        self.segment_cache_size = size;
704        self
705    }
706
707    /// Set the maximum series cardinality per shard.
708    #[must_use]
709    pub fn max_series_cardinality(mut self, max: usize) -> Self {
710        self.max_series_cardinality = max;
711        self
712    }
713
714    /// Set the maximum query result size in bytes (0 = unlimited).
715    #[must_use]
716    pub fn max_query_result_bytes(mut self, max: usize) -> Self {
717        self.max_query_result_bytes = max;
718        self
719    }
720
721    /// Set the per-query memory budget in bytes (0 = unlimited).
722    #[must_use]
723    pub fn per_query_memory_limit(mut self, limit: usize) -> Self {
724        self.per_query_memory_limit = limit;
725        self
726    }
727
728    /// Set the query execution timeout (`Duration::ZERO` = no timeout).
729    #[must_use]
730    pub fn query_timeout(mut self, timeout: Duration) -> Self {
731        self.query_timeout = timeout;
732        self
733    }
734
735    /// Set the analytics engine configuration.
736    #[must_use]
737    pub fn analytics(mut self, config: AnalyticsConfig) -> Self {
738        self.analytics = config;
739        self
740    }
741
742    /// Set the soft-delete TTL for dropped measurements.
743    ///
744    /// When set, `drop_measurement` marks the measurement as pending
745    /// deletion instead of immediately removing data.
746    #[must_use]
747    pub fn soft_delete_ttl(mut self, ttl: Option<Duration>) -> Self {
748        self.soft_delete_ttl = ttl;
749        self
750    }
751
752    /// Set how far ahead of the wall clock a timestamp may be (default: 1 h).
753    #[must_use]
754    pub fn future_write_tolerance(mut self, tolerance: Duration) -> Self {
755        self.future_write_tolerance = tolerance;
756        self
757    }
758
759    /// Build the configuration, validating all constraints.
760    ///
761    /// # Errors
762    ///
763    /// Returns [`ConfigError`] if a required field is missing or a constraint
764    /// is violated.
765    pub fn build(self) -> Result<ChronixConfig, ConfigError> {
766        let data_dir = self
767            .data_dir
768            .ok_or(ConfigError::MissingField { field: "data_dir" })?;
769
770        // Construct the config first, then validate shared invariants via
771        // the single `ChronixConfig::validate()` method.
772        let config = ChronixConfig {
773            data_dir,
774            wal: self.wal,
775            memtable_flush_threshold: self.memtable_flush_threshold,
776            max_memtable_memory: self.max_memtable_memory,
777            shard_duration: self.shard_duration,
778            ooo_shard_tolerance: self.ooo_shard_tolerance,
779            retention: self.retention,
780            measurement_retention: self.measurement_retention,
781            compression: self.compression,
782            zstd_level: self.zstd_level,
783            zstd_dict_training: self.zstd_dict_training,
784            float_encoding: self.float_encoding,
785            segment_cache_size: self.segment_cache_size,
786            cdc_capacity: self.cdc_capacity,
787            enable_last_value_cache: self.enable_last_value_cache,
788            lvc_measurements: self.lvc_measurements,
789            compaction_concurrency: self.compaction_concurrency,
790            maintenance_interval: self.maintenance_interval,
791            max_series_cardinality: self.max_series_cardinality,
792            max_query_result_bytes: self.max_query_result_bytes,
793            per_query_memory_limit: self.per_query_memory_limit,
794            query_timeout: self.query_timeout,
795            analytics: self.analytics,
796            soft_delete_ttl: self.soft_delete_ttl,
797            future_write_tolerance: self.future_write_tolerance,
798        };
799
800        // Shared structural validation (same checks as from_toml path).
801        config.validate()?;
802
803        // Builder-only checks below.
804
805        // Warn when segment_cache_size is zero.
806        if config.segment_cache_size == 0 {
807            tracing::warn!("segment_cache_size is 0 — segment caching is effectively disabled");
808        }
809
810        Ok(config)
811    }
812}
813
814#[cfg(test)]
815mod tests {
816    use super::*;
817
818    /// A TOML file may set only what it changes.
819    ///
820    /// `from_toml` used to require **all** of `ChronixConfig`'s fields and
821    /// answered a file that set only `data_dir` with `TOML parse error at
822    /// line 1, column 1` — so it could not load any file a person would
823    /// write, and it had no callers.
824    #[test]
825    fn a_partial_toml_file_loads_with_defaults_for_the_rest() {
826        let toml = r#"
827            data_dir = "/var/lib/chronix"
828            memtable_flush_threshold = 8388608
829
830            [analytics]
831            max_forecast_horizon = 720
832        "#;
833        let config: ChronixConfig = toml::from_str(toml).expect("a partial file must load");
834        assert_eq!(config.data_dir, PathBuf::from("/var/lib/chronix"));
835        assert_eq!(config.memtable_flush_threshold, 8 * 1024 * 1024);
836        assert_eq!(config.analytics.max_forecast_horizon, 720);
837        // Untouched settings keep their defaults.
838        assert_eq!(config.shard_duration, Duration::from_secs(3600));
839        assert_eq!(config.analytics.max_training_points, 1_000_000);
840    }
841
842    /// A *misspelled* key is still an error, because a silently ignored
843    /// setting is the failure this file has been bitten by.
844    #[test]
845    fn a_misspelled_key_is_refused() {
846        let toml = "data_dir = \"/tmp\"\nmemtable_flush_threshhold = 1\n";
847        assert!(toml::from_str::<ChronixConfig>(toml).is_err());
848    }
849
850    #[test]
851    fn builder_defaults_valid() {
852        let config = ChronixConfig::builder().data_dir("./data").build().unwrap();
853
854        assert_eq!(config.data_dir, PathBuf::from("./data"));
855        assert_eq!(config.wal.fsync_policy, FsyncPolicy::PerBatch);
856        assert_eq!(config.wal.max_file_size, 32 * 1024 * 1024);
857        assert_eq!(config.wal.max_unflushed_wals, 4);
858        assert_eq!(config.memtable_flush_threshold, 64 * 1024 * 1024);
859        assert_eq!(config.max_memtable_memory, 256 * 1024 * 1024);
860        assert_eq!(config.shard_duration, Duration::from_secs(3600));
861        assert_eq!(config.compression, CompressionCodec::Lz4);
862        assert_eq!(config.float_encoding, FloatEncoding::Chimp);
863        assert!(!config.enable_last_value_cache);
864        assert_eq!(config.compaction_concurrency, 2);
865        assert_eq!(config.max_series_cardinality, 1_000_000);
866    }
867
868    #[test]
869    fn builder_missing_data_dir() {
870        let err = ChronixConfig::builder().build().unwrap_err();
871        assert!(matches!(
872            err,
873            ConfigError::MissingField { field: "data_dir" }
874        ));
875    }
876
877    #[test]
878    fn builder_zero_flush_threshold() {
879        let err = ChronixConfig::builder()
880            .data_dir("./data")
881            .memtable_flush_threshold(0)
882            .build()
883            .unwrap_err();
884        assert!(err.to_string().contains("memtable_flush_threshold"));
885    }
886
887    #[test]
888    fn builder_memory_less_than_threshold() {
889        let err = ChronixConfig::builder()
890            .data_dir("./data")
891            .memtable_flush_threshold(100)
892            .max_memtable_memory(50)
893            .build()
894            .unwrap_err();
895        assert!(err.to_string().contains("max_memtable_memory"));
896    }
897
898    #[test]
899    fn builder_retention_less_than_shard() {
900        let err = ChronixConfig::builder()
901            .data_dir("./data")
902            .shard_duration(Duration::from_secs(3600))
903            .retention(Some(Duration::from_secs(60)))
904            .build()
905            .unwrap_err();
906        assert!(err.to_string().contains("retention"));
907    }
908
909    #[test]
910    fn builder_zero_shard_duration() {
911        let err = ChronixConfig::builder()
912            .data_dir("./data")
913            .shard_duration(Duration::ZERO)
914            .build()
915            .unwrap_err();
916        assert!(err.to_string().contains("shard_duration"));
917    }
918
919    #[test]
920    fn builder_no_retention() {
921        let config = ChronixConfig::builder()
922            .data_dir("./data")
923            .retention(None)
924            .build()
925            .unwrap();
926        assert!(config.retention.is_none());
927    }
928
929    #[test]
930    fn builder_custom_wal() {
931        let config = ChronixConfig::builder()
932            .data_dir("./data")
933            .wal_fsync_policy(FsyncPolicy::PerWrite)
934            .wal_max_file_size(16 * 1024 * 1024)
935            .wal_max_unflushed(8)
936            .build()
937            .unwrap();
938        assert_eq!(config.wal.fsync_policy, FsyncPolicy::PerWrite);
939        assert_eq!(config.wal.max_file_size, 16 * 1024 * 1024);
940        assert_eq!(config.wal.max_unflushed_wals, 8);
941    }
942
943    #[test]
944    fn config_serde_roundtrip() {
945        let config = ChronixConfig::builder().data_dir("./data").build().unwrap();
946        let json = serde_json::to_string(&config).unwrap();
947        let back: ChronixConfig = serde_json::from_str(&json).unwrap();
948        assert_eq!(config.data_dir, back.data_dir);
949        assert_eq!(config.wal.max_file_size, back.wal.max_file_size);
950    }
951
952    /// Miri runs with filesystem isolation, so the three tests that reach the
953    /// filesystem carry their own `ignore` rather than being named in the CI
954    /// command: a `--skip` list has to be edited every time such a test is
955    /// added, and the one nobody remembered to add is the one that breaks the
956    /// job.
957    #[test]
958    #[cfg_attr(miri, ignore = "reaches the filesystem")]
959    fn config_toml_roundtrip() {
960        let dir = tempfile::tempdir().unwrap();
961        let path = dir.path().join("config.toml");
962
963        let config = ChronixConfig::builder().data_dir("./data").build().unwrap();
964
965        let toml_str = toml::to_string_pretty(&config).unwrap();
966        std::fs::write(&path, &toml_str).unwrap();
967
968        let loaded = ChronixConfig::from_toml(&path).unwrap();
969        assert_eq!(config.data_dir, loaded.data_dir);
970    }
971
972    #[test]
973    fn fsync_policy_default() {
974        assert_eq!(FsyncPolicy::default(), FsyncPolicy::PerBatch);
975    }
976
977    #[test]
978    fn compression_codec_default() {
979        assert_eq!(CompressionCodec::default(), CompressionCodec::Lz4);
980    }
981
982    #[test]
983    fn float_encoding_default() {
984        assert_eq!(FloatEncoding::default(), FloatEncoding::Chimp);
985    }
986
987    #[test]
988    fn builder_zero_max_memtable_memory() {
989        let err = ChronixConfig::builder()
990            .data_dir("./data")
991            .max_memtable_memory(0)
992            .build()
993            .unwrap_err();
994        assert!(err.to_string().contains("max_memtable_memory"));
995    }
996
997    #[test]
998    fn builder_zero_compaction_concurrency() {
999        let err = ChronixConfig::builder()
1000            .data_dir("./data")
1001            .compaction_concurrency(0)
1002            .build()
1003            .unwrap_err();
1004        assert!(err.to_string().contains("compaction_concurrency"));
1005    }
1006
1007    #[test]
1008    fn builder_zero_wal_max_file_size() {
1009        let err = ChronixConfig::builder()
1010            .data_dir("./data")
1011            .wal_max_file_size(0)
1012            .build()
1013            .unwrap_err();
1014        assert!(err.to_string().contains("max_file_size"));
1015    }
1016
1017    #[test]
1018    fn builder_zero_wal_max_unflushed() {
1019        let err = ChronixConfig::builder()
1020            .data_dir("./data")
1021            .wal_max_unflushed(0)
1022            .build()
1023            .unwrap_err();
1024        assert!(err.to_string().contains("max_unflushed"));
1025    }
1026
1027    #[test]
1028    #[cfg_attr(miri, ignore = "reaches the filesystem")]
1029    fn from_toml_nonexistent_file() {
1030        let err = ChronixConfig::from_toml(std::path::Path::new("/no/such/file.toml")).unwrap_err();
1031        assert!(matches!(err, ConfigError::Io(_)));
1032    }
1033
1034    #[test]
1035    #[cfg_attr(miri, ignore = "reaches the filesystem")]
1036    fn from_toml_invalid_content() {
1037        let dir = tempfile::tempdir().unwrap();
1038        let path = dir.path().join("bad.toml");
1039        std::fs::write(&path, "this is not valid toml [[[").unwrap();
1040        let err = ChronixConfig::from_toml(&path).unwrap_err();
1041        assert!(matches!(err, ConfigError::Parse(_)));
1042    }
1043
1044    #[test]
1045    fn fsync_policy_display() {
1046        assert_eq!(FsyncPolicy::PerWrite.to_string(), "per-write");
1047        assert_eq!(FsyncPolicy::PerBatch.to_string(), "per-batch");
1048        let periodic = FsyncPolicy::Periodic(Duration::from_millis(500));
1049        assert!(periodic.to_string().contains("500"));
1050    }
1051
1052    #[test]
1053    fn compression_codec_display() {
1054        assert_eq!(CompressionCodec::Lz4.to_string(), "lz4");
1055        assert_eq!(CompressionCodec::Zstd.to_string(), "zstd");
1056        assert_eq!(CompressionCodec::None.to_string(), "none");
1057    }
1058
1059    #[test]
1060    fn float_encoding_display() {
1061        assert_eq!(FloatEncoding::Chimp.to_string(), "chimp");
1062        assert_eq!(FloatEncoding::Gorilla.to_string(), "gorilla");
1063        assert_eq!(FloatEncoding::Plain.to_string(), "plain");
1064    }
1065
1066    #[test]
1067    fn wal_config_serde_roundtrip() {
1068        let config = WalConfig {
1069            fsync_policy: FsyncPolicy::Periodic(Duration::from_secs(5)),
1070            max_file_size: 16 * 1024 * 1024,
1071            max_unflushed_wals: 8,
1072            compress: true,
1073            group_sync_timeout_secs: 5,
1074        };
1075        let json = serde_json::to_string(&config).unwrap();
1076        let back: WalConfig = serde_json::from_str(&json).unwrap();
1077        assert_eq!(config, back);
1078    }
1079
1080    // ── Analytics validation ─────────────────────────────────────
1081
1082    #[test]
1083    fn validate_zero_max_forecast_horizon() {
1084        let analytics = AnalyticsConfig {
1085            max_forecast_horizon: 0,
1086            ..AnalyticsConfig::default()
1087        };
1088        let err = ChronixConfig::builder()
1089            .data_dir("./data")
1090            .analytics(analytics)
1091            .build()
1092            .unwrap_err();
1093        assert!(err.to_string().contains("max_forecast_horizon"));
1094    }
1095
1096    #[test]
1097    fn validate_zero_max_training_points() {
1098        let analytics = AnalyticsConfig {
1099            max_training_points: 0,
1100            ..AnalyticsConfig::default()
1101        };
1102        let err = ChronixConfig::builder()
1103            .data_dir("./data")
1104            .analytics(analytics)
1105            .build()
1106            .unwrap_err();
1107        assert!(err.to_string().contains("max_training_points"));
1108    }
1109
1110    #[test]
1111    fn small_preset_is_valid_and_bounded() {
1112        let cfg = ChronixConfig::small("/tmp/chronix-small");
1113        assert!(cfg.validate().is_ok());
1114        assert_eq!(cfg.memtable_flush_threshold, 8 * 1024 * 1024);
1115        assert_eq!(cfg.max_memtable_memory, 24 * 1024 * 1024);
1116        assert_eq!(cfg.segment_cache_size, 16 * 1024 * 1024);
1117        assert_eq!(cfg.compaction_concurrency, 1);
1118        assert!(matches!(cfg.wal.fsync_policy, FsyncPolicy::Periodic(_)));
1119        assert_eq!(cfg.wal.max_file_size, 8 * 1024 * 1024);
1120    }
1121}