Skip to main content

ipfrs_transport/
session_config.rs

1//! Per-session block exchange configuration and metrics.
2//!
3//! This module provides fine-grained, per-session configuration for block
4//! exchange operations (timeouts, concurrency, retry policy) alongside
5//! lock-free atomic metrics collection and a registry for tracking multiple
6//! concurrent sessions.
7
8use parking_lot::RwLock;
9use std::collections::HashMap;
10use std::sync::atomic::{AtomicU64, Ordering};
11use std::sync::Arc;
12use std::time::Duration;
13
14// ─── SessionConfig ────────────────────────────────────────────────────────────
15
16/// Per-session transfer configuration for block exchange operations.
17///
18/// Controls timeouts, concurrency limits, and retry behaviour for a single
19/// block-exchange session.  Three convenience constructors cover the most
20/// common scenarios; individual fields can always be overridden afterwards.
21///
22/// Note: this type lives in the `session_config` module and is distinct from
23/// [`crate::session::SessionConfig`], which governs higher-level session
24/// orchestration (priorities, concurrent blocks, etc.).
25#[derive(Debug, Clone)]
26pub struct SessionConfig {
27    /// Session-level timeout.  Default: 30 s.
28    pub timeout: Duration,
29    /// Maximum total bytes to transfer in this session.  Default: 1 GiB.
30    pub max_bytes: u64,
31    /// Maximum concurrent block requests.  Default: 16.
32    pub max_concurrent_requests: usize,
33    /// Per-request timeout.  Default: 10 s.
34    pub request_timeout: Duration,
35    /// Maximum retry attempts per block.  Default: 3.
36    pub max_retries: usize,
37    /// Exponential backoff base duration.  Default: 200 ms.
38    pub backoff_base: Duration,
39}
40
41impl Default for SessionConfig {
42    fn default() -> Self {
43        Self {
44            timeout: Duration::from_secs(30),
45            max_bytes: 1024 * 1024 * 1024,
46            max_concurrent_requests: 16,
47            request_timeout: Duration::from_secs(10),
48            max_retries: 3,
49            backoff_base: Duration::from_millis(200),
50        }
51    }
52}
53
54impl SessionConfig {
55    /// Create a high-throughput config (longer timeouts, more concurrency).
56    pub fn high_throughput() -> Self {
57        Self {
58            timeout: Duration::from_secs(120),
59            max_bytes: 10 * 1024 * 1024 * 1024,
60            max_concurrent_requests: 64,
61            request_timeout: Duration::from_secs(30),
62            max_retries: 5,
63            backoff_base: Duration::from_millis(100),
64        }
65    }
66
67    /// Create a low-latency config (short timeouts, fail fast).
68    pub fn low_latency() -> Self {
69        Self {
70            timeout: Duration::from_secs(5),
71            max_bytes: 100 * 1024 * 1024,
72            max_concurrent_requests: 8,
73            request_timeout: Duration::from_secs(2),
74            max_retries: 1,
75            backoff_base: Duration::from_millis(50),
76        }
77    }
78
79    /// Compute backoff duration for retry `n` using exponential backoff.
80    ///
81    /// `backoff = base × 2^min(retry, 6)` (capped at 32× base to prevent
82    /// runaway growth).
83    pub fn backoff_for_retry(&self, retry: usize) -> Duration {
84        let exp = retry.min(6) as u32;
85        self.backoff_base.saturating_mul(2u32.pow(exp))
86    }
87}
88
89// ─── SessionMetricsSnapshot ───────────────────────────────────────────────────
90
91/// Immutable metrics snapshot for a completed or in-progress session.
92#[derive(Debug, Clone)]
93pub struct SessionMetricsSnapshot {
94    /// Unique identifier for this session.
95    pub session_id: String,
96    /// Total bytes successfully transferred.
97    pub bytes_transferred: u64,
98    /// Number of block requests issued.
99    pub blocks_requested: u64,
100    /// Number of blocks successfully received.
101    pub blocks_received: u64,
102    /// Number of blocks that ultimately failed.
103    pub blocks_failed: u64,
104    /// Total number of retried block requests.
105    pub total_retries: u64,
106    /// Wall-clock elapsed time in milliseconds.
107    pub elapsed_ms: u64,
108    /// Throughput in bytes per second (`bytes_transferred / (elapsed_ms / 1000)`).
109    pub throughput_bps: f64,
110}
111
112// ─── SessionMetrics ───────────────────────────────────────────────────────────
113
114/// Live per-session metrics backed by lock-free atomics.
115///
116/// All `record_*` methods use `Relaxed` ordering; the `snapshot` method uses
117/// `Acquire` to observe a consistent view.
118pub struct SessionMetrics {
119    /// Unique identifier for this session.
120    pub session_id: String,
121    bytes_transferred: AtomicU64,
122    blocks_requested: AtomicU64,
123    blocks_received: AtomicU64,
124    blocks_failed: AtomicU64,
125    total_retries: AtomicU64,
126    started_at_ms: u64,
127}
128
129impl SessionMetrics {
130    /// Create a new metrics instance wrapped in an `Arc`.
131    ///
132    /// `started_at_ms` is the session start time as Unix milliseconds.
133    pub fn new(session_id: impl Into<String>, started_at_ms: u64) -> Arc<Self> {
134        Arc::new(Self {
135            session_id: session_id.into(),
136            bytes_transferred: AtomicU64::new(0),
137            blocks_requested: AtomicU64::new(0),
138            blocks_received: AtomicU64::new(0),
139            blocks_failed: AtomicU64::new(0),
140            total_retries: AtomicU64::new(0),
141            started_at_ms,
142        })
143    }
144
145    /// Accumulate `bytes` into the total bytes-transferred counter.
146    pub fn record_bytes(&self, bytes: u64) {
147        self.bytes_transferred.fetch_add(bytes, Ordering::Relaxed);
148    }
149
150    /// Increment the blocks-requested counter by one.
151    pub fn record_block_requested(&self) {
152        self.blocks_requested.fetch_add(1, Ordering::Relaxed);
153    }
154
155    /// Increment the blocks-received counter by one.
156    pub fn record_block_received(&self) {
157        self.blocks_received.fetch_add(1, Ordering::Relaxed);
158    }
159
160    /// Increment the blocks-failed counter by one.
161    pub fn record_block_failed(&self) {
162        self.blocks_failed.fetch_add(1, Ordering::Relaxed);
163    }
164
165    /// Increment the total-retries counter by one.
166    pub fn record_retry(&self) {
167        self.total_retries.fetch_add(1, Ordering::Relaxed);
168    }
169
170    /// Produce a consistent snapshot observed at `now_ms` (Unix milliseconds).
171    pub fn snapshot(&self, now_ms: u64) -> SessionMetricsSnapshot {
172        let bytes_transferred = self.bytes_transferred.load(Ordering::Acquire);
173        let elapsed_ms = now_ms.saturating_sub(self.started_at_ms);
174        let elapsed_secs = elapsed_ms as f64 / 1_000.0;
175        let throughput_bps = if elapsed_secs > 0.0 {
176            bytes_transferred as f64 / elapsed_secs
177        } else {
178            0.0
179        };
180
181        SessionMetricsSnapshot {
182            session_id: self.session_id.clone(),
183            bytes_transferred,
184            blocks_requested: self.blocks_requested.load(Ordering::Acquire),
185            blocks_received: self.blocks_received.load(Ordering::Acquire),
186            blocks_failed: self.blocks_failed.load(Ordering::Acquire),
187            total_retries: self.total_retries.load(Ordering::Acquire),
188            elapsed_ms,
189            throughput_bps,
190        }
191    }
192
193    /// Return the current bytes-transferred value.
194    pub fn bytes_transferred(&self) -> u64 {
195        self.bytes_transferred.load(Ordering::Relaxed)
196    }
197
198    /// Return the current blocks-received value.
199    pub fn blocks_received(&self) -> u64 {
200        self.blocks_received.load(Ordering::Relaxed)
201    }
202}
203
204impl std::fmt::Debug for SessionMetrics {
205    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
206        f.debug_struct("SessionMetrics")
207            .field("session_id", &self.session_id)
208            .field(
209                "bytes_transferred",
210                &self.bytes_transferred.load(Ordering::Relaxed),
211            )
212            .field(
213                "blocks_requested",
214                &self.blocks_requested.load(Ordering::Relaxed),
215            )
216            .field(
217                "blocks_received",
218                &self.blocks_received.load(Ordering::Relaxed),
219            )
220            .field("blocks_failed", &self.blocks_failed.load(Ordering::Relaxed))
221            .field("total_retries", &self.total_retries.load(Ordering::Relaxed))
222            .field("started_at_ms", &self.started_at_ms)
223            .finish()
224    }
225}
226
227// ─── SessionMetricsStore ──────────────────────────────────────────────────────
228
229/// Registry of per-session metrics.
230///
231/// Active sessions are stored by ID.  When a session is completed via
232/// [`complete_session`](SessionMetricsStore::complete_session) its final
233/// snapshot is appended to the `completed` ring buffer (capped at
234/// `max_sessions` entries, oldest evicted first).
235pub struct SessionMetricsStore {
236    sessions: RwLock<HashMap<String, Arc<SessionMetrics>>>,
237    max_sessions: usize,
238    completed: RwLock<Vec<SessionMetricsSnapshot>>,
239}
240
241impl SessionMetricsStore {
242    /// Create a new store wrapped in an `Arc`.
243    ///
244    /// `max_sessions` caps the number of retained completed-session snapshots.
245    pub fn new(max_sessions: usize) -> Arc<Self> {
246        Arc::new(Self {
247            sessions: RwLock::new(HashMap::new()),
248            max_sessions,
249            completed: RwLock::new(Vec::new()),
250        })
251    }
252
253    /// Create and register a new active session, returning its metrics handle.
254    ///
255    /// If a session with the same ID already exists it is replaced.
256    pub fn create_session(
257        &self,
258        session_id: impl Into<String>,
259        started_at_ms: u64,
260    ) -> Arc<SessionMetrics> {
261        let id: String = session_id.into();
262        let metrics = SessionMetrics::new(id.clone(), started_at_ms);
263        self.sessions.write().insert(id, Arc::clone(&metrics));
264        metrics
265    }
266
267    /// Look up an active session by ID.
268    pub fn get_session(&self, session_id: &str) -> Option<Arc<SessionMetrics>> {
269        self.sessions.read().get(session_id).cloned()
270    }
271
272    /// Mark a session as complete: snapshot it and move it to the completed
273    /// ring buffer.
274    ///
275    /// Returns `true` if the session was found and moved, `false` otherwise.
276    pub fn complete_session(&self, session_id: &str, now_ms: u64) -> bool {
277        let removed = self.sessions.write().remove(session_id);
278        match removed {
279            None => false,
280            Some(metrics) => {
281                let snap = metrics.snapshot(now_ms);
282                let mut completed = self.completed.write();
283                completed.push(snap);
284                // Evict oldest entries when the ring buffer is full.
285                while completed.len() > self.max_sessions {
286                    completed.remove(0);
287                }
288                true
289            }
290        }
291    }
292
293    /// Return the number of currently active (not yet completed) sessions.
294    pub fn active_count(&self) -> usize {
295        self.sessions.read().len()
296    }
297
298    /// Clone and return all completed session snapshots.
299    pub fn completed_snapshots(&self) -> Vec<SessionMetricsSnapshot> {
300        self.completed.read().clone()
301    }
302
303    /// Sum `bytes_transferred` across all currently active sessions.
304    pub fn total_bytes_transferred(&self) -> u64 {
305        self.sessions
306            .read()
307            .values()
308            .map(|m| m.bytes_transferred())
309            .sum()
310    }
311}
312
313impl std::fmt::Debug for SessionMetricsStore {
314    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
315        f.debug_struct("SessionMetricsStore")
316            .field("active_count", &self.active_count())
317            .field("max_sessions", &self.max_sessions)
318            .field("completed_count", &self.completed.read().len())
319            .finish()
320    }
321}
322
323// ─── Tests ────────────────────────────────────────────────────────────────────
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328
329    // ── BlockExchangeConfig ──────────────────────────────────────────────────
330
331    #[test]
332    fn test_default_config_values() {
333        let cfg = SessionConfig::default();
334        assert_eq!(cfg.timeout, Duration::from_secs(30));
335        assert_eq!(cfg.max_bytes, 1024 * 1024 * 1024);
336        assert_eq!(cfg.max_concurrent_requests, 16);
337        assert_eq!(cfg.request_timeout, Duration::from_secs(10));
338        assert_eq!(cfg.max_retries, 3);
339        assert_eq!(cfg.backoff_base, Duration::from_millis(200));
340    }
341
342    #[test]
343    fn test_high_throughput_config() {
344        let cfg = SessionConfig::high_throughput();
345        assert_eq!(cfg.timeout, Duration::from_secs(120));
346        assert_eq!(cfg.max_bytes, 10 * 1024 * 1024 * 1024);
347        assert_eq!(cfg.max_concurrent_requests, 64);
348        assert_eq!(cfg.request_timeout, Duration::from_secs(30));
349        assert_eq!(cfg.max_retries, 5);
350        assert_eq!(cfg.backoff_base, Duration::from_millis(100));
351    }
352
353    #[test]
354    fn test_low_latency_config() {
355        let cfg = SessionConfig::low_latency();
356        assert_eq!(cfg.timeout, Duration::from_secs(5));
357        assert_eq!(cfg.max_bytes, 100 * 1024 * 1024);
358        assert_eq!(cfg.max_concurrent_requests, 8);
359        assert_eq!(cfg.request_timeout, Duration::from_secs(2));
360        assert_eq!(cfg.max_retries, 1);
361        assert_eq!(cfg.backoff_base, Duration::from_millis(50));
362    }
363
364    #[test]
365    fn test_backoff_exponential() {
366        let cfg = SessionConfig::default(); // base = 200 ms
367        assert_eq!(cfg.backoff_for_retry(0), Duration::from_millis(200)); // 200 * 2^0
368        assert_eq!(cfg.backoff_for_retry(1), Duration::from_millis(400)); // 200 * 2^1
369        assert_eq!(cfg.backoff_for_retry(2), Duration::from_millis(800)); // 200 * 2^2
370        assert_eq!(cfg.backoff_for_retry(3), Duration::from_millis(1_600));
371        assert_eq!(cfg.backoff_for_retry(4), Duration::from_millis(3_200));
372        assert_eq!(cfg.backoff_for_retry(5), Duration::from_millis(6_400));
373        // retry 6 and above are all capped at 2^6 = 64× base = 12 800 ms
374        assert_eq!(cfg.backoff_for_retry(6), Duration::from_millis(12_800));
375        assert_eq!(cfg.backoff_for_retry(7), Duration::from_millis(12_800));
376        assert_eq!(cfg.backoff_for_retry(100), Duration::from_millis(12_800));
377    }
378
379    // ── SessionMetrics ───────────────────────────────────────────────────────
380
381    #[test]
382    fn test_session_metrics_record_bytes() {
383        let m = SessionMetrics::new("sess-bytes", 0);
384        assert_eq!(m.bytes_transferred(), 0);
385        m.record_bytes(512);
386        m.record_bytes(1024);
387        assert_eq!(m.bytes_transferred(), 1_536);
388    }
389
390    #[test]
391    fn test_session_metrics_record_blocks() {
392        let m = SessionMetrics::new("sess-blocks", 0);
393        m.record_block_requested();
394        m.record_block_requested();
395        m.record_block_requested();
396        m.record_block_received();
397        m.record_block_received();
398        m.record_block_failed();
399        m.record_retry();
400        m.record_retry();
401
402        let snap = m.snapshot(1_000);
403        assert_eq!(snap.blocks_requested, 3);
404        assert_eq!(snap.blocks_received, 2);
405        assert_eq!(snap.blocks_failed, 1);
406        assert_eq!(snap.total_retries, 2);
407        assert_eq!(m.blocks_received(), 2);
408    }
409
410    #[test]
411    fn test_session_metrics_snapshot_throughput() {
412        let m = SessionMetrics::new("sess-tput", 0);
413        // Transfer 1 MiB in a simulated 2 000 ms window.
414        let mib: u64 = 1024 * 1024;
415        m.record_bytes(mib);
416        let snap = m.snapshot(2_000);
417        assert_eq!(snap.elapsed_ms, 2_000);
418        assert_eq!(snap.bytes_transferred, mib);
419        // throughput = 1 048 576 bytes / 2 s = 524 288 bps
420        let expected = mib as f64 / 2.0;
421        let diff = (snap.throughput_bps - expected).abs();
422        assert!(
423            diff < 1.0,
424            "throughput mismatch: got {}, expected {}",
425            snap.throughput_bps,
426            expected
427        );
428    }
429
430    // ── SessionMetricsStore ──────────────────────────────────────────────────
431
432    #[test]
433    fn test_session_metrics_store_create_and_get() {
434        let store = SessionMetricsStore::new(10);
435        assert_eq!(store.active_count(), 0);
436
437        let m = store.create_session("alpha", 1_000);
438        m.record_bytes(256);
439        assert_eq!(store.active_count(), 1);
440
441        let fetched = store.get_session("alpha").expect("session should exist");
442        assert_eq!(fetched.bytes_transferred(), 256);
443
444        assert!(store.get_session("missing").is_none());
445    }
446
447    #[test]
448    fn test_session_metrics_store_complete() {
449        let store = SessionMetricsStore::new(10);
450        store.create_session("beta", 0).record_bytes(4_096);
451
452        assert_eq!(store.active_count(), 1);
453        assert!(store.completed_snapshots().is_empty());
454
455        let found = store.complete_session("beta", 1_000);
456        assert!(found, "complete_session should return true");
457        assert_eq!(store.active_count(), 0);
458
459        let snaps = store.completed_snapshots();
460        assert_eq!(snaps.len(), 1);
461        assert_eq!(snaps[0].session_id, "beta");
462        assert_eq!(snaps[0].bytes_transferred, 4_096);
463
464        // Completing an unknown session returns false.
465        assert!(!store.complete_session("ghost", 2_000));
466    }
467
468    #[test]
469    fn test_session_metrics_store_total_bytes() {
470        let store = SessionMetricsStore::new(10);
471        store.create_session("s1", 0).record_bytes(100);
472        store.create_session("s2", 0).record_bytes(200);
473        store.create_session("s3", 0).record_bytes(300);
474
475        assert_eq!(store.total_bytes_transferred(), 600);
476
477        // Completing s2 removes it from active; total_bytes only covers active.
478        store.complete_session("s2", 1_000);
479        assert_eq!(store.total_bytes_transferred(), 400);
480    }
481
482    #[test]
483    fn test_max_sessions_cap() {
484        // max_sessions = 3: completing more sessions evicts the oldest snapshots.
485        let store = SessionMetricsStore::new(3);
486
487        for i in 0..6u64 {
488            let id = format!("sess-{}", i);
489            store.create_session(&id, i * 100).record_bytes(i * 10);
490            store.complete_session(&id, (i + 1) * 100);
491        }
492
493        // Active sessions: all completed.
494        assert_eq!(store.active_count(), 0);
495
496        // Completed ring buffer must not exceed max_sessions (3).
497        let snaps = store.completed_snapshots();
498        assert_eq!(snaps.len(), 3);
499
500        // The three most recent sessions (3, 4, 5) should be retained.
501        let ids: Vec<&str> = snaps.iter().map(|s| s.session_id.as_str()).collect();
502        assert!(ids.contains(&"sess-3"));
503        assert!(ids.contains(&"sess-4"));
504        assert!(ids.contains(&"sess-5"));
505
506        // Creating new sessions beyond any limit always succeeds.
507        let extra = store.create_session("extra-1", 9_000);
508        extra.record_bytes(999);
509        assert_eq!(store.active_count(), 1);
510        assert_eq!(extra.bytes_transferred(), 999);
511    }
512}