Skip to main content

syncweb_core/sync/
peer_tracker.rs

1use std::{
2    collections::{HashMap, HashSet},
3    time::{Duration, Instant},
4};
5
6use iroh::PublicKey;
7use iroh_blobs::Hash;
8
9/// Policy used when the peer availability cache reaches its size limit.
10#[derive(Clone, Copy, Debug, Eq, PartialEq)]
11#[non_exhaustive]
12pub enum EvictionStrategy {
13    Lru,
14    Fifo,
15}
16
17#[derive(Clone, Debug)]
18struct Availability {
19    peers: HashSet<PublicKey>,
20    inserted_at: u64,
21    accessed_at: u64,
22    last_seen: Instant,
23}
24
25/// Bounded cache mapping blob hashes to the peers observed serving them.
26///
27/// `len()` returns the number of unique blob hashes tracked, not the total
28/// number of peers. Use [`peer_count`](Self::peer_count) for per-blob counts.
29#[derive(Clone, Debug)]
30pub struct PeerTracker {
31    entries: HashMap<Hash, Availability>,
32    max_cache_size: usize,
33    strategy: EvictionStrategy,
34    expiry: Duration,
35    clock: u64,
36}
37
38impl PeerTracker {
39    #[must_use]
40    pub fn new(max_cache_size: usize, strategy: EvictionStrategy) -> Self {
41        Self::with_expiry(max_cache_size, strategy, Duration::from_mins(5))
42    }
43
44    #[must_use]
45    pub fn with_expiry(max_cache_size: usize, strategy: EvictionStrategy, expiry: Duration) -> Self {
46        Self {
47            entries: HashMap::new(),
48            max_cache_size,
49            strategy,
50            expiry,
51            clock: 0,
52        }
53    }
54
55    pub fn record_peer(&mut self, blob_hash: Hash, node_id: PublicKey) {
56        self.clock = self.clock.saturating_add(1);
57        let now = Instant::now();
58        let entry = self.entries.entry(blob_hash).or_insert_with(|| Availability {
59            peers: HashSet::new(),
60            inserted_at: self.clock,
61            accessed_at: self.clock,
62            last_seen: now,
63        });
64        entry.peers.insert(node_id);
65        entry.accessed_at = self.clock;
66        entry.last_seen = now;
67        self.evict_to_limit();
68    }
69
70    /// Record a peer observed during a blob transfer.
71    pub fn on_blob_fetched(&mut self, blob_hash: Hash, node_id: PublicKey) {
72        self.record_peer(blob_hash, node_id);
73    }
74
75    #[must_use]
76    pub fn get_peers(&mut self, blob_hash: &Hash) -> Vec<PublicKey> {
77        self.clock = self.clock.saturating_add(1);
78        self.entries.get_mut(blob_hash).map_or_else(Vec::new, |entry| {
79            if self.strategy == EvictionStrategy::Lru {
80                entry.accessed_at = self.clock;
81            }
82            entry.peers.iter().copied().collect()
83        })
84    }
85
86    #[must_use]
87    pub fn peers(&mut self, blob_hash: &Hash) -> Vec<PublicKey> {
88        self.clock = self.clock.saturating_add(1);
89        self.entries.get_mut(blob_hash).map_or_else(Vec::new, |entry| {
90            if self.strategy == EvictionStrategy::Lru {
91                entry.accessed_at = self.clock;
92            }
93            entry.peers.iter().copied().collect()
94        })
95    }
96
97    #[must_use]
98    pub fn peer_count(&self, blob_hash: &Hash) -> usize {
99        self.entries.get(blob_hash).map_or(0, |entry| entry.peers.len())
100    }
101
102    #[must_use]
103    pub fn contains(&self, blob_hash: &Hash, node_id: &PublicKey) -> bool {
104        self.entries
105            .get(blob_hash)
106            .is_some_and(|entry| entry.peers.contains(node_id))
107    }
108
109    /// Remove a peer from all cached blob availability records.
110    pub fn on_peer_disconnected(&mut self, node_id: &PublicKey) {
111        self.entries.retain(|_, entry| {
112            entry.peers.remove(node_id);
113            !entry.peers.is_empty()
114        });
115    }
116
117    #[must_use]
118    pub fn is_fresh(&self, blob_hash: &Hash) -> bool {
119        self.entries
120            .get(blob_hash)
121            .is_some_and(|entry| entry.last_seen.elapsed() <= self.expiry)
122    }
123
124    #[must_use]
125    pub fn len(&self) -> usize {
126        self.entries.len()
127    }
128
129    #[must_use]
130    pub fn is_empty(&self) -> bool {
131        self.entries.is_empty()
132    }
133
134    pub fn tick_and_maybe_evict(&mut self) {
135        let now = Instant::now();
136        self.entries
137            .retain(|_, entry| now.saturating_duration_since(entry.last_seen) <= self.expiry);
138        self.evict_to_limit();
139    }
140
141    fn evict_to_limit(&mut self) {
142        while self.entries.len() > self.max_cache_size {
143            let oldest = self
144                .entries
145                .iter()
146                .min_by_key(|(_, entry)| match self.strategy {
147                    EvictionStrategy::Lru => entry.accessed_at,
148                    EvictionStrategy::Fifo => entry.inserted_at,
149                })
150                .map(|(hash, _)| *hash);
151            if let Some(hash) = oldest {
152                self.entries.remove(&hash);
153            } else {
154                break;
155            }
156        }
157    }
158}
159
160/// Compact peer presence cache using stable integer indices.
161#[derive(Clone, Debug, Default)]
162pub struct EfficientPeerCache {
163    hash_to_indices: HashMap<Hash, Vec<u16>>,
164    presence: HashMap<Hash, Vec<u64>>,
165    peer_to_index: HashMap<PublicKey, u16>,
166    index_peer: Vec<PublicKey>,
167    last_seen: HashMap<PublicKey, Instant>,
168}
169
170impl EfficientPeerCache {
171    #[must_use]
172    pub fn new() -> Self {
173        Self::default()
174    }
175
176    /// Record that a peer can serve a blob.
177    ///
178    /// # Errors
179    ///
180    /// Returns an error after all `u16` peer indices have been allocated.
181    pub fn add_presence(&mut self, hash: Hash, peer: PublicKey) -> crate::Result<()> {
182        let index = if let Some(index) = self.peer_to_index.get(&peer) {
183            *index
184        } else {
185            let index = u16::try_from(self.index_peer.len())
186                .map_err(|error| crate::SyncwebError::operation("peer cache capacity exceeded", error))?;
187            self.index_peer.push(peer);
188            self.peer_to_index.insert(peer, index);
189            index
190        };
191        self.last_seen.insert(peer, Instant::now());
192        let indices = self.hash_to_indices.entry(hash).or_default();
193        match indices.binary_search(&index) {
194            Ok(_) => {}
195            Err(position) => indices.insert(position, index),
196        }
197        let index_usize = usize::from(index);
198        let word_index = index_usize.checked_div(64).unwrap_or(0);
199        let bit_index = index_usize.checked_rem(64).unwrap_or(0);
200        let mask = 1_u64.checked_shl(u32::try_from(bit_index).unwrap_or(0)).unwrap_or(0);
201        let presence = self.presence.entry(hash).or_default();
202        presence.resize(word_index.saturating_add(1), 0);
203        if let Some(word) = presence.get_mut(word_index) {
204            *word |= mask;
205        }
206        Ok(())
207    }
208
209    pub fn remove_presence(&mut self, hash: &Hash, peer: &PublicKey) -> bool {
210        let Some(index) = self.peer_to_index.get(peer) else {
211            return false;
212        };
213        let Some(indices) = self.hash_to_indices.get_mut(hash) else {
214            return false;
215        };
216        let removed = indices.binary_search(index).is_ok_and(|position| {
217            indices.remove(position);
218            true
219        });
220        if indices.is_empty() {
221            self.hash_to_indices.remove(hash);
222        }
223        if removed {
224            let index_usize = usize::from(*index);
225            let word_index = index_usize.checked_div(64).unwrap_or(0);
226            let bit_index = index_usize.checked_rem(64).unwrap_or(0);
227            let mask = 1_u64.checked_shl(u32::try_from(bit_index).unwrap_or(0)).unwrap_or(0);
228            if let Some(word) = self.presence.get_mut(hash).and_then(|words| words.get_mut(word_index)) {
229                *word &= !mask;
230            }
231        }
232        removed
233    }
234
235    #[must_use]
236    pub fn has_peer(&self, hash: &Hash, peer: &PublicKey) -> bool {
237        self.peer_to_index.get(peer).is_some_and(|index| {
238            let index_usize = usize::from(*index);
239            let word_index = index_usize.checked_div(64).unwrap_or(0);
240            let bit_index = index_usize.checked_rem(64).unwrap_or(0);
241            let mask = 1_u64.checked_shl(u32::try_from(bit_index).unwrap_or(0)).unwrap_or(0);
242            self.presence
243                .get(hash)
244                .and_then(|words| words.get(word_index))
245                .is_some_and(|word| *word & mask != 0)
246        })
247    }
248
249    #[must_use]
250    pub fn peers(&self, hash: &Hash) -> Vec<PublicKey> {
251        self.hash_to_indices.get(hash).map_or_else(Vec::new, |indices| {
252            indices
253                .iter()
254                .filter_map(|index| self.index_peer.get(usize::from(*index)).copied())
255                .collect()
256        })
257    }
258
259    /// Return the total number of *unique* peers registered across all blobs.
260    /// For per-blob counts see [`active_peer_count`](Self::active_peer_count).
261    #[must_use]
262    pub const fn total_peers(&self) -> usize {
263        self.index_peer.len()
264    }
265
266    #[must_use]
267    pub fn active_peer_count(&self, hash: &Hash) -> usize {
268        self.hash_to_indices.get(hash).map_or(0, Vec::len)
269    }
270
271    #[must_use]
272    pub fn memory_usage(&self) -> usize {
273        let word_bytes = self
274            .presence
275            .values()
276            .map(|words| words.len().saturating_mul(8))
277            .sum::<usize>();
278        let index_bytes = self
279            .hash_to_indices
280            .values()
281            .map(|indices| indices.len().saturating_mul(2))
282            .sum::<usize>();
283        word_bytes
284            .saturating_add(index_bytes)
285            .saturating_add(
286                self.peer_to_index
287                    .len()
288                    .saturating_mul(std::mem::size_of::<PublicKey>().saturating_add(2)),
289            )
290            .saturating_add(self.index_peer.len().saturating_mul(std::mem::size_of::<PublicKey>()))
291    }
292
293    /// Remove a peer from every blob while retaining stable indices for peers
294    /// that may be observed again later.
295    pub fn remove_peer(&mut self, peer: &PublicKey) {
296        if !self.peer_to_index.contains_key(peer) {
297            return;
298        }
299        let hashes = self.hash_to_indices.keys().copied().collect::<Vec<_>>();
300        for hash in hashes {
301            let _removed = self.remove_presence(&hash, peer);
302        }
303        self.last_seen.remove(peer);
304    }
305
306    /// Remove peers whose `last_seen` timestamp exceeds the given TTL.
307    ///
308    /// Fully reclaims each removed peer's index, so the total number of
309    /// tracked peers stays bounded.
310    ///
311    /// Returns the number of peers removed.
312    pub fn retain_active(&mut self, ttl: Duration) -> usize {
313        self.retain_active_at(ttl, Instant::now())
314    }
315
316    /// Like [`retain_active`](Self::retain_active) but with an explicit
317    /// observation time for deterministic testing.
318    fn retain_active_at(&mut self, ttl: Duration, now: Instant) -> usize {
319        let cutoff = now.checked_sub(ttl).unwrap_or(now);
320        let stale: Vec<PublicKey> = self
321            .last_seen
322            .iter()
323            .filter(|(_, last_seen)| **last_seen < cutoff)
324            .map(|(peer, _)| *peer)
325            .collect();
326        let count = stale.len();
327        for peer in stale {
328            self.remove_peer(&peer);
329            if let Some(stale_index) = self.peer_to_index.remove(&peer) {
330                let last_index = self.index_peer.len().saturating_sub(1);
331                if usize::from(stale_index) != last_index
332                    && let Some(last_peer) = self.index_peer.get(last_index).copied()
333                {
334                    // Update presence bitmask for the moved peer: its bit
335                    // is still at last_index but must be moved to stale_index.
336                    self.remap_presence(last_index, usize::from(stale_index));
337                    if let Some(slot) = self.index_peer.get_mut(usize::from(stale_index)) {
338                        *slot = last_peer;
339                    }
340                    self.peer_to_index.insert(last_peer, stale_index);
341                }
342                self.index_peer.pop();
343            }
344        }
345        count
346    }
347
348    /// Move all presence bits from `old_index` to `new_index` after a
349    /// swap-remove compaction of `index_peer`.
350    fn remap_presence(&mut self, old_index: usize, new_index: usize) {
351        let old_u16 = u16::try_from(old_index).unwrap_or(u16::MAX);
352        let new_u16 = u16::try_from(new_index).unwrap_or(u16::MAX);
353        for (hash, indices) in &mut self.hash_to_indices {
354            // binary_search returns a valid index in the Ok case
355            let Ok(pos) = indices.binary_search(&old_u16) else {
356                continue;
357            };
358            if let Some(slot) = indices.get_mut(pos) {
359                *slot = new_u16;
360            }
361            let mut p = pos;
362            while p > 0 {
363                let prev = p.wrapping_sub(1);
364                if indices.get(prev).is_none_or(|&v| v <= new_u16) {
365                    break;
366                }
367                indices.swap(prev, p);
368                p = prev;
369            }
370            // Update bitvector: clear old bit, set new bit
371            if let Some(words) = self.presence.get_mut(hash) {
372                let old_word = old_index.checked_div(64).unwrap_or(0);
373                let old_bit = old_index.checked_rem(64).unwrap_or(0);
374                if let Some(word) = words.get_mut(old_word) {
375                    *word &= !(1_u64 << old_bit);
376                }
377                let new_word = new_index.checked_div(64).unwrap_or(0);
378                let new_bit = new_index.checked_rem(64).unwrap_or(0);
379                if new_word >= words.len() {
380                    words.resize(new_word.saturating_add(1), 0);
381                }
382                if let Some(word) = words.get_mut(new_word) {
383                    *word |= 1_u64 << new_bit;
384                }
385            }
386        }
387    }
388}