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::{BTreeMap, 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: the bounds on what one call may ask for.
134///
135/// Configurable via TOML under the `[analytics]` section.
136///
137/// **Both fields are bounds, and that is the whole section on purpose.** The
138/// model, the detector, the confidence level and the threshold are arguments
139/// to the analytics API — `ForecastConfig` and `AnomalyConfig`, passed per
140/// call — and no deployment-wide default could reach them anyway: `chronixd`
141/// serves no forecasting or anomaly endpoint, and SQL splits the choice
142/// across two named functions.
143///
144/// Four `default_*` keys for exactly those once sat here with a serde
145/// default, a validated type and no reader. Their absence is load-bearing
146/// rather than tidy: `deny_unknown_fields` is the same mechanism that refuses
147/// a typo, so a key that parses reads as a key that works.
148#[derive(Debug, Clone, Serialize, Deserialize)]
149#[serde(deny_unknown_fields)]
150pub struct AnalyticsConfig {
151    /// Maximum forecast horizon in data points.
152    #[serde(default = "default_max_horizon")]
153    pub max_forecast_horizon: usize,
154    /// Maximum number of training points fed to a model.
155    #[serde(default = "default_max_training")]
156    pub max_training_points: usize,
157}
158
159fn default_max_horizon() -> usize {
160    8760
161}
162fn default_max_training() -> usize {
163    1_000_000
164}
165fn default_max_query_result_bytes() -> usize {
166    256 * 1024 * 1024 // 256 MB
167}
168fn default_per_query_memory_limit() -> usize {
169    256 * 1024 * 1024 // 256 MiB
170}
171fn default_query_timeout() -> Duration {
172    Duration::from_secs(60)
173}
174fn default_future_write_tolerance() -> Duration {
175    Duration::from_secs(3600)
176}
177
178impl Default for AnalyticsConfig {
179    fn default() -> Self {
180        Self {
181            max_forecast_horizon: default_max_horizon(),
182            max_training_points: default_max_training(),
183        }
184    }
185}
186
187// ─── Per-measurement Analytics Overrides ────────────────────────────
188
189/// Default Zstd compression level.
190const fn default_zstd_level() -> i32 {
191    3
192}
193
194/// Taking a checkpoint on a schedule, and keeping the last few.
195///
196/// Run by the maintenance thread, beside the flush, compaction, rollup and
197/// retention passes — the deployments this is for have no scheduler of their
198/// own. Off unless `interval_secs` is set.
199///
200/// ```toml
201/// [database.checkpoints]
202/// interval_secs = 21600      # every six hours
203/// directory     = "/mnt/backup/chronix"
204/// keep          = 4          # a day of history
205/// ```
206///
207/// Each run writes `<directory>/<timestamp>`; the oldest are removed once
208/// more than `keep` remain, and a run with no manifest is removed on sight.
209/// A failed run leaves the existing checkpoints alone and increments
210/// `chronix_backup_failures_total`.
211///
212/// `directory` must be outside the data directory's `segments/` tree. On the
213/// same filesystem the segments are hard-linked, so the checkpoint is not a
214/// second copy of the bytes — point it at a different volume if surviving
215/// that volume is the aim.
216#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
217#[serde(deny_unknown_fields)]
218pub struct Checkpoints {
219    /// How often to take one. `None` — the default — is off.
220    #[serde(default)]
221    pub interval_secs: Option<u64>,
222    /// Where they go. Required when `interval_secs` is set.
223    #[serde(default)]
224    pub directory: Option<PathBuf>,
225    /// How many to keep. Default 3; `0` keeps only the newest.
226    #[serde(default = "default_checkpoints_keep")]
227    pub keep: usize,
228}
229
230/// Three, so a corrupt or half-finished one is never the only copy.
231const fn default_checkpoints_keep() -> usize {
232    3
233}
234
235impl Checkpoints {
236    /// The interval, when scheduled checkpoints are on.
237    #[must_use]
238    pub fn interval(&self) -> Option<Duration> {
239        self.interval_secs
240            .filter(|s| *s > 0)
241            .map(Duration::from_secs)
242    }
243
244    /// Check the declaration is usable.
245    ///
246    /// # Errors
247    ///
248    /// [`ConfigError::Validation`] if an interval is set without a
249    /// directory — the one combination that reads as "on" and does nothing.
250    pub fn validate(&self) -> Result<(), ConfigError> {
251        if self.interval().is_some() && self.directory.is_none() {
252            return Err(ConfigError::Validation {
253                message: "checkpoints.interval_secs is set but checkpoints.directory is not — \
254                          a schedule with nowhere to write is a backup that silently never \
255                          happens"
256                    .into(),
257            });
258        }
259        Ok(())
260    }
261}
262
263/// Which columns are encrypted in the `.csx` format, and where their keys
264/// come from.
265///
266/// **The keys are named, never written here.** A `keys` entry is the name of
267/// an environment variable holding the base64 of 32 bytes, so the
268/// configuration file carries no secret — which is what makes this worth
269/// having over full-disk encryption, whose key is on the machine too.
270///
271/// ```toml
272/// [database.field_encryption]
273/// columns = { patient_id = "phi-2026", diagnosis = "phi-2026" }
274/// keys    = { phi-2026 = "CHRONIX_FIELD_KEY_PHI" }
275/// ```
276///
277/// Column names are **global**, not per measurement. Only a field may be
278/// encrypted: a tag is part of the series key, which is stored in plaintext
279/// beside the segment, and every scan ranges on the time column.
280///
281/// Declaring several key ids at once is a rotation — segments written under
282/// the old one stay readable. Every declared key must resolve at startup,
283/// including one no column currently names.
284#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
285#[serde(deny_unknown_fields)]
286pub struct FieldEncryption {
287    /// Column name → the id of the key that encrypts it.
288    #[serde(default)]
289    pub columns: BTreeMap<String, String>,
290    /// Key id → the environment variable holding its base64-encoded 32 bytes.
291    #[serde(default)]
292    pub keys: BTreeMap<String, String>,
293}
294
295impl FieldEncryption {
296    /// Whether anything is encrypted at all.
297    #[must_use]
298    pub fn is_empty(&self) -> bool {
299        self.columns.is_empty()
300    }
301
302    /// Whether `column` is declared encrypted.
303    #[must_use]
304    pub fn covers(&self, column: &str) -> bool {
305        self.columns.contains_key(column)
306    }
307
308    /// Check the declaration is self-consistent.
309    ///
310    /// # Errors
311    ///
312    /// [`ConfigError::Validation`] if a column names a key that is not
313    /// declared, if a name is empty, or if the time column is named — a
314    /// timestamp cannot be encrypted, because every scan ranges on it and
315    /// every retention decision reads it.
316    pub fn validate(&self) -> Result<(), ConfigError> {
317        for (column, key_id) in &self.columns {
318            if column.is_empty() || key_id.is_empty() {
319                return Err(ConfigError::Validation {
320                    message: "field_encryption.columns: column names and key ids must not be empty"
321                        .into(),
322                });
323            }
324            if crate::types::RESERVED_COLUMN_NAMES.contains(&column.as_str()) {
325                return Err(ConfigError::Validation {
326                    message: format!(
327                        "field_encryption.columns: '{column}' is the time column and cannot be \
328                         encrypted — every scan ranges on it and every retention decision \
329                         reads it"
330                    ),
331                });
332            }
333            if !self.keys.contains_key(key_id) {
334                return Err(ConfigError::Validation {
335                    message: format!(
336                        "field_encryption: column '{column}' names key '{key_id}', which is \
337                         not declared under [field_encryption.keys]"
338                    ),
339                });
340            }
341        }
342        for (key_id, var) in &self.keys {
343            if key_id.is_empty() || var.is_empty() {
344                return Err(ConfigError::Validation {
345                    message:
346                        "field_encryption.keys: key ids and environment variable names must not \
347                         be empty"
348                            .into(),
349                });
350            }
351        }
352        Ok(())
353    }
354}
355
356/// Top-level database configuration.
357///
358/// Constructed via the builder pattern: `ChronixConfig::builder()`.
359/// Every field defaults, so a TOML file may set only what it changes.
360///
361/// Without this, `from_toml` required **all twenty-five** settings to be
362/// present and answered a file that set only `data_dir` with `TOML parse
363/// error at line 1, column 1` — no field named, no hint. There is no such
364/// thing as a config file a person writes that lists every setting, so the
365/// loader could not load anything anybody would write.
366///
367/// `deny_unknown_fields` stays: a key that is *misspelled* must still be an
368/// error, because a silently ignored setting is the failure this whole file
369/// has been bitten by.
370#[derive(Debug, Clone, Serialize, Deserialize)]
371#[serde(deny_unknown_fields, default)]
372pub struct ChronixConfig {
373    /// Path to the data directory.
374    pub data_dir: PathBuf,
375    /// WAL configuration.
376    pub wal: WalConfig,
377    /// Memtable flush threshold in bytes (default: 64 MB).
378    pub memtable_flush_threshold: usize,
379    /// Maximum total memtable memory across active + frozen (default: 256 MB).
380    pub max_memtable_memory: usize,
381    /// Time-shard duration (default: 1 hour).
382    pub shard_duration: Duration,
383    /// Out-of-order tolerance in shard units (default: 2).
384    pub ooo_shard_tolerance: u32,
385    /// Retention period, enforced by the background compaction pass.
386    ///
387    /// `None` — the default — keeps data forever. A database that deletes
388    /// data by default is a database that deletes data nobody asked it to;
389    /// retention is opt-in, exactly as it is in InfluxDB and SQLite-shaped
390    /// stores, and unlike a scrape server.
391    pub retention: Option<Duration>,
392    /// Per-measurement retention overrides.
393    pub measurement_retention: HashMap<String, Duration>,
394    /// Compression codec for segments (default: LZ4).
395    pub compression: CompressionCodec,
396    /// Zstd compression level for new segments (1–22, default: 3).
397    ///
398    /// Only meaningful when `compression` is `Zstd`. Higher is smaller and
399    /// slower; cold-tier re-compression uses 9.
400    #[serde(default = "default_zstd_level")]
401    pub zstd_level: i32,
402    /// Train a Zstd dictionary from the first row group of each segment
403    /// (default: false).
404    ///
405    /// The writer collects the first row group's encoded blocks as training
406    /// samples and, if there is enough of them (≥ 8 blocks, ≥ 16 KiB),
407    /// compresses every later row group against the trained dictionary,
408    /// storing it in the segment metadata for the reader. It helps most where
409    /// blocks are small and repetitive, and costs a training pass per segment.
410    ///
411    /// Only meaningful when `compression` is `Zstd`.
412    #[serde(default)]
413    pub zstd_dict_training: bool,
414    /// Float encoding algorithm (default: Chimp).
415    pub float_encoding: FloatEncoding,
416    /// Segment cache size in bytes (default: 512 MB).
417    pub segment_cache_size: usize,
418    /// CDC event-bus capacity — events buffered before the oldest are dropped
419    /// for a slow subscriber (default: 65 536).
420    ///
421    /// The ring costs `capacity` × ~112 bytes and is allocated on the first
422    /// subscription, so a deployment that never reads the CDC stream pays
423    /// nothing. The default is ~7 MiB, sized for a server;
424    /// [`ChronixConfig::small`] sets 4 096.
425    #[serde(default = "default_cdc_capacity")]
426    pub cdc_capacity: usize,
427    /// Enable Last Value Cache (default: false).
428    pub enable_last_value_cache: bool,
429    /// Per-measurement LVC opt-in. When `Some`, only listed measurements
430    /// participate in the LVC. When `None`, all measurements use the LVC
431    /// (provided `enable_last_value_cache` is `true`).
432    pub lvc_measurements: Option<HashSet<String>>,
433    /// Maximum concurrent compaction tasks (default: 2).
434    pub compaction_concurrency: usize,
435    /// How often the built-in maintenance thread compacts, materialises
436    /// rollups, collects garbage and enforces retention (default: 30 s).
437    ///
438    /// Flushes are not on this schedule — the thread flushes as soon as a
439    /// memtable crosses its threshold. `Duration::ZERO` disables the
440    /// periodic passes (flush-on-demand stays), for a caller that runs
441    /// `compact()` and `enforce_retention()` on its own schedule.
442    #[serde(default = "default_maintenance_interval")]
443    pub maintenance_interval: Duration,
444    /// Maximum number of distinct series in the database (default: 1M).
445    pub max_series_cardinality: usize,
446    /// Maximum query result size in bytes (default: 256 MB, 0 = unlimited).
447    /// Queries whose intermediate batches exceed this are aborted early.
448    #[serde(default = "default_max_query_result_bytes")]
449    pub max_query_result_bytes: usize,
450    /// Per-query memory budget in bytes (default: 256 MiB, 0 = unlimited).
451    ///
452    /// Each query execution creates a `MemoryTracker` with this
453    /// budget.  Intermediate allocations (collected `RecordBatch`es)
454    /// are charged against the tracker; if the budget is exceeded the
455    /// query fails with `QueryMemoryExceeded` instead of causing OOM.
456    #[serde(default = "default_per_query_memory_limit")]
457    pub per_query_memory_limit: usize,
458    /// Query execution timeout (default: 60s, `Duration::ZERO` = no timeout).
459    ///
460    /// Queries that exceed this duration are aborted with a
461    /// `QueryTimeout` error to prevent runaway scans.
462    #[serde(default = "default_query_timeout")]
463    pub query_timeout: Duration,
464    /// Analytics engine configuration.
465    #[serde(default)]
466    pub analytics: AnalyticsConfig,
467    /// Soft-delete TTL for dropped measurements.
468    ///
469    /// When set, `drop_measurement` marks the measurement as "pending
470    /// deletion" with a deadline instead of immediately removing its
471    /// segments. A background GC pass hard-deletes measurements that
472    /// have been pending longer than this TTL, allowing accidental drops
473    /// to be recovered within the grace window.
474    ///
475    /// Default: `None` (immediate hard-delete, original behaviour).
476    #[serde(default)]
477    pub soft_delete_ttl: Option<Duration>,
478    /// How far ahead of the wall clock a point's timestamp may be
479    /// (default: 1 hour).
480    ///
481    /// A point beyond this is rejected before admission, on every write
482    /// path including `backfill`. The out-of-order window is anchored on
483    /// the newest *admitted* timestamp, so without this bound one point
484    /// from a device with a broken clock would move the window years into
485    /// the future and every real write after it would be rejected until
486    /// the next restart.
487    #[serde(default = "default_future_write_tolerance")]
488    pub future_write_tolerance: Duration,
489    /// Per-column encryption in the segment format — see [`FieldEncryption`].
490    ///
491    /// Empty by default: nothing is encrypted, and the data directory is the
492    /// filesystem's job.
493    #[serde(default)]
494    pub field_encryption: FieldEncryption,
495    /// Take a checkpoint on a schedule — see [`Checkpoints`].
496    ///
497    /// Off by default. A backup is a policy decision, and a database that
498    /// starts writing copies of itself somewhere the operator did not ask
499    /// for is worse than one that does nothing.
500    #[serde(default)]
501    pub checkpoints: Checkpoints,
502}
503
504impl Default for ChronixConfig {
505    fn default() -> Self {
506        // Through the builder, so the defaults have one definition rather
507        // than two that can disagree. `data_dir` matches the server's own
508        // default, which is what an operator who omits it expects.
509        ChronixConfigBuilder::default()
510            .data_dir("./chronix-data")
511            .build()
512            .expect("the builder's own defaults must be valid")
513    }
514}
515
516impl ChronixConfig {
517    /// Returns a builder with sensible defaults.
518    /// A configuration preset for small-footprint deployments — IoT
519    /// gateways and edge devices with 512 MB–1 GB of RAM and flash (eMMC/SD)
520    /// storage.
521    ///
522    /// Compared to the default configuration:
523    ///
524    /// - **Memory budget ≈ 48 MB** — 8 MB memtable flush threshold,
525    ///   24 MB total memtable budget, 16 MB segment cache.
526    /// - **Flash-friendly WAL** — `Periodic(5s)` fsync coalesces syncs to
527    ///   reduce write amplification and flash wear; up to ~5 s of the most
528    ///   recent writes may be lost on power failure. Choose
529    ///   [`FsyncPolicy::PerBatch`] instead if that is not acceptable.
530    /// - **Single compaction worker** — bounds background CPU and I/O.
531    ///
532    /// All fields remain overridable after construction.
533    #[must_use]
534    pub fn small(data_dir: impl Into<PathBuf>) -> Self {
535        Self::builder()
536            .data_dir(data_dir)
537            .memtable_flush_threshold(8 * 1024 * 1024) // 8 MB
538            .max_memtable_memory(24 * 1024 * 1024) // 24 MB
539            .segment_cache_size(16 * 1024 * 1024) // 16 MB
540            // 4 096 events ≈ 450 KiB, and at the design partner's 50 points/s
541            // still eighty seconds of tolerance for a slow subscriber. The
542            // 65 536 default is a server's number.
543            .cdc_capacity(4_096)
544            .compaction_concurrency(1)
545            .maintenance_interval(Duration::from_secs(60))
546            .wal_fsync_policy(FsyncPolicy::Periodic(Duration::from_secs(5)))
547            .wal_max_file_size(8 * 1024 * 1024) // 8 MB — bounded replay on tiny devices
548            .build()
549            .expect("BUG: the small-footprint preset must always validate")
550    }
551
552    /// Returns a builder with sensible defaults.
553    #[must_use]
554    pub fn builder() -> ChronixConfigBuilder {
555        ChronixConfigBuilder::default()
556    }
557
558    /// Load configuration from a TOML file, then apply builder overrides.
559    ///
560    /// # Errors
561    ///
562    /// Returns [`ConfigError`] if the file cannot be read, parsed, or fails
563    /// validation.
564    pub fn from_toml(path: &Path) -> Result<Self, ConfigError> {
565        let content = std::fs::read_to_string(path)?;
566        let config: Self = toml::from_str(&content)?;
567        config.validate()?;
568        Ok(config)
569    }
570
571    /// Validate configuration invariants.
572    ///
573    /// Called by `from_toml` and [`ChronixConfigBuilder::build`] to enforce
574    /// structural invariants on the final configuration.
575    /// # Errors
576    ///
577    /// Returns [`ConfigError`] when any field is out of range or
578    /// internally inconsistent (see the individual checks).
579    pub fn validate(&self) -> Result<(), ConfigError> {
580        self.field_encryption.validate()?;
581        self.checkpoints.validate()?;
582        if self.memtable_flush_threshold == 0 {
583            return Err(ConfigError::Validation {
584                message: "memtable_flush_threshold must be > 0".into(),
585            });
586        }
587        if self.max_memtable_memory == 0 {
588            return Err(ConfigError::Validation {
589                message: "max_memtable_memory must be > 0".into(),
590            });
591        }
592        if self.max_memtable_memory < self.memtable_flush_threshold {
593            return Err(ConfigError::Validation {
594                message: format!(
595                    "max_memtable_memory ({}) must be >= memtable_flush_threshold ({})",
596                    self.max_memtable_memory, self.memtable_flush_threshold
597                ),
598            });
599        }
600        if self.shard_duration.is_zero() {
601            return Err(ConfigError::Validation {
602                message: "shard_duration must be > 0".into(),
603            });
604        }
605        if let Some(retention) = &self.retention
606            && *retention < self.shard_duration
607        {
608            return Err(ConfigError::Validation {
609                message: format!(
610                    "retention ({retention:?}) must be >= shard_duration ({:?})",
611                    self.shard_duration
612                ),
613            });
614        }
615        if self.compaction_concurrency == 0 {
616            return Err(ConfigError::Validation {
617                message: "compaction_concurrency must be > 0".into(),
618            });
619        }
620        if self.max_series_cardinality == 0 {
621            return Err(ConfigError::Validation {
622                message: "max_series_cardinality must be > 0".into(),
623            });
624        }
625        if self.wal.max_file_size == 0 {
626            return Err(ConfigError::Validation {
627                message: "WAL max_file_size must be > 0".into(),
628            });
629        }
630        if self.wal.max_unflushed_wals == 0 {
631            return Err(ConfigError::Validation {
632                message: "WAL max_unflushed_wals must be > 0".into(),
633            });
634        }
635
636        // Validate compaction and analytics settings at startup
637        // to surface misconfigurations early.
638        if self.analytics.max_forecast_horizon == 0 {
639            return Err(ConfigError::Validation {
640                message: "analytics.max_forecast_horizon must be > 0".into(),
641            });
642        }
643        if self.analytics.max_training_points == 0 {
644            return Err(ConfigError::Validation {
645                message: "analytics.max_training_points must be > 0".into(),
646            });
647        }
648        Ok(())
649    }
650}
651
652/// Builder for [`ChronixConfig`] with validation on `build()`.
653#[derive(Debug, Clone)]
654pub struct ChronixConfigBuilder {
655    data_dir: Option<PathBuf>,
656    wal: WalConfig,
657    memtable_flush_threshold: usize,
658    max_memtable_memory: usize,
659    shard_duration: Duration,
660    ooo_shard_tolerance: u32,
661    retention: Option<Duration>,
662    measurement_retention: HashMap<String, Duration>,
663    compression: CompressionCodec,
664    zstd_level: i32,
665    zstd_dict_training: bool,
666    float_encoding: FloatEncoding,
667    segment_cache_size: usize,
668    cdc_capacity: usize,
669    enable_last_value_cache: bool,
670    lvc_measurements: Option<HashSet<String>>,
671    compaction_concurrency: usize,
672    maintenance_interval: Duration,
673    field_encryption: FieldEncryption,
674    checkpoints: Checkpoints,
675    max_series_cardinality: usize,
676    max_query_result_bytes: usize,
677    per_query_memory_limit: usize,
678    query_timeout: Duration,
679    analytics: AnalyticsConfig,
680    soft_delete_ttl: Option<Duration>,
681    future_write_tolerance: Duration,
682}
683
684impl Default for ChronixConfigBuilder {
685    fn default() -> Self {
686        Self {
687            data_dir: None,
688            wal: WalConfig::default(),
689            memtable_flush_threshold: 64 * 1024 * 1024, // 64 MB
690            max_memtable_memory: 256 * 1024 * 1024,     // 256 MB
691            shard_duration: Duration::from_secs(3600),  // 1 hour
692            ooo_shard_tolerance: 2,
693            retention: None,
694            measurement_retention: HashMap::new(),
695            compression: CompressionCodec::default(),
696            zstd_level: default_zstd_level(),
697            zstd_dict_training: false,
698            float_encoding: FloatEncoding::default(),
699            segment_cache_size: 512 * 1024 * 1024, // 512 MB
700            cdc_capacity: default_cdc_capacity(),
701            enable_last_value_cache: false,
702            lvc_measurements: None,
703            compaction_concurrency: 2,
704            maintenance_interval: default_maintenance_interval(),
705            field_encryption: FieldEncryption::default(),
706            checkpoints: Checkpoints::default(),
707            max_series_cardinality: 1_000_000,
708            max_query_result_bytes: default_max_query_result_bytes(),
709            per_query_memory_limit: default_per_query_memory_limit(),
710            query_timeout: default_query_timeout(),
711            analytics: AnalyticsConfig::default(),
712            soft_delete_ttl: None,
713            future_write_tolerance: default_future_write_tolerance(),
714        }
715    }
716}
717
718impl ChronixConfigBuilder {
719    /// Set the data directory path (required).
720    #[must_use]
721    pub fn data_dir(mut self, path: impl Into<PathBuf>) -> Self {
722        self.data_dir = Some(path.into());
723        self
724    }
725
726    /// Set the WAL fsync policy.
727    #[must_use]
728    pub fn wal_fsync_policy(mut self, policy: FsyncPolicy) -> Self {
729        self.wal.fsync_policy = policy;
730        self
731    }
732
733    /// Set the maximum WAL file size.
734    #[must_use]
735    pub fn wal_max_file_size(mut self, size: usize) -> Self {
736        self.wal.max_file_size = size;
737        self
738    }
739
740    /// Set the maximum number of unflushed WAL files.
741    #[must_use]
742    pub fn wal_max_unflushed(mut self, count: usize) -> Self {
743        self.wal.max_unflushed_wals = count;
744        self
745    }
746
747    /// Set the memtable flush threshold in bytes.
748    #[must_use]
749    pub fn memtable_flush_threshold(mut self, size: usize) -> Self {
750        self.memtable_flush_threshold = size;
751        self
752    }
753
754    /// Set the maximum total memtable memory.
755    #[must_use]
756    pub fn max_memtable_memory(mut self, size: usize) -> Self {
757        self.max_memtable_memory = size;
758        self
759    }
760
761    /// Set the time-shard duration.
762    #[must_use]
763    pub fn shard_duration(mut self, duration: Duration) -> Self {
764        self.shard_duration = duration;
765        self
766    }
767
768    /// Set the retention period (None for infinite).
769    #[must_use]
770    pub fn retention(mut self, duration: Option<Duration>) -> Self {
771        self.retention = duration;
772        self
773    }
774
775    /// Set the compression codec.
776    #[must_use]
777    pub fn compression(mut self, codec: CompressionCodec) -> Self {
778        self.compression = codec;
779        self
780    }
781
782    /// Set the Zstd compression level for new segments (1–22).
783    #[must_use]
784    pub fn zstd_level(mut self, level: i32) -> Self {
785        self.zstd_level = level;
786        self
787    }
788
789    /// Enable Zstd dictionary training for new segments.
790    #[must_use]
791    pub fn zstd_dict_training(mut self, enabled: bool) -> Self {
792        self.zstd_dict_training = enabled;
793        self
794    }
795
796    /// Set the float encoding algorithm.
797    #[must_use]
798    pub fn float_encoding(mut self, encoding: FloatEncoding) -> Self {
799        self.float_encoding = encoding;
800        self
801    }
802
803    /// Set the out-of-order shard tolerance (in shard units).
804    #[must_use]
805    pub fn ooo_shard_tolerance(mut self, tolerance: u32) -> Self {
806        self.ooo_shard_tolerance = tolerance;
807        self
808    }
809
810    /// Add a per-measurement retention override.
811    #[must_use]
812    pub fn measurement_retention(
813        mut self,
814        measurement: impl Into<String>,
815        duration: Duration,
816    ) -> Self {
817        self.measurement_retention
818            .insert(measurement.into(), duration);
819        self
820    }
821
822    /// Set the maximum concurrent compaction tasks.
823    #[must_use]
824    pub fn compaction_concurrency(mut self, concurrency: usize) -> Self {
825        self.compaction_concurrency = concurrency;
826        self
827    }
828
829    /// Declare which columns are encrypted and where their keys live.
830    ///
831    /// See [`FieldEncryption`]. The declaration is checked at
832    /// [`build`](Self::build); the keys themselves are resolved from the
833    /// environment when the database is opened, so a missing variable is a
834    /// startup error rather than a column that silently writes plaintext.
835    #[must_use]
836    pub fn field_encryption(mut self, settings: FieldEncryption) -> Self {
837        self.field_encryption = settings;
838        self
839    }
840
841    /// Take a checkpoint on a schedule — see [`Checkpoints`].
842    #[must_use]
843    pub fn checkpoints(mut self, settings: Checkpoints) -> Self {
844        self.checkpoints = settings;
845        self
846    }
847
848    /// Set how often the built-in maintenance thread compacts, materialises
849    /// rollups and enforces retention. `Duration::ZERO` disables the
850    /// periodic passes.
851    #[must_use]
852    pub fn maintenance_interval(mut self, interval: Duration) -> Self {
853        self.maintenance_interval = interval;
854        self
855    }
856
857    /// Enable or disable the Last Value Cache.
858    #[must_use]
859    pub fn enable_last_value_cache(mut self, enable: bool) -> Self {
860        self.enable_last_value_cache = enable;
861        self
862    }
863
864    /// Restrict the Last Value Cache to specific measurements.
865    ///
866    /// When set, only the listed measurements participate in the LVC.
867    /// Requires `enable_last_value_cache(true)` to have any effect.
868    #[must_use]
869    pub fn lvc_measurements(mut self, measurements: HashSet<String>) -> Self {
870        self.lvc_measurements = Some(measurements);
871        self
872    }
873
874    /// CDC event-bus capacity, in events.
875    ///
876    /// The ring is allocated on the first subscription; a deployment that
877    /// never reads the CDC stream pays nothing for this.
878    #[must_use]
879    pub fn cdc_capacity(mut self, events: usize) -> Self {
880        self.cdc_capacity = events;
881        self
882    }
883
884    /// Set the segment cache size in bytes.
885    #[must_use]
886    pub fn segment_cache_size(mut self, size: usize) -> Self {
887        self.segment_cache_size = size;
888        self
889    }
890
891    /// Set the maximum series cardinality per shard.
892    #[must_use]
893    pub fn max_series_cardinality(mut self, max: usize) -> Self {
894        self.max_series_cardinality = max;
895        self
896    }
897
898    /// Set the maximum query result size in bytes (0 = unlimited).
899    #[must_use]
900    pub fn max_query_result_bytes(mut self, max: usize) -> Self {
901        self.max_query_result_bytes = max;
902        self
903    }
904
905    /// Set the per-query memory budget in bytes (0 = unlimited).
906    #[must_use]
907    pub fn per_query_memory_limit(mut self, limit: usize) -> Self {
908        self.per_query_memory_limit = limit;
909        self
910    }
911
912    /// Set the query execution timeout (`Duration::ZERO` = no timeout).
913    #[must_use]
914    pub fn query_timeout(mut self, timeout: Duration) -> Self {
915        self.query_timeout = timeout;
916        self
917    }
918
919    /// Set the analytics engine configuration.
920    #[must_use]
921    pub fn analytics(mut self, config: AnalyticsConfig) -> Self {
922        self.analytics = config;
923        self
924    }
925
926    /// Set the soft-delete TTL for dropped measurements.
927    ///
928    /// When set, `drop_measurement` marks the measurement as pending
929    /// deletion instead of immediately removing data.
930    #[must_use]
931    pub fn soft_delete_ttl(mut self, ttl: Option<Duration>) -> Self {
932        self.soft_delete_ttl = ttl;
933        self
934    }
935
936    /// Set how far ahead of the wall clock a timestamp may be (default: 1 h).
937    #[must_use]
938    pub fn future_write_tolerance(mut self, tolerance: Duration) -> Self {
939        self.future_write_tolerance = tolerance;
940        self
941    }
942
943    /// Build the configuration, validating all constraints.
944    ///
945    /// # Errors
946    ///
947    /// Returns [`ConfigError`] if a required field is missing or a constraint
948    /// is violated.
949    pub fn build(self) -> Result<ChronixConfig, ConfigError> {
950        let data_dir = self
951            .data_dir
952            .ok_or(ConfigError::MissingField { field: "data_dir" })?;
953
954        // Construct the config first, then validate shared invariants via
955        // the single `ChronixConfig::validate()` method.
956        let config = ChronixConfig {
957            data_dir,
958            wal: self.wal,
959            memtable_flush_threshold: self.memtable_flush_threshold,
960            max_memtable_memory: self.max_memtable_memory,
961            shard_duration: self.shard_duration,
962            ooo_shard_tolerance: self.ooo_shard_tolerance,
963            retention: self.retention,
964            measurement_retention: self.measurement_retention,
965            compression: self.compression,
966            zstd_level: self.zstd_level,
967            zstd_dict_training: self.zstd_dict_training,
968            float_encoding: self.float_encoding,
969            segment_cache_size: self.segment_cache_size,
970            cdc_capacity: self.cdc_capacity,
971            enable_last_value_cache: self.enable_last_value_cache,
972            lvc_measurements: self.lvc_measurements,
973            compaction_concurrency: self.compaction_concurrency,
974            maintenance_interval: self.maintenance_interval,
975            max_series_cardinality: self.max_series_cardinality,
976            max_query_result_bytes: self.max_query_result_bytes,
977            per_query_memory_limit: self.per_query_memory_limit,
978            query_timeout: self.query_timeout,
979            analytics: self.analytics,
980            soft_delete_ttl: self.soft_delete_ttl,
981            future_write_tolerance: self.future_write_tolerance,
982            field_encryption: self.field_encryption,
983            checkpoints: self.checkpoints,
984        };
985
986        // Shared structural validation (same checks as from_toml path).
987        config.validate()?;
988
989        // Builder-only checks below.
990
991        // Warn when segment_cache_size is zero.
992        if config.segment_cache_size == 0 {
993            tracing::warn!("segment_cache_size is 0 — segment caching is effectively disabled");
994        }
995
996        Ok(config)
997    }
998}
999
1000#[cfg(test)]
1001mod tests {
1002    /// A setting the section does not act on is not a setting it accepts.
1003    ///
1004    /// `scripts/check-docs.sh` asks the same question of every config struct
1005    /// in the tree — a `pub` field nothing ever writes `.name` against is a
1006    /// key a file may set and be ignored on. This pins the consequence for
1007    /// the four that were found that way.
1008    #[test]
1009    fn the_section_refuses_a_key_it_would_not_act_on() {
1010        let ok: AnalyticsConfig =
1011            toml::from_str("max_forecast_horizon = 100\nmax_training_points = 500\n")
1012                .expect("the documented keys must load");
1013        assert_eq!(ok.max_forecast_horizon, 100);
1014        assert_eq!(ok.max_training_points, 500);
1015
1016        for key in [
1017            "default_forecast_model = \"holt\"",
1018            "default_anomaly_method = \"iqr\"",
1019            "default_confidence_level = 0.99",
1020            "default_anomaly_threshold = 4.0",
1021        ] {
1022            let err = toml::from_str::<AnalyticsConfig>(key)
1023                .expect_err("a key the section does not act on must be refused")
1024                .to_string();
1025            assert!(
1026                err.contains("unknown field"),
1027                "`{key}` was refused for the wrong reason: {err}"
1028            );
1029        }
1030    }
1031
1032    use super::*;
1033
1034    /// A TOML file may set only what it changes.
1035    ///
1036    /// `from_toml` used to require **all** of `ChronixConfig`'s fields and
1037    /// answered a file that set only `data_dir` with `TOML parse error at
1038    /// line 1, column 1` — so it could not load any file a person would
1039    /// write, and it had no callers.
1040    #[test]
1041    fn a_partial_toml_file_loads_with_defaults_for_the_rest() {
1042        let toml = r#"
1043            data_dir = "/var/lib/chronix"
1044            memtable_flush_threshold = 8388608
1045
1046            [analytics]
1047            max_forecast_horizon = 720
1048        "#;
1049        let config: ChronixConfig = toml::from_str(toml).expect("a partial file must load");
1050        assert_eq!(config.data_dir, PathBuf::from("/var/lib/chronix"));
1051        assert_eq!(config.memtable_flush_threshold, 8 * 1024 * 1024);
1052        assert_eq!(config.analytics.max_forecast_horizon, 720);
1053        // Untouched settings keep their defaults.
1054        assert_eq!(config.shard_duration, Duration::from_secs(3600));
1055        assert_eq!(config.analytics.max_training_points, 1_000_000);
1056    }
1057
1058    /// A *misspelled* key is still an error, because a silently ignored
1059    /// setting is the failure this file has been bitten by.
1060    #[test]
1061    fn a_misspelled_key_is_refused() {
1062        let toml = "data_dir = \"/tmp\"\nmemtable_flush_threshhold = 1\n";
1063        assert!(toml::from_str::<ChronixConfig>(toml).is_err());
1064    }
1065
1066    #[test]
1067    fn builder_defaults_valid() {
1068        let config = ChronixConfig::builder().data_dir("./data").build().unwrap();
1069
1070        assert_eq!(config.data_dir, PathBuf::from("./data"));
1071        assert_eq!(config.wal.fsync_policy, FsyncPolicy::PerBatch);
1072        assert_eq!(config.wal.max_file_size, 32 * 1024 * 1024);
1073        assert_eq!(config.wal.max_unflushed_wals, 4);
1074        assert_eq!(config.memtable_flush_threshold, 64 * 1024 * 1024);
1075        assert_eq!(config.max_memtable_memory, 256 * 1024 * 1024);
1076        assert_eq!(config.shard_duration, Duration::from_secs(3600));
1077        assert_eq!(config.compression, CompressionCodec::Lz4);
1078        assert_eq!(config.float_encoding, FloatEncoding::Chimp);
1079        assert!(!config.enable_last_value_cache);
1080        assert_eq!(config.compaction_concurrency, 2);
1081        assert_eq!(config.max_series_cardinality, 1_000_000);
1082    }
1083
1084    #[test]
1085    fn builder_missing_data_dir() {
1086        let err = ChronixConfig::builder().build().unwrap_err();
1087        assert!(matches!(
1088            err,
1089            ConfigError::MissingField { field: "data_dir" }
1090        ));
1091    }
1092
1093    #[test]
1094    fn builder_zero_flush_threshold() {
1095        let err = ChronixConfig::builder()
1096            .data_dir("./data")
1097            .memtable_flush_threshold(0)
1098            .build()
1099            .unwrap_err();
1100        assert!(err.to_string().contains("memtable_flush_threshold"));
1101    }
1102
1103    #[test]
1104    fn builder_memory_less_than_threshold() {
1105        let err = ChronixConfig::builder()
1106            .data_dir("./data")
1107            .memtable_flush_threshold(100)
1108            .max_memtable_memory(50)
1109            .build()
1110            .unwrap_err();
1111        assert!(err.to_string().contains("max_memtable_memory"));
1112    }
1113
1114    #[test]
1115    fn builder_retention_less_than_shard() {
1116        let err = ChronixConfig::builder()
1117            .data_dir("./data")
1118            .shard_duration(Duration::from_secs(3600))
1119            .retention(Some(Duration::from_secs(60)))
1120            .build()
1121            .unwrap_err();
1122        assert!(err.to_string().contains("retention"));
1123    }
1124
1125    #[test]
1126    fn builder_zero_shard_duration() {
1127        let err = ChronixConfig::builder()
1128            .data_dir("./data")
1129            .shard_duration(Duration::ZERO)
1130            .build()
1131            .unwrap_err();
1132        assert!(err.to_string().contains("shard_duration"));
1133    }
1134
1135    #[test]
1136    fn builder_no_retention() {
1137        let config = ChronixConfig::builder()
1138            .data_dir("./data")
1139            .retention(None)
1140            .build()
1141            .unwrap();
1142        assert!(config.retention.is_none());
1143    }
1144
1145    #[test]
1146    fn builder_custom_wal() {
1147        let config = ChronixConfig::builder()
1148            .data_dir("./data")
1149            .wal_fsync_policy(FsyncPolicy::PerWrite)
1150            .wal_max_file_size(16 * 1024 * 1024)
1151            .wal_max_unflushed(8)
1152            .build()
1153            .unwrap();
1154        assert_eq!(config.wal.fsync_policy, FsyncPolicy::PerWrite);
1155        assert_eq!(config.wal.max_file_size, 16 * 1024 * 1024);
1156        assert_eq!(config.wal.max_unflushed_wals, 8);
1157    }
1158
1159    #[test]
1160    fn config_serde_roundtrip() {
1161        let config = ChronixConfig::builder().data_dir("./data").build().unwrap();
1162        let json = serde_json::to_string(&config).unwrap();
1163        let back: ChronixConfig = serde_json::from_str(&json).unwrap();
1164        assert_eq!(config.data_dir, back.data_dir);
1165        assert_eq!(config.wal.max_file_size, back.wal.max_file_size);
1166    }
1167
1168    /// Miri runs with filesystem isolation, so the three tests that reach the
1169    /// filesystem carry their own `ignore` rather than being named in the CI
1170    /// command: a `--skip` list has to be edited every time such a test is
1171    /// added, and the one nobody remembered to add is the one that breaks the
1172    /// job.
1173    #[test]
1174    #[cfg_attr(miri, ignore = "reaches the filesystem")]
1175    fn config_toml_roundtrip() {
1176        let dir = tempfile::tempdir().unwrap();
1177        let path = dir.path().join("config.toml");
1178
1179        let config = ChronixConfig::builder().data_dir("./data").build().unwrap();
1180
1181        let toml_str = toml::to_string_pretty(&config).unwrap();
1182        std::fs::write(&path, &toml_str).unwrap();
1183
1184        let loaded = ChronixConfig::from_toml(&path).unwrap();
1185        assert_eq!(config.data_dir, loaded.data_dir);
1186    }
1187
1188    #[test]
1189    fn fsync_policy_default() {
1190        assert_eq!(FsyncPolicy::default(), FsyncPolicy::PerBatch);
1191    }
1192
1193    #[test]
1194    fn compression_codec_default() {
1195        assert_eq!(CompressionCodec::default(), CompressionCodec::Lz4);
1196    }
1197
1198    #[test]
1199    fn float_encoding_default() {
1200        assert_eq!(FloatEncoding::default(), FloatEncoding::Chimp);
1201    }
1202
1203    #[test]
1204    fn builder_zero_max_memtable_memory() {
1205        let err = ChronixConfig::builder()
1206            .data_dir("./data")
1207            .max_memtable_memory(0)
1208            .build()
1209            .unwrap_err();
1210        assert!(err.to_string().contains("max_memtable_memory"));
1211    }
1212
1213    #[test]
1214    fn builder_zero_compaction_concurrency() {
1215        let err = ChronixConfig::builder()
1216            .data_dir("./data")
1217            .compaction_concurrency(0)
1218            .build()
1219            .unwrap_err();
1220        assert!(err.to_string().contains("compaction_concurrency"));
1221    }
1222
1223    #[test]
1224    fn builder_zero_wal_max_file_size() {
1225        let err = ChronixConfig::builder()
1226            .data_dir("./data")
1227            .wal_max_file_size(0)
1228            .build()
1229            .unwrap_err();
1230        assert!(err.to_string().contains("max_file_size"));
1231    }
1232
1233    #[test]
1234    fn builder_zero_wal_max_unflushed() {
1235        let err = ChronixConfig::builder()
1236            .data_dir("./data")
1237            .wal_max_unflushed(0)
1238            .build()
1239            .unwrap_err();
1240        assert!(err.to_string().contains("max_unflushed"));
1241    }
1242
1243    #[test]
1244    #[cfg_attr(miri, ignore = "reaches the filesystem")]
1245    fn from_toml_nonexistent_file() {
1246        let err = ChronixConfig::from_toml(std::path::Path::new("/no/such/file.toml")).unwrap_err();
1247        assert!(matches!(err, ConfigError::Io(_)));
1248    }
1249
1250    #[test]
1251    #[cfg_attr(miri, ignore = "reaches the filesystem")]
1252    fn from_toml_invalid_content() {
1253        let dir = tempfile::tempdir().unwrap();
1254        let path = dir.path().join("bad.toml");
1255        std::fs::write(&path, "this is not valid toml [[[").unwrap();
1256        let err = ChronixConfig::from_toml(&path).unwrap_err();
1257        assert!(matches!(err, ConfigError::Parse(_)));
1258    }
1259
1260    #[test]
1261    fn fsync_policy_display() {
1262        assert_eq!(FsyncPolicy::PerWrite.to_string(), "per-write");
1263        assert_eq!(FsyncPolicy::PerBatch.to_string(), "per-batch");
1264        let periodic = FsyncPolicy::Periodic(Duration::from_millis(500));
1265        assert!(periodic.to_string().contains("500"));
1266    }
1267
1268    #[test]
1269    fn compression_codec_display() {
1270        assert_eq!(CompressionCodec::Lz4.to_string(), "lz4");
1271        assert_eq!(CompressionCodec::Zstd.to_string(), "zstd");
1272        assert_eq!(CompressionCodec::None.to_string(), "none");
1273    }
1274
1275    #[test]
1276    fn float_encoding_display() {
1277        assert_eq!(FloatEncoding::Chimp.to_string(), "chimp");
1278        assert_eq!(FloatEncoding::Gorilla.to_string(), "gorilla");
1279        assert_eq!(FloatEncoding::Plain.to_string(), "plain");
1280    }
1281
1282    #[test]
1283    fn wal_config_serde_roundtrip() {
1284        let config = WalConfig {
1285            fsync_policy: FsyncPolicy::Periodic(Duration::from_secs(5)),
1286            max_file_size: 16 * 1024 * 1024,
1287            max_unflushed_wals: 8,
1288            compress: true,
1289            group_sync_timeout_secs: 5,
1290        };
1291        let json = serde_json::to_string(&config).unwrap();
1292        let back: WalConfig = serde_json::from_str(&json).unwrap();
1293        assert_eq!(config, back);
1294    }
1295
1296    // ── Analytics validation ─────────────────────────────────────
1297
1298    #[test]
1299    fn validate_zero_max_forecast_horizon() {
1300        let analytics = AnalyticsConfig {
1301            max_forecast_horizon: 0,
1302            ..AnalyticsConfig::default()
1303        };
1304        let err = ChronixConfig::builder()
1305            .data_dir("./data")
1306            .analytics(analytics)
1307            .build()
1308            .unwrap_err();
1309        assert!(err.to_string().contains("max_forecast_horizon"));
1310    }
1311
1312    #[test]
1313    fn validate_zero_max_training_points() {
1314        let analytics = AnalyticsConfig {
1315            max_training_points: 0,
1316            ..AnalyticsConfig::default()
1317        };
1318        let err = ChronixConfig::builder()
1319            .data_dir("./data")
1320            .analytics(analytics)
1321            .build()
1322            .unwrap_err();
1323        assert!(err.to_string().contains("max_training_points"));
1324    }
1325
1326    #[test]
1327    fn small_preset_is_valid_and_bounded() {
1328        let cfg = ChronixConfig::small("/tmp/chronix-small");
1329        assert!(cfg.validate().is_ok());
1330        assert_eq!(cfg.memtable_flush_threshold, 8 * 1024 * 1024);
1331        assert_eq!(cfg.max_memtable_memory, 24 * 1024 * 1024);
1332        assert_eq!(cfg.segment_cache_size, 16 * 1024 * 1024);
1333        assert_eq!(cfg.compaction_concurrency, 1);
1334        assert!(matches!(cfg.wal.fsync_policy, FsyncPolicy::Periodic(_)));
1335        assert_eq!(cfg.wal.max_file_size, 8 * 1024 * 1024);
1336    }
1337}