spire_core/frontier/seen/
bloom.rs1use 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
11pub 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#[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#[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
70const 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 #[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 #[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 #[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 #[must_use]
128 pub fn len(&self) -> usize {
129 lock(&self.bits).inserted
130 }
131
132 #[must_use]
134 pub fn is_empty(&self) -> bool {
135 self.len() == 0
136 }
137
138 #[must_use]
140 pub fn size_in_bytes(&self) -> usize {
141 lock(&self.bits).words.len() * 8
142 }
143
144 #[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 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 #[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 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 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 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}