1use std::fs::{File, OpenOptions};
36use std::path::{Path, PathBuf};
37
38use memmap2::{MmapMut, MmapOptions};
39
40use crate::shared_bit_vec::{BitVecError, SharedBitVec};
41
42pub const BLOOM_MAGIC: u64 = 0x4150_424C_4F4F_4D31;
43
44#[repr(C, align(64))]
45pub struct BloomHeader {
46 pub magic: u64,
47 pub n_bits: u64,
48 pub n_hashes: u32,
49 _pad: [u8; 44],
50}
51
52const _: () = {
53 assert!(std::mem::size_of::<BloomHeader>() == 64);
54};
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum BloomError {
58 BitVec(BitVecError),
59 LayoutMismatch,
60 InvalidConfig,
61 IoError(std::io::ErrorKind),
62}
63
64impl From<BitVecError> for BloomError {
65 fn from(e: BitVecError) -> Self { Self::BitVec(e) }
66}
67impl From<std::io::Error> for BloomError {
68 fn from(e: std::io::Error) -> Self { Self::IoError(e.kind()) }
69}
70
71fn header_path(base: &Path) -> PathBuf {
72 let mut p = base.to_path_buf();
73 let stem = p.file_name().unwrap().to_string_lossy().to_string();
74 p.set_file_name(format!("{stem}.bloom.bin"));
75 p
76}
77fn bits_path(base: &Path) -> PathBuf {
78 let mut p = base.to_path_buf();
79 let stem = p.file_name().unwrap().to_string_lossy().to_string();
80 p.set_file_name(format!("{stem}.bits.bin"));
81 p
82}
83
84pub struct SharedBloomFilter {
85 _file: File,
86 _mmap: MmapMut,
87 bits: SharedBitVec,
88 n_bits: u64,
89 n_hashes: u32,
90 header_sidecar: subetha_core::HandshakeHeader,
91 ring_sidecar: Box<subetha_core::ObservationRing>,
92}
93
94unsafe impl Send for SharedBloomFilter {}
95unsafe impl Sync for SharedBloomFilter {}
96
97impl subetha_sidecar::AdaptiveInstance for SharedBloomFilter {
98 fn header(&self) -> &subetha_core::HandshakeHeader { &self.header_sidecar }
99 fn ring(&self) -> &subetha_core::ObservationRing { &self.ring_sidecar }
100 fn make_policy(&self) -> Box<dyn subetha_sidecar::Policy> {
101 Box::new(subetha_sidecar::NoMigrationPolicy)
102 }
103}
104
105const FNV_OFFSET_BASIS_1: u64 = 0xcbf2_9ce4_8422_2325;
106const FNV_OFFSET_BASIS_2: u64 = 0x84222325_cbf29ce4;
107const FNV_PRIME: u64 = 0x100_0000_01b3;
108
109#[inline]
110fn fnv1a_seeded(bytes: &[u8], offset_basis: u64) -> u64 {
111 let mut h = offset_basis;
112 for &b in bytes {
113 h ^= b as u64;
114 h = h.wrapping_mul(FNV_PRIME);
115 }
116 h
117}
118
119#[inline]
125fn fmix64(mut h: u64) -> u64 {
126 h ^= h >> 33;
127 h = h.wrapping_mul(0xff51_afd7_ed55_8ccd);
128 h ^= h >> 33;
129 h = h.wrapping_mul(0xc4ce_b9fe_1a85_ec53);
130 h ^= h >> 33;
131 h
132}
133
134impl SharedBloomFilter {
135 pub fn suggest_config(n_items: usize, p: f64) -> (usize, u32) {
138 let n = n_items as f64;
139 let n_bits = (-n * p.ln() / (std::f64::consts::LN_2 * std::f64::consts::LN_2)).ceil() as usize;
140 let n_hashes = ((n_bits as f64 / n) * std::f64::consts::LN_2).round() as u32;
141 (n_bits.max(64), n_hashes.max(1))
142 }
143
144 pub fn create(
150 base_path: impl AsRef<Path>, n_bits: usize, n_hashes: u32,
151 ) -> Result<Self, BloomError> {
152 if n_bits == 0 || n_hashes == 0 {
153 return Err(BloomError::InvalidConfig);
154 }
155 let base = base_path.as_ref();
156 let (file, mmap) = crate::mmf_attach::create_or_attach(
157 &header_path(base),
158 std::mem::size_of::<BloomHeader>(),
159 |ptr| unsafe { Self::init_region(ptr, n_bits, n_hashes) },
160 |ptr| unsafe { (*(ptr as *const BloomHeader)).magic == BLOOM_MAGIC },
161 )?;
162 Self::check_header(&mmap, n_bits, n_hashes)?;
163 let bits = SharedBitVec::create(bits_path(base), n_bits)?;
164 Ok(Self::assemble(file, mmap, bits, n_bits, n_hashes))
165 }
166
167 pub fn reset(
171 base_path: impl AsRef<Path>, n_bits: usize, n_hashes: u32,
172 ) -> Result<Self, BloomError> {
173 if n_bits == 0 || n_hashes == 0 {
174 return Err(BloomError::InvalidConfig);
175 }
176 let base = base_path.as_ref();
177 let (file, mmap) = crate::mmf_attach::reset(
178 &header_path(base),
179 std::mem::size_of::<BloomHeader>(),
180 |ptr| unsafe { Self::init_region(ptr, n_bits, n_hashes) },
181 )?;
182 let bits = SharedBitVec::reset(bits_path(base), n_bits)?;
183 Ok(Self::assemble(file, mmap, bits, n_bits, n_hashes))
184 }
185
186 unsafe fn init_region(ptr: *mut u8, n_bits: usize, n_hashes: u32) {
193 let hdr = ptr as *mut BloomHeader;
194 unsafe {
195 (*hdr).n_bits = n_bits as u64;
196 (*hdr).n_hashes = n_hashes;
197 std::ptr::write_volatile(&raw mut (*hdr).magic, BLOOM_MAGIC);
198 }
199 }
200
201 fn check_header(mmap: &MmapMut, n_bits: usize, n_hashes: u32) -> Result<(), BloomError> {
203 let hdr = unsafe { &*(mmap.as_ptr() as *const BloomHeader) };
204 if hdr.magic != BLOOM_MAGIC
205 || hdr.n_bits != n_bits as u64
206 || hdr.n_hashes != n_hashes
207 {
208 return Err(BloomError::LayoutMismatch);
209 }
210 Ok(())
211 }
212
213 fn assemble(
214 file: File,
215 mmap: MmapMut,
216 bits: SharedBitVec,
217 n_bits: usize,
218 n_hashes: u32,
219 ) -> Self {
220 Self {
221 _file: file, _mmap: mmap, bits,
222 n_bits: n_bits as u64, n_hashes,
223 header_sidecar: subetha_core::HandshakeHeader::new(),
224 ring_sidecar: Box::new(subetha_core::ObservationRing::new()),
225 }
226 }
227
228 pub fn open(
229 base_path: impl AsRef<Path>, n_bits: usize, n_hashes: u32,
230 ) -> Result<Self, BloomError> {
231 let base = base_path.as_ref();
232 let hpath = header_path(base);
233 let file = OpenOptions::new().read(true).write(true).open(&hpath)?;
234 if file.metadata()?.len() < std::mem::size_of::<BloomHeader>() as u64 {
235 return Err(BloomError::LayoutMismatch);
236 }
237 let mmap = unsafe {
238 MmapOptions::new().len(std::mem::size_of::<BloomHeader>()).map_mut(&file)?
239 };
240 Self::check_header(&mmap, n_bits, n_hashes)?;
241 let bits = SharedBitVec::open(bits_path(base), n_bits)?;
242 Ok(Self::assemble(file, mmap, bits, n_bits, n_hashes))
243 }
244
245 #[inline]
246 pub fn n_bits(&self) -> u64 { self.n_bits }
247 #[inline]
248 pub fn n_hashes(&self) -> u32 { self.n_hashes }
249
250 #[inline]
259 fn position(&self, h1: u64, h2: u64, i: u64) -> usize {
260 let h = h1.wrapping_add(i.wrapping_mul(h2));
261 ((h as u128 * self.n_bits as u128) >> 64) as usize
262 }
263
264 pub fn insert(&self, item: &[u8]) -> Result<(), BloomError> {
265 let h1 = fmix64(fnv1a_seeded(item, FNV_OFFSET_BASIS_1));
266 let h2 = fmix64(fnv1a_seeded(item, FNV_OFFSET_BASIS_2));
267 for i in 0..self.n_hashes as u64 {
268 self.bits.set(self.position(h1, h2, i))?;
269 }
270 self.ring_sidecar
271 .push_op(crate::sidecar_ops::sketch::OP_INSERT, 0);
272 Ok(())
273 }
274
275 pub fn contains(&self, item: &[u8]) -> Result<bool, BloomError> {
278 let h1 = fmix64(fnv1a_seeded(item, FNV_OFFSET_BASIS_1));
279 let h2 = fmix64(fnv1a_seeded(item, FNV_OFFSET_BASIS_2));
280 for i in 0..self.n_hashes as u64 {
281 if !self.bits.get(self.position(h1, h2, i))? {
282 self.ring_sidecar
283 .push_op(crate::sidecar_ops::sketch::OP_QUERY, 2); return Ok(false);
285 }
286 }
287 self.ring_sidecar
288 .push_op(crate::sidecar_ops::sketch::OP_QUERY, 0);
289 Ok(true)
290 }
291
292 pub fn clear(&self) {
294 self.bits.clear_all();
295 self.ring_sidecar
296 .push_op(crate::sidecar_ops::sketch::OP_CLEAR, 0);
297 }
298
299 pub fn estimated_false_positive_rate(&self) -> f64 {
303 let fill = self.bits.count_ones() as f64;
304 let n = self.n_bits as f64;
305 let k = self.n_hashes as f64;
306 if fill == 0.0 { return 0.0; }
307 (fill / n).powf(k)
308 }
309
310 pub fn estimated_insert_count(&self) -> u64 {
313 let fill = self.bits.count_ones() as f64;
314 let n = self.n_bits as f64;
315 let k = self.n_hashes as f64;
316 if fill == 0.0 { return 0; }
317 if fill >= n { return u64::MAX; }
318 ((-n / k) * (1.0 - fill / n).ln()).round() as u64
319 }
320
321 pub fn flush(&self) -> Result<(), BloomError> {
322 Ok(self.bits.flush()?)
323 }
324
325 pub fn flush_async(&self) -> Result<(), BloomError> {
326 Ok(self.bits.flush_async()?)
327 }
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333
334 fn tmp_base(name: &str) -> PathBuf {
335 let mut p = std::env::temp_dir();
336 let pid = std::process::id();
337 p.push(format!("subetha-bloom-{name}-{pid}"));
338 p
339 }
340
341 fn cleanup(base: &Path) {
342 std::fs::remove_file(header_path(base)).ok();
343 std::fs::remove_file(bits_path(base)).ok();
344 }
345
346 #[test]
349 fn second_create_attaches_and_keeps_members() {
350 let base = tmp_base("attach");
351 cleanup(&base);
352 let b = SharedBloomFilter::create(&base, 1024, 3).unwrap();
353 b.insert(b"member").unwrap();
354
355 let b2 = SharedBloomFilter::create(&base, 1024, 3).unwrap();
356 assert!(b2.contains(b"member").unwrap(), "attach lost a member");
357 assert!(matches!(
358 SharedBloomFilter::create(&base, 512, 3),
359 Err(BloomError::LayoutMismatch),
360 ));
361
362 drop(b);
365 drop(b2);
366 let fresh = SharedBloomFilter::reset(&base, 1024, 3).unwrap();
367 assert!(!fresh.contains(b"member").unwrap(), "reset kept a member");
368 drop(fresh);
369 cleanup(&base);
370 }
371
372 #[test]
373 fn empty_filter_contains_nothing() {
374 let base = tmp_base("empty");
375 let b = SharedBloomFilter::create(&base, 1024, 3).unwrap();
376 assert!(!b.contains(b"anything").unwrap());
377 assert!(!b.contains(b"").unwrap());
378 cleanup(&base);
379 }
380
381 #[test]
382 fn insert_then_contains_returns_true_no_false_negatives() {
383 let base = tmp_base("no-fn");
384 let b = SharedBloomFilter::create(&base, 1024, 3).unwrap();
385 let items: &[&[u8]] = &[
386 b"hello", b"world", b"adaptive-prims", b"42",
387 b"the quick brown fox", b"", b"a",
388 ];
389 for &item in items {
390 b.insert(item).unwrap();
391 }
392 for &item in items {
393 assert!(b.contains(item).unwrap(),
394 "false negative for item: {item:?}");
395 }
396 cleanup(&base);
397 }
398
399 #[test]
400 fn invalid_config_rejected() {
401 let base = tmp_base("invalid");
402 assert_eq!(
403 SharedBloomFilter::create(&base, 0, 3).err(),
404 Some(BloomError::InvalidConfig)
405 );
406 assert_eq!(
407 SharedBloomFilter::create(&base, 1024, 0).err(),
408 Some(BloomError::InvalidConfig)
409 );
410 cleanup(&base);
411 }
412
413 #[test]
414 fn false_positive_rate_within_bounds_at_target_load() {
415 let (n_bits, n_hashes) = SharedBloomFilter::suggest_config(1000, 0.01);
416 let base = tmp_base("fpr");
417 let b = SharedBloomFilter::create(&base, n_bits, n_hashes).unwrap();
418 for i in 0..1000u32 {
419 b.insert(format!("item-{i:04}").as_bytes()).unwrap();
420 }
421 let mut fp = 0u32;
422 for i in 10_000u32..20_000 {
423 if b.contains(format!("query-{i:05}").as_bytes()).unwrap() {
424 fp += 1;
425 }
426 }
427 let observed_fpr = fp as f64 / 10_000.0;
428 assert!(observed_fpr < 0.03,
430 "observed FPR {observed_fpr} should be below 0.03 (target was 0.01)");
431 cleanup(&base);
432 }
433
434 #[test]
435 fn clear_resets_filter() {
436 let base = tmp_base("clear");
437 let b = SharedBloomFilter::create(&base, 1024, 3).unwrap();
438 b.insert(b"foo").unwrap();
439 b.insert(b"bar").unwrap();
440 assert!(b.contains(b"foo").unwrap());
441 b.clear();
442 assert!(!b.contains(b"foo").unwrap());
443 assert!(!b.contains(b"bar").unwrap());
444 cleanup(&base);
445 }
446
447 #[test]
448 fn cross_handle_visibility() {
449 let base = tmp_base("cross-handle");
450 let writer = SharedBloomFilter::create(&base, 1024, 3).unwrap();
451 let reader = SharedBloomFilter::open(&base, 1024, 3).unwrap();
452 writer.insert(b"cross-process").unwrap();
453 assert!(reader.contains(b"cross-process").unwrap());
454 assert!(!reader.contains(b"not-inserted").unwrap());
455 cleanup(&base);
456 }
457
458 #[test]
459 fn config_mismatch_at_open_rejected() {
460 let base = tmp_base("mismatch");
461 let _w = SharedBloomFilter::create(&base, 1024, 3).unwrap();
462 assert!(matches!(
463 SharedBloomFilter::open(&base, 2048, 3),
464 Err(BloomError::LayoutMismatch)
465 ));
466 assert!(matches!(
467 SharedBloomFilter::open(&base, 1024, 5),
468 Err(BloomError::LayoutMismatch)
469 ));
470 cleanup(&base);
471 }
472
473 #[test]
474 fn estimated_count_tracks_real_inserts() {
475 let base = tmp_base("count-est");
476 let (n_bits, n_hashes) = SharedBloomFilter::suggest_config(1000, 0.01);
477 let b = SharedBloomFilter::create(&base, n_bits, n_hashes).unwrap();
478 for i in 0..500u32 {
479 b.insert(format!("k{i}").as_bytes()).unwrap();
480 }
481 let est = b.estimated_insert_count();
482 assert!(est > 350 && est < 650,
484 "estimated insert count {est} should be near 500");
485 cleanup(&base);
486 }
487
488 #[test]
489 fn suggest_config_returns_sensible_values() {
490 let (n_bits, n_hashes) = SharedBloomFilter::suggest_config(10_000, 0.01);
491 assert!(n_bits > 90_000 && n_bits < 100_000,
492 "n_bits {n_bits} should be ~95k");
493 assert!((6..=8).contains(&n_hashes),
494 "n_hashes {n_hashes} should be ~7");
495 }
496
497 #[test]
498 fn disk_persistence_survives_reopen() {
499 let base = tmp_base("disk");
500 {
501 let b = SharedBloomFilter::create(&base, 1024, 3).unwrap();
502 b.insert(b"persisted").unwrap();
503 b.insert(b"also-persisted").unwrap();
504 b.flush().unwrap();
505 }
506 let b2 = SharedBloomFilter::open(&base, 1024, 3).unwrap();
507 assert!(b2.contains(b"persisted").unwrap());
508 assert!(b2.contains(b"also-persisted").unwrap());
509 assert!(!b2.contains(b"not-there").unwrap());
510 cleanup(&base);
511 }
512
513 #[test]
514 fn concurrent_inserters_no_lost_updates() {
515 use std::sync::Arc;
516 use std::thread;
517 let base = tmp_base("concurrent");
518 let b: Arc<SharedBloomFilter>
519 = Arc::new(SharedBloomFilter::create(&base, 8192, 5).unwrap());
520 let n_threads = 4;
521 let per_thread = 100;
522 let mut handles = vec![];
523 for t in 0..n_threads {
524 let b = b.clone();
525 handles.push(thread::spawn(move || {
526 for i in 0..per_thread {
527 b.insert(format!("t{t}-i{i}").as_bytes()).unwrap();
528 }
529 }));
530 }
531 for h in handles { h.join().unwrap(); }
532 for t in 0..n_threads {
533 for i in 0..per_thread {
534 let item = format!("t{t}-i{i}");
535 assert!(b.contains(item.as_bytes()).unwrap(),
536 "missing item {item}");
537 }
538 }
539 cleanup(&base);
540 }
541}