Skip to main content

sqry_db/
config.rs

1//! Configuration for `QueryDb`.
2//!
3//! All settings have sensible defaults. Override via [`QueryDbConfig::builder`]
4//! or environment variables when running as MCP/CLI.
5
6/// Configuration for the incremental query database.
7#[derive(Debug, Clone)]
8pub struct QueryDbConfig {
9    /// Number of cache shards. Must be a power of two. Default: 64.
10    pub shard_count: usize,
11    /// Arena fragmentation threshold for triggering compaction (0.0–1.0).
12    /// Default: 0.20 (20%).
13    pub compaction_fragmentation_threshold: f64,
14    /// Delta buffer ratio threshold for triggering compaction (0.0–1.0).
15    /// Default: 0.10 (10%).
16    pub compaction_delta_ratio_threshold: f64,
17    /// Path for persisted derived facts (relative to `.sqry/graph/`).
18    /// Default: `"derived.sqry"`.
19    pub derived_persistence_filename: String,
20    /// Whether to enable async background compaction. Default: true.
21    pub enable_background_compaction: bool,
22    /// Maximum bytes per cached entry (raw key+value postcard encoding).
23    ///
24    /// Entries whose serialized size exceeds this cap are evaluated normally
25    /// but **NOT cached**. This prevents a single oversized result from
26    /// evicting many smaller entries and wasting shard memory.
27    ///
28    /// Default: 1 MiB (1_048_576 bytes = `1 << 20`).
29    pub max_entry_size_bytes: usize,
30}
31
32impl Default for QueryDbConfig {
33    fn default() -> Self {
34        Self {
35            shard_count: 64,
36            compaction_fragmentation_threshold: 0.20,
37            compaction_delta_ratio_threshold: 0.10,
38            derived_persistence_filename: "derived.sqry".to_owned(),
39            enable_background_compaction: true,
40            max_entry_size_bytes: 1 << 20, // 1 MiB
41        }
42    }
43}
44
45impl QueryDbConfig {
46    /// Returns a builder for fluent configuration.
47    #[must_use]
48    pub fn builder() -> QueryDbConfigBuilder {
49        QueryDbConfigBuilder(Self::default())
50    }
51
52    /// Creates a config from environment variables, falling back to defaults.
53    ///
54    /// Recognized variables:
55    /// - `SQRY_DB_SHARD_COUNT` — cache shard count (power of two)
56    /// - `SQRY_DB_COMPACTION_FRAG` — fragmentation threshold (float)
57    /// - `SQRY_DB_COMPACTION_DELTA` — delta ratio threshold (float)
58    /// - `SQRY_DB_DERIVED_FILE` — persistence filename
59    /// - `SQRY_DB_BG_COMPACTION` — `0` to disable background compaction
60    /// - `SQRY_DB_MAX_ENTRY_SIZE_BYTES` — maximum serialized bytes per cached
61    ///   entry; must parse as a positive `usize` (rejects `0` and
62    ///   non-numeric values — falls back to default of 1 MiB)
63    #[must_use]
64    pub fn from_env() -> Self {
65        Self::from_env_impl(|key| std::env::var(key).ok())
66    }
67
68    /// Inner implementation of [`from_env`] that accepts an arbitrary env
69    /// lookup function.  Separating the two lets unit tests inject a fake
70    /// environment without touching the real process environment.
71    fn from_env_impl(getter: impl Fn(&str) -> Option<String>) -> Self {
72        let mut cfg = Self::default();
73
74        if let Some(v) = getter("SQRY_DB_SHARD_COUNT")
75            && let Ok(n) = v.parse::<usize>()
76            && n.is_power_of_two()
77            && n > 0
78        {
79            cfg.shard_count = n;
80        }
81        if let Some(v) = getter("SQRY_DB_COMPACTION_FRAG")
82            && let Ok(f) = v.parse::<f64>()
83            && (0.0..=1.0).contains(&f)
84        {
85            cfg.compaction_fragmentation_threshold = f;
86        }
87        if let Some(v) = getter("SQRY_DB_COMPACTION_DELTA")
88            && let Ok(f) = v.parse::<f64>()
89            && (0.0..=1.0).contains(&f)
90        {
91            cfg.compaction_delta_ratio_threshold = f;
92        }
93        if let Some(v) = getter("SQRY_DB_DERIVED_FILE")
94            && !v.is_empty()
95        {
96            cfg.derived_persistence_filename = v;
97        }
98        if let Some(v) = getter("SQRY_DB_BG_COMPACTION") {
99            cfg.enable_background_compaction = v != "0";
100        }
101        if let Some(v) = getter("SQRY_DB_MAX_ENTRY_SIZE_BYTES") {
102            match v.parse::<usize>() {
103                Ok(0) => {
104                    log::warn!(
105                        "SQRY_DB_MAX_ENTRY_SIZE_BYTES=0 is invalid (must be > 0); \
106                         using default {}",
107                        cfg.max_entry_size_bytes
108                    );
109                }
110                Ok(n) => {
111                    cfg.max_entry_size_bytes = n;
112                }
113                Err(_) => {
114                    log::warn!(
115                        "SQRY_DB_MAX_ENTRY_SIZE_BYTES={:?} could not be parsed as usize; \
116                         using default {}",
117                        v,
118                        cfg.max_entry_size_bytes
119                    );
120                }
121            }
122        }
123
124        cfg
125    }
126}
127
128/// Builder for [`QueryDbConfig`].
129pub struct QueryDbConfigBuilder(QueryDbConfig);
130
131impl QueryDbConfigBuilder {
132    /// Sets the number of cache shards. Must be a power of two.
133    ///
134    /// # Panics
135    ///
136    /// Panics if `count` is not a power of two or is zero.
137    #[must_use]
138    pub fn shard_count(mut self, count: usize) -> Self {
139        assert!(
140            count > 0 && count.is_power_of_two(),
141            "shard_count must be a positive power of two"
142        );
143        self.0.shard_count = count;
144        self
145    }
146
147    /// Sets the arena fragmentation threshold (0.0–1.0).
148    #[must_use]
149    pub fn compaction_fragmentation_threshold(mut self, threshold: f64) -> Self {
150        self.0.compaction_fragmentation_threshold = threshold.clamp(0.0, 1.0);
151        self
152    }
153
154    /// Sets the delta buffer ratio threshold (0.0–1.0).
155    #[must_use]
156    pub fn compaction_delta_ratio_threshold(mut self, threshold: f64) -> Self {
157        self.0.compaction_delta_ratio_threshold = threshold.clamp(0.0, 1.0);
158        self
159    }
160
161    /// Sets the derived persistence filename.
162    #[must_use]
163    pub fn derived_persistence_filename(mut self, filename: impl Into<String>) -> Self {
164        self.0.derived_persistence_filename = filename.into();
165        self
166    }
167
168    /// Enables or disables background compaction.
169    #[must_use]
170    pub fn enable_background_compaction(mut self, enable: bool) -> Self {
171        self.0.enable_background_compaction = enable;
172        self
173    }
174
175    /// Sets the maximum serialized size (in bytes) for a single cached entry.
176    ///
177    /// Entries that exceed this limit are evaluated normally but not stored in
178    /// the cache. See [`QueryDbConfig::max_entry_size_bytes`] for full
179    /// semantics.
180    ///
181    /// # Panics
182    ///
183    /// Panics if `bytes` is zero.
184    #[must_use]
185    pub fn max_entry_size_bytes(mut self, bytes: usize) -> Self {
186        assert!(bytes > 0, "max_entry_size_bytes must be > 0");
187        self.0.max_entry_size_bytes = bytes;
188        self
189    }
190
191    /// Builds the config.
192    #[must_use]
193    pub fn build(self) -> QueryDbConfig {
194        self.0
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    // ---------------------------------------------------------------------------
203    // Default value
204    // ---------------------------------------------------------------------------
205
206    #[test]
207    fn max_entry_size_bytes_default_is_1_mib() {
208        let cfg = QueryDbConfig::default();
209        assert_eq!(cfg.max_entry_size_bytes, 1 << 20);
210        assert_eq!(cfg.max_entry_size_bytes, 1_048_576);
211    }
212
213    // ---------------------------------------------------------------------------
214    // from_env_impl — deterministic, no global env state
215    // ---------------------------------------------------------------------------
216
217    fn fake_env<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
218        move |key| {
219            pairs
220                .iter()
221                .find(|(k, _)| *k == key)
222                .map(|(_, v)| (*v).to_owned())
223        }
224    }
225
226    #[test]
227    fn max_entry_size_bytes_from_env_parses_valid() {
228        let cfg =
229            QueryDbConfig::from_env_impl(fake_env(&[("SQRY_DB_MAX_ENTRY_SIZE_BYTES", "2097152")]));
230        assert_eq!(cfg.max_entry_size_bytes, 2_097_152);
231    }
232
233    #[test]
234    fn max_entry_size_bytes_from_env_rejects_zero() {
235        // Zero is rejected; field should remain at its default (1 MiB).
236        let cfg = QueryDbConfig::from_env_impl(fake_env(&[("SQRY_DB_MAX_ENTRY_SIZE_BYTES", "0")]));
237        assert_eq!(cfg.max_entry_size_bytes, 1 << 20);
238    }
239
240    #[test]
241    fn max_entry_size_bytes_from_env_rejects_unparseable() {
242        // Non-numeric value is rejected; field should remain at its default.
243        let cfg = QueryDbConfig::from_env_impl(fake_env(&[(
244            "SQRY_DB_MAX_ENTRY_SIZE_BYTES",
245            "not_a_number",
246        )]));
247        assert_eq!(cfg.max_entry_size_bytes, 1 << 20);
248    }
249
250    // ---------------------------------------------------------------------------
251    // Builder
252    // ---------------------------------------------------------------------------
253
254    #[test]
255    #[should_panic(expected = "max_entry_size_bytes must be > 0")]
256    fn builder_rejects_zero_max_entry_size_bytes() {
257        let _ = QueryDbConfig::builder().max_entry_size_bytes(0).build();
258    }
259
260    #[test]
261    fn builder_accepts_nonzero_max_entry_size_bytes() {
262        let cfg = QueryDbConfig::builder()
263            .max_entry_size_bytes(512 * 1024) // 512 KiB
264            .build();
265        assert_eq!(cfg.max_entry_size_bytes, 524_288);
266    }
267}