1use 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#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
17pub enum FsyncPolicy {
18 PerWrite,
20 #[default]
22 PerBatch,
23 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
40pub enum CompressionCodec {
41 #[default]
43 Lz4,
44 Zstd,
46 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
62pub enum FloatEncoding {
63 #[default]
65 Chimp,
66 Gorilla,
68 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
84#[serde(deny_unknown_fields)]
85pub struct WalConfig {
86 pub fsync_policy: FsyncPolicy,
88 pub max_file_size: usize,
90 pub max_unflushed_wals: usize,
92 #[serde(default = "default_compress_wal")]
95 pub compress: bool,
96 #[serde(default = "default_group_sync_timeout_secs")]
99 pub group_sync_timeout_secs: u64,
100}
101
102const 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, max_unflushed_wals: 4,
125 compress: true,
126 group_sync_timeout_secs: 30,
127 }
128 }
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
137#[serde(deny_unknown_fields)]
138pub struct AnalyticsConfig {
139 #[serde(default = "default_forecast_model")]
141 pub default_forecast_model: String,
142 #[serde(default = "default_anomaly_method")]
144 pub default_anomaly_method: String,
145 #[serde(default = "default_confidence")]
147 pub default_confidence_level: f64,
148 #[serde(default = "default_max_horizon")]
150 pub max_forecast_horizon: usize,
151 #[serde(default = "default_max_training")]
153 pub max_training_points: usize,
154 #[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 }
180fn default_per_query_memory_limit() -> usize {
181 256 * 1024 * 1024 }
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
203const fn default_zstd_level() -> i32 {
207 3
208}
209
210#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
233#[serde(deny_unknown_fields)]
234pub struct Checkpoints {
235 #[serde(default)]
237 pub interval_secs: Option<u64>,
238 #[serde(default)]
240 pub directory: Option<PathBuf>,
241 #[serde(default = "default_checkpoints_keep")]
243 pub keep: usize,
244}
245
246const fn default_checkpoints_keep() -> usize {
248 3
249}
250
251impl Checkpoints {
252 #[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 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#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
301#[serde(deny_unknown_fields)]
302pub struct FieldEncryption {
303 #[serde(default)]
305 pub columns: BTreeMap<String, String>,
306 #[serde(default)]
308 pub keys: BTreeMap<String, String>,
309}
310
311impl FieldEncryption {
312 #[must_use]
314 pub fn is_empty(&self) -> bool {
315 self.columns.is_empty()
316 }
317
318 #[must_use]
320 pub fn covers(&self, column: &str) -> bool {
321 self.columns.contains_key(column)
322 }
323
324 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#[derive(Debug, Clone, Serialize, Deserialize)]
387#[serde(deny_unknown_fields, default)]
388pub struct ChronixConfig {
389 pub data_dir: PathBuf,
391 pub wal: WalConfig,
393 pub memtable_flush_threshold: usize,
395 pub max_memtable_memory: usize,
397 pub shard_duration: Duration,
399 pub ooo_shard_tolerance: u32,
401 pub retention: Option<Duration>,
408 pub measurement_retention: HashMap<String, Duration>,
410 pub compression: CompressionCodec,
412 #[serde(default = "default_zstd_level")]
417 pub zstd_level: i32,
418 #[serde(default)]
429 pub zstd_dict_training: bool,
430 pub float_encoding: FloatEncoding,
432 pub segment_cache_size: usize,
434 #[serde(default = "default_cdc_capacity")]
442 pub cdc_capacity: usize,
443 pub enable_last_value_cache: bool,
445 pub lvc_measurements: Option<HashSet<String>>,
449 pub compaction_concurrency: usize,
451 #[serde(default = "default_maintenance_interval")]
459 pub maintenance_interval: Duration,
460 pub max_series_cardinality: usize,
462 #[serde(default = "default_max_query_result_bytes")]
465 pub max_query_result_bytes: usize,
466 #[serde(default = "default_per_query_memory_limit")]
473 pub per_query_memory_limit: usize,
474 #[serde(default = "default_query_timeout")]
479 pub query_timeout: Duration,
480 #[serde(default)]
482 pub analytics: AnalyticsConfig,
483 #[serde(default)]
493 pub soft_delete_ttl: Option<Duration>,
494 #[serde(default = "default_future_write_tolerance")]
504 pub future_write_tolerance: Duration,
505 #[serde(default)]
510 pub field_encryption: FieldEncryption,
511 #[serde(default)]
517 pub checkpoints: Checkpoints,
518}
519
520impl Default for ChronixConfig {
521 fn default() -> Self {
522 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 #[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) .max_memtable_memory(24 * 1024 * 1024) .segment_cache_size(16 * 1024 * 1024) .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) .build()
565 .expect("BUG: the small-footprint preset must always validate")
566 }
567
568 #[must_use]
570 pub fn builder() -> ChronixConfigBuilder {
571 ChronixConfigBuilder::default()
572 }
573
574 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 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 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#[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, max_memtable_memory: 256 * 1024 * 1024, shard_duration: Duration::from_secs(3600), 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, 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 #[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 #[must_use]
744 pub fn wal_fsync_policy(mut self, policy: FsyncPolicy) -> Self {
745 self.wal.fsync_policy = policy;
746 self
747 }
748
749 #[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 #[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 #[must_use]
765 pub fn memtable_flush_threshold(mut self, size: usize) -> Self {
766 self.memtable_flush_threshold = size;
767 self
768 }
769
770 #[must_use]
772 pub fn max_memtable_memory(mut self, size: usize) -> Self {
773 self.max_memtable_memory = size;
774 self
775 }
776
777 #[must_use]
779 pub fn shard_duration(mut self, duration: Duration) -> Self {
780 self.shard_duration = duration;
781 self
782 }
783
784 #[must_use]
786 pub fn retention(mut self, duration: Option<Duration>) -> Self {
787 self.retention = duration;
788 self
789 }
790
791 #[must_use]
793 pub fn compression(mut self, codec: CompressionCodec) -> Self {
794 self.compression = codec;
795 self
796 }
797
798 #[must_use]
800 pub fn zstd_level(mut self, level: i32) -> Self {
801 self.zstd_level = level;
802 self
803 }
804
805 #[must_use]
807 pub fn zstd_dict_training(mut self, enabled: bool) -> Self {
808 self.zstd_dict_training = enabled;
809 self
810 }
811
812 #[must_use]
814 pub fn float_encoding(mut self, encoding: FloatEncoding) -> Self {
815 self.float_encoding = encoding;
816 self
817 }
818
819 #[must_use]
821 pub fn ooo_shard_tolerance(mut self, tolerance: u32) -> Self {
822 self.ooo_shard_tolerance = tolerance;
823 self
824 }
825
826 #[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 #[must_use]
840 pub fn compaction_concurrency(mut self, concurrency: usize) -> Self {
841 self.compaction_concurrency = concurrency;
842 self
843 }
844
845 #[must_use]
852 pub fn field_encryption(mut self, settings: FieldEncryption) -> Self {
853 self.field_encryption = settings;
854 self
855 }
856
857 #[must_use]
859 pub fn checkpoints(mut self, settings: Checkpoints) -> Self {
860 self.checkpoints = settings;
861 self
862 }
863
864 #[must_use]
868 pub fn maintenance_interval(mut self, interval: Duration) -> Self {
869 self.maintenance_interval = interval;
870 self
871 }
872
873 #[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 #[must_use]
885 pub fn lvc_measurements(mut self, measurements: HashSet<String>) -> Self {
886 self.lvc_measurements = Some(measurements);
887 self
888 }
889
890 #[must_use]
895 pub fn cdc_capacity(mut self, events: usize) -> Self {
896 self.cdc_capacity = events;
897 self
898 }
899
900 #[must_use]
902 pub fn segment_cache_size(mut self, size: usize) -> Self {
903 self.segment_cache_size = size;
904 self
905 }
906
907 #[must_use]
909 pub fn max_series_cardinality(mut self, max: usize) -> Self {
910 self.max_series_cardinality = max;
911 self
912 }
913
914 #[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 #[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 #[must_use]
930 pub fn query_timeout(mut self, timeout: Duration) -> Self {
931 self.query_timeout = timeout;
932 self
933 }
934
935 #[must_use]
937 pub fn analytics(mut self, config: AnalyticsConfig) -> Self {
938 self.analytics = config;
939 self
940 }
941
942 #[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 #[must_use]
954 pub fn future_write_tolerance(mut self, tolerance: Duration) -> Self {
955 self.future_write_tolerance = tolerance;
956 self
957 }
958
959 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 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 config.validate()?;
1004
1005 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 #[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 assert_eq!(config.shard_duration, Duration::from_secs(3600));
1041 assert_eq!(config.analytics.max_training_points, 1_000_000);
1042 }
1043
1044 #[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 #[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 #[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}