1use std::{
2 collections::VecDeque,
3 sync::{
4 Arc,
5 atomic::{AtomicU64, Ordering},
6 },
7 time::{Duration, Instant},
8};
9
10use hopr_api::types::internal::{prelude::HoprPseudonym, routing::SurbMatcher};
11use hopr_crypto_packet::prelude::*;
12use moka::notification::RemovalCause;
13use validator::ValidationError;
14
15use crate::{FoundSurb, SurbInsertOutcome, traits::SurbStore};
16
17pub const MINIMUM_SURB_LIFETIME: Duration = Duration::from_secs(30);
22const MINIMUM_OPENER_PSEUDONYMS: usize = 1000;
23const MINIMUM_OPENERS_PER_PSEUDONYM: usize = 1000;
24const MINIMUM_SURBS_PER_PSEUDONYM: usize = 1000;
25const MINIMUM_OPENER_LIFETIME: Duration = Duration::from_secs(60);
26const MIN_SURB_RB_CAPACITY: usize = 1024;
27
28fn validate_pseudonyms_lifetime(lifetime: &Duration) -> Result<(), ValidationError> {
29 if lifetime < &MINIMUM_SURB_LIFETIME {
30 Err(ValidationError::new("pseudonyms_lifetime is too low"))
31 } else {
32 Ok(())
33 }
34}
35
36fn validate_reply_opener_lifetime(lifetime: &Duration) -> Result<(), ValidationError> {
37 if lifetime < &MINIMUM_OPENER_LIFETIME {
38 Err(ValidationError::new("reply_opener_lifetime is too low"))
39 } else {
40 Ok(())
41 }
42}
43
44fn default_rb_capacity() -> usize {
45 15_000
46}
47
48fn default_distress_threshold() -> usize {
49 500
50}
51
52fn default_max_openers_per_pseudonym() -> usize {
53 100_000
54}
55
56fn default_max_pseudonyms() -> usize {
57 10_000
58}
59
60fn default_pseudonyms_lifetime() -> Duration {
61 Duration::from_secs(600)
62}
63
64fn default_reply_opener_lifetime() -> Duration {
65 Duration::from_secs(3600)
66}
67
68#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, strum::EnumString, strum::Display)]
72#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
73#[strum(serialize_all = "lowercase")]
74#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
75pub enum SurbPopOrder {
76 #[default]
78 Fifo,
79 Lifo,
82}
83
84#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, smart_default::SmartDefault, validator::Validate)]
92#[cfg_attr(
93 feature = "serde",
94 derive(serde::Deserialize, serde::Serialize),
95 serde(deny_unknown_fields)
96)]
97pub struct SurbStoreConfig {
98 #[default(default_rb_capacity())]
107 #[validate(range(min = 1024, message = "rb_capacity must be at least 1024"))]
108 #[cfg_attr(feature = "serde", serde(default = "default_rb_capacity"))]
109 pub rb_capacity: usize,
110 #[cfg_attr(feature = "serde", serde(default))]
114 pub pop_order: SurbPopOrder,
115 #[default(default_distress_threshold())]
120 #[validate(range(min = 10, message = "distress_threshold must be at least 10"))]
121 #[cfg_attr(feature = "serde", serde(default = "default_distress_threshold"))]
122 pub distress_threshold: usize,
123 #[default(default_max_openers_per_pseudonym())]
134 #[validate(range(min = 100, message = "max_openers_per_pseudonym must be at least 100"))]
135 #[cfg_attr(feature = "serde", serde(default = "default_max_openers_per_pseudonym"))]
136 pub max_openers_per_pseudonym: usize,
137 #[default(default_max_pseudonyms())]
145 #[validate(range(min = 100, message = "max_pseudonyms must be at least 100"))]
146 #[cfg_attr(feature = "serde", serde(default = "default_max_pseudonyms"))]
147 pub max_pseudonyms: usize,
148 #[default(default_pseudonyms_lifetime())]
162 #[validate(custom(function = "validate_pseudonyms_lifetime"))]
163 #[cfg_attr(
164 feature = "serde",
165 serde(default = "default_pseudonyms_lifetime", with = "humantime_serde")
166 )]
167 pub pseudonyms_lifetime: Duration,
168 #[default(default_reply_opener_lifetime())]
179 #[validate(custom(function = "validate_reply_opener_lifetime"))]
180 #[cfg_attr(
181 feature = "serde",
182 serde(default = "default_reply_opener_lifetime", with = "humantime_serde")
183 )]
184 pub reply_opener_lifetime: Duration,
185}
186
187const SIZE_EVICTION_WARN_INTERVAL: Duration = Duration::from_secs(60);
189
190struct SizeEvictionReporter {
199 cache: &'static str,
201 dropped: AtomicU64,
202 last_warned: parking_lot::Mutex<Option<Instant>>,
204}
205
206impl SizeEvictionReporter {
207 fn new(cache: &'static str) -> Self {
208 Self {
209 cache,
210 dropped: AtomicU64::new(0),
211 last_warned: parking_lot::Mutex::new(None),
212 }
213 }
214
215 fn record(&self, cause: RemovalCause) {
217 if cause != RemovalCause::Size {
218 return;
219 }
220
221 self.dropped.fetch_add(1, Ordering::Relaxed);
222
223 let mut last_warned = self.last_warned.lock();
224 if last_warned.is_some_and(|at| at.elapsed() < SIZE_EVICTION_WARN_INTERVAL) {
225 return;
226 }
227 *last_warned = Some(Instant::now());
228 drop(last_warned);
229
230 tracing::warn!(
231 cache = self.cache,
232 dropped = self.dropped.swap(0, Ordering::Relaxed),
233 "SURB store cache is at capacity, dropping entries"
234 );
235 }
236}
237
238struct EvictionReporters {
243 opener_pseudonyms: SizeEvictionReporter,
244 surb_pseudonyms: SizeEvictionReporter,
245 openers: SizeEvictionReporter,
246}
247
248impl Default for EvictionReporters {
249 fn default() -> Self {
250 Self {
251 opener_pseudonyms: SizeEvictionReporter::new("reply openers by pseudonym"),
252 surb_pseudonyms: SizeEvictionReporter::new("surbs by pseudonym"),
253 openers: SizeEvictionReporter::new("reply openers"),
254 }
255 }
256}
257
258#[derive(Clone)]
264pub struct MemorySurbStore {
265 pseudonym_openers: moka::sync::Cache<HoprPseudonym, moka::sync::Cache<HoprSurbId, ReplyOpener>>,
266 surbs_per_pseudonym: moka::sync::Cache<HoprPseudonym, SurbRingBuffer<HoprSurb>>,
267 invalidated_relayers: Arc<parking_lot::RwLock<std::collections::HashSet<HoprKeyIdent>>>,
270 reporters: Arc<EvictionReporters>,
271 cfg: Arc<SurbStoreConfig>,
272}
273
274impl MemorySurbStore {
275 pub fn new(cfg: SurbStoreConfig) -> Self {
277 let reporters = Arc::new(EvictionReporters::default());
278 let opener_pseudonym_reporter = reporters.clone();
279 let surb_pseudonym_reporter = reporters.clone();
280
281 Self {
282 pseudonym_openers: moka::sync::Cache::builder()
286 .time_to_idle(cfg.pseudonyms_lifetime.max(MINIMUM_SURB_LIFETIME))
287 .eviction_policy(moka::policy::EvictionPolicy::lru())
288 .eviction_listener(move |sender_id, _reply_opener, cause| {
289 tracing::debug!(?sender_id, ?cause, "evicting reply opener for pseudonym");
290 opener_pseudonym_reporter.opener_pseudonyms.record(cause);
291 })
292 .max_capacity(cfg.max_openers_per_pseudonym.max(MINIMUM_OPENER_PSEUDONYMS) as u64)
293 .build(),
294 surbs_per_pseudonym: moka::sync::Cache::builder()
297 .time_to_idle(cfg.pseudonyms_lifetime.max(MINIMUM_SURB_LIFETIME))
298 .eviction_policy(moka::policy::EvictionPolicy::lru())
299 .eviction_listener(move |pseudonym, _reply_opener, cause| {
300 tracing::debug!(%pseudonym, ?cause, "evicting surb for pseudonym");
301 surb_pseudonym_reporter.surb_pseudonyms.record(cause);
302 })
303 .max_capacity(cfg.max_pseudonyms.max(MINIMUM_SURBS_PER_PSEUDONYM) as u64)
304 .build(),
305 invalidated_relayers: Default::default(),
306 reporters,
307 cfg: cfg.into(),
308 }
309 }
310
311 pub fn is_relayer_invalidated(&self, relayer: &HoprKeyIdent) -> bool {
313 self.invalidated_relayers.read().contains(relayer)
314 }
315
316 fn is_surb_usable(&self, surb: &HoprSurb) -> bool {
322 match surb.additional_data_receiver.proof_of_relay_values().chain_length() {
323 1 => true,
325 0 => {
331 tracing::warn!(
332 first_relayer = %surb.first_relayer,
333 "refusing a malformed SURB declaring a zero-length return path"
334 );
335 false
336 }
337 _ => {
338 let usable = !self.invalidated_relayers.read().contains(&surb.first_relayer);
339 if !usable {
340 tracing::trace!(
341 first_relayer = %surb.first_relayer,
342 "refusing a SURB whose first relayer is invalidated"
343 );
344 }
345 usable
346 }
347 }
348 }
349}
350
351impl Default for MemorySurbStore {
352 fn default() -> Self {
353 Self::new(SurbStoreConfig::default())
354 }
355}
356
357#[async_trait::async_trait]
358impl SurbStore for MemorySurbStore {
359 #[tracing::instrument(skip_all, level = "trace", fields(?matcher), ret)]
360 fn find_surb(&self, matcher: SurbMatcher) -> Option<FoundSurb> {
361 let pseudonym = matcher.pseudonym();
362 let surbs_for_pseudonym = self.surbs_per_pseudonym.get(&pseudonym)?;
363
364 match matcher {
365 SurbMatcher::Pseudonym(_) => surbs_for_pseudonym
368 .pop_next_valid(|_, surb| self.is_surb_usable(surb))
369 .map(|popped_surb| FoundSurb {
370 sender_id: HoprSenderId::from_pseudonym_and_id(&pseudonym, popped_surb.id),
371 surb: popped_surb.surb,
372 remaining: popped_surb.remaining,
373 }),
374 SurbMatcher::Exact(id) => {
379 surbs_for_pseudonym
380 .pop_one_if_has_id(&id.surb_id())
381 .map(|popped_surb| FoundSurb {
382 sender_id: HoprSenderId::from_pseudonym_and_id(&pseudonym, popped_surb.id),
383 surb: popped_surb.surb,
384 remaining: popped_surb.remaining, })
386 }
387 }
388 }
389
390 #[tracing::instrument(skip_all, level = "trace", fields(%pseudonym, num_surbs = surbs.len()))]
391 fn insert_surbs(&self, pseudonym: HoprPseudonym, surbs: Vec<(HoprSurbId, HoprSurb)>) -> SurbInsertOutcome {
392 self.surbs_per_pseudonym
393 .entry_by_ref(&pseudonym)
394 .or_insert_with(|| SurbRingBuffer::new(self.cfg.rb_capacity.max(MIN_SURB_RB_CAPACITY), self.cfg.pop_order))
395 .value()
396 .push(surbs)
397 }
398
399 #[tracing::instrument(skip_all, level = "trace", fields(?sender_id))]
400 fn insert_reply_opener(&self, sender_id: HoprSenderId, opener: ReplyOpener) {
401 let opener_lifetime = self.cfg.reply_opener_lifetime.max(MINIMUM_OPENER_LIFETIME);
402 let max_openers_per_pseudonym = self.cfg.max_openers_per_pseudonym.max(MINIMUM_OPENERS_PER_PSEUDONYM);
403 let reporters = self.reporters.clone();
404 self.pseudonym_openers
405 .get_with(sender_id.pseudonym(), move || {
406 moka::sync::Cache::builder()
407 .time_to_live(opener_lifetime)
408 .eviction_policy(moka::policy::EvictionPolicy::lru())
417 .eviction_listener(move |id: Arc<HoprSurbId>, _, cause| {
418 if cause != RemovalCause::Explicit {
419 tracing::debug!(
420 pseudonym = %sender_id.pseudonym(),
421 surb_id = const_hex::encode(id.as_slice()),
422 ?cause,
423 "evicting reply opener for sender id"
424 );
425 reporters.openers.record(cause);
426 }
427 })
428 .max_capacity(max_openers_per_pseudonym as u64)
429 .build()
430 })
431 .insert(sender_id.surb_id(), opener);
432 }
433
434 #[tracing::instrument(skip_all, level = "trace")]
435 fn invalidate_relayer(&self, relayer: &HoprKeyIdent) {
436 if self.invalidated_relayers.write().insert(*relayer) {
437 tracing::info!(
438 %relayer,
439 "invalidating stored SURBs whose return path starts with this relayer"
440 );
441 }
442 }
443
444 #[tracing::instrument(skip_all, level = "trace")]
445 fn revalidate_relayer(&self, relayer: &HoprKeyIdent) {
446 if self.invalidated_relayers.write().remove(relayer) {
447 tracing::info!(%relayer, "relayer is usable again for SURB return paths");
448 }
449 }
450
451 #[tracing::instrument(skip_all, level = "trace", fields(?sender_id), ret)]
452 fn find_reply_opener(&self, sender_id: &HoprSenderId) -> Option<ReplyOpener> {
453 self.pseudonym_openers
454 .get(&sender_id.pseudonym())
455 .and_then(|cache| cache.remove(&sender_id.surb_id()))
456 }
457}
458
459#[derive(Debug, Clone)]
461pub struct PoppedSurb<S> {
462 pub id: HoprSurbId,
464 pub surb: S,
466 pub remaining: usize,
468}
469
470#[derive(Clone, Debug)]
477pub struct SurbRingBuffer<S> {
478 surbs: Arc<parking_lot::Mutex<VecDeque<(HoprSurbId, S)>>>,
479 capacity: usize,
480 pop_order: SurbPopOrder,
481}
482
483impl<S> SurbRingBuffer<S> {
484 pub fn new(capacity: usize, pop_order: SurbPopOrder) -> Self {
487 let capacity = capacity.max(1);
488 Self {
489 surbs: Arc::new(parking_lot::Mutex::new(VecDeque::with_capacity(capacity))),
490 capacity,
491 pop_order,
492 }
493 }
494
495 pub fn push<I: IntoIterator<Item = (HoprSurbId, S)>>(&self, surbs: I) -> SurbInsertOutcome {
500 let mut rb = self.surbs.lock();
501 let mut evicted = 0;
502 for surb in surbs {
503 if rb.len() == self.capacity {
506 rb.pop_front();
507 evicted += 1;
508 }
509 rb.push_back(surb);
510 }
511 SurbInsertOutcome {
512 retained: rb.len(),
513 evicted,
514 }
515 }
516
517 pub fn pop_next_valid<F>(&self, is_valid: F) -> Option<PoppedSurb<S>>
527 where
528 F: Fn(&HoprSurbId, &S) -> bool,
529 {
530 loop {
531 let (id, surb, remaining) = {
532 let mut rb = self.surbs.lock();
533 let (id, surb) = match self.pop_order {
534 SurbPopOrder::Fifo => rb.pop_front()?,
535 SurbPopOrder::Lifo => rb.pop_back()?,
536 };
537 (id, surb, rb.len())
538 };
539
540 if is_valid(&id, &surb) {
541 return Some(PoppedSurb { id, surb, remaining });
542 }
543 }
544 }
545
546 pub fn pop_one_if_has_id(&self, id: &HoprSurbId) -> Option<PoppedSurb<S>> {
548 let mut rb = self.surbs.lock();
549
550 let next = match self.pop_order {
551 SurbPopOrder::Fifo => rb.front(),
552 SurbPopOrder::Lifo => rb.back(),
553 };
554
555 if next.is_some_and(|(surb_id, _)| surb_id == id) {
556 let (id, surb) = match self.pop_order {
557 SurbPopOrder::Fifo => rb.pop_front()?,
558 SurbPopOrder::Lifo => rb.pop_back()?,
559 };
560 Some(PoppedSurb {
561 id,
562 surb,
563 remaining: rb.len(),
564 })
565 } else {
566 None
567 }
568 }
569}
570
571#[cfg(test)]
572mod tests {
573 use hopr_api::types::crypto::{crypto_traits::Randomizable, prelude::SecretKey16};
574 use hopr_crypto_packet::sphinx::prelude::SphinxHeaderSpec;
575 use rstest::rstest;
576
577 use super::*;
578
579 impl<S> SurbRingBuffer<S> {
580 fn pop_any(&self) -> Option<PoppedSurb<S>> {
583 self.pop_next_valid(|_, _| true)
584 }
585 }
586
587 fn surb_via(first_relayer: HoprKeyIdent, chain_length: u8) -> anyhow::Result<HoprSurb> {
594 let mut bytes = vec![0u8; HoprSurb::SIZE];
595
596 let key_id_size = HoprSphinxHeaderSpec::KEY_ID_SIZE.get();
597 bytes[..key_id_size].copy_from_slice(first_relayer.as_ref());
598
599 bytes[HoprSurb::SIZE - HoprSphinxHeaderSpec::SURB_RECEIVER_DATA_SIZE] = chain_length;
602
603 let surb = HoprSurb::try_from(bytes.as_slice())?;
604
605 assert_eq!(first_relayer, surb.first_relayer, "fixture: wrong first relayer");
608 assert_eq!(
609 chain_length,
610 surb.additional_data_receiver.proof_of_relay_values().chain_length(),
611 "fixture: wrong chain length"
612 );
613
614 Ok(surb)
615 }
616
617 const TWO_HOP: u8 = 2;
619 const DIRECT: u8 = 1;
621
622 #[test]
623 fn memory_surb_store_should_skip_surbs_whose_first_relayer_was_invalidated() -> anyhow::Result<()> {
624 let (dead, alive) = (HoprKeyIdent::from(1u32), HoprKeyIdent::from(2u32));
625
626 let store = MemorySurbStore::default();
627 let pseudonym = HoprPseudonym::random();
628
629 store.insert_surbs(
631 pseudonym,
632 vec![
633 ([1u8; 8], surb_via(dead, TWO_HOP)?),
634 ([2u8; 8], surb_via(dead, TWO_HOP)?),
635 ([3u8; 8], surb_via(alive, TWO_HOP)?),
636 ],
637 );
638
639 store.invalidate_relayer(&dead);
640
641 let found = store
642 .find_surb(SurbMatcher::Pseudonym(pseudonym))
643 .ok_or(anyhow::anyhow!("expected a usable SURB"))?;
644 assert_eq!([3u8; 8], found.sender_id.surb_id(), "must skip past the dead relayer");
645 assert_eq!(
646 0, found.remaining,
647 "the invalidated SURBs must be discarded, not left behind"
648 );
649
650 assert!(
651 store.find_surb(SurbMatcher::Pseudonym(pseudonym)).is_none(),
652 "no usable SURB should remain"
653 );
654
655 Ok(())
656 }
657
658 #[test]
659 fn memory_surb_store_should_not_invalidate_surbs_with_a_direct_return_path() -> anyhow::Result<()> {
660 let recipient = HoprKeyIdent::from(1u32);
663
664 let store = MemorySurbStore::default();
665 let pseudonym = HoprPseudonym::random();
666
667 store.insert_surbs(pseudonym, vec![([7u8; 8], surb_via(recipient, DIRECT)?)]);
668 store.invalidate_relayer(&recipient);
669
670 let found = store
671 .find_surb(SurbMatcher::Pseudonym(pseudonym))
672 .ok_or(anyhow::anyhow!("a direct-return-path SURB must stay usable"))?;
673 assert_eq!([7u8; 8], found.sender_id.surb_id());
674
675 Ok(())
676 }
677
678 #[test]
679 fn memory_surb_store_should_reject_a_surb_with_a_malformed_chain_length() -> anyhow::Result<()> {
680 let relayer = HoprKeyIdent::from(1u32);
683
684 let store = MemorySurbStore::default();
685 let pseudonym = HoprPseudonym::random();
686
687 store.insert_surbs(pseudonym, vec![([5u8; 8], surb_via(relayer, 0)?)]);
688 store.invalidate_relayer(&relayer);
689
690 assert!(store.find_surb(SurbMatcher::Pseudonym(pseudonym)).is_none());
691
692 Ok(())
693 }
694
695 #[test]
696 fn memory_surb_store_should_make_a_relayer_usable_again_after_revalidation() -> anyhow::Result<()> {
697 let relayer = HoprKeyIdent::from(1u32);
698
699 let store = MemorySurbStore::default();
700 let pseudonym = HoprPseudonym::random();
701
702 store.insert_surbs(pseudonym, vec![([9u8; 8], surb_via(relayer, TWO_HOP)?)]);
703
704 store.invalidate_relayer(&relayer);
705 store.revalidate_relayer(&relayer);
706
707 let found = store
708 .find_surb(SurbMatcher::Pseudonym(pseudonym))
709 .ok_or(anyhow::anyhow!("expected the revalidated SURB"))?;
710 assert_eq!([9u8; 8], found.sender_id.surb_id());
711
712 Ok(())
713 }
714
715 #[test]
716 fn surb_store_config_should_default_to_fifo() {
717 assert_eq!(SurbPopOrder::Fifo, SurbStoreConfig::default().pop_order);
718 assert_eq!(SurbPopOrder::Fifo, SurbPopOrder::default());
719 }
720
721 #[rstest]
724 #[case::fifo(SurbPopOrder::Fifo, [[2u8; 8], [3u8; 8], [4u8; 8]])]
725 #[case::lifo(SurbPopOrder::Lifo, [[4u8; 8], [3u8; 8], [2u8; 8]])]
726 fn surb_ring_buffer_should_drop_oldest_items_when_capacity_is_reached(
727 #[case] order: SurbPopOrder,
728 #[case] expected: [HoprSurbId; 3],
729 ) -> anyhow::Result<()> {
730 let rb = SurbRingBuffer::new(3, order);
731 rb.push([([1u8; 8], 0)]);
732 rb.push([([2u8; 8], 0)]);
733 rb.push([([3u8; 8], 0)]);
734 rb.push([([4u8; 8], 0)]);
735
736 for (i, expected_id) in expected.into_iter().enumerate() {
737 let popped = rb.pop_any().ok_or(anyhow::anyhow!("expected pop"))?;
738 assert_eq!(expected_id, popped.id, "unexpected id at index {i}");
739 assert_eq!(expected.len() - 1 - i, popped.remaining, "unexpected remaining");
740 }
741
742 assert!(rb.pop_any().is_none(), "buffer should be drained");
743
744 Ok(())
745 }
746
747 #[rstest]
748 #[case::fifo(SurbPopOrder::Fifo)]
749 #[case::lifo(SurbPopOrder::Lifo)]
750 fn surb_ring_buffer_should_report_no_eviction_below_capacity(#[case] order: SurbPopOrder) {
751 let rb = SurbRingBuffer::new(4, order);
752
753 assert_eq!(
754 SurbInsertOutcome {
755 retained: 2,
756 evicted: 0
757 },
758 rb.push([([1u8; 8], 0), ([2u8; 8], 0)])
759 );
760 assert_eq!(
761 SurbInsertOutcome {
762 retained: 4,
763 evicted: 0
764 },
765 rb.push([([3u8; 8], 0), ([4u8; 8], 0)])
766 );
767 }
768
769 #[rstest]
773 #[case::fifo(SurbPopOrder::Fifo)]
774 #[case::lifo(SurbPopOrder::Lifo)]
775 fn surb_ring_buffer_should_count_evictions_past_capacity(#[case] order: SurbPopOrder) -> anyhow::Result<()> {
776 let rb = SurbRingBuffer::new(2, order);
777
778 let outcome = rb.push([([1u8; 8], 0), ([2u8; 8], 0), ([3u8; 8], 0)]);
779 assert_eq!(
780 SurbInsertOutcome {
781 retained: 2,
782 evicted: 1
783 },
784 outcome,
785 "a 3-element push into a 2-slot buffer drops exactly one"
786 );
787
788 let ids: Vec<_> = std::iter::from_fn(|| rb.pop_any().map(|p| p.id)).collect();
790 assert!(
791 !ids.contains(&[1u8; 8]),
792 "the oldest entry must be the evicted one, got {ids:?}"
793 );
794
795 Ok(())
796 }
797
798 #[rstest]
801 #[case::fifo(SurbPopOrder::Fifo)]
802 #[case::lifo(SurbPopOrder::Lifo)]
803 fn surb_ring_buffer_should_count_evictions_across_separate_pushes(#[case] order: SurbPopOrder) {
804 let rb = SurbRingBuffer::new(2, order);
805 assert_eq!(0, rb.push([([1u8; 8], 0), ([2u8; 8], 0)]).evicted, "precondition: full");
806
807 assert_eq!(
808 SurbInsertOutcome {
809 retained: 2,
810 evicted: 1
811 },
812 rb.push([([3u8; 8], 0)])
813 );
814 assert_eq!(
815 SurbInsertOutcome {
816 retained: 2,
817 evicted: 2
818 },
819 rb.push([([4u8; 8], 0), ([5u8; 8], 0)])
820 );
821 }
822
823 #[test]
824 fn surb_ring_buffer_should_pop_fifo_by_default() -> anyhow::Result<()> {
825 let rb = SurbRingBuffer::new(5, SurbPopOrder::default());
826
827 let len = rb.push([([1u8; 8], 0)]).retained;
828 assert_eq!(1, len);
829
830 let len = rb.push([([2u8; 8], 0)]).retained;
831 assert_eq!(2, len);
832
833 let popped = rb.pop_any().ok_or(anyhow::anyhow!("expected pop"))?;
834 assert_eq!([1u8; 8], popped.id);
835 assert_eq!(1, popped.remaining);
836
837 let popped = rb.pop_any().ok_or(anyhow::anyhow!("expected pop"))?;
838 assert_eq!([2u8; 8], popped.id);
839 assert_eq!(0, popped.remaining);
840
841 let len = rb.push([([1u8; 8], 0), ([2u8; 8], 0)]).retained;
842 assert_eq!(2, len);
843
844 assert_eq!([1u8; 8], rb.pop_any().ok_or(anyhow::anyhow!("expected pop"))?.id);
845 assert_eq!([2u8; 8], rb.pop_any().ok_or(anyhow::anyhow!("expected pop"))?.id);
846
847 Ok(())
848 }
849
850 #[test]
851 fn surb_ring_buffer_should_pop_lifo_when_configured() -> anyhow::Result<()> {
852 let rb = SurbRingBuffer::new(5, SurbPopOrder::Lifo);
853
854 let len = rb.push([([1u8; 8], 0)]).retained;
855 assert_eq!(1, len);
856
857 let len = rb.push([([2u8; 8], 0)]).retained;
858 assert_eq!(2, len);
859
860 let popped = rb.pop_any().ok_or(anyhow::anyhow!("expected pop"))?;
861 assert_eq!([2u8; 8], popped.id);
862 assert_eq!(1, popped.remaining);
863
864 let popped = rb.pop_any().ok_or(anyhow::anyhow!("expected pop"))?;
865 assert_eq!([1u8; 8], popped.id);
866 assert_eq!(0, popped.remaining);
867
868 let len = rb.push([([1u8; 8], 0), ([2u8; 8], 0)]).retained;
869 assert_eq!(2, len);
870
871 assert_eq!([2u8; 8], rb.pop_any().ok_or(anyhow::anyhow!("expected pop"))?.id);
872 assert_eq!([1u8; 8], rb.pop_any().ok_or(anyhow::anyhow!("expected pop"))?.id);
873
874 Ok(())
875 }
876
877 #[rstest]
878 #[case::fifo(SurbPopOrder::Fifo)]
879 #[case::lifo(SurbPopOrder::Lifo)]
880 fn surb_ring_buffer_should_skip_entries_failing_the_predicate(#[case] order: SurbPopOrder) -> anyhow::Result<()> {
881 let rb = SurbRingBuffer::new(5, order);
882 rb.push([([1u8; 8], 0), ([2u8; 8], 0), ([3u8; 8], 0)]);
883
884 let popped = rb
886 .pop_next_valid(|id, _| id == &[2u8; 8])
887 .ok_or(anyhow::anyhow!("expected pop"))?;
888 assert_eq!([2u8; 8], popped.id);
889
890 assert_eq!(1, popped.remaining);
892 assert!(rb.pop_next_valid(|id, _| id == &[2u8; 8]).is_none());
893
894 Ok(())
895 }
896
897 #[test]
898 fn surb_ring_buffer_should_return_none_when_no_entry_satisfies_the_predicate() -> anyhow::Result<()> {
899 let rb = SurbRingBuffer::new(5, SurbPopOrder::Lifo);
900 rb.push([([1u8; 8], 0), ([2u8; 8], 0)]);
901
902 assert!(rb.pop_next_valid(|_, _| false).is_none());
903 assert!(rb.pop_any().is_none());
905
906 Ok(())
907 }
908
909 #[rstest]
910 #[case::fifo(SurbPopOrder::Fifo)]
911 #[case::lifo(SurbPopOrder::Lifo)]
912 fn surb_ring_buffer_should_not_reallocate_under_steady_overflow(#[case] order: SurbPopOrder) -> anyhow::Result<()> {
913 let rb = SurbRingBuffer::new(8, order);
914 let initial_capacity = rb.surbs.lock().capacity();
915
916 for i in 0..1_000u32 {
917 rb.push([(((i as u64).to_be_bytes()), 0)]);
918 if i % 3 == 0 {
919 rb.pop_any();
920 }
921 assert!(rb.surbs.lock().len() <= 8, "length exceeded capacity");
922 }
923
924 assert_eq!(initial_capacity, rb.surbs.lock().capacity(), "buffer reallocated");
925
926 Ok(())
927 }
928
929 #[rstest]
930 #[case::fifo(SurbPopOrder::Fifo)]
931 #[case::lifo(SurbPopOrder::Lifo)]
932 fn surb_ring_buffer_should_not_pop_if_id_does_not_match(#[case] order: SurbPopOrder) -> anyhow::Result<()> {
933 let rb = SurbRingBuffer::new(5, order);
934
935 rb.push([([1u8; 8], 0)]);
936
937 assert!(rb.pop_one_if_has_id(&[2u8; 8]).is_none());
938 assert_eq!(
939 [1u8; 8],
940 rb.pop_one_if_has_id(&[1u8; 8])
941 .ok_or(anyhow::anyhow!("expected pop"))?
942 .id
943 );
944
945 Ok(())
946 }
947
948 fn cheap_opener() -> ReplyOpener {
951 ReplyOpener {
952 sender_key: SecretKey16::random(),
953 shared_secrets: Vec::new(),
954 }
955 }
956
957 fn flooded_client(max_openers: usize, flood: u64) -> (MemorySurbStore, HoprPseudonym) {
961 let client = MemorySurbStore::new(SurbStoreConfig {
962 max_openers_per_pseudonym: max_openers,
963 ..Default::default()
964 });
965 let pseudonym = HoprPseudonym::random();
966 let opener = cheap_opener();
967 for i in 0..flood {
968 let id: HoprSurbId = i.to_be_bytes();
969 client.insert_reply_opener(HoprSenderId::from_pseudonym_and_id(&pseudonym, id), opener.clone());
970 }
971 client.pseudonym_openers.run_pending_tasks();
972 if let Some(inner) = client.pseudonym_openers.get(&pseudonym) {
973 inner.run_pending_tasks();
974 }
975 (client, pseudonym)
976 }
977
978 #[derive(Default)]
980 struct RecordingSubscriber {
981 warnings: AtomicU64,
982 debugs: AtomicU64,
983 }
984
985 impl tracing::Subscriber for RecordingSubscriber {
986 fn enabled(&self, _: &tracing::Metadata<'_>) -> bool {
987 true
988 }
989
990 fn new_span(&self, _: &tracing::span::Attributes<'_>) -> tracing::span::Id {
991 tracing::span::Id::from_u64(1)
992 }
993
994 fn record(&self, _: &tracing::span::Id, _: &tracing::span::Record<'_>) {}
995
996 fn record_follows_from(&self, _: &tracing::span::Id, _: &tracing::span::Id) {}
997
998 fn event(&self, event: &tracing::Event<'_>) {
999 match *event.metadata().level() {
1000 tracing::Level::WARN => self.warnings.fetch_add(1, Ordering::Relaxed),
1001 tracing::Level::DEBUG => self.debugs.fetch_add(1, Ordering::Relaxed),
1002 _ => 0,
1003 };
1004 }
1005
1006 fn enter(&self, _: &tracing::span::Id) {}
1007
1008 fn exit(&self, _: &tracing::span::Id) {}
1009 }
1010
1011 #[test]
1012 fn expiry_style_evictions_should_not_warn() {
1013 let recorder = Arc::new(RecordingSubscriber::default());
1014 tracing::subscriber::with_default(recorder.clone(), || {
1015 let reporter = SizeEvictionReporter::new("test");
1016 for _ in 0..1000 {
1017 reporter.record(RemovalCause::Expired);
1018 reporter.record(RemovalCause::Explicit);
1019 reporter.record(RemovalCause::Replaced);
1020 }
1021 });
1022
1023 assert_eq!(0, recorder.warnings.load(Ordering::Relaxed));
1024 }
1025
1026 #[test]
1027 fn capacity_evictions_should_warn_once_per_interval() {
1028 let recorder = Arc::new(RecordingSubscriber::default());
1029 tracing::subscriber::with_default(recorder.clone(), || {
1030 let reporter = SizeEvictionReporter::new("test");
1031 for _ in 0..1000 {
1032 reporter.record(RemovalCause::Size);
1033 }
1034 });
1035
1036 assert_eq!(1, recorder.warnings.load(Ordering::Relaxed));
1038 }
1039
1040 #[test]
1041 fn overflowing_caches_should_log_evictions_at_debug_and_warn_about_capacity() -> anyhow::Result<()> {
1042 let recorder = Arc::new(RecordingSubscriber::default());
1043 tracing::subscriber::with_default(recorder.clone(), || -> anyhow::Result<()> {
1044 let _ = flooded_client(MINIMUM_OPENERS_PER_PSEUDONYM, 3 * MINIMUM_OPENERS_PER_PSEUDONYM as u64);
1046
1047 let store = MemorySurbStore::new(SurbStoreConfig {
1049 max_openers_per_pseudonym: MINIMUM_OPENER_PSEUDONYMS,
1050 max_pseudonyms: MINIMUM_SURBS_PER_PSEUDONYM,
1051 ..Default::default()
1052 });
1053 let opener = cheap_opener();
1054 let surb = surb_via(HoprKeyIdent::from(1u32), DIRECT)?;
1055 for _ in 0..1500 {
1056 let pseudonym = HoprPseudonym::random();
1057 store.insert_reply_opener(
1058 HoprSenderId::from_pseudonym_and_id(&pseudonym, [0u8; 8]),
1059 opener.clone(),
1060 );
1061 store.insert_surbs(pseudonym, vec![([0u8; 8], surb.clone())]);
1062 }
1063 store.pseudonym_openers.run_pending_tasks();
1064 store.surbs_per_pseudonym.run_pending_tasks();
1065 Ok(())
1066 })?;
1067
1068 let warnings = recorder.warnings.load(Ordering::Relaxed);
1071 assert!(
1072 (1..=4).contains(&warnings),
1073 "expected a rate-limited capacity warning, got {warnings}"
1074 );
1075 assert!(
1076 recorder.debugs.load(Ordering::Relaxed) > 0,
1077 "expected debug eviction events"
1078 );
1079 Ok(())
1080 }
1081
1082 fn undecryptable_replies_after_upload_flood(
1095 max_openers: usize,
1096 rb_capacity: usize,
1097 order: SurbPopOrder,
1098 flood: usize,
1099 replies: usize,
1100 ) -> usize {
1101 let (client, pseudonym) = flooded_client(max_openers, flood as u64);
1102 let exit = MemorySurbStore::new(SurbStoreConfig {
1103 rb_capacity,
1104 pop_order: order,
1105 ..Default::default()
1106 });
1107
1108 let surb = surb_via(HoprKeyIdent::from(1u32), DIRECT).expect("valid surb fixture");
1112 for i in 0..flood as u64 {
1113 exit.insert_surbs(pseudonym, vec![(i.to_be_bytes(), surb.clone())]);
1114 }
1115
1116 (0..replies)
1117 .filter(|_| {
1118 let found = exit
1119 .find_surb(SurbMatcher::Pseudonym(pseudonym))
1120 .expect("the exit still holds SURBs to reply with");
1121 client.find_reply_opener(&found.sender_id).is_none()
1122 })
1123 .count()
1124 }
1125
1126 #[rstest]
1130 #[case::fifo(SurbPopOrder::Fifo)]
1131 #[case::lifo(SurbPopOrder::Lifo)]
1132 fn replies_stay_decryptable_while_the_opener_cache_has_not_overflowed(#[case] order: SurbPopOrder) {
1133 const MAX_OPENERS: usize = 7000;
1134 let undecryptable = undecryptable_replies_after_upload_flood(MAX_OPENERS, 1050, order, MAX_OPENERS, 200);
1136 assert_eq!(
1137 0, undecryptable,
1138 "no reply should be undecryptable before the cache overflows"
1139 );
1140 }
1141
1142 #[test]
1147 fn a_sustained_upload_keeps_the_newest_reply_openers_and_sheds_the_stalest() {
1148 const MAX_OPENERS: usize = 7000;
1149 const FLOOD: u64 = 21_000;
1150 const EDGE: u64 = 1_050; let (client, pseudonym) = flooded_client(MAX_OPENERS, FLOOD);
1153 let inner = client
1154 .pseudonym_openers
1155 .get(&pseudonym)
1156 .expect("the pseudonym's opener cache exists");
1157
1158 let present =
1159 |lo: u64, hi: u64| -> u64 { (lo..hi).filter(|i| inner.contains_key(&i.to_be_bytes())).count() as u64 };
1160
1161 assert_eq!(0, present(0, EDGE), "the stalest openers are shed");
1162 assert_eq!(
1163 EDGE,
1164 present(FLOOD - EDGE, FLOOD),
1165 "the freshest openers — the ones the exit's newest SURBs need — are kept"
1166 );
1167 }
1168
1169 #[rstest]
1186 #[case::production_fifo(7000, 1050, 21_000, SurbPopOrder::Fifo, 0)]
1187 #[case::production_lifo(7000, 1050, 21_000, SurbPopOrder::Lifo, 0)]
1188 #[case::inverted_lifo(1000, 15_000, 20_000, SurbPopOrder::Lifo, 0)]
1189 #[case::inverted_fifo(1000, 15_000, 20_000, SurbPopOrder::Fifo, 200)]
1190 fn a_sustained_upload_keeps_the_return_path_alive_at_the_deployed_config(
1191 #[case] max_openers: usize,
1192 #[case] rb_capacity: usize,
1193 #[case] flood: usize,
1194 #[case] order: SurbPopOrder,
1195 #[case] expected_undecryptable: usize,
1196 ) {
1197 const REPLIES: usize = 200;
1198 let undecryptable = undecryptable_replies_after_upload_flood(max_openers, rb_capacity, order, flood, REPLIES);
1199 assert_eq!(
1200 expected_undecryptable, undecryptable,
1201 "unexpected undecryptable-reply count for {order:?} at openers={max_openers}, rb={rb_capacity}"
1202 );
1203 }
1204
1205 #[test]
1206 fn surb_ring_buffer_should_check_the_popping_end_for_an_exact_id() -> anyhow::Result<()> {
1207 let fifo = SurbRingBuffer::new(5, SurbPopOrder::Fifo);
1208 fifo.push([([1u8; 8], 0), ([2u8; 8], 0)]);
1209 assert!(fifo.pop_one_if_has_id(&[2u8; 8]).is_none());
1210 assert_eq!(
1211 [1u8; 8],
1212 fifo.pop_one_if_has_id(&[1u8; 8])
1213 .ok_or(anyhow::anyhow!("expected pop"))?
1214 .id
1215 );
1216
1217 let lifo = SurbRingBuffer::new(5, SurbPopOrder::Lifo);
1218 lifo.push([([1u8; 8], 0), ([2u8; 8], 0)]);
1219 assert!(lifo.pop_one_if_has_id(&[1u8; 8]).is_none());
1220 assert_eq!(
1221 [2u8; 8],
1222 lifo.pop_one_if_has_id(&[2u8; 8])
1223 .ok_or(anyhow::anyhow!("expected pop"))?
1224 .id
1225 );
1226
1227 Ok(())
1228 }
1229}