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}