Skip to main content

sochdb_storage/
ssi_scaling.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2// SochDB - LLM-Optimized Embedded Database
3// Copyright (C) 2026 Sushanth Reddy Vanagala (https://github.com/sushanthpy)
4//
5// This program is free software: you can redistribute it and/or modify
6// it under the terms of the GNU Affero General Public License as published by
7// the Free Software Foundation, either version 3 of the License, or
8// (at your option) any later version.
9//
10// This program is distributed in the hope that it will be useful,
11// but WITHOUT ANY WARRANTY; without even the implied warranty of
12// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13// GNU Affero General Public License for more details.
14//
15// You should have received a copy of the GNU Affero General Public License
16// along with this program. If not, see <https://www.gnu.org/licenses/>.
17
18//! # SSI Scaling Guardrails
19//!
20//! Provides scalable alternatives to per-key read sets for SSI:
21//! - **Range Locks**: Interval-based locking for range scans
22//! - **Predicate Locks**: Bloom filter-based summarized read sets
23//! - **Adaptive Switching**: Automatically choose based on workload
24//!
25//! ## Problem Statement
26//!
27//! Naive SSI with per-key read sets is O(n) in scan workloads.
28//! For analytics tables and backups, this causes:
29//! - Memory blowup from storing every key
30//! - O(n) conflict checking at commit time
31//!
32//! ## Solution
33//!
34//! Replace per-key sets with interval/predicate representations:
35//! - Range locks: O(log m) operations where m is number of intervals
36//! - Bloom filters: O(1) membership check with false-positive aborts
37//!
38//! ## Complexity Analysis
39//!
40//! | Operation     | Per-Key      | Range Lock   | Bloom Filter |
41//! |---------------|--------------|--------------|--------------|
42//! | Add read      | O(1)         | O(log m)     | O(k)         |
43//! | Check conflict| O(n)         | O(log m)     | O(k)         |
44//! | Memory        | O(n)         | O(m)         | O(fixed)     |
45//!
46//! where n = keys read, m = intervals, k = hash functions
47
48use std::cmp::Ordering;
49use std::collections::{BTreeMap, HashSet};
50use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering as AtomicOrdering};
51use std::sync::Arc;
52use std::time::Duration;
53
54use parking_lot::RwLock;
55
56/// Transaction ID type
57pub type TxnId = u64;
58
59/// Key range boundary
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub enum RangeBound {
62    /// Unbounded (negative or positive infinity)
63    Unbounded,
64    /// Inclusive bound
65    Inclusive(Vec<u8>),
66    /// Exclusive bound
67    Exclusive(Vec<u8>),
68}
69
70impl RangeBound {
71    /// Check if this bound is less than a key (for start bounds)
72    pub fn is_before(&self, key: &[u8]) -> bool {
73        match self {
74            RangeBound::Unbounded => true,
75            RangeBound::Inclusive(bound) => bound.as_slice() <= key,
76            RangeBound::Exclusive(bound) => bound.as_slice() < key,
77        }
78    }
79
80    /// Check if this bound is greater than a key (for end bounds)
81    pub fn is_after(&self, key: &[u8]) -> bool {
82        match self {
83            RangeBound::Unbounded => true,
84            RangeBound::Inclusive(bound) => bound.as_slice() >= key,
85            RangeBound::Exclusive(bound) => bound.as_slice() > key,
86        }
87    }
88}
89
90/// Key range for range locks
91#[derive(Debug, Clone)]
92pub struct KeyRange {
93    /// Start of range
94    pub start: RangeBound,
95    /// End of range
96    pub end: RangeBound,
97    /// Optional table/index identifier
98    pub table_id: Option<u64>,
99}
100
101impl KeyRange {
102    /// Create a point range (single key)
103    pub fn point(key: Vec<u8>) -> Self {
104        Self {
105            start: RangeBound::Inclusive(key.clone()),
106            end: RangeBound::Inclusive(key),
107            table_id: None,
108        }
109    }
110
111    /// Create a range
112    pub fn range(start: RangeBound, end: RangeBound) -> Self {
113        Self {
114            start,
115            end,
116            table_id: None,
117        }
118    }
119
120    /// Create a full table scan range
121    pub fn full_table(table_id: u64) -> Self {
122        Self {
123            start: RangeBound::Unbounded,
124            end: RangeBound::Unbounded,
125            table_id: Some(table_id),
126        }
127    }
128
129    /// Check if this range contains a key
130    pub fn contains(&self, key: &[u8]) -> bool {
131        self.start.is_before(key) && self.end.is_after(key)
132    }
133
134    /// Check if this range overlaps with another
135    pub fn overlaps(&self, other: &KeyRange) -> bool {
136        // Check table ID first
137        if self.table_id.is_some() && other.table_id.is_some() && self.table_id != other.table_id {
138            return false;
139        }
140
141        // Check range overlap
142        // Ranges overlap if: start1 < end2 AND start2 < end1
143        let self_start_before_other_end = match (&self.start, &other.end) {
144            (RangeBound::Unbounded, _) | (_, RangeBound::Unbounded) => true,
145            (RangeBound::Inclusive(s), RangeBound::Inclusive(e)) => s <= e,
146            (RangeBound::Inclusive(s), RangeBound::Exclusive(e)) => s < e,
147            (RangeBound::Exclusive(s), RangeBound::Inclusive(e)) => s < e,
148            (RangeBound::Exclusive(s), RangeBound::Exclusive(e)) => s < e,
149        };
150
151        let other_start_before_self_end = match (&other.start, &self.end) {
152            (RangeBound::Unbounded, _) | (_, RangeBound::Unbounded) => true,
153            (RangeBound::Inclusive(s), RangeBound::Inclusive(e)) => s <= e,
154            (RangeBound::Inclusive(s), RangeBound::Exclusive(e)) => s < e,
155            (RangeBound::Exclusive(s), RangeBound::Inclusive(e)) => s < e,
156            (RangeBound::Exclusive(s), RangeBound::Exclusive(e)) => s < e,
157        };
158
159        self_start_before_other_end && other_start_before_self_end
160    }
161
162    /// Try to merge with another range (if adjacent or overlapping)
163    pub fn try_merge(&self, other: &KeyRange) -> Option<KeyRange> {
164        if !self.overlaps(other) {
165            return None;
166        }
167
168        // Merge to create smallest enclosing range
169        let start = match (&self.start, &other.start) {
170            (RangeBound::Unbounded, _) | (_, RangeBound::Unbounded) => RangeBound::Unbounded,
171            (RangeBound::Inclusive(a), RangeBound::Inclusive(b)) => {
172                RangeBound::Inclusive(a.min(b).clone())
173            }
174            (RangeBound::Exclusive(a), RangeBound::Exclusive(b)) => {
175                RangeBound::Exclusive(a.min(b).clone())
176            }
177            (RangeBound::Inclusive(a), RangeBound::Exclusive(b)) => {
178                if a <= b {
179                    RangeBound::Inclusive(a.clone())
180                } else {
181                    RangeBound::Exclusive(b.clone())
182                }
183            }
184            (RangeBound::Exclusive(a), RangeBound::Inclusive(b)) => {
185                if b <= a {
186                    RangeBound::Inclusive(b.clone())
187                } else {
188                    RangeBound::Exclusive(a.clone())
189                }
190            }
191        };
192
193        let end = match (&self.end, &other.end) {
194            (RangeBound::Unbounded, _) | (_, RangeBound::Unbounded) => RangeBound::Unbounded,
195            (RangeBound::Inclusive(a), RangeBound::Inclusive(b)) => {
196                RangeBound::Inclusive(a.max(b).clone())
197            }
198            (RangeBound::Exclusive(a), RangeBound::Exclusive(b)) => {
199                RangeBound::Exclusive(a.max(b).clone())
200            }
201            (RangeBound::Inclusive(a), RangeBound::Exclusive(b)) => {
202                if a >= b {
203                    RangeBound::Inclusive(a.clone())
204                } else {
205                    RangeBound::Exclusive(b.clone())
206                }
207            }
208            (RangeBound::Exclusive(a), RangeBound::Inclusive(b)) => {
209                if b >= a {
210                    RangeBound::Inclusive(b.clone())
211                } else {
212                    RangeBound::Exclusive(a.clone())
213                }
214            }
215        };
216
217        Some(KeyRange {
218            start,
219            end,
220            table_id: self.table_id.or(other.table_id),
221        })
222    }
223}
224
225/// Range lock entry
226#[derive(Debug, Clone)]
227struct RangeLockEntry {
228    range: KeyRange,
229    txn_id: TxnId,
230    is_write: bool,
231}
232
233/// Interval-based range lock manager
234///
235/// Uses an interval tree for O(log n) conflict detection.
236pub struct RangeLockManager {
237    /// All active range locks
238    locks: RwLock<Vec<RangeLockEntry>>,
239    /// Statistics
240    stats: RangeLockStats,
241}
242
243/// Range lock statistics
244#[derive(Default)]
245pub struct RangeLockStats {
246    pub total_locks: AtomicU64,
247    pub range_locks: AtomicU64,
248    pub point_locks: AtomicU64,
249    pub conflicts_detected: AtomicU64,
250    pub merges_performed: AtomicU64,
251}
252
253impl RangeLockManager {
254    pub fn new() -> Self {
255        Self {
256            locks: RwLock::new(Vec::new()),
257            stats: RangeLockStats::default(),
258        }
259    }
260
261    /// Acquire a range lock for a transaction
262    pub fn acquire(
263        &self,
264        txn_id: TxnId,
265        range: KeyRange,
266        is_write: bool,
267    ) -> Result<(), RangeLockConflict> {
268        self.stats.total_locks.fetch_add(1, AtomicOrdering::Relaxed);
269
270        // Check for conflicts
271        {
272            let locks = self.locks.read();
273            for entry in locks.iter() {
274                if entry.txn_id == txn_id {
275                    continue; // Own lock
276                }
277
278                if range.overlaps(&entry.range) {
279                    // Conflict if either is a write lock
280                    if is_write || entry.is_write {
281                        self.stats.conflicts_detected.fetch_add(1, AtomicOrdering::Relaxed);
282                        return Err(RangeLockConflict {
283                            holder_txn: entry.txn_id,
284                            requester_txn: txn_id,
285                            is_write_conflict: is_write && entry.is_write,
286                        });
287                    }
288                }
289            }
290        }
291
292        // Acquire the lock
293        let mut locks = self.locks.write();
294
295        // Try to merge with existing locks from same transaction
296        let mut merged = false;
297        for entry in locks.iter_mut() {
298            if entry.txn_id == txn_id && entry.is_write == is_write {
299                if let Some(merged_range) = entry.range.try_merge(&range) {
300                    entry.range = merged_range;
301                    merged = true;
302                    self.stats.merges_performed.fetch_add(1, AtomicOrdering::Relaxed);
303                    break;
304                }
305            }
306        }
307
308        if !merged {
309            locks.push(RangeLockEntry {
310                range,
311                txn_id,
312                is_write,
313            });
314        }
315
316        Ok(())
317    }
318
319    /// Release all locks held by a transaction
320    pub fn release(&self, txn_id: TxnId) {
321        let mut locks = self.locks.write();
322        locks.retain(|entry| entry.txn_id != txn_id);
323    }
324
325    /// Check for conflicts without acquiring
326    pub fn check_conflict(
327        &self,
328        txn_id: TxnId,
329        range: &KeyRange,
330        is_write: bool,
331    ) -> Option<TxnId> {
332        let locks = self.locks.read();
333        for entry in locks.iter() {
334            if entry.txn_id == txn_id {
335                continue;
336            }
337            if range.overlaps(&entry.range) && (is_write || entry.is_write) {
338                return Some(entry.txn_id);
339            }
340        }
341        None
342    }
343
344    /// Get number of locks held by a transaction
345    pub fn lock_count(&self, txn_id: TxnId) -> usize {
346        self.locks
347            .read()
348            .iter()
349            .filter(|e| e.txn_id == txn_id)
350            .count()
351    }
352}
353
354impl Default for RangeLockManager {
355    fn default() -> Self {
356        Self::new()
357    }
358}
359
360/// Range lock conflict error
361#[derive(Debug, Clone)]
362pub struct RangeLockConflict {
363    pub holder_txn: TxnId,
364    pub requester_txn: TxnId,
365    pub is_write_conflict: bool,
366}
367
368impl std::fmt::Display for RangeLockConflict {
369    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
370        write!(
371            f,
372            "Range lock conflict: txn {} blocked by txn {} (write: {})",
373            self.requester_txn, self.holder_txn, self.is_write_conflict
374        )
375    }
376}
377
378impl std::error::Error for RangeLockConflict {}
379
380/// Bloom filter for approximate read set
381///
382/// Uses k hash functions for O(k) operations with configurable
383/// false positive rate.
384pub struct BloomReadSet {
385    /// Bit vector
386    bits: Vec<u64>,
387    /// Number of hash functions
388    k: usize,
389    /// Number of bits
390    m: usize,
391    /// Number of items added
392    count: usize,
393}
394
395impl BloomReadSet {
396    /// Create a new bloom filter
397    ///
398    /// Size is calculated for target false positive rate:
399    /// m = -n * ln(p) / (ln(2))^2
400    /// k = m/n * ln(2)
401    pub fn new(expected_items: usize, false_positive_rate: f64) -> Self {
402        let m = (-((expected_items as f64) * false_positive_rate.ln())
403            / (2.0_f64.ln().powi(2))) as usize;
404        let k = ((m as f64 / expected_items as f64) * 2.0_f64.ln()).ceil() as usize;
405
406        // Round up to 64-bit words
407        let words = m.div_ceil(64);
408
409        Self {
410            bits: vec![0u64; words],
411            k: k.max(1),
412            m: words * 64,
413            count: 0,
414        }
415    }
416
417    /// Add a key to the read set
418    pub fn add(&mut self, key: &[u8]) {
419        for i in 0..self.k {
420            let hash = self.hash(key, i);
421            let bit_idx = hash % self.m;
422            let word_idx = bit_idx / 64;
423            let bit_offset = bit_idx % 64;
424            self.bits[word_idx] |= 1 << bit_offset;
425        }
426        self.count += 1;
427    }
428
429    /// Check if a key might be in the read set
430    ///
431    /// Returns:
432    /// - false: key is definitely NOT in set
433    /// - true: key MIGHT be in set (or false positive)
434    pub fn might_contain(&self, key: &[u8]) -> bool {
435        for i in 0..self.k {
436            let hash = self.hash(key, i);
437            let bit_idx = hash % self.m;
438            let word_idx = bit_idx / 64;
439            let bit_offset = bit_idx % 64;
440            if self.bits[word_idx] & (1 << bit_offset) == 0 {
441                return false;
442            }
443        }
444        true
445    }
446
447    /// Compute hash for key with seed
448    fn hash(&self, key: &[u8], seed: usize) -> usize {
449        // Double hashing: h(i) = h1 + i * h2
450        let h1 = self.hash_fnv1a(key);
451        let h2 = self.hash_murmur_like(key);
452        ((h1 as u128 + (seed as u128) * (h2 as u128)) % (self.m as u128)) as usize
453    }
454
455    fn hash_fnv1a(&self, key: &[u8]) -> u64 {
456        const FNV_OFFSET: u64 = 0xcbf29ce484222325;
457        const FNV_PRIME: u64 = 0x100000001b3;
458
459        let mut hash = FNV_OFFSET;
460        for byte in key {
461            hash ^= *byte as u64;
462            hash = hash.wrapping_mul(FNV_PRIME);
463        }
464        hash
465    }
466
467    fn hash_murmur_like(&self, key: &[u8]) -> u64 {
468        const M: u64 = 0xc6a4a7935bd1e995;
469        const R: u32 = 47;
470
471        let mut h: u64 = 0xdeadbeef ^ (key.len() as u64).wrapping_mul(M);
472
473        for chunk in key.chunks(8) {
474            let mut k = 0u64;
475            for (i, byte) in chunk.iter().enumerate() {
476                k |= (*byte as u64) << (i * 8);
477            }
478            k = k.wrapping_mul(M);
479            k ^= k >> R;
480            k = k.wrapping_mul(M);
481            h ^= k;
482            h = h.wrapping_mul(M);
483        }
484
485        h ^= h >> R;
486        h = h.wrapping_mul(M);
487        h ^= h >> R;
488        h
489    }
490
491    /// Estimated false positive rate
492    pub fn estimated_false_positive_rate(&self) -> f64 {
493        let ones = self.bits.iter().map(|w| w.count_ones()).sum::<u32>() as f64;
494        let ratio = ones / self.m as f64;
495        ratio.powi(self.k as i32)
496    }
497
498    /// Number of items added
499    pub fn count(&self) -> usize {
500        self.count
501    }
502
503    /// Memory usage in bytes
504    pub fn memory_bytes(&self) -> usize {
505        self.bits.len() * 8
506    }
507}
508
509/// Adaptive read set that switches strategy based on size
510pub struct AdaptiveReadSet {
511    /// Threshold for switching from exact to bloom
512    threshold: usize,
513    /// Exact keys (if small enough)
514    exact_keys: Option<HashSet<Vec<u8>>>,
515    /// Bloom filter (if too many keys)
516    bloom: Option<BloomReadSet>,
517    /// Range locks (for range scans)
518    ranges: Vec<KeyRange>,
519}
520
521impl AdaptiveReadSet {
522    /// Create a new adaptive read set
523    pub fn new(threshold: usize) -> Self {
524        Self {
525            threshold,
526            exact_keys: Some(HashSet::new()),
527            bloom: None,
528            ranges: Vec::new(),
529        }
530    }
531
532    /// Record a point read
533    pub fn add_point(&mut self, key: Vec<u8>) {
534        if let Some(ref mut exact) = self.exact_keys {
535            exact.insert(key.clone());
536            if exact.len() >= self.threshold {
537                // Switch to bloom filter
538                let mut bloom = BloomReadSet::new(self.threshold * 10, 0.01);
539                for k in exact.drain() {
540                    bloom.add(&k);
541                }
542                bloom.add(&key);
543                self.bloom = Some(bloom);
544                self.exact_keys = None;
545            }
546        } else if let Some(ref mut bloom) = self.bloom {
547            bloom.add(&key);
548        }
549    }
550
551    /// Record a range scan
552    pub fn add_range(&mut self, range: KeyRange) {
553        // Try to merge with existing ranges
554        let mut merged = false;
555        for existing in &mut self.ranges {
556            if let Some(merged_range) = existing.try_merge(&range) {
557                *existing = merged_range;
558                merged = true;
559                break;
560            }
561        }
562        if !merged {
563            self.ranges.push(range);
564        }
565    }
566
567    /// Check if a key might conflict
568    pub fn might_conflict(&self, key: &[u8]) -> bool {
569        // Check exact keys
570        if let Some(ref exact) = self.exact_keys {
571            if exact.contains(key) {
572                return true;
573            }
574        }
575
576        // Check bloom filter
577        if let Some(ref bloom) = self.bloom {
578            if bloom.might_contain(key) {
579                return true;
580            }
581        }
582
583        // Check ranges
584        for range in &self.ranges {
585            if range.contains(key) {
586                return true;
587            }
588        }
589
590        false
591    }
592
593    /// Memory usage
594    pub fn memory_bytes(&self) -> usize {
595        let exact = self
596            .exact_keys
597            .as_ref()
598            .map(|e| e.iter().map(|k| k.len()).sum::<usize>())
599            .unwrap_or(0);
600        let bloom = self.bloom.as_ref().map(|b| b.memory_bytes()).unwrap_or(0);
601        let ranges = self.ranges.len() * 64; // Approximate
602        exact + bloom + ranges
603    }
604
605    /// Is using exact tracking?
606    pub fn is_exact(&self) -> bool {
607        self.exact_keys.is_some()
608    }
609}
610
611/// Backoff strategy for conflict resolution
612#[derive(Debug, Clone)]
613pub struct BackoffStrategy {
614    /// Initial backoff duration
615    pub initial: Duration,
616    /// Maximum backoff duration
617    pub max: Duration,
618    /// Multiplier for exponential backoff
619    pub multiplier: f64,
620    /// Add jitter to prevent thundering herd
621    pub jitter: bool,
622}
623
624impl Default for BackoffStrategy {
625    fn default() -> Self {
626        Self {
627            initial: Duration::from_micros(100),
628            max: Duration::from_millis(100),
629            multiplier: 2.0,
630            jitter: true,
631        }
632    }
633}
634
635impl BackoffStrategy {
636    /// Calculate backoff for attempt number
637    pub fn backoff_for(&self, attempt: u32) -> Duration {
638        let base = self.initial.as_nanos() as f64 * self.multiplier.powi(attempt as i32);
639        let capped = base.min(self.max.as_nanos() as f64);
640
641        let final_nanos = if self.jitter {
642            // Add ±25% jitter
643            let jitter = (capped * 0.25) * (rand_simple() * 2.0 - 1.0);
644            (capped + jitter).max(0.0)
645        } else {
646            capped
647        };
648
649        Duration::from_nanos(final_nanos as u64)
650    }
651}
652
653/// Simple pseudo-random for jitter (deterministic but varied)
654fn rand_simple() -> f64 {
655    use std::time::SystemTime;
656    let nanos = SystemTime::now()
657        .duration_since(SystemTime::UNIX_EPOCH)
658        .unwrap_or_default()
659        .subsec_nanos();
660    ((nanos % 1000) as f64) / 1000.0
661}
662
663#[cfg(test)]
664mod tests {
665    use super::*;
666
667    #[test]
668    fn test_key_range_contains() {
669        let range = KeyRange::range(
670            RangeBound::Inclusive(b"aaa".to_vec()),
671            RangeBound::Inclusive(b"zzz".to_vec()),
672        );
673
674        assert!(range.contains(b"mmm"));
675        assert!(range.contains(b"aaa"));
676        assert!(range.contains(b"zzz"));
677        assert!(!range.contains(b"AAA"));
678    }
679
680    #[test]
681    fn test_key_range_overlap() {
682        let r1 = KeyRange::range(
683            RangeBound::Inclusive(b"a".to_vec()),
684            RangeBound::Inclusive(b"m".to_vec()),
685        );
686        let r2 = KeyRange::range(
687            RangeBound::Inclusive(b"k".to_vec()),
688            RangeBound::Inclusive(b"z".to_vec()),
689        );
690        let r3 = KeyRange::range(
691            RangeBound::Inclusive(b"n".to_vec()),
692            RangeBound::Inclusive(b"z".to_vec()),
693        );
694
695        assert!(r1.overlaps(&r2)); // a-m overlaps k-z
696        assert!(!r1.overlaps(&r3)); // a-m doesn't overlap n-z
697    }
698
699    #[test]
700    fn test_bloom_filter() {
701        let mut bloom = BloomReadSet::new(1000, 0.01);
702
703        bloom.add(b"key1");
704        bloom.add(b"key2");
705        bloom.add(b"key3");
706
707        assert!(bloom.might_contain(b"key1"));
708        assert!(bloom.might_contain(b"key2"));
709        assert!(bloom.might_contain(b"key3"));
710        // Might have false positives, but shouldn't have false negatives
711    }
712
713    #[test]
714    fn test_range_lock_manager() {
715        let manager = RangeLockManager::new();
716
717        // Acquire non-overlapping ranges
718        assert!(manager
719            .acquire(1, KeyRange::point(b"key1".to_vec()), true)
720            .is_ok());
721        assert!(manager
722            .acquire(2, KeyRange::point(b"key2".to_vec()), true)
723            .is_ok());
724
725        // Conflict on overlapping write
726        assert!(manager
727            .acquire(3, KeyRange::point(b"key1".to_vec()), true)
728            .is_err());
729
730        // Release and retry
731        manager.release(1);
732        assert!(manager
733            .acquire(3, KeyRange::point(b"key1".to_vec()), true)
734            .is_ok());
735    }
736
737    #[test]
738    fn test_adaptive_read_set() {
739        let mut set = AdaptiveReadSet::new(100);
740
741        // Add some point reads
742        for i in 0..50 {
743            set.add_point(format!("key{}", i).into_bytes());
744        }
745        assert!(set.is_exact());
746
747        // Add more to trigger bloom switch
748        for i in 50..150 {
749            set.add_point(format!("key{}", i).into_bytes());
750        }
751        assert!(!set.is_exact());
752
753        // Should still detect conflicts
754        assert!(set.might_conflict(b"key0"));
755        assert!(set.might_conflict(b"key100"));
756    }
757
758    #[test]
759    fn test_backoff_strategy() {
760        let strategy = BackoffStrategy::default();
761
762        let b0 = strategy.backoff_for(0);
763        let b1 = strategy.backoff_for(1);
764        let b5 = strategy.backoff_for(5);
765
766        assert!(b1 > b0);
767        assert!(b5 > b1);
768        assert!(b5 <= strategy.max);
769    }
770}