Skip to main content

spire_core/frontier/seen/
bloom.rs

1use std::fmt;
2use std::future::{Future, ready};
3use std::sync::Mutex;
4use std::sync::atomic::{AtomicBool, Ordering};
5
6use crate::frontier::seed::Fingerprint;
7use crate::frontier::seen::SeenSet;
8use crate::sync::lock;
9use crate::{Result, TRACING_TARGET};
10
11/// A [`SeenSet`] in a Bloom filter: fixed memory, at the cost of rarely
12/// treating a new URL as seen.
13///
14/// The filter is sized for `expected` URLs at a `false_positive_rate`. It
15/// never treats a seen URL as new, so no duplicate is crawled, but it may
16/// treat a new URL as seen, so that page is skipped. At 1% it takes about
17/// 1.2 bytes per expected URL, against about 16 for
18/// [`InMemSeen`](crate::frontier::seen::InMemSeen); at 0.1%, about 1.8.
19///
20/// Past `expected` URLs the rate climbs; the filter logs a warning once.
21///
22/// ```
23/// use spire_core::frontier::seen::BloomSeen;
24/// use spire_core::frontier::{FrontierExt, InMemFrontier};
25///
26/// // Ten million URLs at a 1% false-positive rate: about 12 MB.
27/// let seen = BloomSeen::new(10_000_000, 0.01);
28/// let frontier = InMemFrontier::fifo().dedup_with(seen);
29/// ```
30pub struct BloomSeen {
31    bits: Mutex<Bits>,
32    hashes: u32,
33    expected: usize,
34    warned: AtomicBool,
35}
36
37struct Bits {
38    words: Vec<u64>,
39    inserted: usize,
40}
41
42/// The contents of a [`BloomSeen`], to save a crawl and resume it later.
43#[derive(Debug, Clone, PartialEq, Eq)]
44#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
45pub struct BloomSnapshot {
46    words: Vec<u64>,
47    hashes: u32,
48    expected: usize,
49    inserted: usize,
50}
51
52/// The number of 64-bit words and of hashes for `expected` items at
53/// `rate`, from the standard formulas `m = -n ln p / (ln 2)²` and
54/// `k = (m / n) ln 2`.
55#[expect(
56    clippy::cast_precision_loss,
57    clippy::cast_possible_truncation,
58    clippy::cast_sign_loss,
59    reason = "sizes are positive and far below the precision limits"
60)]
61fn dimensions(expected: usize, rate: f64) -> (usize, u32) {
62    let ln2 = std::f64::consts::LN_2;
63    let items = expected as f64;
64    let bits = (-items * rate.ln() / (ln2 * ln2)).ceil().max(64.0);
65    let words = (bits / 64.0).ceil() as usize;
66    let hashes = ((words * 64) as f64 / items * ln2).round().clamp(1.0, 30.0) as u32;
67    (words, hashes)
68}
69
70/// A 64-bit mix (splitmix64's finalizer), to derive a second hash.
71const fn mix(mut x: u64) -> u64 {
72    x = (x ^ (x >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
73    x = (x ^ (x >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
74    x ^ (x >> 31)
75}
76
77impl BloomSeen {
78    /// Creates a filter for `expected` URLs at `false_positive_rate`.
79    ///
80    /// `expected` is at least 1, and the rate is clamped between one in a
81    /// billion and one half.
82    #[must_use]
83    pub fn new(expected: usize, false_positive_rate: f64) -> Self {
84        let expected = expected.max(1);
85        let rate = if false_positive_rate.is_nan() {
86            0.01
87        } else {
88            false_positive_rate.clamp(1e-9, 0.5)
89        };
90        let (words, hashes) = dimensions(expected, rate);
91
92        Self::from_parts(vec![0; words], hashes, expected, 0)
93    }
94
95    fn from_parts(words: Vec<u64>, hashes: u32, expected: usize, inserted: usize) -> Self {
96        Self {
97            bits: Mutex::new(Bits { words, inserted }),
98            hashes: hashes.max(1),
99            expected: expected.max(1),
100            warned: AtomicBool::new(false),
101        }
102    }
103
104    /// Restores a filter from a [`snapshot`](Self::snapshot).
105    #[must_use]
106    pub fn restore(snapshot: BloomSnapshot) -> Self {
107        let mut words = snapshot.words;
108        if words.is_empty() {
109            words.push(0);
110        }
111        Self::from_parts(words, snapshot.hashes, snapshot.expected, snapshot.inserted)
112    }
113
114    /// Returns the contents of the filter, e.g. to save the crawl.
115    #[must_use]
116    pub fn snapshot(&self) -> BloomSnapshot {
117        let bits = lock(&self.bits);
118        BloomSnapshot {
119            words: bits.words.clone(),
120            hashes: self.hashes,
121            expected: self.expected,
122            inserted: bits.inserted,
123        }
124    }
125
126    /// Returns how many URLs were recorded as new.
127    #[must_use]
128    pub fn len(&self) -> usize {
129        lock(&self.bits).inserted
130    }
131
132    /// Whether nothing was recorded yet.
133    #[must_use]
134    pub fn is_empty(&self) -> bool {
135        self.len() == 0
136    }
137
138    /// Returns the memory the filter's bits take, in bytes.
139    #[must_use]
140    pub fn size_in_bytes(&self) -> usize {
141        lock(&self.bits).words.len() * 8
142    }
143
144    /// Estimates the current false-positive rate from how full the filter
145    /// is: `(1 - e^(-k n / m))^k`.
146    #[must_use]
147    #[expect(
148        clippy::cast_precision_loss,
149        reason = "counts are far below the precision limit"
150    )]
151    pub fn false_positive_rate(&self) -> f64 {
152        let bits = lock(&self.bits);
153        let size = (bits.words.len() * 64) as f64;
154        let hashes = f64::from(self.hashes);
155        let filled = 1.0 - (-hashes * bits.inserted as f64 / size).exp();
156        filled.powf(hashes)
157    }
158
159    /// Returns the word index and bit mask of each of `fingerprint`'s bits,
160    /// by double hashing.
161    fn positions(
162        &self,
163        fingerprint: Fingerprint,
164        words: usize,
165    ) -> impl Iterator<Item = (usize, u64)> {
166        let first = fingerprint.get();
167        let step = mix(first) | 1;
168        let size = words as u64 * 64;
169        (0..u64::from(self.hashes)).map(move |i| {
170            let bit = first.wrapping_add(i.wrapping_mul(step)) % size;
171            (usize::try_from(bit / 64).unwrap_or(0), 1 << (bit % 64))
172        })
173    }
174
175    /// Whether `fingerprint` may have been recorded, without recording it.
176    #[cfg(test)]
177    fn contains(&self, fingerprint: Fingerprint) -> bool {
178        let bits = lock(&self.bits);
179        self.positions(fingerprint, bits.words.len())
180            .all(|(word, mask)| bits.words[word] & mask != 0)
181    }
182
183    /// Sets the bits of `fingerprint`, returning whether any was unset.
184    fn record(&self, fingerprint: Fingerprint) -> bool {
185        let mut bits = lock(&self.bits);
186        let mut new = false;
187        for (word, mask) in self.positions(fingerprint, bits.words.len()) {
188            new |= bits.words[word] & mask == 0;
189            bits.words[word] |= mask;
190        }
191
192        if new {
193            bits.inserted += 1;
194            if bits.inserted > self.expected && !self.warned.swap(true, Ordering::Relaxed) {
195                tracing::warn!(
196                    target: TRACING_TARGET,
197                    expected = self.expected,
198                    "bloom filter is over capacity, its false-positive rate is climbing",
199                );
200            }
201        }
202        new
203    }
204}
205
206impl SeenSet for BloomSeen {
207    fn insert(&self, fingerprint: Fingerprint) -> impl Future<Output = Result<bool>> + Send {
208        ready(Ok(self.record(fingerprint)))
209    }
210}
211
212impl fmt::Debug for BloomSeen {
213    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
214        f.debug_struct("BloomSeen")
215            .field("expected", &self.expected)
216            .field("len", &self.len())
217            .field("size_in_bytes", &self.size_in_bytes())
218            .field("hashes", &self.hashes)
219            .finish_non_exhaustive()
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226    use crate::frontier::seed::Seed;
227
228    fn fingerprint(i: usize) -> Fingerprint {
229        Seed::get(format!("https://example.com/{i}"))
230            .unwrap()
231            .fingerprint()
232    }
233
234    #[test]
235    fn sizes_from_expected_and_rate() {
236        let filter = BloomSeen::new(1_000_000, 0.01);
237        // About 9.6 bits, or 1.2 bytes, per expected URL.
238        assert!((1_190_000..=1_210_000).contains(&filter.size_in_bytes()));
239        assert_eq!(filter.hashes, 7);
240
241        let tiny = BloomSeen::new(0, 2.0);
242        assert_eq!((tiny.expected, tiny.size_in_bytes()), (1, 8));
243    }
244
245    #[tokio::test]
246    async fn never_forgets_and_stays_near_its_rate() {
247        let filter = BloomSeen::new(10_000, 0.01);
248        for i in 0..10_000 {
249            filter.insert(fingerprint(i)).await.unwrap();
250        }
251        for i in 0..10_000 {
252            assert!(
253                !filter.insert(fingerprint(i)).await.unwrap(),
254                "{i} forgotten"
255            );
256        }
257
258        let false_positives = (10_000..20_000)
259            .filter(|i| filter.contains(fingerprint(*i)))
260            .count();
261        // 1% of 10 000, with headroom for chance.
262        assert!(false_positives < 150, "{false_positives} false positives");
263        let estimate = filter.false_positive_rate();
264        assert!((0.008..0.012).contains(&estimate), "estimated {estimate}");
265    }
266
267    #[tokio::test]
268    async fn snapshot_restores_the_same_filter() {
269        let filter = BloomSeen::new(100, 0.01);
270        assert!(filter.is_empty());
271        for i in 0..50 {
272            filter.insert(fingerprint(i)).await.unwrap();
273        }
274
275        let restored = BloomSeen::restore(filter.snapshot());
276        assert_eq!(restored.len(), filter.len());
277        assert_eq!(restored.snapshot(), filter.snapshot());
278        assert!(!restored.insert(fingerprint(7)).await.unwrap());
279    }
280
281    #[cfg(feature = "serde")]
282    #[test]
283    fn snapshot_serializes() {
284        let filter = BloomSeen::new(100, 0.01);
285        filter.record(fingerprint(1));
286        let json = serde_json::to_string(&filter.snapshot()).unwrap();
287        let snapshot: BloomSnapshot = serde_json::from_str(&json).unwrap();
288        assert!(!BloomSeen::restore(snapshot).record(fingerprint(1)));
289    }
290
291    #[tokio::test]
292    async fn dedups_a_frontier() {
293        use crate::frontier::{Admission, Frontier, FrontierExt, InMemFrontier};
294
295        let frontier = InMemFrontier::fifo().dedup_with(BloomSeen::new(100, 0.001));
296        let seed = || Seed::get("https://example.com/page").unwrap();
297        assert_eq!(frontier.push(seed()).await.unwrap(), Admission::Queued);
298        assert_eq!(frontier.push(seed()).await.unwrap(), Admission::Duplicate);
299    }
300}