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;