use std::collections::{BTreeMap, HashMap, HashSet};
use std::fmt;
use std::path::{Path, PathBuf};
use std::time::Duration;
use serde::{Deserialize, Serialize};
use crate::error::ConfigError;
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub enum FsyncPolicy {
PerWrite,
#[default]
PerBatch,
Periodic(Duration),
}
impl fmt::Display for FsyncPolicy {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::PerWrite => write!(f, "per-write"),
Self::PerBatch => write!(f, "per-batch"),
Self::Periodic(d) => write!(f, "periodic({d:?})"),
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum CompressionCodec {
#[default]
Lz4,
Zstd,
None,
}
impl fmt::Display for CompressionCodec {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Lz4 => write!(f, "lz4"),
Self::Zstd => write!(f, "zstd"),
Self::None => write!(f, "none"),
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum FloatEncoding {
#[default]
Chimp,
Gorilla,
Plain,
}
impl fmt::Display for FloatEncoding {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Chimp => write!(f, "chimp"),
Self::Gorilla => write!(f, "gorilla"),
Self::Plain => write!(f, "plain"),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WalConfig {
pub fsync_policy: FsyncPolicy,
pub max_file_size: usize,
pub max_unflushed_wals: usize,
#[serde(default = "default_compress_wal")]
pub compress: bool,
#[serde(default = "default_group_sync_timeout_secs")]
pub group_sync_timeout_secs: u64,
}
const fn default_cdc_capacity() -> usize {
65_536
}
fn default_maintenance_interval() -> Duration {
Duration::from_secs(30)
}
fn default_compress_wal() -> bool {
true
}
fn default_group_sync_timeout_secs() -> u64 {
30
}
impl Default for WalConfig {
fn default() -> Self {
Self {
fsync_policy: FsyncPolicy::default(),
max_file_size: 32 * 1024 * 1024, max_unflushed_wals: 4,
compress: true,
group_sync_timeout_secs: 30,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AnalyticsConfig {
#[serde(default = "default_max_horizon")]
pub max_forecast_horizon: usize,
#[serde(default = "default_max_training")]
pub max_training_points: usize,
}
fn default_max_horizon() -> usize {
8760
}
fn default_max_training() -> usize {
1_000_000
}
fn default_max_query_result_bytes() -> usize {
256 * 1024 * 1024 }
fn default_per_query_memory_limit() -> usize {
256 * 1024 * 1024 }
fn default_query_timeout() -> Duration {
Duration::from_secs(60)
}
fn default_future_write_tolerance() -> Duration {
Duration::from_secs(3600)
}
impl Default for AnalyticsConfig {
fn default() -> Self {
Self {
max_forecast_horizon: default_max_horizon(),
max_training_points: default_max_training(),
}
}
}
const fn default_zstd_level() -> i32 {
3
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Checkpoints {
#[serde(default)]
pub interval_secs: Option<u64>,
#[serde(default)]
pub directory: Option<PathBuf>,
#[serde(default = "default_checkpoints_keep")]
pub keep: usize,
}
const fn default_checkpoints_keep() -> usize {
3
}
impl Checkpoints {
#[must_use]
pub fn interval(&self) -> Option<Duration> {
self.interval_secs
.filter(|s| *s > 0)
.map(Duration::from_secs)
}
pub fn validate(&self) -> Result<(), ConfigError> {
if self.interval().is_some() && self.directory.is_none() {
return Err(ConfigError::Validation {
message: "checkpoints.interval_secs is set but checkpoints.directory is not — \
a schedule with nowhere to write is a backup that silently never \
happens"
.into(),
});
}
Ok(())
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FieldEncryption {
#[serde(default)]
pub columns: BTreeMap<String, String>,
#[serde(default)]
pub keys: BTreeMap<String, String>,
}
impl FieldEncryption {
#[must_use]
pub fn is_empty(&self) -> bool {
self.columns.is_empty()
}
#[must_use]
pub fn covers(&self, column: &str) -> bool {
self.columns.contains_key(column)
}
pub fn validate(&self) -> Result<(), ConfigError> {
for (column, key_id) in &self.columns {
if column.is_empty() || key_id.is_empty() {
return Err(ConfigError::Validation {
message: "field_encryption.columns: column names and key ids must not be empty"
.into(),
});
}
if crate::types::RESERVED_COLUMN_NAMES.contains(&column.as_str()) {
return Err(ConfigError::Validation {
message: format!(
"field_encryption.columns: '{column}' is the time column and cannot be \
encrypted — every scan ranges on it and every retention decision \
reads it"
),
});
}
if !self.keys.contains_key(key_id) {
return Err(ConfigError::Validation {
message: format!(
"field_encryption: column '{column}' names key '{key_id}', which is \
not declared under [field_encryption.keys]"
),
});
}
}
for (key_id, var) in &self.keys {
if key_id.is_empty() || var.is_empty() {
return Err(ConfigError::Validation {
message:
"field_encryption.keys: key ids and environment variable names must not \
be empty"
.into(),
});
}
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct ChronixConfig {
pub data_dir: PathBuf,
pub wal: WalConfig,
pub memtable_flush_threshold: usize,
pub max_memtable_memory: usize,
pub shard_duration: Duration,
pub ooo_shard_tolerance: u32,
pub retention: Option<Duration>,
pub measurement_retention: HashMap<String, Duration>,
pub compression: CompressionCodec,
#[serde(default = "default_zstd_level")]
pub zstd_level: i32,
#[serde(default)]
pub zstd_dict_training: bool,
pub float_encoding: FloatEncoding,
pub segment_cache_size: usize,
#[serde(default = "default_cdc_capacity")]
pub cdc_capacity: usize,
pub enable_last_value_cache: bool,
pub lvc_measurements: Option<HashSet<String>>,
pub compaction_concurrency: usize,
#[serde(default = "default_maintenance_interval")]
pub maintenance_interval: Duration,
pub max_series_cardinality: usize,
#[serde(default = "default_max_query_result_bytes")]
pub max_query_result_bytes: usize,
#[serde(default = "default_per_query_memory_limit")]
pub per_query_memory_limit: usize,
#[serde(default = "default_query_timeout")]
pub query_timeout: Duration,
#[serde(default)]
pub analytics: AnalyticsConfig,
#[serde(default)]
pub soft_delete_ttl: Option<Duration>,
#[serde(default = "default_future_write_tolerance")]
pub future_write_tolerance: Duration,
#[serde(default)]
pub field_encryption: FieldEncryption,
#[serde(default)]
pub checkpoints: Checkpoints,
}
impl Default for ChronixConfig {
fn default() -> Self {
ChronixConfigBuilder::default()
.data_dir("./chronix-data")
.build()
.expect("the builder's own defaults must be valid")
}
}
impl ChronixConfig {
#[must_use]
pub fn small(data_dir: impl Into<PathBuf>) -> Self {
Self::builder()
.data_dir(data_dir)
.memtable_flush_threshold(8 * 1024 * 1024) .max_memtable_memory(24 * 1024 * 1024) .segment_cache_size(16 * 1024 * 1024) .cdc_capacity(4_096)
.compaction_concurrency(1)
.maintenance_interval(Duration::from_secs(60))
.wal_fsync_policy(FsyncPolicy::Periodic(Duration::from_secs(5)))
.wal_max_file_size(8 * 1024 * 1024) .build()
.expect("BUG: the small-footprint preset must always validate")
}
#[must_use]
pub fn builder() -> ChronixConfigBuilder {
ChronixConfigBuilder::default()
}
pub fn from_toml(path: &Path) -> Result<Self, ConfigError> {
let content = std::fs::read_to_string(path)?;
let config: Self = toml::from_str(&content)?;
config.validate()?;
Ok(config)
}
pub fn validate(&self) -> Result<(), ConfigError> {
self.field_encryption.validate()?;
self.checkpoints.validate()?;
if self.memtable_flush_threshold == 0 {
return Err(ConfigError::Validation {
message: "memtable_flush_threshold must be > 0".into(),
});
}
if self.max_memtable_memory == 0 {
return Err(ConfigError::Validation {
message: "max_memtable_memory must be > 0".into(),
});
}
if self.max_memtable_memory < self.memtable_flush_threshold {
return Err(ConfigError::Validation {
message: format!(
"max_memtable_memory ({}) must be >= memtable_flush_threshold ({})",
self.max_memtable_memory, self.memtable_flush_threshold
),
});
}
if self.shard_duration.is_zero() {
return Err(ConfigError::Validation {
message: "shard_duration must be > 0".into(),
});
}
if let Some(retention) = &self.retention
&& *retention < self.shard_duration
{
return Err(ConfigError::Validation {
message: format!(
"retention ({retention:?}) must be >= shard_duration ({:?})",
self.shard_duration
),
});
}
if self.compaction_concurrency == 0 {
return Err(ConfigError::Validation {
message: "compaction_concurrency must be > 0".into(),
});
}
if self.max_series_cardinality == 0 {
return Err(ConfigError::Validation {
message: "max_series_cardinality must be > 0".into(),
});
}
if self.wal.max_file_size == 0 {
return Err(ConfigError::Validation {
message: "WAL max_file_size must be > 0".into(),
});
}
if self.wal.max_unflushed_wals == 0 {
return Err(ConfigError::Validation {
message: "WAL max_unflushed_wals must be > 0".into(),
});
}
if self.analytics.max_forecast_horizon == 0 {
return Err(ConfigError::Validation {
message: "analytics.max_forecast_horizon must be > 0".into(),
});
}
if self.analytics.max_training_points == 0 {
return Err(ConfigError::Validation {
message: "analytics.max_training_points must be > 0".into(),
});
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct ChronixConfigBuilder {
data_dir: Option<PathBuf>,
wal: WalConfig,
memtable_flush_threshold: usize,
max_memtable_memory: usize,
shard_duration: Duration,
ooo_shard_tolerance: u32,
retention: Option<Duration>,
measurement_retention: HashMap<String, Duration>,
compression: CompressionCodec,
zstd_level: i32,
zstd_dict_training: bool,
float_encoding: FloatEncoding,
segment_cache_size: usize,
cdc_capacity: usize,
enable_last_value_cache: bool,
lvc_measurements: Option<HashSet<String>>,
compaction_concurrency: usize,
maintenance_interval: Duration,
field_encryption: FieldEncryption,
checkpoints: Checkpoints,
max_series_cardinality: usize,
max_query_result_bytes: usize,
per_query_memory_limit: usize,
query_timeout: Duration,
analytics: AnalyticsConfig,
soft_delete_ttl: Option<Duration>,
future_write_tolerance: Duration,
}
impl Default for ChronixConfigBuilder {
fn default() -> Self {
Self {
data_dir: None,
wal: WalConfig::default(),
memtable_flush_threshold: 64 * 1024 * 1024, max_memtable_memory: 256 * 1024 * 1024, shard_duration: Duration::from_secs(3600), ooo_shard_tolerance: 2,
retention: None,
measurement_retention: HashMap::new(),
compression: CompressionCodec::default(),
zstd_level: default_zstd_level(),
zstd_dict_training: false,
float_encoding: FloatEncoding::default(),
segment_cache_size: 512 * 1024 * 1024, cdc_capacity: default_cdc_capacity(),
enable_last_value_cache: false,
lvc_measurements: None,
compaction_concurrency: 2,
maintenance_interval: default_maintenance_interval(),
field_encryption: FieldEncryption::default(),
checkpoints: Checkpoints::default(),
max_series_cardinality: 1_000_000,
max_query_result_bytes: default_max_query_result_bytes(),
per_query_memory_limit: default_per_query_memory_limit(),
query_timeout: default_query_timeout(),
analytics: AnalyticsConfig::default(),
soft_delete_ttl: None,
future_write_tolerance: default_future_write_tolerance(),
}
}
}
impl ChronixConfigBuilder {
#[must_use]
pub fn data_dir(mut self, path: impl Into<PathBuf>) -> Self {
self.data_dir = Some(path.into());
self
}
#[must_use]
pub fn wal_fsync_policy(mut self, policy: FsyncPolicy) -> Self {
self.wal.fsync_policy = policy;
self
}
#[must_use]
pub fn wal_max_file_size(mut self, size: usize) -> Self {
self.wal.max_file_size = size;
self
}
#[must_use]
pub fn wal_max_unflushed(mut self, count: usize) -> Self {
self.wal.max_unflushed_wals = count;
self
}
#[must_use]
pub fn memtable_flush_threshold(mut self, size: usize) -> Self {
self.memtable_flush_threshold = size;
self
}
#[must_use]
pub fn max_memtable_memory(mut self, size: usize) -> Self {
self.max_memtable_memory = size;
self
}
#[must_use]
pub fn shard_duration(mut self, duration: Duration) -> Self {
self.shard_duration = duration;
self
}
#[must_use]
pub fn retention(mut self, duration: Option<Duration>) -> Self {
self.retention = duration;
self
}
#[must_use]
pub fn compression(mut self, codec: CompressionCodec) -> Self {
self.compression = codec;
self
}
#[must_use]
pub fn zstd_level(mut self, level: i32) -> Self {
self.zstd_level = level;
self
}
#[must_use]
pub fn zstd_dict_training(mut self, enabled: bool) -> Self {
self.zstd_dict_training = enabled;
self
}
#[must_use]
pub fn float_encoding(mut self, encoding: FloatEncoding) -> Self {
self.float_encoding = encoding;
self
}
#[must_use]
pub fn ooo_shard_tolerance(mut self, tolerance: u32) -> Self {
self.ooo_shard_tolerance = tolerance;
self
}
#[must_use]
pub fn measurement_retention(
mut self,
measurement: impl Into<String>,
duration: Duration,
) -> Self {
self.measurement_retention
.insert(measurement.into(), duration);
self
}
#[must_use]
pub fn compaction_concurrency(mut self, concurrency: usize) -> Self {
self.compaction_concurrency = concurrency;
self
}
#[must_use]
pub fn field_encryption(mut self, settings: FieldEncryption) -> Self {
self.field_encryption = settings;
self
}
#[must_use]
pub fn checkpoints(mut self, settings: Checkpoints) -> Self {
self.checkpoints = settings;
self
}
#[must_use]
pub fn maintenance_interval(mut self, interval: Duration) -> Self {
self.maintenance_interval = interval;
self
}
#[must_use]
pub fn enable_last_value_cache(mut self, enable: bool) -> Self {
self.enable_last_value_cache = enable;
self
}
#[must_use]
pub fn lvc_measurements(mut self, measurements: HashSet<String>) -> Self {
self.lvc_measurements = Some(measurements);
self
}
#[must_use]
pub fn cdc_capacity(mut self, events: usize) -> Self {
self.cdc_capacity = events;
self
}
#[must_use]
pub fn segment_cache_size(mut self, size: usize) -> Self {
self.segment_cache_size = size;
self
}
#[must_use]
pub fn max_series_cardinality(mut self, max: usize) -> Self {
self.max_series_cardinality = max;
self
}
#[must_use]
pub fn max_query_result_bytes(mut self, max: usize) -> Self {
self.max_query_result_bytes = max;
self
}
#[must_use]
pub fn per_query_memory_limit(mut self, limit: usize) -> Self {
self.per_query_memory_limit = limit;
self
}
#[must_use]
pub fn query_timeout(mut self, timeout: Duration) -> Self {
self.query_timeout = timeout;
self
}
#[must_use]
pub fn analytics(mut self, config: AnalyticsConfig) -> Self {
self.analytics = config;
self
}
#[must_use]
pub fn soft_delete_ttl(mut self, ttl: Option<Duration>) -> Self {
self.soft_delete_ttl = ttl;
self
}
#[must_use]
pub fn future_write_tolerance(mut self, tolerance: Duration) -> Self {
self.future_write_tolerance = tolerance;
self
}
pub fn build(self) -> Result<ChronixConfig, ConfigError> {
let data_dir = self
.data_dir
.ok_or(ConfigError::MissingField { field: "data_dir" })?;
let config = ChronixConfig {
data_dir,
wal: self.wal,
memtable_flush_threshold: self.memtable_flush_threshold,
max_memtable_memory: self.max_memtable_memory,
shard_duration: self.shard_duration,
ooo_shard_tolerance: self.ooo_shard_tolerance,
retention: self.retention,
measurement_retention: self.measurement_retention,
compression: self.compression,
zstd_level: self.zstd_level,
zstd_dict_training: self.zstd_dict_training,
float_encoding: self.float_encoding,
segment_cache_size: self.segment_cache_size,
cdc_capacity: self.cdc_capacity,
enable_last_value_cache: self.enable_last_value_cache,
lvc_measurements: self.lvc_measurements,
compaction_concurrency: self.compaction_concurrency,
maintenance_interval: self.maintenance_interval,
max_series_cardinality: self.max_series_cardinality,
max_query_result_bytes: self.max_query_result_bytes,
per_query_memory_limit: self.per_query_memory_limit,
query_timeout: self.query_timeout,
analytics: self.analytics,
soft_delete_ttl: self.soft_delete_ttl,
future_write_tolerance: self.future_write_tolerance,
field_encryption: self.field_encryption,
checkpoints: self.checkpoints,
};
config.validate()?;
if config.segment_cache_size == 0 {
tracing::warn!("segment_cache_size is 0 — segment caching is effectively disabled");
}
Ok(config)
}
}
#[cfg(test)]
mod tests {
#[test]
fn the_section_refuses_a_key_it_would_not_act_on() {
let ok: AnalyticsConfig =
toml::from_str("max_forecast_horizon = 100\nmax_training_points = 500\n")
.expect("the documented keys must load");
assert_eq!(ok.max_forecast_horizon, 100);
assert_eq!(ok.max_training_points, 500);
for key in [
"default_forecast_model = \"holt\"",
"default_anomaly_method = \"iqr\"",
"default_confidence_level = 0.99",
"default_anomaly_threshold = 4.0",
] {
let err = toml::from_str::<AnalyticsConfig>(key)
.expect_err("a key the section does not act on must be refused")
.to_string();
assert!(
err.contains("unknown field"),
"`{key}` was refused for the wrong reason: {err}"
);
}
}
use super::*;
#[test]
fn a_partial_toml_file_loads_with_defaults_for_the_rest() {
let toml = r#"
data_dir = "/var/lib/chronix"
memtable_flush_threshold = 8388608
[analytics]
max_forecast_horizon = 720
"#;
let config: ChronixConfig = toml::from_str(toml).expect("a partial file must load");
assert_eq!(config.data_dir, PathBuf::from("/var/lib/chronix"));
assert_eq!(config.memtable_flush_threshold, 8 * 1024 * 1024);
assert_eq!(config.analytics.max_forecast_horizon, 720);
assert_eq!(config.shard_duration, Duration::from_secs(3600));
assert_eq!(config.analytics.max_training_points, 1_000_000);
}
#[test]
fn a_misspelled_key_is_refused() {
let toml = "data_dir = \"/tmp\"\nmemtable_flush_threshhold = 1\n";
assert!(toml::from_str::<ChronixConfig>(toml).is_err());
}
#[test]
fn builder_defaults_valid() {
let config = ChronixConfig::builder().data_dir("./data").build().unwrap();
assert_eq!(config.data_dir, PathBuf::from("./data"));
assert_eq!(config.wal.fsync_policy, FsyncPolicy::PerBatch);
assert_eq!(config.wal.max_file_size, 32 * 1024 * 1024);
assert_eq!(config.wal.max_unflushed_wals, 4);
assert_eq!(config.memtable_flush_threshold, 64 * 1024 * 1024);
assert_eq!(config.max_memtable_memory, 256 * 1024 * 1024);
assert_eq!(config.shard_duration, Duration::from_secs(3600));
assert_eq!(config.compression, CompressionCodec::Lz4);
assert_eq!(config.float_encoding, FloatEncoding::Chimp);
assert!(!config.enable_last_value_cache);
assert_eq!(config.compaction_concurrency, 2);
assert_eq!(config.max_series_cardinality, 1_000_000);
}
#[test]
fn builder_missing_data_dir() {
let err = ChronixConfig::builder().build().unwrap_err();
assert!(matches!(
err,
ConfigError::MissingField { field: "data_dir" }
));
}
#[test]
fn builder_zero_flush_threshold() {
let err = ChronixConfig::builder()
.data_dir("./data")
.memtable_flush_threshold(0)
.build()
.unwrap_err();
assert!(err.to_string().contains("memtable_flush_threshold"));
}
#[test]
fn builder_memory_less_than_threshold() {
let err = ChronixConfig::builder()
.data_dir("./data")
.memtable_flush_threshold(100)
.max_memtable_memory(50)
.build()
.unwrap_err();
assert!(err.to_string().contains("max_memtable_memory"));
}
#[test]
fn builder_retention_less_than_shard() {
let err = ChronixConfig::builder()
.data_dir("./data")
.shard_duration(Duration::from_secs(3600))
.retention(Some(Duration::from_secs(60)))
.build()
.unwrap_err();
assert!(err.to_string().contains("retention"));
}
#[test]
fn builder_zero_shard_duration() {
let err = ChronixConfig::builder()
.data_dir("./data")
.shard_duration(Duration::ZERO)
.build()
.unwrap_err();
assert!(err.to_string().contains("shard_duration"));
}
#[test]
fn builder_no_retention() {
let config = ChronixConfig::builder()
.data_dir("./data")
.retention(None)
.build()
.unwrap();
assert!(config.retention.is_none());
}
#[test]
fn builder_custom_wal() {
let config = ChronixConfig::builder()
.data_dir("./data")
.wal_fsync_policy(FsyncPolicy::PerWrite)
.wal_max_file_size(16 * 1024 * 1024)
.wal_max_unflushed(8)
.build()
.unwrap();
assert_eq!(config.wal.fsync_policy, FsyncPolicy::PerWrite);
assert_eq!(config.wal.max_file_size, 16 * 1024 * 1024);
assert_eq!(config.wal.max_unflushed_wals, 8);
}
#[test]
fn config_serde_roundtrip() {
let config = ChronixConfig::builder().data_dir("./data").build().unwrap();
let json = serde_json::to_string(&config).unwrap();
let back: ChronixConfig = serde_json::from_str(&json).unwrap();
assert_eq!(config.data_dir, back.data_dir);
assert_eq!(config.wal.max_file_size, back.wal.max_file_size);
}
#[test]
#[cfg_attr(miri, ignore = "reaches the filesystem")]
fn config_toml_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let config = ChronixConfig::builder().data_dir("./data").build().unwrap();
let toml_str = toml::to_string_pretty(&config).unwrap();
std::fs::write(&path, &toml_str).unwrap();
let loaded = ChronixConfig::from_toml(&path).unwrap();
assert_eq!(config.data_dir, loaded.data_dir);
}
#[test]
fn fsync_policy_default() {
assert_eq!(FsyncPolicy::default(), FsyncPolicy::PerBatch);
}
#[test]
fn compression_codec_default() {
assert_eq!(CompressionCodec::default(), CompressionCodec::Lz4);
}
#[test]
fn float_encoding_default() {
assert_eq!(FloatEncoding::default(), FloatEncoding::Chimp);
}
#[test]
fn builder_zero_max_memtable_memory() {
let err = ChronixConfig::builder()
.data_dir("./data")
.max_memtable_memory(0)
.build()
.unwrap_err();
assert!(err.to_string().contains("max_memtable_memory"));
}
#[test]
fn builder_zero_compaction_concurrency() {
let err = ChronixConfig::builder()
.data_dir("./data")
.compaction_concurrency(0)
.build()
.unwrap_err();
assert!(err.to_string().contains("compaction_concurrency"));
}
#[test]
fn builder_zero_wal_max_file_size() {
let err = ChronixConfig::builder()
.data_dir("./data")
.wal_max_file_size(0)
.build()
.unwrap_err();
assert!(err.to_string().contains("max_file_size"));
}
#[test]
fn builder_zero_wal_max_unflushed() {
let err = ChronixConfig::builder()
.data_dir("./data")
.wal_max_unflushed(0)
.build()
.unwrap_err();
assert!(err.to_string().contains("max_unflushed"));
}
#[test]
#[cfg_attr(miri, ignore = "reaches the filesystem")]
fn from_toml_nonexistent_file() {
let err = ChronixConfig::from_toml(std::path::Path::new("/no/such/file.toml")).unwrap_err();
assert!(matches!(err, ConfigError::Io(_)));
}
#[test]
#[cfg_attr(miri, ignore = "reaches the filesystem")]
fn from_toml_invalid_content() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("bad.toml");
std::fs::write(&path, "this is not valid toml [[[").unwrap();
let err = ChronixConfig::from_toml(&path).unwrap_err();
assert!(matches!(err, ConfigError::Parse(_)));
}
#[test]
fn fsync_policy_display() {
assert_eq!(FsyncPolicy::PerWrite.to_string(), "per-write");
assert_eq!(FsyncPolicy::PerBatch.to_string(), "per-batch");
let periodic = FsyncPolicy::Periodic(Duration::from_millis(500));
assert!(periodic.to_string().contains("500"));
}
#[test]
fn compression_codec_display() {
assert_eq!(CompressionCodec::Lz4.to_string(), "lz4");
assert_eq!(CompressionCodec::Zstd.to_string(), "zstd");
assert_eq!(CompressionCodec::None.to_string(), "none");
}
#[test]
fn float_encoding_display() {
assert_eq!(FloatEncoding::Chimp.to_string(), "chimp");
assert_eq!(FloatEncoding::Gorilla.to_string(), "gorilla");
assert_eq!(FloatEncoding::Plain.to_string(), "plain");
}
#[test]
fn wal_config_serde_roundtrip() {
let config = WalConfig {
fsync_policy: FsyncPolicy::Periodic(Duration::from_secs(5)),
max_file_size: 16 * 1024 * 1024,
max_unflushed_wals: 8,
compress: true,
group_sync_timeout_secs: 5,
};
let json = serde_json::to_string(&config).unwrap();
let back: WalConfig = serde_json::from_str(&json).unwrap();
assert_eq!(config, back);
}
#[test]
fn validate_zero_max_forecast_horizon() {
let analytics = AnalyticsConfig {
max_forecast_horizon: 0,
..AnalyticsConfig::default()
};
let err = ChronixConfig::builder()
.data_dir("./data")
.analytics(analytics)
.build()
.unwrap_err();
assert!(err.to_string().contains("max_forecast_horizon"));
}
#[test]
fn validate_zero_max_training_points() {
let analytics = AnalyticsConfig {
max_training_points: 0,
..AnalyticsConfig::default()
};
let err = ChronixConfig::builder()
.data_dir("./data")
.analytics(analytics)
.build()
.unwrap_err();
assert!(err.to_string().contains("max_training_points"));
}
#[test]
fn small_preset_is_valid_and_bounded() {
let cfg = ChronixConfig::small("/tmp/chronix-small");
assert!(cfg.validate().is_ok());
assert_eq!(cfg.memtable_flush_threshold, 8 * 1024 * 1024);
assert_eq!(cfg.max_memtable_memory, 24 * 1024 * 1024);
assert_eq!(cfg.segment_cache_size, 16 * 1024 * 1024);
assert_eq!(cfg.compaction_concurrency, 1);
assert!(matches!(cfg.wal.fsync_policy, FsyncPolicy::Periodic(_)));
assert_eq!(cfg.wal.max_file_size, 8 * 1024 * 1024);
}
}