Skip to main content

velesdb_core/
config.rs

1//! `VelesDB` Configuration Module
2//!
3//! Provides configuration file support via `velesdb.toml`, environment variables,
4//! and runtime overrides.
5//!
6//! # Priority (highest to lowest)
7//!
8//! 1. Runtime overrides (API, REPL)
9//! 2. Environment variables (`VELESDB_*`)
10//! 3. Configuration file (`velesdb.toml`)
11//! 4. Default values
12
13use figment::{
14    providers::{Env, Format, Serialized, Toml},
15    Figment,
16};
17use serde::{Deserialize, Serialize};
18use std::path::Path;
19use thiserror::Error;
20
21// Re-export quantization types so existing `crate::config::Quantization*` paths work.
22pub use crate::config_quantization::{QuantizationConfig, QuantizationType};
23
24/// Configuration errors.
25#[derive(Error, Debug)]
26#[non_exhaustive]
27pub enum ConfigError {
28    /// Failed to parse configuration file.
29    #[error("Failed to parse configuration: {0}")]
30    ParseError(String),
31
32    /// Invalid configuration value.
33    #[error("Invalid configuration value for '{key}': {message}")]
34    InvalidValue {
35        /// Configuration key that failed validation.
36        key: String,
37        /// Validation error message.
38        message: String,
39    },
40
41    /// Configuration file not found.
42    #[error("Configuration file not found: {0}")]
43    FileNotFound(String),
44
45    /// IO error.
46    #[error("IO error: {0}")]
47    IoError(#[from] std::io::Error),
48}
49
50/// Search mode presets.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
52#[serde(rename_all = "snake_case")]
53#[non_exhaustive]
54pub enum SearchMode {
55    /// Fast search with `ef_search=96`, ~95% recall.
56    Fast,
57    /// Balanced search with `ef_search=160`, ~99.5% recall (default).
58    #[default]
59    Balanced,
60    /// Accurate search with `ef_search=512`, ~100% recall.
61    Accurate,
62    /// Perfect recall via **exhaustive bruteforce** (`ef_search = usize::MAX`
63    /// signals a full scan): every vector is scored, no HNSW graph traversal, so
64    /// recall is 100% by construction at O(n) cost.
65    ///
66    /// Distinct from `SearchQuality::Perfect`
67    /// (`crate::index::hnsw::SearchQuality`) despite the shared name:
68    /// `SearchMode` picks the **engine** (bruteforce here vs. the HNSW graph),
69    /// whereas `SearchQuality::Perfect` stays *on* the graph with a very high
70    /// `ef_search` (`4096.max(k*100)`) — ~1.0 recall up to ~100K, ~0.9994 at 1M,
71    /// at graph cost rather than a full scan. Pick `SearchMode::Perfect` only
72    /// when an exact guarantee is worth the linear scan.
73    Perfect,
74}
75
76impl SearchMode {
77    /// Returns the `ef_search` value for this mode.
78    #[must_use]
79    pub fn ef_search(&self) -> usize {
80        match self {
81            Self::Fast => 96,
82            Self::Balanced => 160,
83            Self::Accurate => 512,
84            Self::Perfect => usize::MAX, // Signals bruteforce
85        }
86    }
87}
88
89/// Search configuration section.
90#[derive(Debug, Clone, Serialize, Deserialize)]
91#[serde(default)]
92pub struct SearchConfig {
93    /// Default search mode.
94    pub default_mode: SearchMode,
95    /// Override `ef_search` (if set, overrides mode).
96    pub ef_search: Option<usize>,
97    /// Maximum results per query.
98    pub max_results: usize,
99    /// Query timeout in milliseconds.
100    pub query_timeout_ms: u64,
101}
102
103impl Default for SearchConfig {
104    fn default() -> Self {
105        Self {
106            default_mode: SearchMode::Balanced,
107            ef_search: None,
108            max_results: 1000,
109            query_timeout_ms: 30000,
110        }
111    }
112}
113
114/// HNSW index configuration section.
115#[derive(Debug, Clone, Default, Serialize, Deserialize)]
116#[serde(default)]
117pub struct HnswConfig {
118    /// Number of connections per node (M parameter).
119    /// `None` = auto based on dimension.
120    pub m: Option<usize>,
121    /// Size of the candidate pool during construction.
122    /// `None` = auto based on dimension.
123    pub ef_construction: Option<usize>,
124    /// Maximum number of layers (0 = auto).
125    pub max_layers: usize,
126}
127
128/// Server-layer configuration types (HTTP transport, logging, storage paths).
129///
130/// These types are intentionally separated from the core engine configuration
131/// (`SearchConfig`, `HnswConfig`, `LimitsConfig`) to enforce layer boundaries.
132/// Import via `config::server::ServerConfig` or use the crate-root re-exports.
133pub mod server {
134    use serde::{Deserialize, Serialize};
135
136    /// Storage configuration section.
137    #[derive(Debug, Clone, Serialize, Deserialize)]
138    #[serde(default)]
139    pub struct StorageConfig {
140        /// Data directory path.
141        pub data_dir: String,
142        /// Storage mode: `"mmap"` or `"memory"`.
143        pub storage_mode: String,
144        /// Mmap cache size in megabytes.
145        pub mmap_cache_mb: usize,
146        /// Vector alignment in bytes.
147        pub vector_alignment: usize,
148    }
149
150    impl Default for StorageConfig {
151        fn default() -> Self {
152            Self {
153                data_dir: "./velesdb_data".to_string(),
154                storage_mode: "mmap".to_string(),
155                mmap_cache_mb: 1024,
156                vector_alignment: 64,
157            }
158        }
159    }
160
161    /// Server configuration section.
162    #[derive(Debug, Clone, Serialize, Deserialize)]
163    #[serde(default)]
164    pub struct ServerConfig {
165        /// Host address.
166        pub host: String,
167        /// Port number.
168        pub port: u16,
169        /// Number of worker threads (0 = auto).
170        pub workers: usize,
171        /// Maximum HTTP body size in bytes.
172        pub max_body_size: usize,
173        /// Enable CORS.
174        pub cors_enabled: bool,
175        /// CORS allowed origins.
176        pub cors_origins: Vec<String>,
177    }
178
179    impl Default for ServerConfig {
180        fn default() -> Self {
181            Self {
182                host: "127.0.0.1".to_string(),
183                port: 8080,
184                workers: 0,
185                max_body_size: 104_857_600,
186                cors_enabled: false,
187                cors_origins: vec!["*".to_string()],
188            }
189        }
190    }
191
192    /// Logging configuration section.
193    #[derive(Debug, Clone, Serialize, Deserialize)]
194    #[serde(default)]
195    pub struct LoggingConfig {
196        /// Log level: `error`, `warn`, `info`, `debug`, `trace`.
197        pub level: String,
198        /// Log format: `text` or `json`.
199        pub format: String,
200        /// Log file path (empty = stdout).
201        pub file: String,
202    }
203
204    impl Default for LoggingConfig {
205        fn default() -> Self {
206            Self {
207                level: "info".to_string(),
208                format: "text".to_string(),
209                file: String::new(),
210            }
211        }
212    }
213}
214
215// Backward-compatible re-exports at module level.
216pub use server::{LoggingConfig, ServerConfig, StorageConfig};
217
218/// Limits configuration section.
219///
220/// `#[non_exhaustive]`: build from [`LimitsConfig::default`] and adjust fields
221/// so future limits stay backward compatible for downstream crates.
222#[derive(Debug, Clone, Serialize, Deserialize)]
223#[serde(default)]
224#[non_exhaustive]
225pub struct LimitsConfig {
226    /// Maximum vector dimensions.
227    pub max_dimensions: usize,
228    /// Maximum vectors per collection.
229    pub max_vectors_per_collection: usize,
230    /// Maximum number of collections.
231    pub max_collections: usize,
232    /// Maximum payload size in bytes.
233    pub max_payload_size: usize,
234    /// Maximum vectors for perfect mode (bruteforce).
235    pub max_perfect_mode_vectors: usize,
236}
237
238impl Default for LimitsConfig {
239    fn default() -> Self {
240        Self {
241            max_dimensions: 4096,
242            max_vectors_per_collection: 100_000_000,
243            max_collections: 1000,
244            max_payload_size: 1_048_576, // 1 MB
245            max_perfect_mode_vectors: 500_000,
246        }
247    }
248}
249
250// ---------------------------------------------------------------------------
251// WAL batch commit configuration
252// ---------------------------------------------------------------------------
253
254/// Default commit delay in microseconds for WAL group commit.
255const fn default_commit_delay_us() -> u64 {
256    100
257}
258
259/// Default maximum entries per WAL batch.
260const fn default_max_batch_size() -> usize {
261    128
262}
263
264/// Configuration for WAL group commit batching.
265///
266/// When enabled, multiple concurrent writes are batched into a single
267/// `sync_all()` call, amortizing the fsync cost across the batch.
268///
269/// # Example (TOML)
270///
271/// ```toml
272/// [wal_batch]
273/// enabled = true
274/// commit_delay_us = 200
275/// max_batch_size = 256
276/// ```
277#[derive(Debug, Clone, Serialize, Deserialize)]
278pub struct WalBatchConfig {
279    /// Whether group commit is enabled. Default: `false`.
280    #[serde(default)]
281    pub enabled: bool,
282    /// Maximum delay in microseconds before flushing a batch. Default: `100`.
283    #[serde(default = "default_commit_delay_us")]
284    pub commit_delay_us: u64,
285    /// Maximum number of entries per batch. Default: `128`.
286    #[serde(default = "default_max_batch_size")]
287    pub max_batch_size: usize,
288}
289
290impl Default for WalBatchConfig {
291    fn default() -> Self {
292        Self {
293            enabled: false,
294            commit_delay_us: 100,
295            max_batch_size: 128,
296        }
297    }
298}
299
300/// Main `VelesDB` configuration structure.
301#[derive(Debug, Clone, Serialize, Deserialize, Default)]
302#[serde(default)]
303pub struct VelesConfig {
304    /// Search configuration.
305    pub search: SearchConfig,
306    /// HNSW index configuration.
307    pub hnsw: HnswConfig,
308    /// Storage configuration.
309    pub storage: StorageConfig,
310    /// Limits configuration.
311    pub limits: LimitsConfig,
312    /// Server configuration.
313    pub server: ServerConfig,
314    /// Logging configuration.
315    pub logging: LoggingConfig,
316    /// Quantization configuration.
317    pub quantization: QuantizationConfig,
318    /// WAL group commit batching configuration.
319    pub wal_batch: WalBatchConfig,
320}
321
322impl VelesConfig {
323    /// Loads configuration from default sources.
324    ///
325    /// Priority: defaults < file < environment variables.
326    ///
327    /// # Errors
328    ///
329    /// Returns `ConfigError` if the configuration file is malformed or
330    /// environment variables contain invalid values.
331    pub fn load() -> Result<Self, ConfigError> {
332        Self::load_from_path("velesdb.toml")
333    }
334
335    /// Loads configuration from a specific file path.
336    ///
337    /// # Arguments
338    ///
339    /// * `path` - Path to the configuration file.
340    ///
341    /// # Errors
342    ///
343    /// Returns an error if configuration parsing fails.
344    pub fn load_from_path<P: AsRef<Path>>(path: P) -> Result<Self, ConfigError> {
345        let figment = Figment::new()
346            .merge(Serialized::defaults(Self::default()))
347            .merge(Toml::file(path.as_ref()))
348            .merge(Env::prefixed("VELESDB_").split("_").lowercase(false));
349
350        let config: Self = figment
351            .extract()
352            .map_err(|e| ConfigError::ParseError(e.to_string()))?;
353        config.validate()?;
354        Ok(config)
355    }
356
357    /// Creates a configuration from a TOML string.
358    ///
359    /// # Arguments
360    ///
361    /// * `toml_str` - TOML configuration string.
362    ///
363    /// # Errors
364    ///
365    /// Returns an error if parsing fails.
366    pub fn from_toml(toml_str: &str) -> Result<Self, ConfigError> {
367        let figment = Figment::new()
368            .merge(Serialized::defaults(Self::default()))
369            .merge(Toml::string(toml_str));
370
371        let config: Self = figment
372            .extract()
373            .map_err(|e| ConfigError::ParseError(e.to_string()))?;
374        config.validate()?;
375        Ok(config)
376    }
377
378    /// The top-level TOML tables that belong to the *engine* — as opposed
379    /// to `server` and `logging`, which are also fields on this struct but
380    /// exist for standalone/embedded consumers of `VelesConfig`. A hosting
381    /// shell (e.g. `velesdb-server`) that owns its own same-named
382    /// `[server]` table in the same file — different shape, different
383    /// meaning (HTTP bind port vs. this struct's own `server.port`) — would
384    /// otherwise have that table parsed into *this* struct too and
385    /// rejected by [`Self::validate`]'s rules for a value it was never
386    /// meant to apply to. See [`Self::load_from_path_engine_only`].
387    const ENGINE_SECTIONS: &'static [&'static str] = &[
388        "search",
389        "hnsw",
390        "storage",
391        "limits",
392        "quantization",
393        "wal_batch",
394    ];
395
396    /// Drops every top-level TOML table not in [`Self::ENGINE_SECTIONS`].
397    fn filter_to_engine_sections(raw: &str) -> Result<String, ConfigError> {
398        let mut doc: toml::Value =
399            toml::from_str(raw).map_err(|e| ConfigError::ParseError(e.to_string()))?;
400        if let Some(table) = doc.as_table_mut() {
401            table.retain(|k, _| Self::ENGINE_SECTIONS.contains(&k));
402        }
403        toml::to_string(&doc).map_err(|e| ConfigError::ParseError(e.to_string()))
404    }
405
406    /// Loads configuration from a specific file path, considering **only**
407    /// the engine sections (`[search]`/`[hnsw]`/`[storage]`/`[limits]`/
408    /// `[quantization]`/`[wal_batch]`) and silently dropping any other
409    /// top-level table before parsing — notably `[server]` and `[logging]`.
410    ///
411    /// Use this instead of [`Self::load_from_path`] when the TOML file is
412    /// **shared** with a hosting shell that owns its own `[server]`/
413    /// `[auth]`/`[tls]`/`[cors]`/... sections under possibly-colliding
414    /// keys — e.g. `velesdb-server --config` reads the same file for its
415    /// own HTTP transport settings (`[server].port` = the bind port) *and*
416    /// for this engine config. Without filtering, `[server] port = 443`
417    /// (a perfectly legitimate low bind port, e.g. behind `setcap`/a
418    /// privileged process) would also land in *this* struct's
419    /// `server.port` and be rejected by [`Self::validate`]'s `port >=
420    /// 1024` rule — a spurious failure with nothing to do with the actual
421    /// value being configured.
422    ///
423    /// As with [`Self::load_from_path`], `VELESDB_*` environment variables
424    /// are layered on top of the (filtered) file and can still override an
425    /// engine value — e.g. `VELESDB_LIMITS_MAX_COLLECTIONS=5` overrides a
426    /// `[limits] max_collections` from the file. Env vars for non-engine
427    /// sections (`VELESDB_SERVER_*`, `VELESDB_LOGGING_*`, ...) are
428    /// harmless here: they don't match any field once those sections are
429    /// filtered out of the base document, so they're ignored the same way
430    /// an unrecognised key always is.
431    ///
432    /// # Errors
433    ///
434    /// Returns an error if the file cannot be read, is not valid TOML, or
435    /// fails validation.
436    pub fn load_from_path_engine_only<P: AsRef<Path>>(path: P) -> Result<Self, ConfigError> {
437        let raw = std::fs::read_to_string(path.as_ref())?;
438        let filtered = Self::filter_to_engine_sections(&raw)?;
439
440        let figment = Figment::new()
441            .merge(Serialized::defaults(Self::default()))
442            .merge(Toml::string(&filtered))
443            .merge(Env::prefixed("VELESDB_").split("_").lowercase(false));
444
445        let config: Self = figment
446            .extract()
447            .map_err(|e| ConfigError::ParseError(e.to_string()))?;
448        config.validate()?;
449        Ok(config)
450    }
451
452    /// Same as [`Self::load_from_path_engine_only`] but from an in-memory
453    /// TOML string, with no environment-variable layer — mirrors how
454    /// [`Self::from_toml`] relates to [`Self::load_from_path`].
455    ///
456    /// # Errors
457    ///
458    /// Returns an error if `toml_str` is not valid TOML or fails
459    /// validation.
460    pub fn from_toml_engine_only(toml_str: &str) -> Result<Self, ConfigError> {
461        let filtered = Self::filter_to_engine_sections(toml_str)?;
462        Self::from_toml(&filtered)
463    }
464
465    // Validation is in config_validation.rs
466
467    /// Returns the effective `ef_search` value.
468    #[must_use]
469    pub fn effective_ef_search(&self) -> usize {
470        self.search
471            .ef_search
472            .unwrap_or_else(|| self.search.default_mode.ef_search())
473    }
474
475    /// Serializes the configuration to TOML.
476    ///
477    /// # Errors
478    ///
479    /// Returns an error if serialization fails.
480    pub fn to_toml(&self) -> Result<String, ConfigError> {
481        toml::to_string_pretty(self).map_err(|e| ConfigError::ParseError(e.to_string()))
482    }
483}
484
485#[cfg(test)]
486mod shared_toml_tests {
487    use super::*;
488
489    /// Documents why `_engine_only` exists: `[server] port = 443` is a
490    /// legitimate low HTTP bind port for a hosting shell (e.g.
491    /// `velesdb-server` behind `setcap`), but fed through the
492    /// whole-struct loader it lands in *this* crate's own `server.port`
493    /// and trips `validate_server`'s `>= 1024` rule — a real, reproducible
494    /// bug when a shell shares its `velesdb.toml` with `VelesConfig`
495    /// as-is (not a regression test to "fix" — `load_from_path` is
496    /// correct for standalone/embedded use where `[server]` truly
497    /// belongs to `VelesConfig`).
498    #[test]
499    fn test_load_from_path_whole_struct_rejects_shell_owned_low_port() {
500        let dir = tempfile::tempdir().expect("test: temp dir");
501        let path = dir.path().join("velesdb.toml");
502        std::fs::write(
503            &path,
504            "[server]\nport = 443\n\n[limits]\nmax_collections = 5\n",
505        )
506        .expect("test: write toml");
507
508        let err = VelesConfig::load_from_path(&path).expect_err(
509            "whole-struct loader must still reject port=443 via its own server section",
510        );
511        assert!(
512            err.to_string().contains("server.port"),
513            "unexpected error: {err}"
514        );
515    }
516
517    /// The actual fix: a shell-owned `[server] port = 443` no longer
518    /// leaks into `VelesConfig`'s own `server` section, and the genuine
519    /// engine section (`[limits]`) is still applied.
520    #[test]
521    fn test_load_from_path_engine_only_ignores_shell_owned_server_section() {
522        let dir = tempfile::tempdir().expect("test: temp dir");
523        let path = dir.path().join("velesdb.toml");
524        std::fs::write(
525            &path,
526            "[server]\nport = 443\n\n[limits]\nmax_collections = 5\n",
527        )
528        .expect("test: write toml");
529
530        let config = VelesConfig::load_from_path_engine_only(&path)
531            .expect("engine-only loader must ignore the shell-owned [server] section");
532
533        // The engine section came through.
534        assert_eq!(config.limits.max_collections, 5);
535        // The shell-owned [server] section did NOT — the struct's own
536        // `server.port` stays at its default, proving the table was
537        // dropped rather than parsed-then-happening-to-pass-validation.
538        assert_eq!(config.server.port, ServerConfig::default().port);
539    }
540
541    #[test]
542    fn test_from_toml_engine_only_ignores_shell_owned_server_section() {
543        let config = VelesConfig::from_toml_engine_only(
544            "[server]\nport = 443\n\n[limits]\nmax_collections = 7\n",
545        )
546        .expect("engine-only parser must ignore the shell-owned [server] section");
547
548        assert_eq!(config.limits.max_collections, 7);
549        assert_eq!(config.server.port, ServerConfig::default().port);
550    }
551
552    #[test]
553    fn test_load_from_path_engine_only_still_applies_non_server_engine_sections() {
554        let dir = tempfile::tempdir().expect("test: temp dir");
555        let path = dir.path().join("velesdb.toml");
556        std::fs::write(
557            &path,
558            "[hnsw]\nm = 24\n\n[wal_batch]\nenabled = true\ncommit_delay_us = 250\n",
559        )
560        .expect("test: write toml");
561
562        let config = VelesConfig::load_from_path_engine_only(&path)
563            .expect("engine-only loader must still apply hnsw/wal_batch");
564
565        assert_eq!(config.hnsw.m, Some(24));
566        assert!(config.wal_batch.enabled);
567        assert_eq!(config.wal_batch.commit_delay_us, 250);
568    }
569
570    #[test]
571    fn test_load_from_path_engine_only_missing_file_errors() {
572        let missing = std::path::Path::new("/nonexistent/velesdb-issue-1549-engine-only.toml");
573        assert!(VelesConfig::load_from_path_engine_only(missing).is_err());
574    }
575
576    #[test]
577    fn test_from_toml_engine_only_invalid_value_still_fails_typed() {
578        // max_collections = 0 is out of range — the fix must not silently
579        // swallow real validation errors, only shell-owned sections.
580        let err = VelesConfig::from_toml_engine_only("[limits]\nmax_collections = 0\n")
581            .expect_err("out-of-range engine value must still fail");
582        assert!(
583            err.to_string().contains("limits.max_collections"),
584            "unexpected error: {err}"
585        );
586    }
587}