Skip to main content

velesdb_core/
config_validation.rs

1//! `VelesConfig` validation logic.
2//!
3//! Extracted from `config.rs` to reduce NLOC below the 500 threshold.
4
5use crate::config::{ConfigError, VelesConfig};
6
7// ---------------------------------------------------------------------------
8// Upper-bound caps for capacity/size limits.
9//
10// These caps reject absurd values that would silently invite resource
11// exhaustion or integer-overflow surprises downstream, while staying well
12// above every realistic deployment (and above the crate defaults so the
13// default config validates through the loaders). `0` is rejected for
14// capacities/sizes because a zero capacity is never a meaningful config.
15// ---------------------------------------------------------------------------
16
17/// Hard ceiling for `limits.max_vectors_per_collection`.
18///
19/// On 64-bit targets this is 10 billion. On 32-bit / WASM targets `usize`
20/// is only 32 bits (max ≈ 4.29 billion), so the literal is capped at
21/// 4 billion to prevent a compile-time integer-overflow error.
22#[cfg(target_pointer_width = "64")]
23const MAX_VECTORS_PER_COLLECTION_CAP: usize = 10_000_000_000;
24#[cfg(not(target_pointer_width = "64"))]
25const MAX_VECTORS_PER_COLLECTION_CAP: usize = 4_000_000_000;
26/// Hard ceiling for `limits.max_collections` (1 million).
27const MAX_COLLECTIONS_CAP: usize = 1_000_000;
28/// Hard ceiling for `limits.max_payload_size` (1 GiB).
29const MAX_PAYLOAD_SIZE_CAP: usize = 1_073_741_824;
30/// Hard ceiling for `limits.max_perfect_mode_vectors` (100 million).
31const MAX_PERFECT_MODE_VECTORS_CAP: usize = 100_000_000;
32/// Hard ceiling for `search.query_timeout_ms` (24 hours). `0` means
33/// "disabled". The previous 1-hour cap rejected legitimate long batch
34/// timeouts; 24h is generous enough for any real query while still rejecting
35/// effectively-unbounded values.
36const QUERY_TIMEOUT_MS_CAP: u64 = 86_400_000;
37/// Hard ceiling for `hnsw.max_layers`. `0` means "auto".
38const MAX_LAYERS_CAP: usize = 64;
39/// Hard ceiling for `storage.mmap_cache_mb` (1 TiB). `0` is rejected: a
40/// zero-byte mmap cache is never a meaningful configuration.
41const MMAP_CACHE_MB_CAP: usize = 1_048_576;
42/// Hard ceiling for `server.workers`. `0` means "auto" (derive from CPU
43/// count), so it is allowed; any positive value is capped to a sane ceiling.
44const WORKERS_CAP: usize = 4_096;
45
46/// Rejects `0` and any value above `cap` for a capacity/size field.
47fn range_check_capacity(key: &str, value: usize, cap: usize) -> Result<(), ConfigError> {
48    if value == 0 || value > cap {
49        return Err(ConfigError::InvalidValue {
50            key: key.to_string(),
51            message: format!("value {value} is out of range [1, {cap}]"),
52        });
53    }
54    Ok(())
55}
56
57/// Range-checks a field where `0` is a valid sentinel (disabled / auto) but any
58/// positive value must not exceed `cap`. Unlike [`range_check_capacity`], `0`
59/// is accepted.
60fn range_check_upper<T: PartialOrd + Copy + std::fmt::Display>(
61    key: &str,
62    value: T,
63    cap: T,
64) -> Result<(), ConfigError> {
65    if value > cap {
66        return Err(ConfigError::InvalidValue {
67            key: key.to_string(),
68            message: format!("value {value} is out of range [0, {cap}]"),
69        });
70    }
71    Ok(())
72}
73
74impl VelesConfig {
75    /// Validates the configuration.
76    ///
77    /// # Errors
78    ///
79    /// Returns an error if any configuration value is invalid.
80    pub fn validate(&self) -> Result<(), ConfigError> {
81        self.validate_search()?;
82        self.validate_hnsw()?;
83        self.validate_limits()?;
84        self.validate_server()?;
85        self.validate_storage()?;
86        self.validate_logging()?;
87        self.warn_inert_wal_batch();
88        self.warn_inert_engine_sections();
89        Ok(())
90    }
91
92    /// The `[search]`, `[hnsw]`, `[storage]` and `[quantization]` sections
93    /// are parsed and validated but not yet applied — only `[limits]`
94    /// reaches the engine (issue #2087). Warn — rather than reject — when a
95    /// config sets any of them away from its defaults, so existing files
96    /// keep loading while no deployment silently believes those knobs work.
97    ///
98    /// Serde-value comparison instead of `PartialEq` derives: the sections
99    /// carry enums and nested types, and this runs once per config load.
100    fn warn_inert_engine_sections(&self) {
101        fn deviates<T: serde::Serialize>(actual: &T, default: &T) -> bool {
102            match (serde_json::to_value(actual), serde_json::to_value(default)) {
103                (Ok(a), Ok(d)) => a != d,
104                _ => false,
105            }
106        }
107
108        let mut inert: Vec<&str> = Vec::new();
109        if deviates(&self.search, &crate::config::SearchConfig::default()) {
110            inert.push("[search]");
111        }
112        if deviates(&self.hnsw, &crate::config::HnswConfig::default()) {
113            inert.push("[hnsw]");
114        }
115        if deviates(
116            &self.storage,
117            &crate::config::server::StorageConfig::default(),
118        ) {
119            inert.push("[storage]");
120        }
121        if deviates(
122            &self.quantization,
123            &crate::config_quantization::QuantizationConfig::default(),
124        ) {
125            inert.push("[quantization]");
126        }
127        if !inert.is_empty() {
128            tracing::warn!(
129                sections = inert.join(", "),
130                "these config sections are parsed and validated but not yet \
131                 applied by the engine — only [limits] is; see issue #2087. \
132                 Query-time WITH (...) overrides are unaffected."
133            );
134        }
135    }
136
137    /// `[wal_batch]` is parsed but not wired (issue #2078): warn — rather
138    /// than reject — when a config enables it, so existing files keep
139    /// loading while no deployment silently believes it has group commit.
140    fn warn_inert_wal_batch(&self) {
141        if self.wal_batch.enabled {
142            tracing::warn!(
143                "[wal_batch] enabled = true is parsed but not yet wired: no group \
144                 commit occurs and every write keeps its own durability barrier \
145                 (see issue #2078)"
146            );
147        }
148    }
149
150    fn validate_search(&self) -> Result<(), ConfigError> {
151        if let Some(ef) = self.search.ef_search {
152            if !(16..=4096).contains(&ef) {
153                return Err(ConfigError::InvalidValue {
154                    key: "search.ef_search".to_string(),
155                    message: format!("value {ef} is out of range [16, 4096]"),
156                });
157            }
158        }
159
160        if self.search.max_results == 0 || self.search.max_results > 10000 {
161            return Err(ConfigError::InvalidValue {
162                key: "search.max_results".to_string(),
163                message: format!(
164                    "value {} is out of range [1, 10000]",
165                    self.search.max_results
166                ),
167            });
168        }
169
170        // `query_timeout_ms == 0` disables the timeout (see `QueryContext`);
171        // any positive value is capped to avoid effectively-unbounded queries.
172        range_check_upper(
173            "search.query_timeout_ms",
174            self.search.query_timeout_ms,
175            QUERY_TIMEOUT_MS_CAP,
176        )
177    }
178
179    fn validate_hnsw(&self) -> Result<(), ConfigError> {
180        if let Some(m) = self.hnsw.m {
181            if !(4..=128).contains(&m) {
182                return Err(ConfigError::InvalidValue {
183                    key: "hnsw.m".to_string(),
184                    message: format!("value {m} is out of range [4, 128]"),
185                });
186            }
187        }
188
189        if let Some(ef) = self.hnsw.ef_construction {
190            if !(100..=2000).contains(&ef) {
191                return Err(ConfigError::InvalidValue {
192                    key: "hnsw.ef_construction".to_string(),
193                    message: format!("value {ef} is out of range [100, 2000]"),
194                });
195            }
196        }
197
198        // `max_layers == 0` means "auto" (see `HnswConfig`); a positive value
199        // is capped to a sane ceiling.
200        range_check_upper("hnsw.max_layers", self.hnsw.max_layers, MAX_LAYERS_CAP)
201    }
202
203    fn validate_limits(&self) -> Result<(), ConfigError> {
204        let limits = &self.limits;
205        range_check_capacity("limits.max_dimensions", limits.max_dimensions, 65536)?;
206        range_check_capacity(
207            "limits.max_vectors_per_collection",
208            limits.max_vectors_per_collection,
209            MAX_VECTORS_PER_COLLECTION_CAP,
210        )?;
211        range_check_capacity(
212            "limits.max_collections",
213            limits.max_collections,
214            MAX_COLLECTIONS_CAP,
215        )?;
216        range_check_capacity(
217            "limits.max_payload_size",
218            limits.max_payload_size,
219            MAX_PAYLOAD_SIZE_CAP,
220        )?;
221        range_check_capacity(
222            "limits.max_perfect_mode_vectors",
223            limits.max_perfect_mode_vectors,
224            MAX_PERFECT_MODE_VECTORS_CAP,
225        )
226    }
227
228    fn validate_server(&self) -> Result<(), ConfigError> {
229        if self.server.port < 1024 {
230            return Err(ConfigError::InvalidValue {
231                key: "server.port".to_string(),
232                message: format!("value {} must be >= 1024", self.server.port),
233            });
234        }
235
236        // `workers == 0` means "auto" (derive from CPU count); a positive
237        // value is capped so a typo cannot spawn an absurd thread count.
238        range_check_upper("server.workers", self.server.workers, WORKERS_CAP)
239    }
240
241    fn validate_storage(&self) -> Result<(), ConfigError> {
242        let valid_modes = ["mmap", "memory"];
243        if !valid_modes.contains(&self.storage.storage_mode.as_str()) {
244            return Err(ConfigError::InvalidValue {
245                key: "storage.storage_mode".to_string(),
246                message: format!(
247                    "value '{}' is invalid, expected one of: {:?}",
248                    self.storage.storage_mode, valid_modes
249                ),
250            });
251        }
252
253        // A zero-byte mmap cache is meaningless; cap the upper bound so an
254        // out-of-range value cannot drive an absurd reservation.
255        range_check_capacity(
256            "storage.mmap_cache_mb",
257            self.storage.mmap_cache_mb,
258            MMAP_CACHE_MB_CAP,
259        )?;
260        Ok(())
261    }
262
263    fn validate_logging(&self) -> Result<(), ConfigError> {
264        let valid_levels = ["error", "warn", "info", "debug", "trace"];
265        if !valid_levels.contains(&self.logging.level.as_str()) {
266            return Err(ConfigError::InvalidValue {
267                key: "logging.level".to_string(),
268                message: format!(
269                    "value '{}' is invalid, expected one of: {:?}",
270                    self.logging.level, valid_levels
271                ),
272            });
273        }
274        Ok(())
275    }
276}