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///
91/// **Reserved — parsed and validated, not yet applied.** Only `[limits]`
92/// reaches the engine today; [`VelesConfig::validate`] warns when this
93/// section deviates from its defaults so a config cannot silently promise
94/// behavior the engine does not deliver. Wiring these values as engine
95/// defaults is tracked in issue #2087. Per-query runtime overrides
96/// (`WITH (ef_search = N)`) are a separate, working mechanism.
97#[derive(Debug, Clone, Serialize, Deserialize)]
98#[serde(default)]
99pub struct SearchConfig {
100    /// Default search mode.
101    pub default_mode: SearchMode,
102    /// Override `ef_search` (if set, overrides mode).
103    pub ef_search: Option<usize>,
104    /// Maximum results per query.
105    pub max_results: usize,
106    /// Query timeout in milliseconds.
107    pub query_timeout_ms: u64,
108}
109
110impl Default for SearchConfig {
111    fn default() -> Self {
112        Self {
113            default_mode: SearchMode::Balanced,
114            ef_search: None,
115            max_results: 1000,
116            query_timeout_ms: 30000,
117        }
118    }
119}
120
121/// HNSW index configuration section.
122///
123/// **Reserved — parsed and validated, not yet applied.** Only `[limits]`
124/// reaches the engine today; [`VelesConfig::validate`] warns when this
125/// section deviates from its defaults so a config cannot silently promise
126/// behavior the engine does not deliver. Wiring these values as engine
127/// defaults is tracked in issue #2087. Per-query runtime overrides
128/// (`WITH (ef_search = N)`) are a separate, working mechanism.
129#[derive(Debug, Clone, Default, Serialize, Deserialize)]
130#[serde(default)]
131pub struct HnswConfig {
132    /// Number of connections per node (M parameter).
133    /// `None` = auto based on dimension.
134    pub m: Option<usize>,
135    /// Size of the candidate pool during construction.
136    /// `None` = auto based on dimension.
137    pub ef_construction: Option<usize>,
138    /// Maximum number of layers (0 = auto).
139    pub max_layers: usize,
140}
141
142/// Server-layer configuration types (HTTP transport, logging, storage paths).
143///
144/// These types are intentionally separated from the core engine configuration
145/// (`SearchConfig`, `HnswConfig`, `LimitsConfig`) to enforce layer boundaries.
146/// Import via `config::server::ServerConfig` or use the crate-root re-exports.
147pub mod server {
148    use serde::{Deserialize, Serialize};
149
150    /// Storage configuration section.
151    ///
152    /// **Reserved — parsed and validated, not yet applied.** Only
153    /// `[limits]` reaches the engine today; `VelesConfig::validate` warns
154    /// when this section deviates from its defaults. Wiring is tracked in
155    /// issue #2087 (`data_dir` in particular conflicts with the path passed
156    /// to `Database::open` and may go through deprecation instead).
157    #[derive(Debug, Clone, Serialize, Deserialize)]
158    #[serde(default)]
159    pub struct StorageConfig {
160        /// Data directory path.
161        pub data_dir: String,
162        /// Storage mode: `"mmap"` or `"memory"`.
163        pub storage_mode: String,
164        /// Mmap cache size in megabytes.
165        pub mmap_cache_mb: usize,
166        /// Vector alignment in bytes.
167        pub vector_alignment: usize,
168    }
169
170    impl Default for StorageConfig {
171        fn default() -> Self {
172            Self {
173                data_dir: "./velesdb_data".to_string(),
174                storage_mode: "mmap".to_string(),
175                mmap_cache_mb: 1024,
176                vector_alignment: 64,
177            }
178        }
179    }
180
181    /// Server configuration section.
182    #[derive(Debug, Clone, Serialize, Deserialize)]
183    #[serde(default)]
184    pub struct ServerConfig {
185        /// Host address.
186        pub host: String,
187        /// Port number.
188        pub port: u16,
189        /// Number of worker threads (0 = auto).
190        pub workers: usize,
191        /// Maximum HTTP body size in bytes.
192        pub max_body_size: usize,
193        /// Enable CORS.
194        pub cors_enabled: bool,
195        /// CORS allowed origins.
196        pub cors_origins: Vec<String>,
197    }
198
199    impl Default for ServerConfig {
200        fn default() -> Self {
201            Self {
202                host: "127.0.0.1".to_string(),
203                port: 8080,
204                workers: 0,
205                max_body_size: 104_857_600,
206                cors_enabled: false,
207                cors_origins: vec!["*".to_string()],
208            }
209        }
210    }
211
212    /// Logging configuration section.
213    #[derive(Debug, Clone, Serialize, Deserialize)]
214    #[serde(default)]
215    pub struct LoggingConfig {
216        /// Log level: `error`, `warn`, `info`, `debug`, `trace`.
217        pub level: String,
218        /// Log format: `text` or `json`.
219        pub format: String,
220        /// Log file path (empty = stdout).
221        pub file: String,
222    }
223
224    impl Default for LoggingConfig {
225        fn default() -> Self {
226            Self {
227                level: "info".to_string(),
228                format: "text".to_string(),
229                file: String::new(),
230            }
231        }
232    }
233}
234
235// Backward-compatible re-exports at module level.
236pub use server::{LoggingConfig, ServerConfig, StorageConfig};
237
238/// Limits configuration section.
239///
240/// `#[non_exhaustive]`: build from [`LimitsConfig::default`] and adjust fields
241/// so future limits stay backward compatible for downstream crates.
242#[derive(Debug, Clone, Serialize, Deserialize)]
243#[serde(default)]
244#[non_exhaustive]
245pub struct LimitsConfig {
246    /// Maximum vector dimensions.
247    pub max_dimensions: usize,
248    /// Maximum vectors per collection.
249    pub max_vectors_per_collection: usize,
250    /// Maximum number of collections.
251    pub max_collections: usize,
252    /// Maximum payload size in bytes.
253    pub max_payload_size: usize,
254    /// Maximum vectors for perfect mode (bruteforce).
255    pub max_perfect_mode_vectors: usize,
256}
257
258impl Default for LimitsConfig {
259    fn default() -> Self {
260        Self {
261            max_dimensions: 4096,
262            max_vectors_per_collection: 100_000_000,
263            max_collections: 1000,
264            max_payload_size: 1_048_576, // 1 MB
265            max_perfect_mode_vectors: 500_000,
266        }
267    }
268}
269
270// ---------------------------------------------------------------------------
271// WAL batch commit configuration
272// ---------------------------------------------------------------------------
273
274/// Default commit delay in microseconds for WAL group commit.
275const fn default_commit_delay_us() -> u64 {
276    100
277}
278
279/// Default maximum entries per WAL batch.
280const fn default_max_batch_size() -> usize {
281    128
282}
283
284/// Configuration for WAL group commit batching.
285///
286/// **Reserved — parsed but not yet wired.** Setting `enabled = true` changes
287/// nothing today: no group commit occurs, and every write keeps its own
288/// durability barrier (batch APIs already amortize to one barrier per call).
289/// [`VelesConfig::validate`] logs a warning when the flag is set so a config
290/// cannot promise behavior the engine does not deliver. Wiring the
291/// `WalBatcher` (or removing this section) is tracked in issue #2078 — the
292/// decision needs a multi-writer measurement, not a default.
293///
294/// When wired, group commit would batch multiple concurrent writes into a
295/// single `sync_all()` call, amortizing the fsync cost across the batch.
296///
297/// # Example (TOML)
298///
299/// ```toml
300/// [wal_batch]
301/// enabled = true
302/// commit_delay_us = 200
303/// max_batch_size = 256
304/// ```
305#[derive(Debug, Clone, Serialize, Deserialize)]
306pub struct WalBatchConfig {
307    /// Whether group commit is enabled. Default: `false`.
308    #[serde(default)]
309    pub enabled: bool,
310    /// Maximum delay in microseconds before flushing a batch. Default: `100`.
311    #[serde(default = "default_commit_delay_us")]
312    pub commit_delay_us: u64,
313    /// Maximum number of entries per batch. Default: `128`.
314    #[serde(default = "default_max_batch_size")]
315    pub max_batch_size: usize,
316}
317
318impl Default for WalBatchConfig {
319    fn default() -> Self {
320        Self {
321            enabled: false,
322            commit_delay_us: 100,
323            max_batch_size: 128,
324        }
325    }
326}
327
328/// Main `VelesDB` configuration structure.
329#[derive(Debug, Clone, Serialize, Deserialize, Default)]
330#[serde(default)]
331pub struct VelesConfig {
332    /// Search configuration.
333    pub search: SearchConfig,
334    /// HNSW index configuration.
335    pub hnsw: HnswConfig,
336    /// Storage configuration.
337    pub storage: StorageConfig,
338    /// Limits configuration.
339    pub limits: LimitsConfig,
340    /// Server configuration.
341    pub server: ServerConfig,
342    /// Logging configuration.
343    pub logging: LoggingConfig,
344    /// Quantization configuration.
345    pub quantization: QuantizationConfig,
346    /// WAL group commit batching configuration.
347    pub wal_batch: WalBatchConfig,
348}
349
350impl VelesConfig {
351    /// Loads configuration from default sources.
352    ///
353    /// Priority: defaults < file < environment variables.
354    ///
355    /// # Errors
356    ///
357    /// Returns `ConfigError` if the configuration file is malformed or
358    /// environment variables contain invalid values.
359    pub fn load() -> Result<Self, ConfigError> {
360        Self::load_from_path("velesdb.toml")
361    }
362
363    /// Loads configuration from a specific file path.
364    ///
365    /// # Arguments
366    ///
367    /// * `path` - Path to the configuration file.
368    ///
369    /// # Errors
370    ///
371    /// Returns an error if configuration parsing fails.
372    pub fn load_from_path<P: AsRef<Path>>(path: P) -> Result<Self, ConfigError> {
373        let figment = Figment::new()
374            .merge(Serialized::defaults(Self::default()))
375            .merge(Toml::file(path.as_ref()))
376            .merge(Env::prefixed("VELESDB_").split("_").lowercase(false));
377
378        Self::finish(&figment)
379    }
380
381    /// Creates a configuration from a TOML string.
382    ///
383    /// # Arguments
384    ///
385    /// * `toml_str` - TOML configuration string.
386    ///
387    /// # Errors
388    ///
389    /// Returns an error if parsing fails.
390    pub fn from_toml(toml_str: &str) -> Result<Self, ConfigError> {
391        let figment = Figment::new()
392            .merge(Serialized::defaults(Self::default()))
393            .merge(Toml::string(toml_str));
394
395        Self::finish(&figment)
396    }
397
398    /// The top-level TOML tables that belong to the *engine* — as opposed
399    /// to `server` and `logging`, which are also fields on this struct but
400    /// exist for standalone/embedded consumers of `VelesConfig`. A hosting
401    /// shell (e.g. `velesdb-server`) that owns its own same-named
402    /// `[server]` table in the same file — different shape, different
403    /// meaning (HTTP bind port vs. this struct's own `server.port`) — would
404    /// otherwise have that table parsed into *this* struct too and
405    /// rejected by [`Self::validate`]'s rules for a value it was never
406    /// meant to apply to. See [`Self::load_from_path_engine_only`].
407    const ENGINE_SECTIONS: &'static [&'static str] = &[
408        "search",
409        "hnsw",
410        "storage",
411        "limits",
412        "quantization",
413        "wal_batch",
414    ];
415
416    /// Drops every top-level TOML table not in [`Self::ENGINE_SECTIONS`].
417    fn filter_to_engine_sections(raw: &str) -> Result<String, ConfigError> {
418        let mut doc: toml::Value =
419            toml::from_str(raw).map_err(|e| ConfigError::ParseError(e.to_string()))?;
420        if let Some(table) = doc.as_table_mut() {
421            table.retain(|k, _| Self::ENGINE_SECTIONS.contains(&k));
422        }
423        toml::to_string(&doc).map_err(|e| ConfigError::ParseError(e.to_string()))
424    }
425
426    /// Loads configuration from a specific file path, considering **only**
427    /// the engine sections (`[search]`/`[hnsw]`/`[storage]`/`[limits]`/
428    /// `[quantization]`/`[wal_batch]`) and silently dropping any other
429    /// top-level table before parsing — notably `[server]` and `[logging]`.
430    ///
431    /// Use this instead of [`Self::load_from_path`] when the TOML file is
432    /// **shared** with a hosting shell that owns its own `[server]`/
433    /// `[auth]`/`[tls]`/`[cors]`/... sections under possibly-colliding
434    /// keys — e.g. `velesdb-server --config` reads the same file for its
435    /// own HTTP transport settings (`[server].port` = the bind port) *and*
436    /// for this engine config. Without filtering, `[server] port = 443`
437    /// (a perfectly legitimate low bind port, e.g. behind `setcap`/a
438    /// privileged process) would also land in *this* struct's
439    /// `server.port` and be rejected by [`Self::validate`]'s `port >=
440    /// 1024` rule — a spurious failure with nothing to do with the actual
441    /// value being configured.
442    ///
443    /// As with [`Self::load_from_path`], `VELESDB_*` environment variables
444    /// are layered on top of the (filtered) file and can still override an
445    /// engine value — e.g. `VELESDB_LIMITS_MAX_COLLECTIONS=5` overrides a
446    /// `[limits] max_collections` from the file. Env vars for non-engine
447    /// sections (`VELESDB_SERVER_*`, `VELESDB_LOGGING_*`, ...) are
448    /// harmless here: they don't match any field once those sections are
449    /// filtered out of the base document, so they're ignored the same way
450    /// an unrecognised key always is.
451    ///
452    /// # Errors
453    ///
454    /// Returns an error if the file cannot be read, is not valid TOML, or
455    /// fails validation.
456    pub fn load_from_path_engine_only<P: AsRef<Path>>(path: P) -> Result<Self, ConfigError> {
457        let raw = std::fs::read_to_string(path.as_ref())?;
458        let filtered = Self::filter_to_engine_sections(&raw)?;
459
460        let figment = Figment::new()
461            .merge(Serialized::defaults(Self::default()))
462            .merge(Toml::string(&filtered))
463            .merge(Env::prefixed("VELESDB_").split("_").lowercase(false));
464
465        Self::finish(&figment)
466    }
467
468    /// Extracts a [`Self`] from an assembled [`Figment`] and validates it.
469    /// Shared tail of `load_from_path`, `from_toml`, and
470    /// `load_from_path_engine_only`, which differ only in how `figment` is
471    /// assembled.
472    fn finish(figment: &Figment) -> Result<Self, ConfigError> {
473        let config: Self = figment
474            .extract()
475            .map_err(|e| ConfigError::ParseError(e.to_string()))?;
476        config.validate()?;
477        Ok(config)
478    }
479
480    /// Same as [`Self::load_from_path_engine_only`] but from an in-memory
481    /// TOML string, with no environment-variable layer — mirrors how
482    /// [`Self::from_toml`] relates to [`Self::load_from_path`].
483    ///
484    /// # Errors
485    ///
486    /// Returns an error if `toml_str` is not valid TOML or fails
487    /// validation.
488    pub fn from_toml_engine_only(toml_str: &str) -> Result<Self, ConfigError> {
489        let filtered = Self::filter_to_engine_sections(toml_str)?;
490        Self::from_toml(&filtered)
491    }
492
493    // Validation is in config_validation.rs
494
495    /// Returns the effective `ef_search` value.
496    #[deprecated(
497        since = "5.2.0",
498        note = "never read by the engine — [search] is not applied (issue #2087); \
499                query-time WITH (ef_search = N) is the working override"
500    )]
501    #[must_use]
502    pub fn effective_ef_search(&self) -> usize {
503        self.search
504            .ef_search
505            .unwrap_or_else(|| self.search.default_mode.ef_search())
506    }
507
508    /// Serializes the configuration to TOML.
509    ///
510    /// # Errors
511    ///
512    /// Returns an error if serialization fails.
513    pub fn to_toml(&self) -> Result<String, ConfigError> {
514        toml::to_string_pretty(self).map_err(|e| ConfigError::ParseError(e.to_string()))
515    }
516}
517
518#[cfg(test)]
519#[path = "shared_toml_tests.rs"]
520mod shared_toml_tests;