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)]
149#[serde(deny_unknown_fields)]
150pub struct AnalyticsConfig {
151 #[serde(default = "default_max_horizon")]
153 pub max_forecast_horizon: usize,
154 #[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 }
168fn default_per_query_memory_limit() -> usize {
169 256 * 1024 * 1024 }
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
187const fn default_zstd_level() -> i32 {
191 3
192}
193
194#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
217#[serde(deny_unknown_fields)]
218pub struct Checkpoints {
219 #[serde(default)]
221 pub interval_secs: Option<u64>,
222 #[serde(default)]
224 pub directory: Option<PathBuf>,
225 #[serde(default = "default_checkpoints_keep")]
227 pub keep: usize,
228}
229
230const fn default_checkpoints_keep() -> usize {
232 3
233}
234
235impl Checkpoints {
236 #[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 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#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
285#[serde(deny_unknown_fields)]
286pub struct FieldEncryption {
287 #[serde(default)]
289 pub columns: BTreeMap<String, String>,
290 #[serde(default)]
292 pub keys: BTreeMap<String, String>,
293}
294
295impl FieldEncryption {
296 #[must_use]
298 pub fn is_empty(&self) -> bool {
299 self.columns.is_empty()
300 }
301
302 #[must_use]
304 pub fn covers(&self, column: &str) -> bool {
305 self.columns.contains_key(column)
306 }
307
308 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#[derive(Debug, Clone, Serialize, Deserialize)]
371#[serde(deny_unknown_fields, default)]
372pub struct ChronixConfig {
373 pub data_dir: PathBuf,
375 pub wal: WalConfig,
377 pub memtable_flush_threshold: usize,
379 pub max_memtable_memory: usize,
381 pub shard_duration: Duration,
383 pub ooo_shard_tolerance: u32,
385 pub retention: Option<Duration>,
392 pub measurement_retention: HashMap<String, Duration>,
394 pub compression: CompressionCodec,
396 #[serde(default = "default_zstd_level")]
401 pub zstd_level: i32,
402 #[serde(default)]
413 pub zstd_dict_training: bool,
414 pub float_encoding: FloatEncoding,
416 pub segment_cache_size: usize,
418 #[serde(default = "default_cdc_capacity")]
426 pub cdc_capacity: usize,
427 pub enable_last_value_cache: bool,
429 pub lvc_measurements: Option<HashSet<String>>,
433 pub compaction_concurrency: usize,
435 #[serde(default = "default_maintenance_interval")]
443 pub maintenance_interval: Duration,
444 pub max_series_cardinality: usize,
446 #[serde(default = "default_max_query_result_bytes")]
449 pub max_query_result_bytes: usize,
450 #[serde(default = "default_per_query_memory_limit")]
457 pub per_query_memory_limit: usize,
458 #[serde(default = "default_query_timeout")]
463 pub query_timeout: Duration,
464 #[serde(default)]
466 pub analytics: AnalyticsConfig,
467 #[serde(default)]
477 pub soft_delete_ttl: Option<Duration>,
478 #[serde(default = "default_future_write_tolerance")]
488 pub future_write_tolerance: Duration,
489 #[serde(default)]
494 pub field_encryption: FieldEncryption,
495 #[serde(default)]
501 pub checkpoints: Checkpoints,
502}
503
504impl Default for ChronixConfig {
505 fn default() -> Self {
506 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 #[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) .max_memtable_memory(24 * 1024 * 1024) .segment_cache_size(16 * 1024 * 1024) .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) .build()
549 .expect("BUG: the small-footprint preset must always validate")
550 }
551
552 #[must_use]
554 pub fn builder() -> ChronixConfigBuilder {
555 ChronixConfigBuilder::default()
556 }
557
558 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 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 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#[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, max_memtable_memory: 256 * 1024 * 1024, shard_duration: Duration::from_secs(3600), 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, 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 #[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 #[must_use]
728 pub fn wal_fsync_policy(mut self, policy: FsyncPolicy) -> Self {
729 self.wal.fsync_policy = policy;
730 self
731 }
732
733 #[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 #[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 #[must_use]
749 pub fn memtable_flush_threshold(mut self, size: usize) -> Self {
750 self.memtable_flush_threshold = size;
751 self
752 }
753
754 #[must_use]
756 pub fn max_memtable_memory(mut self, size: usize) -> Self {
757 self.max_memtable_memory = size;
758 self
759 }
760
761 #[must_use]
763 pub fn shard_duration(mut self, duration: Duration) -> Self {
764 self.shard_duration = duration;
765 self
766 }
767
768 #[must_use]
770 pub fn retention(mut self, duration: Option<Duration>) -> Self {
771 self.retention = duration;
772 self
773 }
774
775 #[must_use]
777 pub fn compression(mut self, codec: CompressionCodec) -> Self {
778 self.compression = codec;
779 self
780 }
781
782 #[must_use]
784 pub fn zstd_level(mut self, level: i32) -> Self {
785 self.zstd_level = level;
786 self
787 }
788
789 #[must_use]
791 pub fn zstd_dict_training(mut self, enabled: bool) -> Self {
792 self.zstd_dict_training = enabled;
793 self
794 }
795
796 #[must_use]
798 pub fn float_encoding(mut self, encoding: FloatEncoding) -> Self {
799 self.float_encoding = encoding;
800 self
801 }
802
803 #[must_use]
805 pub fn ooo_shard_tolerance(mut self, tolerance: u32) -> Self {
806 self.ooo_shard_tolerance = tolerance;
807 self
808 }
809
810 #[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 #[must_use]
824 pub fn compaction_concurrency(mut self, concurrency: usize) -> Self {
825 self.compaction_concurrency = concurrency;
826 self
827 }
828
829 #[must_use]
836 pub fn field_encryption(mut self, settings: FieldEncryption) -> Self {
837 self.field_encryption = settings;
838 self
839 }
840
841 #[must_use]
843 pub fn checkpoints(mut self, settings: Checkpoints) -> Self {
844 self.checkpoints = settings;
845 self
846 }
847
848 #[must_use]
852 pub fn maintenance_interval(mut self, interval: Duration) -> Self {
853 self.maintenance_interval = interval;
854 self
855 }
856
857 #[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 #[must_use]
869 pub fn lvc_measurements(mut self, measurements: HashSet<String>) -> Self {
870 self.lvc_measurements = Some(measurements);
871 self
872 }
873
874 #[must_use]
879 pub fn cdc_capacity(mut self, events: usize) -> Self {
880 self.cdc_capacity = events;
881 self
882 }
883
884 #[must_use]
886 pub fn segment_cache_size(mut self, size: usize) -> Self {
887 self.segment_cache_size = size;
888 self
889 }
890
891 #[must_use]
893 pub fn max_series_cardinality(mut self, max: usize) -> Self {
894 self.max_series_cardinality = max;
895 self
896 }
897
898 #[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 #[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 #[must_use]
914 pub fn query_timeout(mut self, timeout: Duration) -> Self {
915 self.query_timeout = timeout;
916 self
917 }
918
919 #[must_use]
921 pub fn analytics(mut self, config: AnalyticsConfig) -> Self {
922 self.analytics = config;
923 self
924 }
925
926 #[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 #[must_use]
938 pub fn future_write_tolerance(mut self, tolerance: Duration) -> Self {
939 self.future_write_tolerance = tolerance;
940 self
941 }
942
943 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 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 config.validate()?;
988
989 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 #[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 #[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 assert_eq!(config.shard_duration, Duration::from_secs(3600));
1055 assert_eq!(config.analytics.max_training_points, 1_000_000);
1056 }
1057
1058 #[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 #[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 #[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}