Skip to main content

sonic/store/
fst.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
8use fst::automaton::AlwaysMatch;
9use fst::set::Stream as FSTStream;
10use fst::{
11    Automaton, Error as FSTError, IntoStreamer, Set as FSTSet, SetBuilder as FSTSetBuilder,
12    Streamer,
13};
14use fst_levenshtein::Levenshtein;
15use fst_regex::Regex;
16use hashbrown::{HashMap, HashSet};
17use radix::RadixNum;
18use regex_syntax::escape as regex_escape;
19use std::collections::VecDeque;
20use std::fmt;
21use std::fs::{self, File};
22use std::io::{self, BufRead, BufReader, BufWriter, Write};
23use std::iter::FromIterator;
24use std::path::{Path, PathBuf};
25use std::str;
26use std::sync::{Arc, Mutex, RwLock, RwLockReadGuard, RwLockWriteGuard};
27use std::thread;
28use std::time::{Duration, SystemTime};
29
30use super::generic::{
31    StoreGeneric, StoreGenericActionBuilder, StoreGenericBuilder, StoreGenericPool,
32};
33use super::keyer::StoreKeyerHasher;
34use crate::lexer::ranges::LexerRegexRange;
35
36// NOTE: This type cannot be generic over a lifetime as spawning threads would
37//   force it to be `'static`.
38#[derive(Clone)]
39pub struct StoreFSTPool {
40    fst_store_config: Arc<crate::config::ConfigStoreFST>,
41    graph_pool: Arc<RwLock<HashMap<StoreFSTKey, StoreFSTBox>>>,
42    graph_acquire_lock: Arc<Mutex<()>>,
43    graph_rebuild_lock: Arc<Mutex<()>>,
44    graph_access_lock: Arc<RwLock<()>>,
45    graph_consolidate: Arc<RwLock<HashSet<StoreFSTKey>>>,
46}
47
48pub struct StoreFSTBuilder<'build> {
49    fst_store_config: &'build crate::config::ConfigStoreFST,
50    graph_consolidate: Arc<RwLock<HashSet<StoreFSTKey>>>,
51}
52
53pub struct StoreFST {
54    graph: FSTSet,
55    target: StoreFSTKey,
56    pending: StoreFSTPending,
57    last_used: Arc<RwLock<SystemTime>>,
58    last_consolidated: Arc<RwLock<SystemTime>>,
59    graph_consolidate: Arc<RwLock<HashSet<StoreFSTKey>>>,
60}
61
62#[derive(Default)]
63pub struct StoreFSTPending {
64    pop: Arc<RwLock<HashSet<Vec<u8>>>>,
65    push: Arc<RwLock<HashSet<Vec<u8>>>>,
66}
67
68pub struct StoreFSTActionBuilder<'build> {
69    pub fst_store_config: &'build crate::config::ConfigStoreFST,
70}
71
72pub struct StoreFSTAction {
73    store: StoreFSTBox,
74}
75
76#[derive(PartialEq, Eq, Hash, Clone, Copy)]
77pub struct StoreFSTKey {
78    collection_hash: StoreFSTAtom,
79    bucket_hash: StoreFSTAtom,
80}
81
82pub struct StoreFSTMisc;
83
84#[derive(Copy, Clone)]
85enum StoreFSTPathMode {
86    Permanent,
87    Temporary,
88    Backup,
89}
90
91type StoreFSTAtom = u32;
92type StoreFSTBox = Arc<StoreFST>;
93
94const WORD_LIMIT_LENGTH: usize = 40;
95const ATOM_HASH_RADIX: usize = 16;
96
97impl StoreFSTPathMode {
98    fn extension(&self) -> &'static str {
99        match self {
100            StoreFSTPathMode::Permanent => ".fst",
101            StoreFSTPathMode::Temporary => ".fst.tmp",
102            StoreFSTPathMode::Backup => ".fst.bck",
103        }
104    }
105}
106
107impl StoreFSTPool {
108    pub fn new(fst_store_config: Arc<crate::config::ConfigStoreFST>) -> Self {
109        Self {
110            fst_store_config,
111            graph_pool: Arc::default(),
112            graph_acquire_lock: Arc::default(),
113            graph_rebuild_lock: Arc::default(),
114            graph_access_lock: Arc::default(),
115            graph_consolidate: Arc::default(),
116        }
117    }
118
119    pub fn count(&self) -> (usize, usize) {
120        (
121            self.graph_pool.read().unwrap().len(),
122            self.graph_consolidate.read().unwrap().len(),
123        )
124    }
125
126    pub fn lock_read_access<'a>(&'a self) -> RwLockReadGuard<'a, ()> {
127        self.graph_access_lock.read().unwrap()
128    }
129
130    pub fn lock_write_access<'a>(&'a self) -> RwLockWriteGuard<'a, ()> {
131        self.graph_access_lock.write().unwrap()
132    }
133
134    pub fn acquire<T: AsRef<str>>(&self, collection: T, bucket: T) -> Result<StoreFSTBox, ()> {
135        let (collection_str, bucket_str) = (collection.as_ref(), bucket.as_ref());
136
137        let pool_key = StoreFSTKey::from_str(collection_str, bucket_str);
138
139        // Freeze acquire lock, and reference it in context
140        // Notice: this prevents two graphs on the same collection to be opened at the same time.
141        let _acquire = self.graph_acquire_lock.lock().unwrap();
142
143        // Acquire a thread-safe store pool reference in read mode
144        let graph_pool_read = self.graph_pool.read().unwrap();
145
146        if let Some(store_fst) = graph_pool_read.get(&pool_key) {
147            Self::proceed_acquire_cache("fst", collection_str, pool_key, store_fst)
148        } else {
149            tracing::info!(
150                "fst store not in pool for collection: {} <{:x}> / bucket: {} <{:x}>, opening it",
151                collection_str,
152                pool_key.collection_hash,
153                bucket_str,
154                pool_key.bucket_hash
155            );
156
157            // Important: we need to drop the read reference first, to avoid dead-locking \
158            //   when acquiring the RWLock in write mode in this block.
159            drop(graph_pool_read);
160
161            let builder = StoreFSTBuilder {
162                fst_store_config: &self.fst_store_config,
163                graph_consolidate: Arc::clone(&self.graph_consolidate),
164            };
165
166            Self::proceed_acquire_open("fst", collection_str, pool_key, &self.graph_pool, &builder)
167        }
168    }
169
170    pub fn janitor(&self) {
171        Self::proceed_janitor(
172            "fst",
173            &self.graph_pool,
174            self.fst_store_config.pool.inactive_after,
175            &self.graph_access_lock,
176        )
177    }
178
179    pub fn backup(&self, path: &Path) -> Result<(), io::Error> {
180        tracing::debug!("backing up all fst stores to path: {:?}", path);
181
182        // Create backup directory (full path)
183        fs::create_dir_all(path)?;
184
185        // Proceed dump action (backup)
186        self.dump_action(
187            "backup",
188            StoreFSTPathMode::Permanent,
189            &self.fst_store_config.path,
190            path,
191            &Self::backup_item,
192        )
193    }
194
195    pub fn restore(&self, path: &Path) -> Result<(), io::Error> {
196        tracing::debug!("restoring all fst stores from path: {:?}", path);
197
198        // Proceed dump action (restore)
199        self.dump_action(
200            "restore",
201            StoreFSTPathMode::Backup,
202            path,
203            &self.fst_store_config.path,
204            &Self::restore_item,
205        )
206    }
207
208    pub fn consolidate(&self, force: bool) {
209        tracing::debug!("scanning for fst store pool items to consolidate");
210
211        // Notice: we do not consolidate all items at each tick, we try to even out multiple \
212        //   consolidation tasks over time. This lowers the overall HZ of the tasker system for \
213        //   certain heavy tasks, which is better to spread out consolidation steps over time over \
214        //   a large number of very active buckets.
215
216        // Acquire rebuild lock, and reference it in context
217        // Notice: this prevents two consolidate operations to be executed at the same time.
218        let _rebuild = self.graph_rebuild_lock.lock().unwrap();
219
220        // Exit trap: Register is empty? Abort there.
221        if self.graph_consolidate.read().unwrap().is_empty() {
222            tracing::info!("no fst store pool items to consolidate in register");
223
224            return;
225        }
226
227        // Step 1: List keys to be consolidated
228        let mut keys_consolidate: Vec<StoreFSTKey> = Vec::new();
229
230        {
231            // Acquire access lock (in blocking write mode), and reference it in context
232            // Notice: this prevents store to be acquired from any context
233            let _access = self.graph_access_lock.write().unwrap();
234
235            let (graph_pool_read, graph_consolidate_read) = (
236                self.graph_pool.read().unwrap(),
237                self.graph_consolidate.read().unwrap(),
238            );
239
240            for key in &*graph_consolidate_read {
241                if let Some(store) = graph_pool_read.get(key) {
242                    // Important: be lenient with system clock going back to a past duration, \
243                    //   since we may be running in a virtualized environment where clock is not \
244                    //   guaranteed to be monotonic. This is done to avoid poisoning associated \
245                    //   mutexes by crashing on unwrap().
246                    let not_consolidated_for = store
247                        .last_consolidated
248                        .read()
249                        .unwrap()
250                        .elapsed()
251                        .unwrap_or_else(|err| {
252                            tracing::error!(
253                                "fst key: {} last consolidated duration clock issue, zeroing: {}",
254                                key,
255                                err
256                            );
257
258                            // Assuming a zero seconds fallback duration
259                            Duration::from_secs(0)
260                        })
261                        .as_secs();
262
263                    if force
264                        || not_consolidated_for >= self.fst_store_config.graph.consolidate_after
265                    {
266                        tracing::info!(
267                            "fst key: {} not consolidated for: {} seconds, may consolidate",
268                            key,
269                            not_consolidated_for
270                        );
271
272                        keys_consolidate.push(*key);
273                    } else {
274                        tracing::debug!(
275                            "fst key: {} not consolidated for: {} seconds, no consolidate",
276                            key,
277                            not_consolidated_for
278                        );
279                    }
280                }
281            }
282        }
283
284        // Exit trap: Nothing to consolidate yet? Abort there.
285        if keys_consolidate.is_empty() {
286            tracing::info!("no fst store pool items need to consolidate at the moment");
287
288            return;
289        }
290
291        // Step 2: Clear keys to be consolidated from register
292        {
293            // Acquire access lock (in blocking write mode), and reference it in context
294            // Notice: this prevents store to be acquired from any context
295            let _access = self.graph_access_lock.write().unwrap();
296
297            let mut graph_consolidate_write = self.graph_consolidate.write().unwrap();
298
299            for key in &keys_consolidate {
300                graph_consolidate_write.remove(key);
301
302                tracing::debug!("fst key: {} cleared from consolidate register", key);
303            }
304        }
305
306        // Step 3: Consolidate FSTs, one-by-one (sequential locking; this avoids global locks)
307        let (mut count_moved, mut count_pushed, mut count_popped) = (0, 0, 0);
308
309        {
310            for key in &keys_consolidate {
311                {
312                    // As we may be renaming the FST file, ensure no consumer out of this is \
313                    //   trying to access the FST file as it gets processed. This also waits for \
314                    //   current consumers to finish reading the FST, and prevents any new \
315                    //   consumer from opening it while we are not done there.
316                    let _access = self.graph_access_lock.write().unwrap();
317
318                    let do_close = if let Some(store) = self.graph_pool.read().unwrap().get(key) {
319                        tracing::debug!("fst key: {} consolidate started", key);
320
321                        let consolidate_counts = self.consolidate_item(store);
322
323                        count_moved += consolidate_counts.1;
324                        count_pushed += consolidate_counts.2;
325                        count_popped += consolidate_counts.3;
326
327                        tracing::debug!("fst key: {} consolidate complete", key);
328
329                        // Should close this FST?
330                        consolidate_counts.0
331                    } else {
332                        false
333                    };
334
335                    // Nuke old opened FST?
336                    // Notice: last consolidated date will be bumped to a new date in the future \
337                    //   when a push or pop operation will be done, thus effectively scheduling \
338                    //   a consolidation in the future properly.
339                    // Notice: we remove this one early as to release write lock early
340                    if do_close {
341                        self.graph_pool.write().unwrap().remove(key);
342                    }
343                }
344
345                // Give a bit of time to other threads before continuing (a consolidate operation \
346                //   must not block all other threads until it completes); this method tells the \
347                //   thread scheduler to give a bit of priority to other threads, and get back \
348                //   to this thread's work when other threads are done. On large setups, this \
349                //   loop can starve other threads due to the locks used (unfortunately they \
350                //   are all necessary).
351                thread::yield_now();
352            }
353        }
354
355        tracing::info!(
356            "done scanning for fst store pool items to consolidate (move: {}, push: {}, pop: {})",
357            count_moved,
358            count_pushed,
359            count_popped
360        );
361    }
362
363    #[allow(clippy::type_complexity)]
364    fn dump_action(
365        &self,
366        action: &str,
367        path_mode: StoreFSTPathMode,
368        read_path: &Path,
369        write_path: &Path,
370        fn_item: &dyn Fn(&Self, &Path, &Path, &str, &str) -> Result<(), io::Error>,
371    ) -> Result<(), io::Error> {
372        let fst_extension = path_mode.extension();
373        let fst_extension_len = fst_extension.len();
374
375        // Iterate on FST collections
376        for collection in fs::read_dir(read_path)? {
377            let collection = collection?;
378
379            // Actual collection found?
380            if let (Ok(collection_file_type), Some(collection_name)) =
381                (collection.file_type(), collection.file_name().to_str())
382            {
383                if collection_file_type.is_dir() {
384                    tracing::debug!("fst collection ongoing {}: {}", action, collection_name);
385
386                    // Create write folder for collection
387                    fs::create_dir_all(write_path.join(collection_name))?;
388
389                    // Iterate on FST collection buckets
390                    for bucket in fs::read_dir(read_path.join(collection_name))? {
391                        let bucket = bucket?;
392
393                        // Actual bucket found?
394                        if let (Ok(bucket_file_type), Some(bucket_file_name)) =
395                            (bucket.file_type(), bucket.file_name().to_str())
396                        {
397                            let bucket_file_name_len = bucket_file_name.len();
398
399                            if bucket_file_type.is_file()
400                                && bucket_file_name_len > fst_extension_len
401                                && bucket_file_name.ends_with(fst_extension)
402                            {
403                                // Acquire bucket name (from full file name)
404                                let bucket_name =
405                                    &bucket_file_name[..(bucket_file_name_len - fst_extension_len)];
406
407                                tracing::debug!(
408                                    "fst bucket ongoing {}: {}/{}",
409                                    action,
410                                    collection_name,
411                                    bucket_name
412                                );
413
414                                fn_item(
415                                    self,
416                                    write_path,
417                                    &bucket.path(),
418                                    collection_name,
419                                    bucket_name,
420                                )?;
421                            }
422                        }
423                    }
424                }
425            }
426        }
427
428        Ok(())
429    }
430
431    fn backup_item(
432        &self,
433        backup_path: &Path,
434        _origin_path: &Path,
435        collection_name: &str,
436        bucket_name: &str,
437    ) -> Result<(), io::Error> {
438        // Acquire access lock (in blocking write mode), and reference it in context
439        // Notice: this prevents store to be acquired from any context
440        let _access = self.graph_access_lock.write().unwrap();
441
442        // Generate path to FST backup
443        let fst_backup_path = backup_path.join(collection_name).join(format!(
444            "{}{}",
445            bucket_name,
446            StoreFSTPathMode::Backup.extension()
447        ));
448
449        tracing::debug!(
450            "fst bucket: {}/{} backing up to path: {:?}",
451            collection_name,
452            bucket_name,
453            fst_backup_path
454        );
455
456        // Erase any previously-existing FST backup
457        fs::remove_file(&fst_backup_path).ok();
458
459        // Stream actual FST data to FST backup
460        let backup_fst_file = File::create(&fst_backup_path)?;
461        let mut backup_fst_writer = BufWriter::new(backup_fst_file);
462
463        let mut count_words = 0;
464
465        // Convert names to hashes (as names are hashes encoded as base-16 strings, but we need \
466        //   them as proper integers)
467        if let (Ok(collection_radix), Ok(bucket_radix)) = (
468            RadixNum::from_str(collection_name, ATOM_HASH_RADIX),
469            RadixNum::from_str(bucket_name, ATOM_HASH_RADIX),
470        ) {
471            if let (Ok(collection_hash), Ok(bucket_hash)) =
472                (collection_radix.as_decimal(), bucket_radix.as_decimal())
473            {
474                let origin_fst = StoreFSTBuilder::open(
475                    collection_hash as StoreFSTAtom,
476                    bucket_hash as StoreFSTAtom,
477                    &self.fst_store_config,
478                )
479                .map_err(|_| io::Error::other("graph open failure"))?;
480
481                let mut origin_fst_stream = origin_fst.stream();
482
483                while let Some(word) = origin_fst_stream.next() {
484                    count_words += 1;
485
486                    // Write word, and append a new line
487                    backup_fst_writer.write_all(word)?;
488                    backup_fst_writer.write_all(b"\n")?;
489                }
490
491                tracing::info!(
492                    "fst bucket: {}/{} backed up to path: {:?} ({} words)",
493                    collection_name,
494                    bucket_name,
495                    fst_backup_path,
496                    count_words
497                );
498            }
499        }
500
501        Ok(())
502    }
503
504    fn restore_item(
505        &self,
506        _backup_path: &Path,
507        origin_path: &Path,
508        collection_name: &str,
509        bucket_name: &str,
510    ) -> Result<(), io::Error> {
511        // Acquire access lock (in blocking write mode), and reference it in context
512        // Notice: this prevents store to be acquired from any context
513        let _access = self.graph_access_lock.write().unwrap();
514
515        tracing::debug!(
516            "fst bucket: {}/{} restoring from path: {:?}",
517            collection_name,
518            bucket_name,
519            origin_path
520        );
521
522        // Convert names to hashes (as names are hashes encoded as base-16 strings, but we need \
523        //   them as proper integers)
524        if let (Ok(collection_radix), Ok(bucket_radix)) = (
525            RadixNum::from_str(collection_name, ATOM_HASH_RADIX),
526            RadixNum::from_str(bucket_name, ATOM_HASH_RADIX),
527        ) {
528            if let (Ok(collection_hash), Ok(bucket_hash)) =
529                (collection_radix.as_decimal(), bucket_radix.as_decimal())
530            {
531                // Force a FST store close
532                self.close(collection_hash as StoreFSTAtom, bucket_hash as StoreFSTAtom);
533
534                // Generate path to FST
535                let fst_path = self.fst_store_config.path(
536                    StoreFSTPathMode::Permanent,
537                    collection_hash as StoreFSTAtom,
538                    Some(bucket_hash as StoreFSTAtom),
539                );
540
541                // Remove existing FST data?
542                if fst_path.exists() {
543                    fs::remove_file(&fst_path)?;
544                }
545
546                // Stream backup words to restored FST
547                let fst_writer = BufWriter::new(File::create(&fst_path)?);
548                let fst_backup_reader = BufReader::new(File::open(&origin_path)?);
549
550                let mut fst_builder = FSTSetBuilder::new(fst_writer)
551                    .map_err(|_| io::Error::other("graph restore builder failure"))?;
552
553                for word in fst_backup_reader.lines() {
554                    let word = word?;
555
556                    fst_builder
557                        .insert(word)
558                        .map_err(|_| io::Error::other("graph restore word insert failure"))?;
559                }
560
561                fst_builder
562                    .finish()
563                    .map_err(|_| io::Error::other("graph restore finish failure"))?;
564
565                tracing::info!(
566                    "fst bucket: {}/{} restored to path: {:?} from backup: {:?}",
567                    collection_name,
568                    bucket_name,
569                    fst_path,
570                    origin_path
571                );
572            }
573        }
574
575        Ok(())
576    }
577
578    fn consolidate_item(&self, store: &StoreFSTBox) -> (bool, usize, usize, usize) {
579        let (mut should_close, mut count_moved, mut count_pushed, mut count_popped) =
580            (false, 0, 0, 0);
581
582        // Acquire write references to pending sets
583        let (mut pending_push_write, mut pending_pop_write) = (
584            store.pending.push.write().unwrap(),
585            store.pending.pop.write().unwrap(),
586        );
587
588        // Do consolidate? (any change committed)
589        // Notice: if both pending sets are empty do not consolidate as there may have been a \
590        //   push then a pop of this push, nulling out any committed change.
591        if !(pending_push_write.is_empty() && pending_pop_write.is_empty()) {
592            // Read old FST (or default to empty FST)
593            if let Ok(old_fst) = StoreFSTBuilder::open(
594                store.target.collection_hash,
595                store.target.bucket_hash,
596                &self.fst_store_config,
597            ) {
598                // Initialize the new FST (temporary)
599                let bucket_tmp_path = self.fst_store_config.path(
600                    StoreFSTPathMode::Temporary,
601                    store.target.collection_hash,
602                    Some(store.target.bucket_hash),
603                );
604
605                let bucket_tmp_path_parent = bucket_tmp_path.parent().unwrap();
606
607                if fs::create_dir_all(&bucket_tmp_path_parent).is_ok() {
608                    // Erase any previously-existing temporary FST (eg. process stopped while \
609                    //   writing the temporary FST); there is no guarantee this succeeds.
610                    fs::remove_file(&bucket_tmp_path).ok();
611
612                    if let Ok(tmp_fst_file) = File::create(&bucket_tmp_path) {
613                        let tmp_fst_writer = BufWriter::new(tmp_fst_file);
614
615                        // Create a builder that can be used to insert new key-value pairs.
616                        if let Ok(mut tmp_fst_builder) = FSTSetBuilder::new(tmp_fst_writer) {
617                            // Convert push keys to an ordered vector
618                            // Notice: we must go from a Vec to a VecDeque as to sort values, \
619                            //   which is a requirement for FST insertions.
620                            let mut ordered_push_vec: Vec<&[u8]> =
621                                Vec::from_iter(pending_push_write.iter().map(|item| item.as_ref()));
622
623                            ordered_push_vec.sort();
624
625                            let mut ordered_push: VecDeque<&[u8]> =
626                                VecDeque::from_iter(ordered_push_vec);
627
628                            // Append words not in pop list to new FST (ie. old words minus pop \
629                            //   words)
630                            let mut old_fst_stream = old_fst.stream();
631
632                            'old: while let Some(old_fst_word) = old_fst_stream.next() {
633                                // Append new words from front? (ie. push words)
634                                // Notice: as an FST is ordered, inserts would fail if they are \
635                                //   committed out-of-order. Thus, the only way to check for \
636                                //   order is there.
637                                // Notice: a quick check is done before engaging in the loop, to \
638                                //   prevent any de-optimized jump instruction, as we may call \
639                                //   this code block a lot on large FSTs, and the loop should not \
640                                //   be engaged that often on stabilized FSTs (ie. mature FSTs).
641                                if let Some(push_first_ref) = ordered_push.front() {
642                                    // Engage the loop?
643                                    if *push_first_ref <= old_fst_word {
644                                        while let Some(push_front_ref) = ordered_push.front() {
645                                            if *push_front_ref <= old_fst_word {
646                                                // Pop front item and consume it
647                                                // Notice: as we validated previously that there \
648                                                //   is a front value, this unwrap is safe.
649                                                let push_front = ordered_push.pop_front().unwrap();
650
651                                                if StoreFSTMisc::check_over_limits(
652                                                    tmp_fst_builder.bytes_written() as usize,
653                                                    count_pushed + count_moved,
654                                                    &self.fst_store_config.graph,
655                                                ) {
656                                                    // FST cannot accept more items (limits reached)
657                                                    tracing::warn!(
658                                                        "limit reached on new from old in fst"
659                                                    );
660
661                                                    // Important: stop the main loop (limit reached)
662                                                    break 'old;
663                                                }
664
665                                                if let Err(err) = tmp_fst_builder.insert(push_front)
666                                                {
667                                                    // Could not insert word in FST
668                                                    tracing::error!(
669                                                        "failed inserting new from old in fst: {}",
670                                                        err
671                                                    );
672                                                } else {
673                                                    // Word inserted in FST
674                                                    count_pushed += 1;
675                                                }
676
677                                                // Continue scanning next word (may also come \
678                                                //   before this FST word in order)
679                                                continue;
680                                            }
681
682                                            // Important: stop loop on next front item (always \
683                                            //   the same)
684                                            break;
685                                        }
686                                    }
687                                }
688
689                                // Restore old word (if not popped)
690                                if !pending_pop_write.contains(old_fst_word) {
691                                    if StoreFSTMisc::check_over_limits(
692                                        tmp_fst_builder.bytes_written() as usize,
693                                        count_pushed + count_moved,
694                                        &self.fst_store_config.graph,
695                                    ) {
696                                        // FST cannot accept more items (limits reached)
697                                        tracing::warn!("limit reached on old word in fst");
698
699                                        // Important: stop the main loop (limit reached)
700                                        break 'old;
701                                    }
702
703                                    if let Err(err) = tmp_fst_builder.insert(old_fst_word) {
704                                        // Could not move word to FST
705                                        tracing::error!(
706                                            "failed inserting old word in fst: {}",
707                                            err
708                                        );
709                                    } else {
710                                        // Word moved to FST
711                                        count_moved += 1;
712                                    }
713                                } else {
714                                    count_popped += 1;
715                                }
716                            }
717
718                            // Complete FST with last pushed items
719                            // Notice: this is necessary if the FST was empty, or if we have push \
720                            //   items that come after the last ordered word of the FST.
721                            while let Some(push_front) = ordered_push.pop_front() {
722                                if StoreFSTMisc::check_over_limits(
723                                    tmp_fst_builder.bytes_written() as usize,
724                                    count_pushed + count_moved,
725                                    &self.fst_store_config.graph,
726                                ) {
727                                    // FST cannot accept more items (limits reached)
728                                    tracing::warn!(
729                                        "limit reached on new word from complete in fst"
730                                    );
731
732                                    // Important: stop the main loop (limit reached)
733                                    break;
734                                }
735
736                                if let Err(err) = tmp_fst_builder.insert(push_front) {
737                                    // Could not insert word in FST
738                                    tracing::error!(
739                                        "failed inserting new word from complete in fst: {}",
740                                        err
741                                    );
742                                } else {
743                                    // Word inserted in FST
744                                    count_pushed += 1;
745                                }
746                            }
747
748                            // Finish building new FST
749                            if tmp_fst_builder.finish().is_ok() {
750                                // Should close open store reference to old FST
751                                should_close = true;
752
753                                // Replace old FST with new FST (this nukes the old FST)
754                                // Notice: there is no need to re-open the new FST, as it will be \
755                                //   automatically opened on its next access.
756                                let bucket_final_path = self.fst_store_config.path(
757                                    StoreFSTPathMode::Permanent,
758                                    store.target.collection_hash,
759                                    Some(store.target.bucket_hash),
760                                );
761
762                                // Proceed temporary FST to final FST path rename
763                                if fs::rename(&bucket_tmp_path, &bucket_final_path).is_ok() {
764                                    tracing::info!(
765                                        "done consolidate fst at path: {:?}",
766                                        bucket_final_path
767                                    );
768                                } else {
769                                    tracing::error!(
770                                        "error consolidating fst at path: {:?}",
771                                        bucket_final_path
772                                    );
773                                }
774                            } else {
775                                tracing::error!(
776                                    "error finishing building temporary fst at path: {:?}",
777                                    bucket_tmp_path
778                                );
779                            }
780                        } else {
781                            tracing::error!(
782                                "error starting building temporary fst at path: {:?}",
783                                bucket_tmp_path
784                            );
785                        }
786                    } else {
787                        tracing::error!(
788                            "error initializing temporary fst at path: {:?}",
789                            bucket_tmp_path
790                        );
791                    }
792                } else {
793                    tracing::error!(
794                        "error initializing temporary fst directory at path: {:?}",
795                        bucket_tmp_path_parent
796                    );
797                }
798            } else {
799                tracing::error!("error opening old fst");
800            }
801
802            // Reset all pending sets
803            *pending_push_write = HashSet::new();
804            *pending_pop_write = HashSet::new();
805        }
806
807        (should_close, count_moved, count_pushed, count_popped)
808    }
809
810    fn close(&self, collection_hash: StoreFSTAtom, bucket_hash: StoreFSTAtom) {
811        tracing::debug!(
812            "closing finite-state transducer graph for collection: <{:x}> and bucket: <{:x}>",
813            collection_hash,
814            bucket_hash
815        );
816
817        let bucket_target = StoreFSTKey::from_atom(collection_hash, bucket_hash);
818
819        self.graph_pool.write().unwrap().remove(&bucket_target);
820        self.graph_consolidate
821            .write()
822            .unwrap()
823            .remove(&bucket_target);
824    }
825}
826
827impl<'build> StoreGenericPool<StoreFSTKey, StoreFST, StoreFSTBuilder<'build>> for StoreFSTPool {}
828
829impl<'build> StoreFSTBuilder<'build> {
830    fn open(
831        collection_hash: StoreFSTAtom,
832        bucket_hash: StoreFSTAtom,
833        fst_store_config: &crate::config::ConfigStoreFST,
834    ) -> Result<FSTSet, FSTError> {
835        tracing::debug!(
836            "opening finite-state transducer graph for collection: <{:x}> and bucket: <{:x}>",
837            collection_hash,
838            bucket_hash
839        );
840
841        let collection_bucket_path = fst_store_config.path(
842            StoreFSTPathMode::Permanent,
843            collection_hash,
844            Some(bucket_hash),
845        );
846
847        if collection_bucket_path.exists() {
848            // Open graph at path for collection
849            // Notice: this is unsafe, as loaded memory is a memory-mapped file, that cannot be \
850            //   guaranteed not to be muted while we own a read handle to it. Though, we use \
851            //   higher-level locking mechanisms on all callers of this method, so we are safe.
852            unsafe { FSTSet::from_path(collection_bucket_path) }
853        } else {
854            // FST does not exist on disk, generate an empty FST for now; until a consolidation \
855            //   task occurs and populates the on-disk-FST.
856            let empty_iter: Vec<&str> = Vec::new();
857
858            FSTSet::from_iter(empty_iter)
859        }
860    }
861}
862
863impl crate::config::ConfigStoreFST {
864    fn path(
865        &self,
866        mode: StoreFSTPathMode,
867        collection_hash: StoreFSTAtom,
868        bucket_hash: Option<StoreFSTAtom>,
869    ) -> PathBuf {
870        let mut final_path = self.path.join(format!("{:x}", collection_hash));
871
872        if let Some(bucket_hash) = bucket_hash {
873            final_path = final_path.join(format!("{:x}{}", bucket_hash, mode.extension()));
874        }
875
876        final_path
877    }
878}
879
880impl<'build> StoreGenericBuilder<StoreFSTKey, StoreFST> for StoreFSTBuilder<'build> {
881    fn build(&self, pool_key: StoreFSTKey) -> Result<StoreFST, ()> {
882        Self::open(
883            pool_key.collection_hash,
884            pool_key.bucket_hash,
885            self.fst_store_config,
886        )
887        .map(|graph| {
888            let now = SystemTime::now();
889
890            StoreFST {
891                graph,
892                target: pool_key,
893                pending: StoreFSTPending::default(),
894                last_used: Arc::new(RwLock::new(now)),
895                last_consolidated: Arc::new(RwLock::new(now)),
896                graph_consolidate: Arc::clone(&self.graph_consolidate),
897            }
898        })
899        .map_err(|err| {
900            tracing::error!("failed opening fst: {}", err);
901        })
902    }
903}
904
905impl StoreFST {
906    pub fn cardinality(&self) -> usize {
907        self.graph.len()
908    }
909
910    pub fn as_stream(&self) -> FSTStream<'_, AlwaysMatch> {
911        self.graph.into_stream()
912    }
913
914    pub fn lookup_begins(&self, word: &str) -> Result<FSTStream<'_, Regex>, ()> {
915        // Notice: this regex maps over an unicode range, for speed reasons at scale. \
916        //   We found out that the 'match any' syntax ('.*') was super-slow. Using the restrictive \
917        //   syntax below divided the cost of eg. a search query by 2. The regex below has been \
918        //   found out to be nearly zero-cost to compile and execute, for whatever reason.
919        // Regex format: '{escaped_word}([{unicode_range}]*)'
920        let mut regex_str = regex_escape(word);
921
922        regex_str.push('(');
923
924        let write_result = LexerRegexRange::from(word)
925            .unwrap_or_default()
926            .write_to(&mut regex_str);
927
928        regex_str.push_str("*)");
929
930        // Regex write failed? (this should not happen)
931        if let Err(err) = write_result {
932            tracing::error!(
933                "could not lookup word in fst via 'begins': {} because regex write failed: {}",
934                word,
935                err
936            );
937
938            return Err(());
939        }
940
941        // Proceed word lookup
942        tracing::debug!(
943            "looking-up word in fst via 'begins': {} with regex: {}",
944            word,
945            regex_str
946        );
947
948        if let Ok(regex) = Regex::new(&regex_str) {
949            Ok(self.graph.search(regex).into_stream())
950        } else {
951            Err(())
952        }
953    }
954
955    pub fn lookup_typos(
956        &self,
957        word: &str,
958        max_factor: Option<u32>,
959    ) -> Result<FSTStream<'_, Levenshtein>, ()> {
960        // Allow more typos in word as the word gets longer, up to a maximum limit
961        let mut typo_factor = match word.len() {
962            1..=3 => 0,
963            4..=6 => 1,
964            7..=9 => 2,
965            _ => 3,
966        };
967
968        // Cap typo factor to set maximum?
969        if let Some(max_factor) = max_factor {
970            if typo_factor > max_factor {
971                tracing::debug!(
972                    "Capping typo factor from {typo_factor} to {max_factor} \
973                    because of max allowed factor"
974                );
975                typo_factor = max_factor;
976            }
977        }
978
979        tracing::debug!(
980            "looking-up word in fst via 'typos': {} with typo factor: {}",
981            word,
982            typo_factor
983        );
984
985        if let Ok(fuzzy) = Levenshtein::new(word, typo_factor) {
986            Ok(self.graph.search(fuzzy).into_stream())
987        } else {
988            Err(())
989        }
990    }
991
992    pub fn should_consolidate(&self) {
993        // Check if not already scheduled
994        if !self
995            .graph_consolidate
996            .read()
997            .unwrap()
998            .contains(&self.target)
999        {
1000            // Schedule target for next consolidation tick (ie. collection + bucket tuple)
1001            self.graph_consolidate.write().unwrap().insert(self.target);
1002
1003            // Bump 'last consolidated' time, effectively de-bouncing consolidation to a fixed \
1004            //   and predictable tick time in the future.
1005            let mut last_consolidated_value = self.last_consolidated.write().unwrap();
1006
1007            *last_consolidated_value = SystemTime::now();
1008
1009            // Perform an early drop of the lock (frees up write lock early)
1010            drop(last_consolidated_value);
1011
1012            tracing::info!("graph consolidation scheduled on pool key: {}", self.target);
1013        } else {
1014            tracing::debug!(
1015                "graph consolidation already scheduled on pool key: {}",
1016                self.target
1017            );
1018        }
1019    }
1020}
1021
1022impl StoreGeneric for StoreFST {
1023    fn ref_last_used(&self) -> &RwLock<SystemTime> {
1024        &self.last_used
1025    }
1026}
1027
1028impl<'build> StoreFSTActionBuilder<'build> {
1029    pub fn access(store: StoreFSTBox) -> StoreFSTAction {
1030        Self::build(store)
1031    }
1032
1033    fn build(store: StoreFSTBox) -> StoreFSTAction {
1034        StoreFSTAction { store }
1035    }
1036}
1037
1038impl StoreFSTPool {
1039    pub fn erase<T: AsRef<str>>(&self, collection: T, bucket: Option<T>) -> Result<u32, ()> {
1040        self.dispatch_erase("fst", collection, bucket)
1041    }
1042}
1043
1044impl StoreGenericActionBuilder for StoreFSTPool {
1045    fn proceed_erase_collection(&self, collection_str: &str) -> Result<u32, ()> {
1046        let path_mode = StoreFSTPathMode::Permanent;
1047
1048        let collection_atom = StoreKeyerHasher::to_compact(collection_str);
1049        let collection_path = self.fst_store_config.path(path_mode, collection_atom, None);
1050
1051        // Force a FST graph close (on all contained buckets)
1052        // Notice: we first need to scan for opened buckets in-memory, as not all FSTs may be \
1053        //   committed to disk; thus some FST stores that exist in-memory may not exist on-disk.
1054        let mut bucket_atoms: Vec<StoreFSTAtom> = Vec::new();
1055
1056        {
1057            let graph_pool_read = self.graph_pool.read().unwrap();
1058
1059            for target_key in graph_pool_read.keys() {
1060                if target_key.collection_hash == collection_atom {
1061                    bucket_atoms.push(target_key.bucket_hash);
1062                }
1063            }
1064        }
1065
1066        if !bucket_atoms.is_empty() {
1067            tracing::debug!(
1068                "will force-close {} fst buckets for collection: {}",
1069                bucket_atoms.len(),
1070                collection_str
1071            );
1072
1073            let (mut graph_pool_write, mut graph_consolidate_write) = (
1074                self.graph_pool.write().unwrap(),
1075                self.graph_consolidate.write().unwrap(),
1076            );
1077
1078            for bucket_atom in bucket_atoms {
1079                tracing::debug!(
1080                    "fst bucket graph force close for bucket: {}/<{:x}>",
1081                    collection_str,
1082                    bucket_atom
1083                );
1084
1085                let bucket_target = StoreFSTKey::from_atom(collection_atom, bucket_atom);
1086
1087                graph_pool_write.remove(&bucket_target);
1088                graph_consolidate_write.remove(&bucket_target);
1089            }
1090        }
1091
1092        // Remove all FSTs on-disk
1093        if collection_path.exists() {
1094            tracing::debug!(
1095                "fst collection store exists, erasing: {}/* at path: {:?}",
1096                collection_str,
1097                &collection_path
1098            );
1099
1100            // Remove FST graph storage from filesystem
1101            let erase_result = fs::remove_dir_all(&collection_path);
1102
1103            if erase_result.is_ok() {
1104                tracing::debug!("done with fst collection erasure");
1105
1106                Ok(1)
1107            } else {
1108                Err(())
1109            }
1110        } else {
1111            tracing::debug!(
1112                "fst collection store does not exist, consider already erased: {}/* at path: {:?}",
1113                collection_str,
1114                &collection_path
1115            );
1116
1117            Ok(0)
1118        }
1119    }
1120
1121    fn proceed_erase_bucket(&self, collection_str: &str, bucket_str: &str) -> Result<u32, ()> {
1122        tracing::debug!(
1123            "sub-erase on fst bucket: {} for collection: {}",
1124            bucket_str,
1125            collection_str
1126        );
1127
1128        let (collection_atom, bucket_atom) = (
1129            StoreKeyerHasher::to_compact(collection_str),
1130            StoreKeyerHasher::to_compact(bucket_str),
1131        );
1132
1133        let bucket_path = self.fst_store_config.path(
1134            StoreFSTPathMode::Permanent,
1135            collection_atom,
1136            Some(bucket_atom),
1137        );
1138
1139        // Force a FST graph close
1140        self.close(collection_atom, bucket_atom);
1141
1142        // Remove FST on-disk
1143        if bucket_path.exists() {
1144            tracing::debug!(
1145                "fst bucket graph exists, erasing: {}/{} at path: {:?}",
1146                collection_str,
1147                bucket_str,
1148                &bucket_path
1149            );
1150
1151            // Remove FST graph storage from filesystem
1152            let erase_result = fs::remove_file(&bucket_path);
1153
1154            if erase_result.is_ok() {
1155                tracing::debug!("done with fst bucket erasure");
1156
1157                Ok(1)
1158            } else {
1159                Err(())
1160            }
1161        } else {
1162            tracing::debug!(
1163                "fst bucket graph does not exist, consider already erased: {}/{} at path: {:?}",
1164                collection_str,
1165                bucket_str,
1166                &bucket_path
1167            );
1168
1169            Ok(0)
1170        }
1171    }
1172}
1173
1174impl StoreFSTAction {
1175    pub fn push_word(&self, word: &str, fst_store_config: &crate::config::ConfigStoreFST) -> bool {
1176        // Word over limit? (abort, the FST does not perform well over large words)
1177        if Self::word_over_limit(word) {
1178            return false;
1179        }
1180
1181        let word_bytes = word.as_bytes();
1182
1183        // Nuke word from 'pop' set? (void a previous un-consolidated commit)
1184        if self.store.pending.pop.read().unwrap().contains(word_bytes) {
1185            self.store.pending.pop.write().unwrap().remove(word_bytes);
1186        }
1187
1188        // Add word in 'push' set? (only if word is not in FST)
1189        // Notice: also check whether FST is over limits or not from there, to avoid stacking \
1190        //   words that could never be consolidated to final FST anyway.
1191        let graph_fst = self.store.graph.as_fst();
1192
1193        if !self.store.graph.contains(&word)
1194            && !self.store.pending.push.read().unwrap().contains(word_bytes)
1195            && self.store.pending.push.read().unwrap().len() < fst_store_config.graph.max_words
1196            && !StoreFSTMisc::check_over_limits(
1197                graph_fst.size(),
1198                graph_fst.len(),
1199                &fst_store_config.graph,
1200            )
1201        {
1202            self.store
1203                .pending
1204                .push
1205                .write()
1206                .unwrap()
1207                .insert(word_bytes.to_vec());
1208
1209            self.store.should_consolidate();
1210
1211            // Pushed
1212            true
1213        } else {
1214            // Not pushed
1215            false
1216        }
1217    }
1218
1219    pub fn pop_word(&self, word: &str) -> bool {
1220        // Word over limit? (abort, the FST does not perform well over large words)
1221        if Self::word_over_limit(word) {
1222            return false;
1223        }
1224
1225        let word_bytes = word.as_bytes();
1226
1227        // Nuke word from 'push' set? (void a previous un-consolidated commit)
1228        if self.store.pending.push.read().unwrap().contains(word_bytes) {
1229            self.store.pending.push.write().unwrap().remove(word_bytes);
1230        }
1231
1232        // Add word in 'pop' set? (only if word is in FST)
1233        if self.store.graph.contains(word_bytes)
1234            && !self.store.pending.pop.read().unwrap().contains(word_bytes)
1235        {
1236            self.store
1237                .pending
1238                .pop
1239                .write()
1240                .unwrap()
1241                .insert(word_bytes.to_vec());
1242
1243            self.store.should_consolidate();
1244
1245            // Popped
1246            true
1247        } else {
1248            // Not popped
1249            false
1250        }
1251    }
1252
1253    pub fn suggest_words(
1254        &self,
1255        from_word: &str,
1256        limit: usize,
1257        max_typo_factor: Option<u32>,
1258    ) -> Option<Vec<String>> {
1259        // Word over limit? (abort, the FST does not perform well over large words)
1260        if Self::word_over_limit(from_word) {
1261            return None;
1262        }
1263
1264        let mut found_words = Vec::with_capacity(limit);
1265
1266        // Try to complete provided word
1267        if let Ok(stream) = self.store.lookup_begins(from_word) {
1268            tracing::debug!("looking up for word: {} in 'begins' fst stream", from_word);
1269
1270            Self::find_words_stream(stream, &mut found_words, limit);
1271        }
1272
1273        // Try to fuzzy-suggest other words? (eg. correct typos)
1274        if found_words.len() < limit {
1275            if let Ok(stream) = self.store.lookup_typos(from_word, max_typo_factor) {
1276                tracing::debug!("looking up for word: {} in 'typos' fst stream", from_word);
1277
1278                Self::find_words_stream(stream, &mut found_words, limit);
1279            }
1280        }
1281
1282        if !found_words.is_empty() {
1283            Some(found_words)
1284        } else {
1285            None
1286        }
1287    }
1288
1289    pub fn list_words(&self, limit: usize, offset: usize) -> Result<Vec<String>, ()> {
1290        let stream = self.store.as_stream();
1291
1292        // Enumerate words from FST stream
1293        match stream
1294            .into_strs()
1295            .map(|words| words.into_iter().skip(offset).take(limit).collect())
1296        {
1297            Err(err) => {
1298                tracing::debug!("conversion of stream failed: {}", err);
1299                Err(())
1300            }
1301            Ok(words) => Ok(words),
1302        }
1303    }
1304
1305    pub fn count_words(&self) -> usize {
1306        self.store.cardinality()
1307    }
1308
1309    fn word_over_limit(word: &str) -> bool {
1310        if word.len() > WORD_LIMIT_LENGTH {
1311            tracing::debug!("got over-limit fst word: {}", word);
1312
1313            true
1314        } else {
1315            false
1316        }
1317    }
1318
1319    fn find_words_stream<A: Automaton>(
1320        mut stream: FSTStream<A>,
1321        found_words: &mut Vec<String>,
1322        limit: usize,
1323    ) {
1324        while let Some(word) = stream.next() {
1325            if let Ok(word_str) = str::from_utf8(word) {
1326                let word_string = word_str.to_string();
1327
1328                if !found_words.contains(&word_string) {
1329                    found_words.push(word_string);
1330
1331                    // Requested limit reached? Stop there.
1332                    if found_words.len() >= limit {
1333                        break;
1334                    }
1335                }
1336            }
1337        }
1338    }
1339}
1340
1341impl StoreFSTMisc {
1342    pub fn count_collection_buckets(
1343        collection: impl AsRef<str>,
1344        fst_store_config: &crate::config::ConfigStoreFST,
1345    ) -> Result<usize, ()> {
1346        let mut count = 0;
1347
1348        let path_mode = StoreFSTPathMode::Permanent;
1349
1350        let collection_atom = StoreKeyerHasher::to_compact(collection.as_ref());
1351        let collection_path = fst_store_config.path(path_mode, collection_atom, None);
1352
1353        if collection_path.exists() {
1354            // Scan collection directory for contained buckets (count them)
1355            if let Ok(entries) = fs::read_dir(&collection_path) {
1356                let fst_extension = path_mode.extension();
1357                let fst_extension_len = fst_extension.len();
1358
1359                for entry in entries.flatten() {
1360                    if let Some(entry_name) = entry.file_name().to_str() {
1361                        let entry_name_len = entry_name.len();
1362
1363                        // FST file found? This is a bucket.
1364                        if entry_name_len > fst_extension_len && entry_name.ends_with(fst_extension)
1365                        {
1366                            count += 1;
1367                        }
1368                    }
1369                }
1370            } else {
1371                tracing::error!("failed reading directory for count: {:?}", collection_path);
1372
1373                return Err(());
1374            }
1375        }
1376
1377        Ok(count)
1378    }
1379
1380    fn check_over_limits(
1381        bytes_count: usize,
1382        words_count: usize,
1383        fst_graph_config: &crate::config::ConfigStoreFSTGraph,
1384    ) -> bool {
1385        // Over bytes limit?
1386        let max_size = fst_graph_config.max_size * 1024;
1387
1388        if bytes_count >= max_size {
1389            tracing::info!(
1390                "fst has exceeded maximum allowed bytes: {} over limit: {}",
1391                bytes_count,
1392                max_size
1393            );
1394
1395            return true;
1396        }
1397
1398        // Over words limit?
1399        if words_count >= fst_graph_config.max_words {
1400            tracing::info!(
1401                "fst has exceeded maximum allowed words: {} over limit: {}",
1402                words_count,
1403                fst_graph_config.max_words
1404            );
1405
1406            return true;
1407        }
1408
1409        // Not over limit
1410        false
1411    }
1412}
1413
1414impl StoreFSTKey {
1415    pub fn from_atom(collection_hash: StoreFSTAtom, bucket_hash: StoreFSTAtom) -> StoreFSTKey {
1416        StoreFSTKey {
1417            collection_hash,
1418            bucket_hash,
1419        }
1420    }
1421
1422    pub fn from_str(collection_str: &str, bucket_str: &str) -> StoreFSTKey {
1423        StoreFSTKey {
1424            collection_hash: StoreKeyerHasher::to_compact(collection_str),
1425            bucket_hash: StoreKeyerHasher::to_compact(bucket_str),
1426        }
1427    }
1428}
1429
1430impl fmt::Display for StoreFSTKey {
1431    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
1432        write!(f, "<{:x}>/<{:x}>", self.collection_hash, self.bucket_hash)
1433    }
1434}
1435
1436#[cfg(test)]
1437mod tests {
1438    use super::*;
1439
1440    #[test]
1441    fn it_acquires_graph() {
1442        let fst_store_config = test_fst_store_config();
1443        let fst_pool = StoreFSTPool::new(fst_store_config);
1444
1445        assert!(fst_pool.acquire("c:test:1", "b:test:1").is_ok());
1446    }
1447
1448    #[test]
1449    fn it_janitors_graph() {
1450        let fst_store_config = test_fst_store_config();
1451        let fst_pool = StoreFSTPool::new(fst_store_config);
1452
1453        fst_pool.janitor();
1454    }
1455
1456    #[test]
1457    fn it_proceeds_primitives() {
1458        let fst_store_config = test_fst_store_config();
1459        let fst_pool = StoreFSTPool::new(fst_store_config);
1460
1461        let store = fst_pool.acquire("c:test:2", "b:test:2").unwrap();
1462
1463        assert!(store.lookup_typos("valerien", None).is_ok());
1464    }
1465
1466    fn test_fst_store_config() -> Arc<crate::config::ConfigStoreFST> {
1467        Arc::new(
1468            config::Config::builder()
1469                .add_source(config::File::from_str(
1470                    crate::config::tests::defaults_toml(),
1471                    config::FileFormat::Toml,
1472                ))
1473                .build()
1474                .unwrap()
1475                .get::<crate::config::ConfigStoreFST>("store.fst")
1476                .unwrap(),
1477        )
1478    }
1479}
1480
1481// MARK: - Boilerplate
1482
1483impl fmt::Debug for StoreFSTPool {
1484    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1485        use crate::util::fmt::{AsPrettyMutex, AsPrettyRwLock};
1486
1487        // NOTE: Deconstructing to future-proof this function.
1488        let Self {
1489            graph_pool,
1490            graph_acquire_lock,
1491            graph_rebuild_lock,
1492            graph_access_lock,
1493            graph_consolidate,
1494            // NOTE: We don’t care about the configuration,
1495            //   we can see it elsewhere if needed.
1496            fst_store_config: _fst_store_config,
1497        } = self;
1498
1499        f.debug_struct("StoreFSTPool")
1500            .field("graph_pool", &AsPrettyRwLock(graph_pool))
1501            .field("graph_acquire_lock", &AsPrettyMutex(graph_acquire_lock))
1502            .field("graph_rebuild_lock", &AsPrettyMutex(graph_rebuild_lock))
1503            .field("graph_access_lock", &AsPrettyRwLock(graph_access_lock))
1504            .field("graph_consolidate", &AsPrettyRwLock(graph_consolidate))
1505            .finish_non_exhaustive()
1506    }
1507}
1508
1509impl fmt::Debug for StoreFSTKey {
1510    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1511        fmt::Display::fmt(&self, f)
1512    }
1513}
1514
1515impl fmt::Debug for StoreFST {
1516    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1517        use crate::util::fmt::AsPrettyRwLock;
1518
1519        // NOTE: Deconstructing to future-proof this function.
1520        let Self {
1521            graph,
1522            target,
1523            pending,
1524            last_used,
1525            last_consolidated,
1526            graph_consolidate,
1527        } = self;
1528
1529        f.debug_struct("StoreFST")
1530            .field("graph", graph)
1531            .field("target", target)
1532            .field("pending", pending)
1533            .field("last_used", &AsPrettyRwLock(last_used))
1534            .field("last_consolidated", &AsPrettyRwLock(last_consolidated))
1535            .field("graph_consolidate", &AsPrettyRwLock(graph_consolidate))
1536            .finish()
1537    }
1538}
1539
1540impl fmt::Debug for StoreFSTPending {
1541    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1542        use crate::util::fmt::AsPrettyRwLock;
1543
1544        // NOTE: Deconstructing to future-proof this function.
1545        let Self { pop, push } = self;
1546
1547        f.debug_struct("StoreFSTPending")
1548            .field("pop", &AsPrettyRwLock(pop))
1549            .field("push", &AsPrettyRwLock(push))
1550            .finish()
1551    }
1552}