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    value::{Uncased, UncasedStr},
16    Figment,
17};
18use serde::{Deserialize, Serialize};
19use std::path::Path;
20use thiserror::Error;
21
22// Re-export quantization types so existing `crate::config::Quantization*` paths work.
23pub use crate::config_quantization::{QuantizationConfig, QuantizationType};
24
25/// Configuration errors.
26#[derive(Error, Debug)]
27#[non_exhaustive]
28pub enum ConfigError {
29    /// Failed to parse configuration file.
30    #[error("Failed to parse configuration: {0}")]
31    ParseError(String),
32
33    /// Invalid configuration value.
34    #[error("Invalid configuration value for '{key}': {message}")]
35    InvalidValue {
36        /// Configuration key that failed validation.
37        key: String,
38        /// Validation error message.
39        message: String,
40    },
41
42    /// Configuration file not found.
43    #[error("Configuration file not found: {0}")]
44    FileNotFound(String),
45
46    /// IO error.
47    #[error("IO error: {0}")]
48    IoError(#[from] std::io::Error),
49}
50
51/// Search mode presets.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
53#[serde(rename_all = "snake_case")]
54#[non_exhaustive]
55pub enum SearchMode {
56    /// Fast search with `ef_search=96`, ~95% recall.
57    Fast,
58    /// Balanced search with `ef_search=160`, ~99.5% recall (default).
59    #[default]
60    Balanced,
61    /// Accurate search with `ef_search=512`, ~100% recall.
62    Accurate,
63    /// Perfect recall via **exhaustive bruteforce** (`ef_search = usize::MAX`
64    /// signals a full scan): every vector is scored, no HNSW graph traversal, so
65    /// recall is 100% by construction at O(n) cost.
66    ///
67    /// Distinct from `SearchQuality::Perfect`
68    /// (`crate::index::hnsw::SearchQuality`) despite the shared name:
69    /// `SearchMode` picks the **engine** (bruteforce here vs. the HNSW graph),
70    /// whereas `SearchQuality::Perfect` stays *on* the graph with a very high
71    /// `ef_search` (`4096.max(k*100)`) — ~1.0 recall up to ~100K, ~0.9994 at 1M,
72    /// at graph cost rather than a full scan. Pick `SearchMode::Perfect` only
73    /// when an exact guarantee is worth the linear scan.
74    Perfect,
75}
76
77impl SearchMode {
78    /// Returns the `ef_search` value for this mode.
79    #[must_use]
80    pub fn ef_search(&self) -> usize {
81        match self {
82            Self::Fast => 96,
83            Self::Balanced => 160,
84            Self::Accurate => 512,
85            Self::Perfect => usize::MAX, // Signals bruteforce
86        }
87    }
88}
89
90/// Search configuration section.
91///
92/// **Reserved — parsed and validated, not yet applied.** `[limits]` and
93/// `[hnsw]` reach the engine; this section does not.
94/// [`VelesConfig::validate`] warns when it deviates from its defaults so a
95/// config cannot silently promise behavior the engine does not deliver.
96/// Wiring is tracked in issue #2087 — `query_timeout_ms` in particular needs
97/// a query timeout the engine does not have, which is a feature of its own.
98/// Per-query runtime overrides (`WITH (ef_search = N)`) are a separate,
99/// working mechanism.
100#[derive(Debug, Clone, Serialize, Deserialize)]
101#[serde(default)]
102pub struct SearchConfig {
103    /// Default search mode.
104    pub default_mode: SearchMode,
105    /// Override `ef_search` (if set, overrides mode).
106    pub ef_search: Option<usize>,
107    /// Maximum results per query.
108    pub max_results: usize,
109    /// Query timeout in milliseconds.
110    pub query_timeout_ms: u64,
111}
112
113impl Default for SearchConfig {
114    fn default() -> Self {
115        Self {
116            default_mode: SearchMode::Balanced,
117            ef_search: None,
118            max_results: 1000,
119            query_timeout_ms: 30000,
120        }
121    }
122}
123
124/// HNSW index configuration section — the deployment-wide default for the
125/// graph topology of every index the engine builds.
126///
127/// **Applied at collection creation** (issue #2087). `m` and
128/// `ef_construction` take effect through
129/// [`HnswParams::from_config`](crate::index::hnsw::HnswParams::from_config),
130/// under this precedence chain:
131///
132/// ```text
133/// per-collection creation argument  >  [hnsw] section  >  HnswParams::auto(dimension)
134/// ```
135///
136/// The values are **creation-time**: they are persisted into the collection's
137/// own config and fix the graph topology, so editing this section afterwards
138/// affects new collections only. Re-tuning an existing one means rebuilding
139/// its index (`auto_reindex`), not reloading a file.
140///
141/// Per-query runtime overrides (`WITH (ef_search = N)`) are a separate,
142/// working mechanism on a different axis: `ef_search` sizes the candidate pool
143/// of one query; nothing here does.
144///
145/// `max_layers` is the exception and is still inert:
146/// [`VelesConfig::validate`] warns when it is set. The HNSW layer count is
147/// drawn per node by the level generator and no engine path caps it, so
148/// honouring the knob is a feature, not a wiring.
149#[derive(Debug, Clone, Default, Serialize, Deserialize)]
150#[serde(default)]
151pub struct HnswConfig {
152    /// Number of connections per node (M parameter).
153    /// `None` = auto based on dimension.
154    pub m: Option<usize>,
155    /// Size of the candidate pool during construction.
156    /// `None` = auto based on dimension.
157    pub ef_construction: Option<usize>,
158    /// Maximum number of layers (0 = auto).
159    ///
160    /// **Reserved — parsed and validated, not applied.** See the type-level
161    /// note above.
162    pub max_layers: usize,
163}
164
165/// Server-layer configuration types (HTTP transport, logging, storage paths).
166///
167/// These types are intentionally separated from the core engine configuration
168/// (`SearchConfig`, `HnswConfig`, `LimitsConfig`) to enforce layer boundaries.
169/// Import via `config::server::ServerConfig` or use the crate-root re-exports.
170pub mod server {
171    use serde::{Deserialize, Serialize};
172
173    /// Storage configuration section.
174    ///
175    /// **Reserved — parsed and validated, not yet applied.** `[limits]`
176    /// and `[hnsw]` reach the engine; this section does not.
177    /// `VelesConfig::validate` warns when it deviates from its defaults.
178    /// Wiring is tracked in issue #2087 (`data_dir` in particular conflicts
179    /// with the path passed to `Database::open` and may go through
180    /// deprecation instead).
181    #[derive(Debug, Clone, Serialize, Deserialize)]
182    #[serde(default)]
183    pub struct StorageConfig {
184        /// Data directory path.
185        pub data_dir: String,
186        /// Storage mode: `"mmap"` or `"memory"`.
187        pub storage_mode: String,
188        /// Mmap cache size in megabytes.
189        pub mmap_cache_mb: usize,
190        /// Vector alignment in bytes.
191        pub vector_alignment: usize,
192    }
193
194    impl Default for StorageConfig {
195        fn default() -> Self {
196            Self {
197                data_dir: "./velesdb_data".to_string(),
198                storage_mode: "mmap".to_string(),
199                mmap_cache_mb: 1024,
200                vector_alignment: 64,
201            }
202        }
203    }
204
205    /// Server configuration section.
206    #[derive(Debug, Clone, Serialize, Deserialize)]
207    #[serde(default)]
208    pub struct ServerConfig {
209        /// Host address.
210        pub host: String,
211        /// Port number.
212        pub port: u16,
213        /// Number of worker threads (0 = auto).
214        pub workers: usize,
215        /// Maximum HTTP body size in bytes.
216        pub max_body_size: usize,
217        /// Enable CORS.
218        pub cors_enabled: bool,
219        /// CORS allowed origins.
220        pub cors_origins: Vec<String>,
221    }
222
223    impl Default for ServerConfig {
224        fn default() -> Self {
225            Self {
226                host: "127.0.0.1".to_string(),
227                port: 8080,
228                workers: 0,
229                max_body_size: 104_857_600,
230                cors_enabled: false,
231                cors_origins: vec!["*".to_string()],
232            }
233        }
234    }
235
236    /// Logging configuration section.
237    #[derive(Debug, Clone, Serialize, Deserialize)]
238    #[serde(default)]
239    pub struct LoggingConfig {
240        /// Log level: `error`, `warn`, `info`, `debug`, `trace`.
241        pub level: String,
242        /// Log format: `text` or `json`.
243        pub format: String,
244        /// Log file path (empty = stdout).
245        pub file: String,
246    }
247
248    impl Default for LoggingConfig {
249        fn default() -> Self {
250            Self {
251                level: "info".to_string(),
252                format: "text".to_string(),
253                file: String::new(),
254            }
255        }
256    }
257}
258
259// Backward-compatible re-exports at module level.
260pub use server::{LoggingConfig, ServerConfig, StorageConfig};
261
262/// Limits configuration section.
263///
264/// `#[non_exhaustive]`: build from [`LimitsConfig::default`] and adjust fields
265/// so future limits stay backward compatible for downstream crates.
266#[derive(Debug, Clone, Serialize, Deserialize)]
267#[serde(default)]
268#[non_exhaustive]
269pub struct LimitsConfig {
270    /// Maximum vector dimensions.
271    pub max_dimensions: usize,
272    /// Maximum vectors per collection.
273    pub max_vectors_per_collection: usize,
274    /// Maximum number of collections.
275    pub max_collections: usize,
276    /// Maximum payload size in bytes.
277    pub max_payload_size: usize,
278    /// Maximum vectors for perfect mode (bruteforce).
279    pub max_perfect_mode_vectors: usize,
280}
281
282impl Default for LimitsConfig {
283    fn default() -> Self {
284        Self {
285            max_dimensions: 4096,
286            max_vectors_per_collection: 100_000_000,
287            max_collections: 1000,
288            max_payload_size: 1_048_576, // 1 MB
289            max_perfect_mode_vectors: 500_000,
290        }
291    }
292}
293
294// ---------------------------------------------------------------------------
295// WAL batch commit configuration
296// ---------------------------------------------------------------------------
297
298/// Default commit delay in microseconds for WAL group commit.
299const fn default_commit_delay_us() -> u64 {
300    100
301}
302
303/// Default maximum entries per WAL batch.
304const fn default_max_batch_size() -> usize {
305    128
306}
307
308/// Configuration for WAL group commit batching.
309///
310/// **Deprecated — parsed and ignored.** Setting `enabled = true` changes
311/// nothing: no group commit occurs, and every write keeps its own durability
312/// barrier (the batch APIs already amortize to one barrier per call).
313/// [`VelesConfig::validate`] logs a warning when the flag is set so a config
314/// cannot promise behavior the engine does not deliver.
315///
316/// Issue #2078 resolved to retire this rather than wire it: the `WalBatcher`
317/// it configured acknowledged a write before its bytes were durable, so it was
318/// a write coalescer and not a group-commit protocol, and its `commit_delay_us`
319/// was read by nothing — not even the batcher. The module is deleted. This
320/// struct and the `[wal_batch]` table stay only so existing TOML files keep
321/// loading; both go at the next major, which is the Rust API break.
322///
323/// When wired, group commit would batch multiple concurrent writes into a
324/// single `sync_all()` call, amortizing the fsync cost across the batch.
325///
326/// # Example (TOML)
327///
328/// ```toml
329/// [wal_batch]
330/// enabled = true
331/// commit_delay_us = 200
332/// max_batch_size = 256
333/// ```
334#[derive(Debug, Clone, Serialize, Deserialize)]
335pub struct WalBatchConfig {
336    /// Whether group commit is enabled. Default: `false`.
337    #[serde(default)]
338    pub enabled: bool,
339    /// Maximum delay in microseconds before flushing a batch. Default: `100`.
340    #[serde(default = "default_commit_delay_us")]
341    pub commit_delay_us: u64,
342    /// Maximum number of entries per batch. Default: `128`.
343    #[serde(default = "default_max_batch_size")]
344    pub max_batch_size: usize,
345}
346
347impl Default for WalBatchConfig {
348    fn default() -> Self {
349        Self {
350            enabled: false,
351            commit_delay_us: 100,
352            max_batch_size: 128,
353        }
354    }
355}
356
357/// Main `VelesDB` configuration structure.
358#[derive(Debug, Clone, Serialize, Deserialize, Default)]
359#[serde(default)]
360pub struct VelesConfig {
361    /// Search configuration.
362    pub search: SearchConfig,
363    /// HNSW index configuration.
364    pub hnsw: HnswConfig,
365    /// Storage configuration.
366    pub storage: StorageConfig,
367    /// Limits configuration.
368    pub limits: LimitsConfig,
369    /// Server configuration.
370    pub server: ServerConfig,
371    /// Logging configuration.
372    pub logging: LoggingConfig,
373    /// Quantization configuration.
374    pub quantization: QuantizationConfig,
375    /// WAL group commit batching configuration.
376    pub wal_batch: WalBatchConfig,
377}
378
379impl VelesConfig {
380    /// Loads configuration from default sources.
381    ///
382    /// Priority: defaults < file < environment variables.
383    ///
384    /// # Errors
385    ///
386    /// Returns `ConfigError` if the configuration file is malformed or
387    /// environment variables contain invalid values.
388    pub fn load() -> Result<Self, ConfigError> {
389        Self::load_from_path("velesdb.toml")
390    }
391
392    /// Loads configuration from a specific file path.
393    ///
394    /// # Arguments
395    ///
396    /// * `path` - Path to the configuration file.
397    ///
398    /// # Errors
399    ///
400    /// Returns an error if configuration parsing fails.
401    pub fn load_from_path<P: AsRef<Path>>(path: P) -> Result<Self, ConfigError> {
402        let figment = Figment::new()
403            .merge(Serialized::defaults(Self::default()))
404            .merge(Toml::file(path.as_ref()))
405            .merge(Self::env_provider());
406
407        Self::finish(&figment)
408    }
409
410    /// Every top-level table of this struct, as a `VELESDB_*` variable would
411    /// name it. Ordered longest-first so that a section whose name prefixes
412    /// another cannot claim its variables (none do today; the ordering keeps
413    /// that true for whatever is added next).
414    ///
415    /// Wider than [`Self::ENGINE_SECTIONS`] on purpose: `server` and
416    /// `logging` are fields here too, and a variable naming one must resolve
417    /// to that field rather than fall through as an unprefixed key.
418    const ENV_SECTIONS: &'static [&'static str] = &[
419        "quantization",
420        "wal_batch",
421        "logging",
422        "storage",
423        "limits",
424        "search",
425        "server",
426        "hnsw",
427    ];
428
429    /// Maps one `VELESDB_`-stripped variable name onto the config path it
430    /// addresses, splitting **only** at the boundary of a known section.
431    ///
432    /// `VELESDB_HNSW_EF_CONSTRUCTION` must reach `hnsw.ef_construction`, not
433    /// `hnsw.ef.construction`. Figment's `split("_")` treats every underscore
434    /// as a nesting separator, which no field carrying an underscore in its
435    /// name survives — and that is most of them (`max_collections`,
436    /// `ef_construction`, `query_timeout_ms`, ...). Splitting at the section
437    /// boundary and nowhere else is what the documented names actually mean.
438    ///
439    /// A name whose first token is not a section passes through lowercased and
440    /// unsplit, so `VELESDB_CONFIG`, `VELESDB_NO_UPDATE_CHECK` and the
441    /// server's own `VELESDB_HOST` / `VELESDB_PORT` keep matching nothing
442    /// here, exactly as they do today.
443    ///
444    /// Issue #2185: before this, the provider also carried `lowercase(false)`,
445    /// which left the key uppercase and made even the single-token
446    /// `VELESDB_HNSW_M` miss `hnsw.m`. Between the two defects, no documented
447    /// engine variable reached its field.
448    pub(crate) fn env_key_to_config_path(key: &UncasedStr) -> Uncased<'_> {
449        let lowered = key.as_str().to_ascii_lowercase();
450        for section in Self::ENV_SECTIONS {
451            if let Some(field) = lowered
452                .strip_prefix(section)
453                .and_then(|rest| rest.strip_prefix('_'))
454            {
455                if !field.is_empty() {
456                    return Uncased::from_owned(format!("{section}.{field}"));
457                }
458            }
459        }
460        Uncased::from_owned(lowered)
461    }
462
463    /// The `VELESDB_*` environment layer, built once so both loaders resolve
464    /// variable names identically — the same single-mapping-point discipline
465    /// `HnswParams::from_config` follows for the `[hnsw]` table.
466    fn env_provider() -> Env {
467        Env::prefixed("VELESDB_").map(Self::env_key_to_config_path)
468    }
469
470    /// Creates a configuration from a TOML string.
471    ///
472    /// # Arguments
473    ///
474    /// * `toml_str` - TOML configuration string.
475    ///
476    /// # Errors
477    ///
478    /// Returns an error if parsing fails.
479    pub fn from_toml(toml_str: &str) -> Result<Self, ConfigError> {
480        let figment = Figment::new()
481            .merge(Serialized::defaults(Self::default()))
482            .merge(Toml::string(toml_str));
483
484        Self::finish(&figment)
485    }
486
487    /// The top-level TOML tables that belong to the *engine* — as opposed
488    /// to `server` and `logging`, which are also fields on this struct but
489    /// exist for standalone/embedded consumers of `VelesConfig`. A hosting
490    /// shell (e.g. `velesdb-server`) that owns its own same-named
491    /// `[server]` table in the same file — different shape, different
492    /// meaning (HTTP bind port vs. this struct's own `server.port`) — would
493    /// otherwise have that table parsed into *this* struct too and
494    /// rejected by [`Self::validate`]'s rules for a value it was never
495    /// meant to apply to. See [`Self::load_from_path_engine_only`].
496    const ENGINE_SECTIONS: &'static [&'static str] = &[
497        "search",
498        "hnsw",
499        "storage",
500        "limits",
501        "quantization",
502        "wal_batch",
503    ];
504
505    /// Drops every top-level TOML table not in [`Self::ENGINE_SECTIONS`].
506    fn filter_to_engine_sections(raw: &str) -> Result<String, ConfigError> {
507        let mut doc: toml::Value =
508            toml::from_str(raw).map_err(|e| ConfigError::ParseError(e.to_string()))?;
509        if let Some(table) = doc.as_table_mut() {
510            table.retain(|k, _| Self::ENGINE_SECTIONS.contains(&k));
511        }
512        toml::to_string(&doc).map_err(|e| ConfigError::ParseError(e.to_string()))
513    }
514
515    /// Loads configuration from a specific file path, considering **only**
516    /// the engine sections (`[search]`/`[hnsw]`/`[storage]`/`[limits]`/
517    /// `[quantization]`/`[wal_batch]`) and silently dropping any other
518    /// top-level table before parsing — notably `[server]` and `[logging]`.
519    ///
520    /// Use this instead of [`Self::load_from_path`] when the TOML file is
521    /// **shared** with a hosting shell that owns its own `[server]`/
522    /// `[auth]`/`[tls]`/`[cors]`/... sections under possibly-colliding
523    /// keys — e.g. `velesdb-server --config` reads the same file for its
524    /// own HTTP transport settings (`[server].port` = the bind port) *and*
525    /// for this engine config. Without filtering, `[server] port = 443`
526    /// (a perfectly legitimate low bind port, e.g. behind `setcap`/a
527    /// privileged process) would also land in *this* struct's
528    /// `server.port` and be rejected by [`Self::validate`]'s `port >=
529    /// 1024` rule — a spurious failure with nothing to do with the actual
530    /// value being configured.
531    ///
532    /// As with [`Self::load_from_path`], `VELESDB_*` environment variables
533    /// are layered on top of the (filtered) file and can still override an
534    /// engine value — e.g. `VELESDB_LIMITS_MAX_COLLECTIONS=5` overrides a
535    /// `[limits] max_collections` from the file. Env vars for non-engine
536    /// sections (`VELESDB_SERVER_*`, `VELESDB_LOGGING_*`, ...) are
537    /// harmless here: they don't match any field once those sections are
538    /// filtered out of the base document, so they're ignored the same way
539    /// an unrecognised key always is.
540    ///
541    /// # Errors
542    ///
543    /// Returns an error if the file cannot be read, is not valid TOML, or
544    /// fails validation.
545    pub fn load_from_path_engine_only<P: AsRef<Path>>(path: P) -> Result<Self, ConfigError> {
546        let raw = std::fs::read_to_string(path.as_ref())?;
547        let filtered = Self::filter_to_engine_sections(&raw)?;
548
549        let figment = Figment::new()
550            .merge(Serialized::defaults(Self::default()))
551            .merge(Toml::string(&filtered))
552            .merge(Self::env_provider());
553
554        Self::finish(&figment)
555    }
556
557    /// Extracts a [`Self`] from an assembled [`Figment`] and validates it.
558    /// Shared tail of `load_from_path`, `from_toml`, and
559    /// `load_from_path_engine_only`, which differ only in how `figment` is
560    /// assembled.
561    fn finish(figment: &Figment) -> Result<Self, ConfigError> {
562        let config: Self = figment
563            .extract()
564            .map_err(|e| ConfigError::ParseError(e.to_string()))?;
565        config.validate()?;
566        Ok(config)
567    }
568
569    /// Same as [`Self::load_from_path_engine_only`] but from an in-memory
570    /// TOML string, with no environment-variable layer — mirrors how
571    /// [`Self::from_toml`] relates to [`Self::load_from_path`].
572    ///
573    /// # Errors
574    ///
575    /// Returns an error if `toml_str` is not valid TOML or fails
576    /// validation.
577    pub fn from_toml_engine_only(toml_str: &str) -> Result<Self, ConfigError> {
578        let filtered = Self::filter_to_engine_sections(toml_str)?;
579        Self::from_toml(&filtered)
580    }
581
582    // Validation is in config_validation.rs
583
584    /// Returns the effective `ef_search` value.
585    #[deprecated(
586        since = "5.2.0",
587        note = "never read by the engine — [search] is not applied (issue #2087); \
588                query-time WITH (ef_search = N) is the working override"
589    )]
590    #[must_use]
591    pub fn effective_ef_search(&self) -> usize {
592        self.search
593            .ef_search
594            .unwrap_or_else(|| self.search.default_mode.ef_search())
595    }
596
597    /// Serializes the configuration to TOML.
598    ///
599    /// # Errors
600    ///
601    /// Returns an error if serialization fails.
602    pub fn to_toml(&self) -> Result<String, ConfigError> {
603        toml::to_string_pretty(self).map_err(|e| ConfigError::ParseError(e.to_string()))
604    }
605}
606
607#[cfg(test)]
608#[path = "shared_toml_tests.rs"]
609mod shared_toml_tests;