Skip to main content

sonic/
config.rs

1// Sonic
2//
3// Fast, lightweight and schema-less search backend
4// Copyright: 2019, Valerian Saliou <valerian@valeriansaliou.name>
5// Copyright: 2026, Rémi Bardon <remi@remibardon.name>
6// License: Mozilla Public License v2.0 (MPL v2.0)
7
8//! Sonic library configuration.
9//!
10//! It does not include server nor channel configuration, which are specific
11//! to the `sonic-server` binary.
12
13use std::collections::HashSet;
14use std::path::PathBuf;
15use std::sync::Arc;
16
17use serde::Deserialize;
18
19use crate::util::serde::env_var;
20
21#[derive(Deserialize)]
22pub struct Config {
23    pub normalization: ConfigNormalization,
24
25    pub tokenization: ConfigTokenization,
26
27    pub stopwords: ConfigStopwords,
28
29    pub search: ConfigSearch,
30
31    pub store: ConfigStore,
32}
33
34impl Config {
35    pub fn validate(&self) {
36        // Check 'write_buffer' for KV
37        if self.store.kv.database.write_buffer_size == Some(0) {
38            panic!("write_buffer for kv must not be zero");
39        }
40
41        // Check 'flush_after' for KV
42        if self.store.kv.database.flush_after >= self.store.kv.pool.inactive_after {
43            panic!("flush_after for kv must be strictly lower than inactive_after");
44        }
45
46        // Check 'flush_after' for KV
47        if self.store.kv.database.max_flushes.is_some()
48            && self.store.kv.database.max_background_jobs.is_some()
49        {
50            panic!("max_background_jobs makes max_flushes unneeded, don’t configure both");
51        }
52
53        // Check 'consolidate_after' for FST
54        if self.store.fst.graph.consolidate_after >= self.store.fst.pool.inactive_after {
55            panic!("consolidate_after for fst must be strictly lower than inactive_after");
56        }
57    }
58}
59
60/// Configuration group for normalization options (Unicode normalization,
61/// stemming, lemmatization…).
62#[derive(Deserialize, Clone, Copy)]
63pub struct ConfigNormalization {
64    #[serde(with = "crate::util::serde::none_string_as_none")]
65    pub unicode_normalization: Option<UnicodeNormalization>,
66
67    pub diacritic_folding_enabled: bool,
68
69    #[cfg(feature = "stemming")]
70    pub stemming_enabled: bool,
71}
72
73#[derive(Deserialize, Debug, Clone, Copy)]
74pub enum UnicodeNormalization {
75    /// Unicode Normalization Form C.
76    Nfc,
77    /// Unicode Normalization Form KC.
78    Nfkc,
79}
80
81/// Configuration group for tokenization options.
82#[derive(Deserialize, Clone, Copy)]
83pub struct ConfigTokenization {
84    pub detect_special_patterns: bool,
85
86    #[serde(alias = "split_special_patterns")]
87    pub compat_split_special_patterns: bool,
88}
89
90#[derive(Deserialize, Clone, Default)]
91pub struct ConfigStopwords {
92    #[serde(deserialize_with = "to_stopwords")]
93    pub allow: HashSet<String>,
94
95    #[serde(deserialize_with = "to_stopwords")]
96    pub deny: HashSet<String>,
97}
98
99fn to_stopwords<'de, D>(deserializer: D) -> Result<HashSet<String>, D::Error>
100where
101    D: serde::de::Deserializer<'de>,
102{
103    use unicode_normalization::UnicodeNormalization as _;
104
105    let vec: Vec<Box<str>> = Deserialize::deserialize(deserializer)?;
106    let stopwords_iter = vec.into_iter().map(|s| s.nfkd().to_string());
107    Ok(HashSet::from_iter(stopwords_iter))
108}
109
110#[derive(Deserialize)]
111pub struct ConfigSearch {
112    pub query_limit_default: u16,
113
114    pub query_limit_maximum: u16,
115
116    pub query_alternates_try: usize,
117
118    pub query_minimum_term_idf_default: f32,
119
120    pub query_minimum_term_idf_minimum_object_count: u64,
121
122    pub suggest_limit_default: u16,
123
124    pub suggest_limit_maximum: u16,
125
126    pub list_limit_default: u16,
127
128    pub list_limit_maximum: u16,
129}
130
131#[derive(Deserialize)]
132pub struct ConfigStore {
133    pub kv: Arc<ConfigStoreKV>,
134
135    pub fst: Arc<ConfigStoreFST>,
136}
137
138#[derive(Deserialize)]
139pub struct ConfigStoreKV {
140    #[serde(deserialize_with = "env_var::path_buf")]
141    pub path: PathBuf,
142
143    pub retain_word_objects: usize,
144
145    pub pool: ConfigStoreKVPool,
146
147    pub database: ConfigStoreKVDatabase,
148}
149
150#[derive(Deserialize)]
151pub struct ConfigStoreKVPool {
152    pub inactive_after: u64,
153}
154
155#[derive(Deserialize)]
156#[serde(deny_unknown_fields)]
157pub struct ConfigStoreKVDatabase {
158    pub flush_after: u64,
159
160    pub write_ahead_log: bool,
161
162    /// Whether or not to compress.
163    ///
164    /// Will get overriden if [`compression_type`](Self::compression_type) is
165    /// also specified.
166    #[serde(default)]
167    pub compress: Option<bool>,
168
169    #[serde(default)]
170    pub parallelism: Option<i32>,
171
172    #[serde(default)]
173    #[serde(alias = "max_files")]
174    pub max_open_files: Option<i32>,
175
176    // TODO(major): Make this MB, as in Kvrocks.
177    /// WARN: In KB!
178    #[serde(default)]
179    #[serde(alias = "write_buffer")]
180    pub write_buffer_size: Option<usize>,
181
182    #[serde(default)]
183    pub max_write_buffer_number: Option<i32>,
184
185    #[serde(default)]
186    pub min_write_buffer_number: Option<i32>,
187
188    #[serde(default)]
189    pub min_write_buffer_number_to_merge: Option<i32>,
190
191    #[serde(default)]
192    pub block_cache_size: Option<u32>,
193
194    #[serde(default)]
195    pub cache_index_and_filter_blocks: Option<bool>,
196
197    #[serde(default)]
198    #[serde(alias = "compression")]
199    #[serde(deserialize_with = "to_rocksdb_compression_type_opt")]
200    pub compression_type: Option<rocksdb::DBCompressionType>,
201
202    #[serde(default)]
203    #[serde(alias = "wal_compression")]
204    #[serde(deserialize_with = "to_rocksdb_compression_type_opt")]
205    pub wal_compression_type: Option<rocksdb::DBCompressionType>,
206
207    #[serde(default)]
208    pub wal_ttl_seconds: Option<u64>,
209
210    #[serde(default)]
211    pub wal_size_limit_mb: Option<u64>,
212
213    #[serde(default)]
214    pub wal_bytes_per_sync: Option<u64>,
215
216    #[serde(default)]
217    #[serde(deserialize_with = "to_rocksdb_recovery_mode_opt")]
218    pub wal_recovery_mode: Option<rocksdb::DBRecoveryMode>,
219
220    #[serde(default)]
221    pub compression_level: Option<i32>,
222
223    #[serde(default)]
224    #[serde(alias = "compression_start_level")]
225    pub min_level_to_compress: Option<std::ffi::c_int>,
226
227    #[serde(default)]
228    #[serde(alias = "level0_file_num_compaction_trigger")]
229    pub level_zero_file_num_compaction_trigger: Option<i32>,
230
231    #[serde(default)]
232    #[serde(alias = "level0_slowdown_writes_trigger")]
233    pub level_zero_slowdown_writes_trigger: Option<i32>,
234
235    #[serde(default)]
236    #[serde(alias = "level0_stop_writes_trigger")]
237    pub level_zero_stop_writes_trigger: Option<i32>,
238
239    #[serde(default)]
240    pub max_bytes_for_level_base: Option<u64>,
241
242    #[serde(default)]
243    pub max_bytes_for_level_multiplier: Option<f64>,
244
245    #[serde(default)]
246    pub target_file_size_base: Option<u64>,
247
248    #[serde(default)]
249    pub max_background_jobs: Option<i32>,
250
251    #[serde(default)]
252    #[serde(alias = "max_compactions")]
253    pub max_subcompactions: Option<u32>,
254
255    #[serde(default)]
256    pub max_flushes: Option<u32>,
257
258    #[serde(default)]
259    pub stats_dump_period_sec: Option<u32>,
260}
261
262fn parse_rocksdb_compression_type<E: serde::de::Error>(
263    str: &str,
264) -> Result<rocksdb::DBCompressionType, E> {
265    // NOTE: Some values are not available because not compiled in rocksdb
266    //   (feature flag is off).
267    match str.to_ascii_lowercase().as_str() {
268        "none" => Ok(rocksdb::DBCompressionType::None),
269        // "snappy" => Ok(rocksdb::DBCompressionType::Snappy),
270        // "zlib" => Ok(rocksdb::DBCompressionType::Zlib),
271        // "bz2" => Ok(rocksdb::DBCompressionType::Bz2),
272        // "lz4" => Ok(rocksdb::DBCompressionType::Lz4),
273        // "lz4hc" => Ok(rocksdb::DBCompressionType::Lz4hc),
274        "zstd" => Ok(rocksdb::DBCompressionType::Zstd),
275        _ => Err(serde::de::Error::unknown_variant(
276            str,
277            &[
278                "none",
279                // "snappy",
280                // "zlib",
281                // "bz2",
282                // "lz4",
283                // "lz4hc",
284                "zstd",
285            ],
286        )),
287    }
288}
289
290fn to_rocksdb_compression_type_opt<'de, D>(
291    deserializer: D,
292) -> Result<Option<rocksdb::DBCompressionType>, D::Error>
293where
294    D: serde::de::Deserializer<'de>,
295{
296    let str: Option<String> = Deserialize::deserialize(deserializer)?;
297    str.map(|s| parse_rocksdb_compression_type(&s)).transpose()
298}
299
300fn parse_rocksdb_recovery_mode<E: serde::de::Error>(
301    str: &str,
302) -> Result<rocksdb::DBRecoveryMode, E> {
303    match str.to_ascii_lowercase().as_str() {
304        "tolerate_corrupted_tail_records" | "TolerateCorruptedTailRecords" => {
305            Ok(rocksdb::DBRecoveryMode::TolerateCorruptedTailRecords)
306        }
307        "absolute_consistency" | "AbsoluteConsistency" => {
308            Ok(rocksdb::DBRecoveryMode::AbsoluteConsistency)
309        }
310        "point_in_time" | "PointInTime" => Ok(rocksdb::DBRecoveryMode::PointInTime),
311        "skip_any_corrupted_record" | "SkipAnyCorruptedRecord" => {
312            Ok(rocksdb::DBRecoveryMode::SkipAnyCorruptedRecord)
313        }
314        _ => Err(serde::de::Error::unknown_variant(
315            str,
316            &[
317                "tolerate_corrupted_tail_records",
318                "absolute_consistency",
319                "point_in_time",
320                "skip_any_corrupted_record",
321            ],
322        )),
323    }
324}
325
326fn to_rocksdb_recovery_mode_opt<'de, D>(
327    deserializer: D,
328) -> Result<Option<rocksdb::DBRecoveryMode>, D::Error>
329where
330    D: serde::de::Deserializer<'de>,
331{
332    let str: Option<String> = Deserialize::deserialize(deserializer)?;
333    str.map(|s| parse_rocksdb_recovery_mode(&s)).transpose()
334}
335
336#[derive(Deserialize)]
337pub struct ConfigStoreFST {
338    #[serde(deserialize_with = "env_var::path_buf")]
339    pub path: PathBuf,
340
341    pub pool: ConfigStoreFSTPool,
342
343    pub graph: ConfigStoreFSTGraph,
344}
345
346#[derive(Deserialize)]
347pub struct ConfigStoreFSTPool {
348    pub inactive_after: u64,
349}
350
351#[derive(Deserialize)]
352pub struct ConfigStoreFSTGraph {
353    pub consolidate_after: u64,
354
355    pub max_size: usize,
356
357    pub max_words: usize,
358}
359
360#[cfg(test)]
361pub(crate) mod tests {
362    pub fn defaults_toml() -> &'static str {
363        r#"
364        [channel]
365        inet = "[::1]:1491"
366        tcp_timeout = 300
367
368        [normalization]
369        unicode_normalization = "none"
370        diacritic_folding_enabled = true
371        stemming_enabled = false
372
373        [tokenization]
374        detect_special_patterns = true
375        compat_split_special_patterns = false
376
377        [stopwords]
378        allow = []
379        deny = []
380
381        [search]
382        query_limit_default = 10
383        query_limit_maximum = 100
384        query_alternates_try = 4
385        query_minimum_term_idf_default = 0.1
386        query_minimum_term_idf_minimum_object_count = 100
387        suggest_limit_default = 5
388        suggest_limit_maximum = 20
389        list_limit_default = 100
390        list_limit_maximum = 500
391
392        [store.kv]
393        path = "./data/store/kv/"
394        retain_word_objects = 1000
395        pool.inactive_after = 1800
396        database.flush_after = 900
397        database.compression_type = "zstd"
398        database.parallelism = 2
399        database.max_subcompactions = 1
400        database.max_flushes = 1
401        database.write_buffer_size = 16384
402        database.write_ahead_log = true
403
404        [store.fst]
405        path = "./data/store/fst/"
406        pool.inactive_after = 300
407        graph.consolidate_after = 180
408        graph.max_size = 2048
409        graph.max_words = 250000
410        "#
411    }
412}