Skip to main content

cuckoo_clock/
filter.rs

1use std::{
2    hash::{BuildHasher, Hash, RandomState},
3    io::Read,
4    iter::repeat_with,
5    sync::{
6        Arc, Mutex, MutexGuard,
7        atomic::{AtomicUsize, Ordering},
8    },
9};
10
11use crate::{
12    associated_data::AssociatedData,
13    bucket::{Bucket, InsertValues, LookupValues},
14    config::CuckooConfiguration,
15    data_block::{DataBlock, Fingerprint},
16    exporter::{
17        CuckooFilterExporter, ExportableBuildHasher, ExportableRandomState, import_config,
18        read_hasher_from,
19    },
20};
21
22/// Thread-safe cuckoo filter, with support for TTL, LRU and custom counters associated with the
23/// stored data.
24///
25/// Instances of [`CuckooFilter`] can be cloned and used across different threads. To ensure thread
26/// safety, locks are used, but locking is done per bucket, meaning that 2 separate threads can
27/// freely access different buckets without conflicts. In most cases locks shouldn't block, because
28/// optimal cuckoo filter configuration will have a large number of buckets, reducing the change of
29/// concurrent access to the same bucket.
30///
31/// Instances of [`CuckooFilter`] build using [`CuckooFilter::new_random_exportable`] or with a
32/// [`BuildHasher`] that also implements [`ExportableBuildHasher`] can be exported and imported,
33/// using [`CuckooFilter::exporter`] and [`CuckooFilter::import`]. Note that configuration is
34/// stored in the exported data too and can't be changed, because any changes to the configuration
35/// data would invalidate all of the stored data.
36///
37/// # Examples
38///
39/// Basic cuckoo filter with default configuration
40/// ```
41/// use cuckoo_clock::{CuckooFilter, config::CuckooConfiguration};
42///
43/// let filter = CuckooFilter::new_random(CuckooConfiguration::builder(100_000).build()?);
44///
45/// // None returned from insertion means no entry was evicted
46/// assert!(filter.insert("example_data").is_none());
47///
48/// // Insertion must have been successful
49/// assert!(filter.contains("example_data"));
50///
51/// // Deletion must have been successful
52/// assert!(filter.remove("example_data"));
53/// assert!(!filter.contains("example_data"));
54///
55/// # Ok::<(), Box<dyn std::error::Error>>(())
56/// ```
57///
58/// More complex use-case, with additional options
59/// ```
60/// use cuckoo_clock::{CuckooFilter, config::{CuckooConfiguration, CounterConfig, TtlConfig}};
61///
62/// let filter = CuckooFilter::new_random(
63///     CuckooConfiguration::builder(10_000_000)
64///         .fingerprint_bits(18.try_into()?)
65///         .bucket_size(8.try_into()?)
66///         .with_counter(CounterConfig {
67///             counter_bits: 4.try_into()?,
68///             ..Default::default()
69///         })
70///         .with_ttl(TtlConfig {
71///             ttl: 600.try_into()?,
72///             ttl_bits: 10.try_into()?
73///         })
74///         .build()?
75/// );
76///
77/// // In this case, we use `insert_if_not_present` to ensure no duplicates, because we care about
78/// // the counter
79/// // None returned from insertion means no entry was evicted
80/// assert!(filter.insert_if_not_present("example_data").is_none());
81/// assert!(filter.insert_if_not_present("example_data").is_none());
82///
83/// // Insertion must have been successful
84/// assert!(filter.contains("example_data"));
85///
86/// // Counter should be 4 now.
87/// // We have accessed this item 3 times, but `get_associated_data` also counts as an access.
88/// assert_eq!(filter.get_associated_data("example_data").unwrap().get_counter()?, 4);
89///
90/// # Ok::<(), Box<dyn std::error::Error>>(())
91/// ```
92///
93/// Export/import
94/// ```
95/// use std::{collections::VecDeque, io::Read};
96/// use cuckoo_clock::{CuckooFilter, config::CuckooConfiguration};
97///
98/// let filter = CuckooFilter::new_random_exportable(CuckooConfiguration::builder(100_000).build()?);
99///
100/// // None returned from insertion means no entry was evicted
101/// assert!(filter.insert("example_data").is_none());
102///
103/// let mut buf = Vec::new();
104/// filter.exporter().write_to(&mut buf)?;
105///
106/// let mut buf = VecDeque::from(buf);
107/// let imported_filter = CuckooFilter::import_random_exportable(&mut buf)?;
108///
109/// // The inserted data is available in the imported filter
110/// assert!(filter.contains("example_data"));
111///
112/// # Ok::<(), Box<dyn std::error::Error>>(())
113/// ```
114///
115#[derive(Clone)]
116pub struct CuckooFilter<H: BuildHasher> {
117    configuration: CuckooConfiguration,
118    buckets: Arc<Vec<Mutex<Bucket>>>,
119    build_hasher: H,
120    items: Arc<AtomicUsize>,
121}
122
123impl CuckooFilter<RandomState> {
124    /// Creates a new instance of [`CuckooFilter`], using [`RandomState`] as its [`BuildHasher`].
125    ///
126    /// # Panics
127    ///
128    /// Panics if allocation of buckets fails (if too much memory was requested).
129    #[must_use]
130    pub fn new_random(configuration: CuckooConfiguration) -> Self {
131        Self::new(configuration, RandomState::new())
132    }
133}
134
135impl CuckooFilter<ExportableRandomState> {
136    /// Creates a new instance of [`CuckooFilter`], using [`ExportableBuildHasher`] based on
137    /// [`RandomState`] as its [`BuildHasher`].
138    /// This instance supports export using [`CuckooFilter::export`].
139    ///
140    /// # Panics
141    ///
142    /// Panics if allocation of buckets fails (if too much memory was requested).
143    #[must_use]
144    pub fn new_random_exportable(configuration: CuckooConfiguration) -> Self {
145        Self::new(configuration, ExportableRandomState::new_random())
146    }
147
148    /// Creates a new instance of [`CuckooFilter`], using [`ExportableBuildHasher`] based on
149    /// exported data.
150    ///
151    /// # Panics
152    ///
153    /// Panics if allocation of buckets fails (if too much memory was requested).
154    pub fn import_random_exportable(reader: impl Read) -> Result<Self, crate::ImportError> {
155        Self::import(reader)
156    }
157}
158
159impl<H: ExportableBuildHasher + BuildHasher> CuckooFilter<H> {
160    /// Creates a cuckoo filter from its exported state.
161    ///
162    /// # Panics
163    ///
164    /// Panics if allocation of buckets fails (if too much memory was requested).
165    pub fn import(mut reader: impl Read) -> Result<Self, crate::ImportError> {
166        let (hasher, configuration) = Self::import_config(&mut reader)?;
167        Self::import_state(hasher, configuration, reader)
168    }
169
170    /// Creates a cuckoo filter configuration from its exported state - skips the actual state.
171    pub fn import_config(
172        mut reader: impl Read,
173    ) -> Result<(H, CuckooConfiguration), crate::ImportError> {
174        let hasher = read_hasher_from::<H>(&mut reader)?;
175        let config = import_config(&mut reader)?;
176        Ok((hasher, config))
177    }
178
179    /// Creates a cuckoo filter from its exported state and already read hasher and configuration.
180    /// This assumes that these 2 were already read from the provided reader.
181    ///
182    /// # Panics
183    ///
184    /// Panics if allocation of buckets fails (if too much memory was requested).
185    pub fn import_state(
186        hasher: H,
187        configuration: CuckooConfiguration,
188        mut reader: impl Read,
189    ) -> Result<Self, crate::ImportError> {
190        let mut buckets = Vec::with_capacity(configuration.bucket_count);
191
192        let mut item_count = 0;
193        for _ in 0..configuration.bucket_count {
194            let bucket = Bucket::take_from(&mut reader, &configuration)?;
195            item_count += bucket.occupied_count(&configuration);
196            buckets.push(Mutex::new(bucket));
197        }
198
199        Ok(Self {
200            configuration,
201            buckets: Arc::new(buckets),
202            build_hasher: hasher,
203            items: Arc::new(AtomicUsize::new(item_count)),
204        })
205    }
206
207    /// Prepares an exporter for this [`CuckooFilter`], enabling to persist it and import it later
208    /// using [`CuckooFilter::import`].
209    pub fn exporter<'a>(&'a self) -> CuckooFilterExporter<'a, H> {
210        CuckooFilterExporter::new(&self.build_hasher, &self.buckets, &self.configuration)
211    }
212}
213
214impl<H: BuildHasher> CuckooFilter<H> {
215    /// Creates a new instance of [`CuckooFilter`], using provided [`BuildHasher`].
216    ///
217    /// # Panics
218    ///
219    /// Panics if allocation of buckets fails (if too much memory was requested).
220    pub fn new(configuration: CuckooConfiguration, build_hasher: H) -> Self {
221        Self {
222            configuration: configuration.clone(),
223            buckets: repeat_with(|| Bucket::new(&configuration).into())
224                .take(configuration.bucket_count)
225                .collect::<Vec<_>>()
226                .into(),
227            build_hasher,
228            items: Arc::new(AtomicUsize::new(0)),
229        }
230    }
231
232    /// Returns the actual bucket count for this [`CuckooFilter`].
233    ///
234    /// Bucket count is calculated as first next power of two of capacity / bucket_size.
235    /// This means that the actual capacity of the filter is usually bigger than the requested
236    /// capacity.
237    pub const fn get_bucket_count(&self) -> usize {
238        self.configuration.bucket_count
239    }
240
241    /// Returns the actual number of items currently stored in this [`CuckooFilter`].
242    pub fn get_item_count(&self) -> usize {
243        self.items.load(Ordering::Relaxed)
244    }
245
246    /// Returns the configuration for this [`CuckooFilter`].
247    pub fn get_configuration(&self) -> CuckooConfiguration {
248        self.configuration.clone()
249    }
250
251    /// Returns the memory usage of this filter in bytes.
252    pub fn get_memory_usage(&self) -> usize {
253        size_of::<Self>()
254            + size_of::<AtomicUsize>()
255            + size_of::<Vec<Mutex<Bucket>>>()
256            + size_of::<Mutex<Bucket>>() * self.buckets.len()
257            + self.configuration.bucket_byte_size * self.buckets.len()
258    }
259
260    /// Inserts a new item into the filter, only if the filter doesn't contain it already.
261    ///
262    /// This is slower than [`CuckooFilter::insert`], but it ensures that no duplicates are present
263    /// in the filter. That can be useful when [`AssociatedData`] is used, to ensure consistent
264    /// results.
265    ///
266    /// Returns fingerprint of the item that was evicted from the filter, if eviction had to take
267    /// place to finalize the insertion. It is possible that the item that was just inserted gets
268    /// evicted in random kicking process. That can be confirmed using
269    /// [`Fingerprint::matches_key`].
270    pub fn insert_if_not_present<K: Hash + ?Sized>(&self, key: &K) -> Option<Fingerprint> {
271        self.insert_if_not_present_with_update(
272            key,
273            InsertValues::default(),
274            LookupValues::default(),
275        )
276    }
277
278    /// Inserts a new item into the filter, only if the filter doesn't contain it already. Also
279    /// applies provided updates.
280    ///
281    /// This is similar to [`CuckooFilter::insert_if_not_present`], but it also updates found
282    /// values, or starts off values with different values.
283    pub fn insert_if_not_present_with_update<K: Hash + ?Sized>(
284        &self,
285        key: &K,
286        insert_values: InsertValues,
287        lookup_update: LookupValues,
288    ) -> Option<Fingerprint> {
289        let (fp, i1) = self.get_fingerprint_and_index(key);
290
291        let mut contains =
292            self.lock_bucket(i1 as usize)
293                .contains(&fp, &self.configuration, &lookup_update);
294
295        if contains {
296            return None;
297        }
298
299        let i2 = self.alt_index(&fp, i1);
300        contains = self
301            .lock_bucket(i2 as usize)
302            .contains(&fp, &self.configuration, &lookup_update);
303
304        if contains {
305            return None;
306        }
307
308        let mut cur_data_block = self.new_data_block(&fp, insert_values);
309
310        let inserted = self
311            .lock_bucket(i1 as usize)
312            .insert(&cur_data_block, &self.configuration);
313
314        if inserted {
315            self.items.fetch_add(1, Ordering::Relaxed);
316            return None;
317        }
318
319        let inserted = self
320            .lock_bucket(i2 as usize)
321            .insert(&cur_data_block, &self.configuration);
322
323        if inserted {
324            self.items.fetch_add(1, Ordering::Relaxed);
325            return None;
326        }
327
328        let mut cur_index = if rand::random::<bool>() { i1 } else { i2 };
329        for _ in 0..self.configuration.max_kicks {
330            {
331                let mut bucket = self.lock_bucket(cur_index as usize);
332                // Replace a random item first
333                if let Some(lru_config) = self.configuration.lru_field_config.as_ref() {
334                    if !bucket.kick_lru(&mut cur_data_block, &self.configuration, lru_config) {
335                        return Some(cur_data_block.get_fingerprint(&self.configuration));
336                    }
337                } else {
338                    bucket.kick_random(&mut cur_data_block, &self.configuration);
339                }
340                cur_index = self.alt_index(
341                    &cur_data_block.get_fingerprint(&self.configuration),
342                    cur_index,
343                );
344            }
345
346            if self
347                .lock_bucket(cur_index as usize)
348                .insert(&cur_data_block, &self.configuration)
349            {
350                self.items.fetch_add(1, Ordering::Relaxed);
351                // Found an alternative spot for evicted item, done with kicks
352                return None;
353            }
354        }
355
356        // Filter is full
357        Some(cur_data_block.get_fingerprint(&self.configuration))
358    }
359
360    /// Inserts a new item into the filter.
361    ///
362    /// If both target buckets for this item are full, random item is kicked out of one of these 2
363    /// buckets and moved into its alternate bucket, starting a recursive kicking process, which
364    /// stops once an empty slot is found in alternate bucket of a kicked item, or
365    /// [`crate::config::CuckooConfigurationBuilder::max_kicks`] is reached.
366    ///
367    /// Returns fingerprint of the item that was evicted from the filter, if eviction had to take
368    /// place to finalize the insertion. It is possible that the item that was just inserted gets
369    /// evicted in random kicking process. That can be confirmed using
370    /// [`Fingerprint::matches_key`].
371    pub fn insert<K: Hash + ?Sized>(&self, key: &K) -> Option<Fingerprint> {
372        self.insert_with_defaults(key, InsertValues::default())
373    }
374
375    /// Inserts a new item into the filter, with defined defaults for associated data.
376    ///
377    /// This is similar to [`CuckooFilter::insert`], but allows overrides for associated data
378    /// defaults.
379    pub fn insert_with_defaults<K: Hash + ?Sized>(
380        &self,
381        key: &K,
382        default: InsertValues,
383    ) -> Option<Fingerprint> {
384        let (fp, i1) = self.get_fingerprint_and_index(key);
385        let mut cur_data_block = self.new_data_block(&fp, default);
386
387        let inserted = self
388            .lock_bucket(i1 as usize)
389            .insert(&cur_data_block, &self.configuration);
390
391        if inserted {
392            self.items.fetch_add(1, Ordering::Relaxed);
393            return None;
394        }
395
396        let i2 = self.alt_index(&fp, i1);
397
398        let inserted = self
399            .lock_bucket(i2 as usize)
400            .insert(&cur_data_block, &self.configuration);
401
402        if inserted {
403            self.items.fetch_add(1, Ordering::Relaxed);
404            return None;
405        }
406
407        let mut cur_index = i1;
408        for _ in 0..self.configuration.max_kicks {
409            {
410                let mut bucket = self.lock_bucket(cur_index as usize);
411                // Replace a random item first
412                if let Some(lru_config) = self.configuration.lru_field_config.as_ref() {
413                    if !bucket.kick_lru(&mut cur_data_block, &self.configuration, lru_config) {
414                        return Some(cur_data_block.get_fingerprint(&self.configuration));
415                    }
416                } else {
417                    // TODO: this can even kick the newest item, which is not ideal
418                    bucket.kick_random(&mut cur_data_block, &self.configuration);
419                }
420                cur_index = self.alt_index(
421                    &cur_data_block.get_fingerprint(&self.configuration),
422                    cur_index,
423                );
424            }
425
426            if self
427                .lock_bucket(cur_index as usize)
428                .insert(&cur_data_block, &self.configuration)
429            {
430                self.items.fetch_add(1, Ordering::Relaxed);
431                // Found an alternative spot for evicted item, done with kicks
432                return None;
433            }
434        }
435
436        // Filter is full
437        Some(cur_data_block.get_fingerprint(&self.configuration))
438    }
439
440    /// Check if this key is stored in the filter and applies the provided [`LookupValues`].
441    ///
442    /// This is similar to [`CuckooFilter::contains`], but allows overrides for updates on
443    /// successful lookup.
444    pub fn contains_with_update<K: Hash + ?Sized>(&self, key: &K, update: LookupValues) -> bool {
445        let (fp, i1) = self.get_fingerprint_and_index(key);
446
447        let mut contains =
448            self.lock_bucket(i1 as usize)
449                .contains(&fp, &self.configuration, &update);
450
451        if !contains {
452            let i2 = self.alt_index(&fp, i1);
453            contains = self
454                .lock_bucket(i2 as usize)
455                .contains(&fp, &self.configuration, &update);
456        }
457
458        contains
459    }
460
461    /// Check if this key is stored in the filter.
462    ///
463    /// Returns true if this key might be present in the filter. If false is returned, then the key
464    /// is definitely not present.
465    pub fn contains<K: Hash + ?Sized>(&self, key: &K) -> bool {
466        self.contains_with_update(key, LookupValues::default())
467    }
468
469    /// Loads associated data of a key stored in the filter.
470    ///
471    /// Returns None if this filter is not present in the filter. Returns associated data for the
472    /// first item with the fingerprint matching this key's fingerprint. Note that it is
473    /// recommended to use [`CuckooFilter::insert_if_not_present`] if consistent [`AssociatedData`]
474    /// is required.
475    pub fn get_associated_data<K: Hash + ?Sized>(&self, key: &K) -> Option<AssociatedData> {
476        self.get_associated_data_with_update(key, LookupValues::default())
477    }
478
479    /// Loads associated data of a key stored in the filter and applies the provided [`LookupValues`].
480    ///
481    /// This is similar to [`CuckooFilter::get_associated_data`], but allows overrides for updates on
482    /// successful lookup.
483    pub fn get_associated_data_with_update<K: Hash + ?Sized>(
484        &self,
485        key: &K,
486        update: LookupValues,
487    ) -> Option<AssociatedData> {
488        let (fp, i1) = self.get_fingerprint_and_index(key);
489
490        let mut contains =
491            self.lock_bucket(i1 as usize)
492                .get_associated_data(&fp, &self.configuration, &update);
493
494        if contains.is_none() {
495            let i2 = self.alt_index(&fp, i1);
496            contains = self.lock_bucket(i2 as usize).get_associated_data(
497                &fp,
498                &self.configuration,
499                &update,
500            );
501        }
502
503        contains
504    }
505
506    /// Removes this key from the filter, if present.
507    ///
508    /// Returns true if the key was present in the filter.
509    pub fn remove<K: Hash + ?Sized>(&self, key: &K) -> bool {
510        let (fp, i1) = self.get_fingerprint_and_index(key);
511
512        let mut removed = self
513            .lock_bucket(i1 as usize)
514            .remove(&fp, &self.configuration);
515
516        if !removed {
517            let i2 = self.alt_index(&fp, i1);
518            removed = self
519                .lock_bucket(i2 as usize)
520                .remove(&fp, &self.configuration);
521        }
522
523        if removed {
524            self.items.fetch_sub(1, Ordering::Relaxed);
525        }
526
527        removed
528    }
529
530    /// Scans all buckets of this filter and reduces TTL and LRU counters.
531    ///
532    /// If LRU and/or TTL features are used, this must be called periodically.
533    /// Each call to this function will age all the LRU and TTL counters. The frequency of calls
534    /// will affect both LRU and TTL in different ways:
535    /// - TTL will get reduced by 1 on each call, meaning that scanning each second indirectly sets
536    ///   the unit of TTL field to be seconds.
537    /// - LRU will get halved on each call. By scanning more frequently, items will require more
538    ///   frequent usage to stay in the filter.
539    ///
540    /// This is a no-op if both LRU and TTL are disabled.
541    ///
542    /// # Examples
543    ///
544    /// ```
545    /// use cuckoo_clock::{CuckooFilter, config::{CuckooConfiguration, CounterConfig, TtlConfig}};
546    ///
547    /// let filter = CuckooFilter::new_random(
548    ///     CuckooConfiguration::builder(10_000)
549    ///         .with_ttl(TtlConfig {
550    ///             ttl: 3.try_into()?,
551    ///             ttl_bits: 2.try_into()?
552    ///         })
553    ///         .build()?
554    /// );
555    ///
556    /// filter.insert("example_data");
557    ///
558    /// assert!(filter.contains("example_data"));
559    ///
560    /// filter.scan_and_update_full();
561    /// assert!(filter.contains("example_data"));
562    ///
563    /// filter.scan_and_update_full();
564    /// assert!(filter.contains("example_data"));
565    ///
566    /// // The item will get removed now, due to expired TTL
567    /// filter.scan_and_update_full();
568    /// assert!(!filter.contains("example_data"));
569    ///
570    /// # Ok::<(), Box<dyn std::error::Error>>(())
571    /// ```
572    pub fn scan_and_update_full(&self) -> usize {
573        if self.configuration.lru_field_config.is_none()
574            && self.configuration.ttl_field_config.is_none()
575        {
576            return 0;
577        }
578
579        let mut removed = 0;
580        for b in self.buckets.iter() {
581            #[expect(clippy::unwrap_used)]
582            let mut bucket = b.lock().unwrap();
583            if let Some(lru_config) = &self.configuration.lru_field_config {
584                bucket.age_lru_counters(&self.configuration, lru_config);
585            }
586            if let Some(ttl_config) = &self.configuration.ttl_field_config {
587                removed += bucket.age_ttl_counters(&self.configuration, ttl_config);
588            }
589        }
590
591        if removed > 0 {
592            self.items.fetch_sub(removed, Ordering::Relaxed);
593        }
594        removed
595    }
596
597    /// Scans all buckets of this filter and reduces TTL counters.
598    ///
599    /// Similar to [`CuckooFilter::scan_and_update_full`], but updates only TTL counters. This
600    /// allows more control, enabling different update frequency for TTL and LRU.
601    ///
602    /// This is a no-op if TTL is disabled.
603    pub fn scan_and_update_ttl(&self) -> usize {
604        if self.configuration.ttl_field_config.is_none() {
605            return 0;
606        }
607
608        let mut removed = 0;
609        for b in self.buckets.iter() {
610            #[expect(clippy::unwrap_used)]
611            let mut bucket = b.lock().unwrap();
612            if let Some(ttl_config) = &self.configuration.ttl_field_config {
613                removed += bucket.age_ttl_counters(&self.configuration, ttl_config);
614            }
615        }
616
617        if removed > 0 {
618            self.items.fetch_sub(removed, Ordering::Relaxed);
619        }
620        removed
621    }
622
623    /// Scans all buckets of this filter and reduces LRU counters.
624    ///
625    /// Similar to [`CuckooFilter::scan_and_update_full`], but updates only LRU counters. This
626    /// allows more control, enabling different update frequency for TTL and LRU.
627    ///
628    /// This is a no-op if LRU is disabled.
629    pub fn scan_and_update_lru(&self) {
630        if self.configuration.lru_field_config.is_none() {
631            return;
632        }
633
634        for b in self.buckets.iter() {
635            #[expect(clippy::unwrap_used)]
636            let mut bucket = b.lock().unwrap();
637            if let Some(lru_config) = &self.configuration.lru_field_config {
638                bucket.age_lru_counters(&self.configuration, lru_config);
639            }
640        }
641    }
642
643    /// Generates the fingerprint and first index for the provided key.
644    pub(crate) fn get_fingerprint<K: Hash + ?Sized>(&self, key: &K) -> Fingerprint {
645        self.get_fingerprint_and_index(key).0
646    }
647
648    fn new_data_block(&self, fp: &Fingerprint, defaults: InsertValues) -> DataBlock<Vec<u8>> {
649        let data = vec![0u8; self.configuration.data_block_size];
650        let mut cur_data_block = DataBlock::from(data);
651        cur_data_block.store_fingerprint(fp, &self.configuration);
652
653        if let Some(ttl_config) = self.configuration.ttl_field_config.as_ref() {
654            cur_data_block.set_ttl(ttl_config, defaults.ttl.unwrap_or(ttl_config.0.ttl.into()));
655        }
656        if let Some(counter_config) = self.configuration.counter_field_config.as_ref() {
657            cur_data_block.update_counter(
658                counter_config,
659                defaults
660                    .counter
661                    .unwrap_or(counter_config.0.change_on_insert),
662            );
663        }
664        if let Some(lru_config) = self.configuration.lru_field_config.as_ref() {
665            cur_data_block.inc_lru_counter(lru_config);
666        }
667        cur_data_block
668    }
669
670    fn get_fingerprint_and_index<K: Hash + ?Sized>(&self, key: &K) -> (Fingerprint, u32) {
671        let result = self.build_hasher.hash_one(key);
672
673        // Fingeprint bits over 32 are definitely an overkill
674        // We can reduce number of hashes by using one hash as fingerprint and first index
675        let fingerprint = (result >> 32) as u32;
676        // Intentional truncation here
677        #[expect(clippy::cast_possible_truncation)]
678        let index = result as u32 & self.configuration.buckets_mask;
679
680        (
681            Fingerprint::new(
682                fingerprint,
683                self.configuration.fingerprint_field_config.value_mask(),
684            ),
685            index,
686        )
687    }
688
689    // Intentional truncation here
690    #[expect(clippy::cast_possible_truncation)]
691    fn alt_index(&self, fingerprint: &Fingerprint, index: u32) -> u32 {
692        let result = self.build_hasher.hash_one(fingerprint);
693
694        (index ^ ((result as u32) & self.configuration.buckets_mask))
695            & self.configuration.buckets_mask
696    }
697
698    #[expect(clippy::unwrap_used)]
699    fn lock_bucket(&self, index: usize) -> MutexGuard<'_, Bucket> {
700        // Any panic while lock is held should come from this library
701        // Any panic produced while the lock is held is a bug in the library!
702        self.buckets[index].lock().unwrap()
703    }
704}
705
706#[cfg(test)]
707#[expect(clippy::unwrap_used)]
708mod tests {
709    use std::{
710        collections::{HashSet, VecDeque},
711        hash::Hasher,
712        ops::Range,
713    };
714
715    use crate::config::{CounterConfig, LruConfig, TtlConfig};
716
717    use super::*;
718
719    fn get_words(range: Range<usize>) -> Vec<String> {
720        std::fs::read_to_string("/usr/share/dict/words")
721            .unwrap()
722            .split("\n")
723            .skip(range.start)
724            .take(range.len())
725            .map(ToString::to_string)
726            .collect()
727    }
728
729    #[test]
730    fn basic_insertion() {
731        let filter = CuckooFilter::new_random(CuckooConfiguration::builder(1000).build().unwrap());
732
733        filter.insert("basic");
734
735        assert!(filter.contains("basic"));
736    }
737
738    #[test]
739    fn basic_removal() {
740        let filter = CuckooFilter::new_random(CuckooConfiguration::builder(1000).build().unwrap());
741
742        filter.insert("basic");
743
744        assert!(filter.contains("basic"));
745
746        filter.remove("basic");
747
748        assert!(!filter.contains("basic"));
749    }
750
751    struct PredefinedBucketItem(u64);
752    struct TestHasher(u64);
753    impl BuildHasher for TestHasher {
754        type Hasher = TestHasher;
755
756        fn build_hasher(&self) -> Self::Hasher {
757            TestHasher(0)
758        }
759    }
760    impl Hasher for TestHasher {
761        fn finish(&self) -> u64 {
762            self.0
763        }
764
765        fn write(&mut self, bytes: &[u8]) {
766            if bytes.len() == 8 {
767                self.0 = u64::from_ne_bytes(bytes.try_into().unwrap());
768            } else {
769                // Shift fingeprint hashes a bit, to allow control
770                self.0 = 1 - (u32::from_ne_bytes(bytes.try_into().unwrap()) as u64 % 2);
771            }
772        }
773    }
774    impl Hash for PredefinedBucketItem {
775        fn hash<H: Hasher>(&self, state: &mut H) {
776            state.write_u64(self.0);
777        }
778    }
779
780    #[test]
781    fn lru_insertion() {
782        let filter = CuckooFilter::new(
783            CuckooConfiguration::builder(1000)
784                .bucket_size(2.try_into().unwrap())
785                .with_lru(LruConfig {
786                    counter_bits: 8.try_into().unwrap(),
787                })
788                .build()
789                .unwrap(),
790            TestHasher(0),
791        );
792
793        let test_item = PredefinedBucketItem(2 << 32);
794        filter.insert(&test_item);
795        filter.contains(&test_item); // Make it more used than others
796
797        let test_item_2 = PredefinedBucketItem(4 << 32);
798        filter.insert(&test_item_2); // Sharing the same bucket as "test", but less used
799
800        let test_item_3 = PredefinedBucketItem((3 << 32) + 2);
801        filter.insert(&test_item_3); // Another bucket, but also valid for "test" bucket
802        filter.contains(&test_item_3); // Make it more used
803
804        let test_item_4 = PredefinedBucketItem((5 << 32) + 2);
805        filter.insert(&test_item_4); // Takes bucket of "test_item_3", but less used
806
807        // Everything fits now
808        assert!(filter.contains(&test_item));
809        assert!(filter.contains(&test_item_2));
810        assert!(filter.contains(&test_item_3));
811        assert!(filter.contains(&test_item_4));
812
813        let test_item_5 = PredefinedBucketItem((1 << 32) + 2);
814        // Insert a new item which has to take one of the 2 fully occupied buckets
815        filter.insert(&test_item_5);
816
817        assert!(filter.contains(&test_item_2));
818        assert!(filter.contains(&test_item));
819        assert!(filter.contains(&test_item_3));
820
821        assert!(
822            !filter.contains(&test_item_5) || !filter.contains(&test_item_4),
823            "No inserted items are missing, but filter can't hold them all"
824        );
825
826        // Insert both of these items again and confirm the more used ones are still there
827        filter.insert(&test_item_5);
828        filter.insert(&test_item_4);
829        assert!(filter.contains(&test_item));
830        assert!(filter.contains(&test_item_3));
831    }
832
833    #[test]
834    fn alt_index() {
835        let words = get_words(0..200_000);
836        let filter = CuckooFilter::new_random(
837            CuckooConfiguration::builder(200_000)
838                .fingerprint_bits(32.try_into().unwrap())
839                .build()
840                .unwrap(),
841        );
842
843        for word in words {
844            let (fp, index) = filter.get_fingerprint_and_index(&word);
845            let alt_index = filter.alt_index(&fp, index);
846            assert_eq!(index, filter.alt_index(&fp, alt_index));
847        }
848    }
849
850    #[test]
851    fn random_kicks() {
852        let filter = CuckooFilter::new(
853            CuckooConfiguration::builder(1000)
854                .bucket_size(2.try_into().unwrap())
855                .build()
856                .unwrap(),
857            TestHasher(0),
858        );
859
860        let test_item = PredefinedBucketItem(2 << 32);
861        filter.insert(&test_item);
862
863        let test_item_2 = PredefinedBucketItem(4 << 32);
864        filter.insert(&test_item_2); // Sharing the same bucket as "test"
865
866        let test_item_3 = PredefinedBucketItem((3 << 32) + 2);
867        filter.insert(&test_item_3); // Another bucket, but also valid for "test" bucket
868
869        let test_item_4 = PredefinedBucketItem((5 << 32) + 2);
870        filter.insert(&test_item_4); // Takes bucket of "test_item_3"
871
872        // This one should not be kicked, because it takes an unrelated bucket
873        let test_item_unrelated = PredefinedBucketItem((10 << 32) + 10);
874        filter.insert(&test_item_unrelated);
875
876        // Everything fits now
877        assert!(filter.contains(&test_item));
878        assert!(filter.contains(&test_item_2));
879        assert!(filter.contains(&test_item_3));
880        assert!(filter.contains(&test_item_4));
881
882        let test_item_5 = PredefinedBucketItem((1 << 32) + 2);
883        // Insert a new item which has to take one of the 2 fully occupied buckets
884        let kicked = filter.insert(&test_item_5);
885        assert!(kicked.is_some(), "An item had to be kicked");
886        assert!(filter.contains(&test_item_5));
887        assert!(filter.contains(&test_item_unrelated));
888
889        for item in [&test_item, &test_item_2, &test_item_3, &test_item_4]
890            .iter()
891            .filter(|i| !kicked.as_ref().unwrap().matches_key(i, &filter))
892        {
893            assert!(filter.contains(item), "Only one item should be kicked");
894        }
895    }
896
897    #[test]
898    fn overriding_defaults() {
899        let filter = CuckooFilter::new_random(
900            CuckooConfiguration::builder(1000)
901                .with_ttl(TtlConfig {
902                    ttl: 30.try_into().unwrap(),
903                    ttl_bits: 8.try_into().unwrap(),
904                })
905                .with_counter(CounterConfig::default())
906                .build()
907                .unwrap(),
908        );
909
910        filter.insert_with_defaults(
911            "basic",
912            InsertValues {
913                ttl: Some(50),
914                counter: Some(10),
915            },
916        );
917
918        assert!(filter.contains("basic"));
919        assert_eq!(
920            filter
921                .get_associated_data("basic")
922                .unwrap()
923                .get_stored_ttl_value()
924                .unwrap(),
925            50
926        );
927        assert_eq!(
928            filter
929                .get_associated_data("basic")
930                .unwrap()
931                .get_counter()
932                .unwrap(),
933            13 // initial 10 + 1 on contains + 2x1 on get_associated_data
934        );
935    }
936
937    #[test]
938    fn overriding_updates() {
939        let filter = CuckooFilter::new_random(
940            CuckooConfiguration::builder(1000)
941                .with_ttl(TtlConfig {
942                    ttl: 30.try_into().unwrap(),
943                    ttl_bits: 8.try_into().unwrap(),
944                })
945                .with_counter(CounterConfig::default())
946                .build()
947                .unwrap(),
948        );
949
950        filter.insert_with_defaults(
951            "basic",
952            InsertValues {
953                ttl: Some(5),
954                counter: Some(1),
955            },
956        );
957
958        assert!(filter.contains_with_update(
959            "basic",
960            LookupValues {
961                ttl: Some(50),
962                counter_diff: Some(10),
963            },
964        ));
965        assert_eq!(
966            filter
967                .get_associated_data("basic")
968                .unwrap()
969                .get_stored_ttl_value()
970                .unwrap(),
971            50
972        );
973        assert_eq!(
974            filter
975                .get_associated_data("basic")
976                .unwrap()
977                .get_counter()
978                .unwrap(),
979            13 // initial 1 + 10 on contains + 2x1 on get_associated_data
980        );
981    }
982
983    #[test]
984    fn scan_and_update_full() {
985        let words = get_words(0..100_000);
986        let filter = CuckooFilter::new_random(
987            CuckooConfiguration::builder(100_000)
988                .fingerprint_bits(32.try_into().unwrap())
989                .with_lru(LruConfig::default())
990                .with_ttl(TtlConfig {
991                    ttl: 3.try_into().unwrap(),
992                    ttl_bits: 2.try_into().unwrap(),
993                })
994                .build()
995                .unwrap(),
996        );
997
998        assert_eq!(filter.get_item_count(), 0);
999
1000        let mut stored_words = HashSet::new();
1001
1002        for (index, word) in words.iter().enumerate() {
1003            stored_words.insert(word);
1004            if let Some(evicted_fp) = filter.insert(word) {
1005                words[0..=index]
1006                    .iter()
1007                    .filter(|w| evicted_fp.matches_key(w, &filter))
1008                    .for_each(|evicted_word| {
1009                        stored_words.remove(evicted_word);
1010                    });
1011            }
1012        }
1013
1014        assert_eq!(filter.get_item_count(), stored_words.len());
1015
1016        for _ in 0..2 {
1017            assert_eq!(filter.scan_and_update_full(), 0);
1018        }
1019
1020        assert_eq!(filter.get_item_count(), stored_words.len());
1021        for word in stored_words.iter() {
1022            assert!(
1023                filter.contains(word),
1024                "Word: {word} expected in the filter, but not found"
1025            );
1026        }
1027
1028        // TTL should remove all entries now
1029        assert_eq!(filter.scan_and_update_full(), stored_words.len());
1030        for word in &words {
1031            assert!(
1032                !filter.contains(word),
1033                "Filter contained {word}, but shouldn't have"
1034            );
1035        }
1036        assert_eq!(filter.get_item_count(), 0);
1037    }
1038
1039    #[test]
1040    fn export_import() {
1041        let words = get_words(0..100_000);
1042        let filter = CuckooFilter::new_random_exportable(
1043            CuckooConfiguration::builder(100_000)
1044                .fingerprint_bits(32.try_into().unwrap())
1045                .with_lru(LruConfig::default())
1046                .with_ttl(TtlConfig {
1047                    ttl: 3.try_into().unwrap(),
1048                    ttl_bits: 2.try_into().unwrap(),
1049                })
1050                .build()
1051                .unwrap(),
1052        );
1053
1054        assert_eq!(filter.get_item_count(), 0);
1055
1056        let mut stored_words = HashSet::new();
1057
1058        for (index, word) in words.iter().enumerate() {
1059            stored_words.insert(word);
1060            if let Some(evicted_fp) = filter.insert(word) {
1061                words[0..=index]
1062                    .iter()
1063                    .filter(|w| evicted_fp.matches_key(w, &filter))
1064                    .for_each(|evicted_word| {
1065                        stored_words.remove(evicted_word);
1066                    });
1067            }
1068        }
1069
1070        assert_eq!(filter.get_item_count(), stored_words.len());
1071
1072        let exported_buf = filter.exporter().snapshot().unwrap();
1073        let mut readable_buf = VecDeque::from(exported_buf);
1074
1075        let imported_filter = CuckooFilter::import_random_exportable(&mut readable_buf).unwrap();
1076
1077        assert_eq!(
1078            imported_filter.get_configuration(),
1079            filter.get_configuration()
1080        );
1081
1082        assert_eq!(imported_filter.get_item_count(), stored_words.len());
1083        for word in stored_words.iter() {
1084            assert!(
1085                imported_filter.contains(word),
1086                "Word: {word} expected in the filter, but not found"
1087            );
1088        }
1089    }
1090}