Skip to main content

ipfrs_network/
request_dedup.rs

1//! Request deduplication for concurrent DHT/Bitswap lookups.
2//!
3//! When multiple callers request the same CID simultaneously, only one
4//! DHT/Bitswap request should be issued. All additional callers coalesce
5//! onto the first ("leader") request and share its result once resolved.
6//!
7//! ## Example
8//!
9//! ```rust
10//! use ipfrs_network::request_dedup::{RequestDeduplicator, AcquireResult, ResolveResult};
11//! use std::time::Duration;
12//!
13//! let dedup = RequestDeduplicator::new(64, Duration::from_secs(30));
14//!
15//! let cid = "QmExample";
16//! match dedup.try_acquire(cid) {
17//!     AcquireResult::Leader => {
18//!         // Issue the DHT/Bitswap request, then call resolve()
19//!         dedup.resolve(cid, ResolveResult::NotFound);
20//!     }
21//!     AcquireResult::Waiter(handle) => {
22//!         // Another request is in flight; handle tracks the waiter slot
23//!         let _ = handle;
24//!     }
25//! }
26//! ```
27
28use std::collections::HashMap;
29use std::sync::atomic::{AtomicU64, Ordering};
30use std::sync::Mutex;
31use std::time::{Duration, Instant};
32
33// ─── ResolveResult ────────────────────────────────────────────────────────────
34
35/// The outcome of a DHT/Bitswap lookup operation.
36#[derive(Debug, Clone)]
37pub enum ResolveResult {
38    /// One or more providers were found (peer IDs / multiaddrs).
39    Providers(Vec<String>),
40    /// The content was not found in the network.
41    NotFound,
42    /// The lookup failed with an error message.
43    Error(String),
44}
45
46// ─── WaiterInfo ──────────────────────────────────────────────────────────────
47
48/// Internal record of a single waiter within a flight.
49///
50/// The `position` and `registered_at` values are handed directly to the public
51/// [`WaiterHandle`] and not re-read from this private struct, so we keep only
52/// the fields that are actually used for internal book-keeping.
53#[derive(Debug, Clone)]
54struct WaiterInfo {}
55
56// ─── FlightRecord ────────────────────────────────────────────────────────────
57
58/// Tracks a single in-flight request and all coalescers waiting for its result.
59///
60/// The CID key is already present as the [`HashMap`] key, so it is not
61/// duplicated here.
62#[derive(Debug)]
63struct FlightRecord {
64    started_at: Instant,
65    waiters: Vec<WaiterInfo>,
66    result: Option<ResolveResult>,
67}
68
69impl FlightRecord {
70    fn new() -> Self {
71        Self {
72            started_at: Instant::now(),
73            waiters: Vec::new(),
74            result: None,
75        }
76    }
77
78    /// Returns `true` if the flight is older than `timeout`.
79    fn is_timed_out(&self, now: Instant, timeout: Duration) -> bool {
80        now.duration_since(self.started_at) >= timeout
81    }
82}
83
84// ─── WaiterHandle ────────────────────────────────────────────────────────────
85
86/// A lightweight handle returned to a caller that coalesced onto an existing
87/// in-flight request.
88#[derive(Debug, Clone)]
89pub struct WaiterHandle {
90    /// The CID that this handle is waiting for.
91    pub cid: String,
92    /// The waiter's slot index within the flight record.
93    pub position: usize,
94    /// When this waiter registered.
95    pub registered_at: Instant,
96}
97
98// ─── AcquireResult ───────────────────────────────────────────────────────────
99
100/// Returned by [`RequestDeduplicator::try_acquire`].
101#[derive(Debug)]
102pub enum AcquireResult {
103    /// This caller should issue the DHT/Bitswap request.
104    Leader,
105    /// Another request for the same CID is already in flight; coalesce onto it.
106    Waiter(WaiterHandle),
107}
108
109// ─── DedupStats ──────────────────────────────────────────────────────────────
110
111/// Atomic performance counters for [`RequestDeduplicator`].
112#[derive(Debug, Default)]
113pub struct DedupStats {
114    /// Number of callers that became leaders (issued an outbound request).
115    pub total_leaders: AtomicU64,
116    /// Number of callers that became waiters (coalesced onto an existing request).
117    pub total_waiters: AtomicU64,
118    /// Number of flight records that were resolved.
119    pub total_resolved: AtomicU64,
120    /// Number of flight records pruned by [`RequestDeduplicator::timeout_expired_flights`].
121    pub total_timeouts: AtomicU64,
122    /// Number of waiters that ultimately received a coalesced result.
123    pub total_coalesced: AtomicU64,
124}
125
126impl DedupStats {
127    /// Returns a point-in-time snapshot of all counters.
128    pub fn snapshot(&self) -> DedupStatsSnapshot {
129        DedupStatsSnapshot {
130            total_leaders: self.total_leaders.load(Ordering::Relaxed),
131            total_waiters: self.total_waiters.load(Ordering::Relaxed),
132            total_resolved: self.total_resolved.load(Ordering::Relaxed),
133            total_timeouts: self.total_timeouts.load(Ordering::Relaxed),
134            total_coalesced: self.total_coalesced.load(Ordering::Relaxed),
135        }
136    }
137}
138
139/// A point-in-time view of [`DedupStats`] counters.
140#[derive(Debug, Clone, PartialEq, Eq)]
141pub struct DedupStatsSnapshot {
142    /// Leaders issued
143    pub total_leaders: u64,
144    /// Waiters registered
145    pub total_waiters: u64,
146    /// Flights resolved
147    pub total_resolved: u64,
148    /// Flights pruned due to timeout
149    pub total_timeouts: u64,
150    /// Waiters that received a coalesced result
151    pub total_coalesced: u64,
152}
153
154// ─── RequestDeduplicator ─────────────────────────────────────────────────────
155
156/// Deduplicates concurrent requests for the same CID.
157///
158/// `try_acquire` returns `Leader` to the first caller; all subsequent callers
159/// for the same CID before `resolve` is called receive a `Waiter` handle.
160/// Once the leader calls `resolve`, the flight record is marked complete and
161/// removed, so the next caller starts a fresh leader/waiter cycle.
162///
163/// If the waiter queue reaches `max_waiters_per_key`, additional callers are
164/// promoted to `Leader` so they can independently issue their own request.
165#[derive(Debug)]
166pub struct RequestDeduplicator {
167    in_flight: Mutex<HashMap<String, FlightRecord>>,
168    /// Maximum number of waiters allowed per CID before overflow callers become leaders.
169    pub max_waiters_per_key: usize,
170    /// Duration after which unresolved flight records are considered stale.
171    pub request_timeout: Duration,
172    /// Atomic counters.
173    pub stats: DedupStats,
174}
175
176impl Default for RequestDeduplicator {
177    fn default() -> Self {
178        Self::new(64, Duration::from_secs(30))
179    }
180}
181
182impl RequestDeduplicator {
183    /// Creates a new deduplicator with the given waiter cap and request timeout.
184    ///
185    /// # Arguments
186    ///
187    /// * `max_waiters_per_key` – once this many waiters exist for a CID, additional
188    ///   callers are promoted to `Leader` instead of waiting.
189    /// * `request_timeout` – flights older than this are pruned by
190    ///   [`timeout_expired_flights`](RequestDeduplicator::timeout_expired_flights).
191    pub fn new(max_waiters_per_key: usize, request_timeout: Duration) -> Self {
192        Self {
193            in_flight: Mutex::new(HashMap::new()),
194            max_waiters_per_key,
195            request_timeout,
196            stats: DedupStats::default(),
197        }
198    }
199
200    /// Attempt to acquire the right to issue the outbound request for `cid`.
201    ///
202    /// Returns `Leader` if no in-flight record exists (or the waiter queue is
203    /// full), or `Waiter(handle)` if the caller should coalesce.
204    pub fn try_acquire(&self, cid: &str) -> AcquireResult {
205        let mut map = self
206            .in_flight
207            .lock()
208            .expect("RequestDeduplicator lock poisoned");
209
210        match map.get_mut(cid) {
211            Some(record) if record.waiters.len() < self.max_waiters_per_key => {
212                // Coalesce: add this caller as a waiter.
213                let position = record.waiters.len();
214                let registered_at = Instant::now();
215                record.waiters.push(WaiterInfo {});
216                self.stats.total_waiters.fetch_add(1, Ordering::Relaxed);
217                AcquireResult::Waiter(WaiterHandle {
218                    cid: cid.to_owned(),
219                    position,
220                    registered_at,
221                })
222            }
223            Some(_) => {
224                // Waiter queue is full — promote to leader so this caller can
225                // issue its own independent request.
226                self.stats.total_leaders.fetch_add(1, Ordering::Relaxed);
227                AcquireResult::Leader
228            }
229            None => {
230                // No in-flight record — create one and crown this caller leader.
231                map.insert(cid.to_owned(), FlightRecord::new());
232                self.stats.total_leaders.fetch_add(1, Ordering::Relaxed);
233                AcquireResult::Leader
234            }
235        }
236    }
237
238    /// Mark the in-flight record for `cid` as resolved with `result`.
239    ///
240    /// The flight record is removed from `in_flight`; the number of waiters
241    /// that were coalesced is added to [`DedupStats::total_coalesced`].
242    /// Returns `true` if an active flight was found and resolved, `false` if
243    /// no matching record existed.
244    pub fn resolve(&self, cid: &str, result: ResolveResult) -> bool {
245        let mut map = self
246            .in_flight
247            .lock()
248            .expect("RequestDeduplicator lock poisoned");
249
250        if let Some(mut record) = map.remove(cid) {
251            record.result = Some(result);
252            let waiter_count = record.waiters.len() as u64;
253            self.stats.total_resolved.fetch_add(1, Ordering::Relaxed);
254            self.stats
255                .total_coalesced
256                .fetch_add(waiter_count, Ordering::Relaxed);
257            true
258        } else {
259            false
260        }
261    }
262
263    /// Prune all flight records that have exceeded `request_timeout`.
264    ///
265    /// Returns the number of records removed.
266    pub fn timeout_expired_flights(&self) -> usize {
267        let now = Instant::now();
268        let timeout = self.request_timeout;
269
270        let mut map = self
271            .in_flight
272            .lock()
273            .expect("RequestDeduplicator lock poisoned");
274
275        let before = map.len();
276        map.retain(|_, record| !record.is_timed_out(now, timeout));
277        let pruned = before - map.len();
278
279        if pruned > 0 {
280            self.stats
281                .total_timeouts
282                .fetch_add(pruned as u64, Ordering::Relaxed);
283        }
284        pruned
285    }
286
287    /// Returns the number of currently in-flight records.
288    pub fn in_flight_count(&self) -> usize {
289        self.in_flight
290            .lock()
291            .expect("RequestDeduplicator lock poisoned")
292            .len()
293    }
294
295    /// Returns the current waiter count for a specific CID, or `None` if no
296    /// in-flight record exists for that CID.
297    pub fn waiter_count_for(&self, cid: &str) -> Option<usize> {
298        self.in_flight
299            .lock()
300            .expect("RequestDeduplicator lock poisoned")
301            .get(cid)
302            .map(|r| r.waiters.len())
303    }
304}
305
306// ─── Tests ───────────────────────────────────────────────────────────────────
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311    use std::time::Duration;
312
313    const CID_A: &str = "QmTestCidA111111111111111111111111111111111111111";
314    const CID_B: &str = "QmTestCidB222222222222222222222222222222222222222";
315
316    fn dedup_default() -> RequestDeduplicator {
317        RequestDeduplicator::new(64, Duration::from_secs(30))
318    }
319
320    // ── 1. First caller gets Leader ───────────────────────────────────────────
321    #[test]
322    fn first_caller_is_leader() {
323        let dedup = dedup_default();
324        let result = dedup.try_acquire(CID_A);
325        assert!(
326            matches!(result, AcquireResult::Leader),
327            "expected Leader for first caller"
328        );
329    }
330
331    // ── 2. Second caller for same CID gets Waiter ────────────────────────────
332    #[test]
333    fn second_caller_same_cid_is_waiter() {
334        let dedup = dedup_default();
335        let _ = dedup.try_acquire(CID_A); // leader
336        let result = dedup.try_acquire(CID_A);
337        assert!(
338            matches!(result, AcquireResult::Waiter(_)),
339            "expected Waiter for second caller with same CID"
340        );
341    }
342
343    // ── 3. Third caller for a DIFFERENT CID gets Leader ──────────────────────
344    #[test]
345    fn different_cid_is_new_leader() {
346        let dedup = dedup_default();
347        let _ = dedup.try_acquire(CID_A);
348        let _ = dedup.try_acquire(CID_A);
349        let result = dedup.try_acquire(CID_B);
350        assert!(
351            matches!(result, AcquireResult::Leader),
352            "expected Leader for a different CID"
353        );
354    }
355
356    // ── 4. WaiterHandle carries correct metadata ──────────────────────────────
357    #[test]
358    fn waiter_handle_metadata() {
359        let dedup = dedup_default();
360        let _ = dedup.try_acquire(CID_A); // leader; position 0 in waiters
361
362        let handle = match dedup.try_acquire(CID_A) {
363            AcquireResult::Waiter(h) => h,
364            AcquireResult::Leader => panic!("expected Waiter"),
365        };
366        assert_eq!(handle.cid, CID_A);
367        assert_eq!(handle.position, 0, "first waiter is at position 0");
368    }
369
370    // ── 5. resolve() marks the flight complete and returns true ───────────────
371    #[test]
372    fn resolve_clears_flight() {
373        let dedup = dedup_default();
374        let _ = dedup.try_acquire(CID_A);
375        assert_eq!(dedup.in_flight_count(), 1);
376
377        let ok = dedup.resolve(CID_A, ResolveResult::NotFound);
378        assert!(ok, "resolve should return true for active flight");
379        assert_eq!(dedup.in_flight_count(), 0, "flight should be removed");
380    }
381
382    // ── 6. resolve() returns false for unknown CID ────────────────────────────
383    #[test]
384    fn resolve_unknown_cid_returns_false() {
385        let dedup = dedup_default();
386        let ok = dedup.resolve("QmNonExistent", ResolveResult::NotFound);
387        assert!(!ok, "resolve on unknown CID should return false");
388    }
389
390    // ── 7. After resolve, next caller becomes Leader again ───────────────────
391    #[test]
392    fn after_resolve_next_caller_is_leader() {
393        let dedup = dedup_default();
394        let _ = dedup.try_acquire(CID_A);
395        dedup.resolve(CID_A, ResolveResult::NotFound);
396
397        let result = dedup.try_acquire(CID_A);
398        assert!(
399            matches!(result, AcquireResult::Leader),
400            "post-resolve caller should become Leader again"
401        );
402    }
403
404    // ── 8. max_waiters_per_key: overflow callers get Leader ───────────────────
405    #[test]
406    fn overflow_waiters_become_leaders() {
407        let max = 3usize;
408        let dedup = RequestDeduplicator::new(max, Duration::from_secs(30));
409
410        // First caller → leader
411        assert!(matches!(dedup.try_acquire(CID_A), AcquireResult::Leader));
412
413        // Fill the waiter queue to the cap
414        for _ in 0..max {
415            assert!(matches!(dedup.try_acquire(CID_A), AcquireResult::Waiter(_)));
416        }
417
418        // One more → must become Leader because the queue is full
419        let overflow = dedup.try_acquire(CID_A);
420        assert!(
421            matches!(overflow, AcquireResult::Leader),
422            "overflow caller should be promoted to Leader"
423        );
424    }
425
426    // ── 9. timeout_expired_flights() prunes old records ──────────────────────
427    #[test]
428    fn timeout_prunes_expired_flights() {
429        // Use a 0-second timeout so the record is immediately stale
430        let dedup = RequestDeduplicator::new(64, Duration::from_secs(0));
431        let _ = dedup.try_acquire(CID_A);
432        assert_eq!(dedup.in_flight_count(), 1);
433
434        let pruned = dedup.timeout_expired_flights();
435        assert_eq!(pruned, 1, "one expired flight should have been pruned");
436        assert_eq!(dedup.in_flight_count(), 0);
437    }
438
439    // ── 10. timeout_expired_flights() leaves fresh records intact ─────────────
440    #[test]
441    fn timeout_leaves_fresh_flights() {
442        let dedup = RequestDeduplicator::new(64, Duration::from_secs(9999));
443        let _ = dedup.try_acquire(CID_A);
444        let pruned = dedup.timeout_expired_flights();
445        assert_eq!(pruned, 0, "fresh flight should not be pruned");
446        assert_eq!(dedup.in_flight_count(), 1);
447    }
448
449    // ── 11. Stats: leaders / waiters counters ─────────────────────────────────
450    #[test]
451    fn stats_leaders_and_waiters() {
452        let dedup = dedup_default();
453        let _ = dedup.try_acquire(CID_A); // leader 1
454        let _ = dedup.try_acquire(CID_B); // leader 2
455        let _ = dedup.try_acquire(CID_A); // waiter 1
456        let _ = dedup.try_acquire(CID_A); // waiter 2
457
458        let snap = dedup.stats.snapshot();
459        assert_eq!(snap.total_leaders, 2, "two distinct leader acquires");
460        assert_eq!(snap.total_waiters, 2, "two coalesced waiters");
461    }
462
463    // ── 12. Stats: resolved and coalesced counters ────────────────────────────
464    #[test]
465    fn stats_resolved_and_coalesced() {
466        let dedup = dedup_default();
467        let _ = dedup.try_acquire(CID_A); // leader
468        let _ = dedup.try_acquire(CID_A); // waiter 1
469        let _ = dedup.try_acquire(CID_A); // waiter 2
470
471        dedup.resolve(
472            CID_A,
473            ResolveResult::Providers(vec!["peer1".to_owned(), "peer2".to_owned()]),
474        );
475
476        let snap = dedup.stats.snapshot();
477        assert_eq!(snap.total_resolved, 1);
478        assert_eq!(snap.total_coalesced, 2, "both waiters coalesced");
479    }
480
481    // ── 13. Stats: timeout counter ────────────────────────────────────────────
482    #[test]
483    fn stats_timeout_counter() {
484        let dedup = RequestDeduplicator::new(64, Duration::from_secs(0));
485        let _ = dedup.try_acquire(CID_A);
486        let _ = dedup.try_acquire(CID_B);
487        let pruned = dedup.timeout_expired_flights();
488        assert_eq!(pruned, 2);
489        let snap = dedup.stats.snapshot();
490        assert_eq!(snap.total_timeouts, 2);
491    }
492
493    // ── 14. Multiple independent CIDs, independent flights ───────────────────
494    #[test]
495    fn multiple_cids_independent_flights() {
496        let dedup = dedup_default();
497        let _ = dedup.try_acquire(CID_A);
498        let _ = dedup.try_acquire(CID_B);
499        assert_eq!(dedup.in_flight_count(), 2);
500
501        dedup.resolve(CID_A, ResolveResult::NotFound);
502        assert_eq!(dedup.in_flight_count(), 1);
503
504        dedup.resolve(CID_B, ResolveResult::Error("timeout".to_owned()));
505        assert_eq!(dedup.in_flight_count(), 0);
506    }
507
508    // ── 15. waiter_count_for helper ───────────────────────────────────────────
509    #[test]
510    fn waiter_count_for_helper() {
511        let dedup = dedup_default();
512        assert_eq!(dedup.waiter_count_for(CID_A), None);
513
514        let _ = dedup.try_acquire(CID_A);
515        assert_eq!(dedup.waiter_count_for(CID_A), Some(0));
516
517        let _ = dedup.try_acquire(CID_A);
518        let _ = dedup.try_acquire(CID_A);
519        assert_eq!(dedup.waiter_count_for(CID_A), Some(2));
520    }
521}