Skip to main content

cuckoo_clock/
filter.rs

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