use std::{
collections::{HashMap, HashSet},
sync::{
Mutex, PoisonError,
atomic::{AtomicU64, Ordering},
},
};
pub type BlockingKey = [u8; 32];
pub const DEFAULT_MAX_BLOCK: usize = 1024;
pub trait CandidateSource: Send + Sync {
fn insert(&self, key: BlockingKey, visitor: &str);
fn candidates(&self, keys: &[BlockingKey]) -> HashSet<String>;
fn dropped(&self) -> u64 {
0
}
fn remove_visitor(&self, _visitor: &str) {}
}
#[derive(Debug)]
pub struct BlockingIndex {
max_block: usize,
index: Mutex<HashMap<BlockingKey, HashSet<String>>>,
dropped: AtomicU64,
}
impl Default for BlockingIndex {
fn default() -> Self {
Self::with_max_block(DEFAULT_MAX_BLOCK)
}
}
impl BlockingIndex {
pub fn new() -> Self {
Self::default()
}
pub fn with_max_block(max_block: usize) -> Self {
Self {
max_block,
index: Mutex::new(HashMap::new()),
dropped: AtomicU64::new(0),
}
}
pub fn insert(&self, key: BlockingKey, visitor: &str) {
let mut index = self.lock();
let block = index.entry(key).or_default();
if block.len() >= self.max_block && !block.contains(visitor) {
self.dropped.fetch_add(1, Ordering::Relaxed);
tracing::warn!(
block_size = block.len(),
max_block = self.max_block,
"blocking block over capacity; dropped visitor (not silently truncated)",
);
return;
}
block.insert(visitor.to_string());
}
pub fn candidates(&self, keys: &[BlockingKey]) -> HashSet<String> {
let index = self.lock();
let mut candidates = HashSet::new();
for key in keys {
if let Some(block) = index.get(key) {
candidates.extend(block.iter().cloned());
}
}
candidates
}
pub fn dropped(&self) -> u64 {
self.dropped.load(Ordering::Relaxed)
}
pub fn remove_visitor(&self, visitor: &str) {
let mut index = self.lock();
index.retain(|_, block| {
block.remove(visitor);
!block.is_empty()
});
}
fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<BlockingKey, HashSet<String>>> {
self.index.lock().unwrap_or_else(PoisonError::into_inner)
}
}
impl CandidateSource for BlockingIndex {
fn insert(&self, key: BlockingKey, visitor: &str) {
BlockingIndex::insert(self, key, visitor);
}
fn candidates(&self, keys: &[BlockingKey]) -> HashSet<String> {
BlockingIndex::candidates(self, keys)
}
fn dropped(&self) -> u64 {
BlockingIndex::dropped(self)
}
fn remove_visitor(&self, visitor: &str) {
BlockingIndex::remove_visitor(self, visitor);
}
}
#[cfg(test)]
mod tests {
use super::BlockingIndex;
fn key(byte: u8) -> [u8; 32] {
[byte; 32]
}
#[test]
fn candidates_union_across_keys() {
let index = BlockingIndex::new();
index.insert(key(1), "alice");
index.insert(key(2), "bob");
index.insert(key(2), "carol");
let got = index.candidates(&[key(1), key(2)]);
assert_eq!(got.len(), 3);
assert!(got.contains("alice") && got.contains("bob") && got.contains("carol"));
assert!(index.candidates(&[key(9)]).is_empty());
}
#[test]
fn recall_survives_one_changed_key() {
let index = BlockingIndex::new();
index.insert(key(1), "alice");
index.insert(key(2), "alice");
let recalled = index.candidates(&[key(7), key(2)]);
assert!(recalled.contains("alice"));
}
#[test]
fn over_capacity_is_dropped_and_counted() {
let index = BlockingIndex::with_max_block(2);
index.insert(key(0), "a");
index.insert(key(0), "b");
index.insert(key(0), "c");
let block = index.candidates(&[key(0)]);
assert_eq!(block.len(), 2);
assert!(!block.contains("c"));
assert_eq!(index.dropped(), 1);
}
#[test]
fn remove_visitor_purges_from_every_block_and_drops_empties() {
let index = BlockingIndex::new();
index.insert(key(1), "alice");
index.insert(key(1), "bob");
index.insert(key(2), "alice");
index.remove_visitor("alice");
let recalled = index.candidates(&[key(1), key(2)]);
assert!(!recalled.contains("alice"));
assert!(recalled.contains("bob"));
assert!(index.candidates(&[key(2)]).is_empty());
index.remove_visitor("alice");
}
#[test]
fn re_inserting_existing_member_at_capacity_is_not_dropped() {
let index = BlockingIndex::with_max_block(2);
index.insert(key(0), "a");
index.insert(key(0), "b");
index.insert(key(0), "a"); assert_eq!(index.dropped(), 0);
assert_eq!(index.candidates(&[key(0)]).len(), 2);
}
}