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