1use std::collections::{HashMap, HashSet};
7use std::fmt;
8use std::path::{Path, PathBuf};
9use std::time::Duration;
10
11use serde::{Deserialize, Serialize};
12
13use crate::error::ConfigError;
14
15#[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, Serialize, Deserialize)]
225#[serde(deny_unknown_fields, default)]
226pub struct ChronixConfig {
227 pub data_dir: PathBuf,
229 pub wal: WalConfig,
231 pub memtable_flush_threshold: usize,
233 pub max_memtable_memory: usize,
235 pub shard_duration: Duration,
237 pub ooo_shard_tolerance: u32,
239 pub retention: Option<Duration>,
246 pub measurement_retention: HashMap<String, Duration>,
248 pub compression: CompressionCodec,
250 #[serde(default = "default_zstd_level")]
255 pub zstd_level: i32,
256 #[serde(default)]
267 pub zstd_dict_training: bool,
268 pub float_encoding: FloatEncoding,
270 pub segment_cache_size: usize,
272 #[serde(default = "default_cdc_capacity")]
280 pub cdc_capacity: usize,
281 pub enable_last_value_cache: bool,
283 pub lvc_measurements: Option<HashSet<String>>,
287 pub compaction_concurrency: usize,
289 #[serde(default = "default_maintenance_interval")]
297 pub maintenance_interval: Duration,
298 pub max_series_cardinality: usize,
300 #[serde(default = "default_max_query_result_bytes")]
303 pub max_query_result_bytes: usize,
304 #[serde(default = "default_per_query_memory_limit")]
311 pub per_query_memory_limit: usize,
312 #[serde(default = "default_query_timeout")]
317 pub query_timeout: Duration,
318 #[serde(default)]
320 pub analytics: AnalyticsConfig,
321 #[serde(default)]
331 pub soft_delete_ttl: Option<Duration>,
332 #[serde(default = "default_future_write_tolerance")]
342 pub future_write_tolerance: Duration,
343}
344
345impl Default for ChronixConfig {
346 fn default() -> Self {
347 ChronixConfigBuilder::default()
351 .data_dir("./chronix-data")
352 .build()
353 .expect("the builder's own defaults must be valid")
354 }
355}
356
357impl ChronixConfig {
358 #[must_use]
375 pub fn small(data_dir: impl Into<PathBuf>) -> Self {
376 Self::builder()
377 .data_dir(data_dir)
378 .memtable_flush_threshold(8 * 1024 * 1024) .max_memtable_memory(24 * 1024 * 1024) .segment_cache_size(16 * 1024 * 1024) .cdc_capacity(4_096)
385 .compaction_concurrency(1)
386 .maintenance_interval(Duration::from_secs(60))
387 .wal_fsync_policy(FsyncPolicy::Periodic(Duration::from_secs(5)))
388 .wal_max_file_size(8 * 1024 * 1024) .build()
390 .expect("BUG: the small-footprint preset must always validate")
391 }
392
393 #[must_use]
395 pub fn builder() -> ChronixConfigBuilder {
396 ChronixConfigBuilder::default()
397 }
398
399 pub fn from_toml(path: &Path) -> Result<Self, ConfigError> {
406 let content = std::fs::read_to_string(path)?;
407 let config: Self = toml::from_str(&content)?;
408 config.validate()?;
409 Ok(config)
410 }
411
412 pub fn validate(&self) -> Result<(), ConfigError> {
421 if self.memtable_flush_threshold == 0 {
422 return Err(ConfigError::Validation {
423 message: "memtable_flush_threshold must be > 0".into(),
424 });
425 }
426 if self.max_memtable_memory == 0 {
427 return Err(ConfigError::Validation {
428 message: "max_memtable_memory must be > 0".into(),
429 });
430 }
431 if self.max_memtable_memory < self.memtable_flush_threshold {
432 return Err(ConfigError::Validation {
433 message: format!(
434 "max_memtable_memory ({}) must be >= memtable_flush_threshold ({})",
435 self.max_memtable_memory, self.memtable_flush_threshold
436 ),
437 });
438 }
439 if self.shard_duration.is_zero() {
440 return Err(ConfigError::Validation {
441 message: "shard_duration must be > 0".into(),
442 });
443 }
444 if let Some(retention) = &self.retention {
445 if *retention < self.shard_duration {
446 return Err(ConfigError::Validation {
447 message: format!(
448 "retention ({retention:?}) must be >= shard_duration ({:?})",
449 self.shard_duration
450 ),
451 });
452 }
453 }
454 if self.compaction_concurrency == 0 {
455 return Err(ConfigError::Validation {
456 message: "compaction_concurrency must be > 0".into(),
457 });
458 }
459 if self.max_series_cardinality == 0 {
460 return Err(ConfigError::Validation {
461 message: "max_series_cardinality must be > 0".into(),
462 });
463 }
464 if self.wal.max_file_size == 0 {
465 return Err(ConfigError::Validation {
466 message: "WAL max_file_size must be > 0".into(),
467 });
468 }
469 if self.wal.max_unflushed_wals == 0 {
470 return Err(ConfigError::Validation {
471 message: "WAL max_unflushed_wals must be > 0".into(),
472 });
473 }
474
475 if self.analytics.max_forecast_horizon == 0 {
478 return Err(ConfigError::Validation {
479 message: "analytics.max_forecast_horizon must be > 0".into(),
480 });
481 }
482 if self.analytics.max_training_points == 0 {
483 return Err(ConfigError::Validation {
484 message: "analytics.max_training_points must be > 0".into(),
485 });
486 }
487 Ok(())
488 }
489}
490
491#[derive(Debug, Clone)]
493pub struct ChronixConfigBuilder {
494 data_dir: Option<PathBuf>,
495 wal: WalConfig,
496 memtable_flush_threshold: usize,
497 max_memtable_memory: usize,
498 shard_duration: Duration,
499 ooo_shard_tolerance: u32,
500 retention: Option<Duration>,
501 measurement_retention: HashMap<String, Duration>,
502 compression: CompressionCodec,
503 zstd_level: i32,
504 zstd_dict_training: bool,
505 float_encoding: FloatEncoding,
506 segment_cache_size: usize,
507 cdc_capacity: usize,
508 enable_last_value_cache: bool,
509 lvc_measurements: Option<HashSet<String>>,
510 compaction_concurrency: usize,
511 maintenance_interval: Duration,
512 max_series_cardinality: usize,
513 max_query_result_bytes: usize,
514 per_query_memory_limit: usize,
515 query_timeout: Duration,
516 analytics: AnalyticsConfig,
517 soft_delete_ttl: Option<Duration>,
518 future_write_tolerance: Duration,
519}
520
521impl Default for ChronixConfigBuilder {
522 fn default() -> Self {
523 Self {
524 data_dir: None,
525 wal: WalConfig::default(),
526 memtable_flush_threshold: 64 * 1024 * 1024, max_memtable_memory: 256 * 1024 * 1024, shard_duration: Duration::from_secs(3600), ooo_shard_tolerance: 2,
530 retention: None,
531 measurement_retention: HashMap::new(),
532 compression: CompressionCodec::default(),
533 zstd_level: default_zstd_level(),
534 zstd_dict_training: false,
535 float_encoding: FloatEncoding::default(),
536 segment_cache_size: 512 * 1024 * 1024, cdc_capacity: default_cdc_capacity(),
538 enable_last_value_cache: false,
539 lvc_measurements: None,
540 compaction_concurrency: 2,
541 maintenance_interval: default_maintenance_interval(),
542 max_series_cardinality: 1_000_000,
543 max_query_result_bytes: default_max_query_result_bytes(),
544 per_query_memory_limit: default_per_query_memory_limit(),
545 query_timeout: default_query_timeout(),
546 analytics: AnalyticsConfig::default(),
547 soft_delete_ttl: None,
548 future_write_tolerance: default_future_write_tolerance(),
549 }
550 }
551}
552
553impl ChronixConfigBuilder {
554 #[must_use]
556 pub fn data_dir(mut self, path: impl Into<PathBuf>) -> Self {
557 self.data_dir = Some(path.into());
558 self
559 }
560
561 #[must_use]
563 pub fn wal_fsync_policy(mut self, policy: FsyncPolicy) -> Self {
564 self.wal.fsync_policy = policy;
565 self
566 }
567
568 #[must_use]
570 pub fn wal_max_file_size(mut self, size: usize) -> Self {
571 self.wal.max_file_size = size;
572 self
573 }
574
575 #[must_use]
577 pub fn wal_max_unflushed(mut self, count: usize) -> Self {
578 self.wal.max_unflushed_wals = count;
579 self
580 }
581
582 #[must_use]
584 pub fn memtable_flush_threshold(mut self, size: usize) -> Self {
585 self.memtable_flush_threshold = size;
586 self
587 }
588
589 #[must_use]
591 pub fn max_memtable_memory(mut self, size: usize) -> Self {
592 self.max_memtable_memory = size;
593 self
594 }
595
596 #[must_use]
598 pub fn shard_duration(mut self, duration: Duration) -> Self {
599 self.shard_duration = duration;
600 self
601 }
602
603 #[must_use]
605 pub fn retention(mut self, duration: Option<Duration>) -> Self {
606 self.retention = duration;
607 self
608 }
609
610 #[must_use]
612 pub fn compression(mut self, codec: CompressionCodec) -> Self {
613 self.compression = codec;
614 self
615 }
616
617 #[must_use]
619 pub fn zstd_level(mut self, level: i32) -> Self {
620 self.zstd_level = level;
621 self
622 }
623
624 #[must_use]
626 pub fn zstd_dict_training(mut self, enabled: bool) -> Self {
627 self.zstd_dict_training = enabled;
628 self
629 }
630
631 #[must_use]
633 pub fn float_encoding(mut self, encoding: FloatEncoding) -> Self {
634 self.float_encoding = encoding;
635 self
636 }
637
638 #[must_use]
640 pub fn ooo_shard_tolerance(mut self, tolerance: u32) -> Self {
641 self.ooo_shard_tolerance = tolerance;
642 self
643 }
644
645 #[must_use]
647 pub fn measurement_retention(
648 mut self,
649 measurement: impl Into<String>,
650 duration: Duration,
651 ) -> Self {
652 self.measurement_retention
653 .insert(measurement.into(), duration);
654 self
655 }
656
657 #[must_use]
659 pub fn compaction_concurrency(mut self, concurrency: usize) -> Self {
660 self.compaction_concurrency = concurrency;
661 self
662 }
663
664 #[must_use]
668 pub fn maintenance_interval(mut self, interval: Duration) -> Self {
669 self.maintenance_interval = interval;
670 self
671 }
672
673 #[must_use]
675 pub fn enable_last_value_cache(mut self, enable: bool) -> Self {
676 self.enable_last_value_cache = enable;
677 self
678 }
679
680 #[must_use]
685 pub fn lvc_measurements(mut self, measurements: HashSet<String>) -> Self {
686 self.lvc_measurements = Some(measurements);
687 self
688 }
689
690 #[must_use]
695 pub fn cdc_capacity(mut self, events: usize) -> Self {
696 self.cdc_capacity = events;
697 self
698 }
699
700 #[must_use]
702 pub fn segment_cache_size(mut self, size: usize) -> Self {
703 self.segment_cache_size = size;
704 self
705 }
706
707 #[must_use]
709 pub fn max_series_cardinality(mut self, max: usize) -> Self {
710 self.max_series_cardinality = max;
711 self
712 }
713
714 #[must_use]
716 pub fn max_query_result_bytes(mut self, max: usize) -> Self {
717 self.max_query_result_bytes = max;
718 self
719 }
720
721 #[must_use]
723 pub fn per_query_memory_limit(mut self, limit: usize) -> Self {
724 self.per_query_memory_limit = limit;
725 self
726 }
727
728 #[must_use]
730 pub fn query_timeout(mut self, timeout: Duration) -> Self {
731 self.query_timeout = timeout;
732 self
733 }
734
735 #[must_use]
737 pub fn analytics(mut self, config: AnalyticsConfig) -> Self {
738 self.analytics = config;
739 self
740 }
741
742 #[must_use]
747 pub fn soft_delete_ttl(mut self, ttl: Option<Duration>) -> Self {
748 self.soft_delete_ttl = ttl;
749 self
750 }
751
752 #[must_use]
754 pub fn future_write_tolerance(mut self, tolerance: Duration) -> Self {
755 self.future_write_tolerance = tolerance;
756 self
757 }
758
759 pub fn build(self) -> Result<ChronixConfig, ConfigError> {
766 let data_dir = self
767 .data_dir
768 .ok_or(ConfigError::MissingField { field: "data_dir" })?;
769
770 let config = ChronixConfig {
773 data_dir,
774 wal: self.wal,
775 memtable_flush_threshold: self.memtable_flush_threshold,
776 max_memtable_memory: self.max_memtable_memory,
777 shard_duration: self.shard_duration,
778 ooo_shard_tolerance: self.ooo_shard_tolerance,
779 retention: self.retention,
780 measurement_retention: self.measurement_retention,
781 compression: self.compression,
782 zstd_level: self.zstd_level,
783 zstd_dict_training: self.zstd_dict_training,
784 float_encoding: self.float_encoding,
785 segment_cache_size: self.segment_cache_size,
786 cdc_capacity: self.cdc_capacity,
787 enable_last_value_cache: self.enable_last_value_cache,
788 lvc_measurements: self.lvc_measurements,
789 compaction_concurrency: self.compaction_concurrency,
790 maintenance_interval: self.maintenance_interval,
791 max_series_cardinality: self.max_series_cardinality,
792 max_query_result_bytes: self.max_query_result_bytes,
793 per_query_memory_limit: self.per_query_memory_limit,
794 query_timeout: self.query_timeout,
795 analytics: self.analytics,
796 soft_delete_ttl: self.soft_delete_ttl,
797 future_write_tolerance: self.future_write_tolerance,
798 };
799
800 config.validate()?;
802
803 if config.segment_cache_size == 0 {
807 tracing::warn!("segment_cache_size is 0 — segment caching is effectively disabled");
808 }
809
810 Ok(config)
811 }
812}
813
814#[cfg(test)]
815mod tests {
816 use super::*;
817
818 #[test]
825 fn a_partial_toml_file_loads_with_defaults_for_the_rest() {
826 let toml = r#"
827 data_dir = "/var/lib/chronix"
828 memtable_flush_threshold = 8388608
829
830 [analytics]
831 max_forecast_horizon = 720
832 "#;
833 let config: ChronixConfig = toml::from_str(toml).expect("a partial file must load");
834 assert_eq!(config.data_dir, PathBuf::from("/var/lib/chronix"));
835 assert_eq!(config.memtable_flush_threshold, 8 * 1024 * 1024);
836 assert_eq!(config.analytics.max_forecast_horizon, 720);
837 assert_eq!(config.shard_duration, Duration::from_secs(3600));
839 assert_eq!(config.analytics.max_training_points, 1_000_000);
840 }
841
842 #[test]
845 fn a_misspelled_key_is_refused() {
846 let toml = "data_dir = \"/tmp\"\nmemtable_flush_threshhold = 1\n";
847 assert!(toml::from_str::<ChronixConfig>(toml).is_err());
848 }
849
850 #[test]
851 fn builder_defaults_valid() {
852 let config = ChronixConfig::builder().data_dir("./data").build().unwrap();
853
854 assert_eq!(config.data_dir, PathBuf::from("./data"));
855 assert_eq!(config.wal.fsync_policy, FsyncPolicy::PerBatch);
856 assert_eq!(config.wal.max_file_size, 32 * 1024 * 1024);
857 assert_eq!(config.wal.max_unflushed_wals, 4);
858 assert_eq!(config.memtable_flush_threshold, 64 * 1024 * 1024);
859 assert_eq!(config.max_memtable_memory, 256 * 1024 * 1024);
860 assert_eq!(config.shard_duration, Duration::from_secs(3600));
861 assert_eq!(config.compression, CompressionCodec::Lz4);
862 assert_eq!(config.float_encoding, FloatEncoding::Chimp);
863 assert!(!config.enable_last_value_cache);
864 assert_eq!(config.compaction_concurrency, 2);
865 assert_eq!(config.max_series_cardinality, 1_000_000);
866 }
867
868 #[test]
869 fn builder_missing_data_dir() {
870 let err = ChronixConfig::builder().build().unwrap_err();
871 assert!(matches!(
872 err,
873 ConfigError::MissingField { field: "data_dir" }
874 ));
875 }
876
877 #[test]
878 fn builder_zero_flush_threshold() {
879 let err = ChronixConfig::builder()
880 .data_dir("./data")
881 .memtable_flush_threshold(0)
882 .build()
883 .unwrap_err();
884 assert!(err.to_string().contains("memtable_flush_threshold"));
885 }
886
887 #[test]
888 fn builder_memory_less_than_threshold() {
889 let err = ChronixConfig::builder()
890 .data_dir("./data")
891 .memtable_flush_threshold(100)
892 .max_memtable_memory(50)
893 .build()
894 .unwrap_err();
895 assert!(err.to_string().contains("max_memtable_memory"));
896 }
897
898 #[test]
899 fn builder_retention_less_than_shard() {
900 let err = ChronixConfig::builder()
901 .data_dir("./data")
902 .shard_duration(Duration::from_secs(3600))
903 .retention(Some(Duration::from_secs(60)))
904 .build()
905 .unwrap_err();
906 assert!(err.to_string().contains("retention"));
907 }
908
909 #[test]
910 fn builder_zero_shard_duration() {
911 let err = ChronixConfig::builder()
912 .data_dir("./data")
913 .shard_duration(Duration::ZERO)
914 .build()
915 .unwrap_err();
916 assert!(err.to_string().contains("shard_duration"));
917 }
918
919 #[test]
920 fn builder_no_retention() {
921 let config = ChronixConfig::builder()
922 .data_dir("./data")
923 .retention(None)
924 .build()
925 .unwrap();
926 assert!(config.retention.is_none());
927 }
928
929 #[test]
930 fn builder_custom_wal() {
931 let config = ChronixConfig::builder()
932 .data_dir("./data")
933 .wal_fsync_policy(FsyncPolicy::PerWrite)
934 .wal_max_file_size(16 * 1024 * 1024)
935 .wal_max_unflushed(8)
936 .build()
937 .unwrap();
938 assert_eq!(config.wal.fsync_policy, FsyncPolicy::PerWrite);
939 assert_eq!(config.wal.max_file_size, 16 * 1024 * 1024);
940 assert_eq!(config.wal.max_unflushed_wals, 8);
941 }
942
943 #[test]
944 fn config_serde_roundtrip() {
945 let config = ChronixConfig::builder().data_dir("./data").build().unwrap();
946 let json = serde_json::to_string(&config).unwrap();
947 let back: ChronixConfig = serde_json::from_str(&json).unwrap();
948 assert_eq!(config.data_dir, back.data_dir);
949 assert_eq!(config.wal.max_file_size, back.wal.max_file_size);
950 }
951
952 #[test]
958 #[cfg_attr(miri, ignore = "reaches the filesystem")]
959 fn config_toml_roundtrip() {
960 let dir = tempfile::tempdir().unwrap();
961 let path = dir.path().join("config.toml");
962
963 let config = ChronixConfig::builder().data_dir("./data").build().unwrap();
964
965 let toml_str = toml::to_string_pretty(&config).unwrap();
966 std::fs::write(&path, &toml_str).unwrap();
967
968 let loaded = ChronixConfig::from_toml(&path).unwrap();
969 assert_eq!(config.data_dir, loaded.data_dir);
970 }
971
972 #[test]
973 fn fsync_policy_default() {
974 assert_eq!(FsyncPolicy::default(), FsyncPolicy::PerBatch);
975 }
976
977 #[test]
978 fn compression_codec_default() {
979 assert_eq!(CompressionCodec::default(), CompressionCodec::Lz4);
980 }
981
982 #[test]
983 fn float_encoding_default() {
984 assert_eq!(FloatEncoding::default(), FloatEncoding::Chimp);
985 }
986
987 #[test]
988 fn builder_zero_max_memtable_memory() {
989 let err = ChronixConfig::builder()
990 .data_dir("./data")
991 .max_memtable_memory(0)
992 .build()
993 .unwrap_err();
994 assert!(err.to_string().contains("max_memtable_memory"));
995 }
996
997 #[test]
998 fn builder_zero_compaction_concurrency() {
999 let err = ChronixConfig::builder()
1000 .data_dir("./data")
1001 .compaction_concurrency(0)
1002 .build()
1003 .unwrap_err();
1004 assert!(err.to_string().contains("compaction_concurrency"));
1005 }
1006
1007 #[test]
1008 fn builder_zero_wal_max_file_size() {
1009 let err = ChronixConfig::builder()
1010 .data_dir("./data")
1011 .wal_max_file_size(0)
1012 .build()
1013 .unwrap_err();
1014 assert!(err.to_string().contains("max_file_size"));
1015 }
1016
1017 #[test]
1018 fn builder_zero_wal_max_unflushed() {
1019 let err = ChronixConfig::builder()
1020 .data_dir("./data")
1021 .wal_max_unflushed(0)
1022 .build()
1023 .unwrap_err();
1024 assert!(err.to_string().contains("max_unflushed"));
1025 }
1026
1027 #[test]
1028 #[cfg_attr(miri, ignore = "reaches the filesystem")]
1029 fn from_toml_nonexistent_file() {
1030 let err = ChronixConfig::from_toml(std::path::Path::new("/no/such/file.toml")).unwrap_err();
1031 assert!(matches!(err, ConfigError::Io(_)));
1032 }
1033
1034 #[test]
1035 #[cfg_attr(miri, ignore = "reaches the filesystem")]
1036 fn from_toml_invalid_content() {
1037 let dir = tempfile::tempdir().unwrap();
1038 let path = dir.path().join("bad.toml");
1039 std::fs::write(&path, "this is not valid toml [[[").unwrap();
1040 let err = ChronixConfig::from_toml(&path).unwrap_err();
1041 assert!(matches!(err, ConfigError::Parse(_)));
1042 }
1043
1044 #[test]
1045 fn fsync_policy_display() {
1046 assert_eq!(FsyncPolicy::PerWrite.to_string(), "per-write");
1047 assert_eq!(FsyncPolicy::PerBatch.to_string(), "per-batch");
1048 let periodic = FsyncPolicy::Periodic(Duration::from_millis(500));
1049 assert!(periodic.to_string().contains("500"));
1050 }
1051
1052 #[test]
1053 fn compression_codec_display() {
1054 assert_eq!(CompressionCodec::Lz4.to_string(), "lz4");
1055 assert_eq!(CompressionCodec::Zstd.to_string(), "zstd");
1056 assert_eq!(CompressionCodec::None.to_string(), "none");
1057 }
1058
1059 #[test]
1060 fn float_encoding_display() {
1061 assert_eq!(FloatEncoding::Chimp.to_string(), "chimp");
1062 assert_eq!(FloatEncoding::Gorilla.to_string(), "gorilla");
1063 assert_eq!(FloatEncoding::Plain.to_string(), "plain");
1064 }
1065
1066 #[test]
1067 fn wal_config_serde_roundtrip() {
1068 let config = WalConfig {
1069 fsync_policy: FsyncPolicy::Periodic(Duration::from_secs(5)),
1070 max_file_size: 16 * 1024 * 1024,
1071 max_unflushed_wals: 8,
1072 compress: true,
1073 group_sync_timeout_secs: 5,
1074 };
1075 let json = serde_json::to_string(&config).unwrap();
1076 let back: WalConfig = serde_json::from_str(&json).unwrap();
1077 assert_eq!(config, back);
1078 }
1079
1080 #[test]
1083 fn validate_zero_max_forecast_horizon() {
1084 let analytics = AnalyticsConfig {
1085 max_forecast_horizon: 0,
1086 ..AnalyticsConfig::default()
1087 };
1088 let err = ChronixConfig::builder()
1089 .data_dir("./data")
1090 .analytics(analytics)
1091 .build()
1092 .unwrap_err();
1093 assert!(err.to_string().contains("max_forecast_horizon"));
1094 }
1095
1096 #[test]
1097 fn validate_zero_max_training_points() {
1098 let analytics = AnalyticsConfig {
1099 max_training_points: 0,
1100 ..AnalyticsConfig::default()
1101 };
1102 let err = ChronixConfig::builder()
1103 .data_dir("./data")
1104 .analytics(analytics)
1105 .build()
1106 .unwrap_err();
1107 assert!(err.to_string().contains("max_training_points"));
1108 }
1109
1110 #[test]
1111 fn small_preset_is_valid_and_bounded() {
1112 let cfg = ChronixConfig::small("/tmp/chronix-small");
1113 assert!(cfg.validate().is_ok());
1114 assert_eq!(cfg.memtable_flush_threshold, 8 * 1024 * 1024);
1115 assert_eq!(cfg.max_memtable_memory, 24 * 1024 * 1024);
1116 assert_eq!(cfg.segment_cache_size, 16 * 1024 * 1024);
1117 assert_eq!(cfg.compaction_concurrency, 1);
1118 assert!(matches!(cfg.wal.fsync_policy, FsyncPolicy::Periodic(_)));
1119 assert_eq!(cfg.wal.max_file_size, 8 * 1024 * 1024);
1120 }
1121}