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;