Skip to main content

ipfrs_storage/
access_logger.rs

1//! Storage access logger for audit trails and access pattern analysis.
2//!
3//! Maintains a structured, bounded FIFO log of storage operations,
4//! enabling audit trails, compliance reporting, and access pattern detection.
5
6use std::collections::VecDeque;
7
8/// The type of storage operation recorded in an access entry.
9#[derive(Clone, Debug, PartialEq)]
10pub enum AccessOp {
11    /// A single block retrieval.
12    Get,
13    /// A single block write.
14    Put,
15    /// A single block deletion.
16    Delete,
17    /// A block existence check.
18    Exists,
19    /// A batch retrieval of multiple blocks.
20    BatchGet {
21        /// Number of blocks in the batch.
22        count: usize,
23    },
24    /// A batch write of multiple blocks.
25    BatchPut {
26        /// Number of blocks in the batch.
27        count: usize,
28    },
29}
30
31/// A single logged storage operation.
32#[derive(Clone, Debug)]
33pub struct AccessEntry {
34    /// Monotonically increasing entry identifier.
35    pub entry_id: u64,
36    /// Content identifier of the block involved.
37    pub cid: String,
38    /// The type of operation performed.
39    pub op: AccessOp,
40    /// Bytes written for Put/BatchPut operations; `None` for Get, Exists, Delete.
41    pub size_bytes: Option<u64>,
42    /// Operation latency in microseconds.
43    pub latency_us: u64,
44    /// Whether the operation completed successfully.
45    pub success: bool,
46    /// Unix timestamp (seconds) at which the operation occurred.
47    pub timestamp_secs: u64,
48    /// Identifies the calling component (e.g. `"bitswap"`, `"gc"`).
49    pub caller_tag: String,
50}
51
52/// Detected access pattern over the recent log window.
53#[derive(Clone, Debug, PartialEq)]
54pub enum AccessPattern {
55    /// Consecutive entries share the same CID prefix (>50% of last 10 entries).
56    Sequential,
57    /// No identifiable structure — the default.
58    Random,
59    /// The same CID has been accessed 3 or more times in the last 10 entries.
60    Repeated {
61        /// The CID that was accessed repeatedly.
62        cid: String,
63    },
64}
65
66/// Aggregated statistics over all logged operations.
67#[derive(Clone, Debug, Default)]
68pub struct AccessStats {
69    /// Total number of operations logged (including failures).
70    pub total_ops: u64,
71    /// Number of `Get` operations.
72    pub gets: u64,
73    /// Number of `Put` operations.
74    pub puts: u64,
75    /// Number of `Delete` operations.
76    pub deletes: u64,
77    /// Total bytes written across all `Put` and `BatchPut` operations.
78    pub total_bytes_written: u64,
79    /// Sum of latencies (microseconds) across all operations.
80    pub total_latency_us: u64,
81    /// Number of operations that did not succeed.
82    pub failures: u64,
83}
84
85impl AccessStats {
86    /// Average latency in microseconds. Returns `0.0` when no operations have been recorded.
87    pub fn avg_latency_us(&self) -> f64 {
88        self.total_latency_us as f64 / self.total_ops.max(1) as f64
89    }
90
91    /// Fraction of operations that failed. Returns `0.0` when no operations have been recorded.
92    pub fn error_rate(&self) -> f64 {
93        self.failures as f64 / self.total_ops.max(1) as f64
94    }
95}
96
97/// Bounded, structured audit log for storage operations.
98///
99/// Maintains a FIFO ring of [`AccessEntry`] records up to `max_entries` in
100/// length. When the ring is full, the oldest entry is evicted before the new
101/// one is appended.  All mutations also update the running [`AccessStats`].
102pub struct StorageAccessLogger {
103    /// The ring of logged entries.
104    pub entries: VecDeque<AccessEntry>,
105    /// Maximum number of entries retained before oldest entries are dropped.
106    pub max_entries: usize,
107    /// Cumulative statistics over the lifetime of this logger (cleared with [`Self::clear`]).
108    pub stats: AccessStats,
109    /// Monotonic counter for the next [`AccessEntry::entry_id`].
110    pub next_id: u64,
111}
112
113impl StorageAccessLogger {
114    /// Create a new logger with the given capacity.
115    ///
116    /// `max_entries` sets the maximum number of entries held in memory.
117    /// When this limit is reached the oldest entry is dropped on each new
118    /// [`Self::log`] call. The default value used by higher-level helpers is
119    /// `5000`.
120    pub fn new(max_entries: usize) -> Self {
121        Self {
122            entries: VecDeque::with_capacity(max_entries.min(4096)),
123            max_entries,
124            stats: AccessStats::default(),
125            next_id: 0,
126        }
127    }
128
129    /// Record a storage operation.
130    ///
131    /// # Parameters
132    /// * `cid`           – Content identifier for the block involved.
133    /// * `op`            – The operation type.
134    /// * `size_bytes`    – Bytes written (meaningful for Put/BatchPut; pass `None` otherwise).
135    /// * `latency_us`    – Operation latency in microseconds.
136    /// * `success`       – Whether the operation completed without error.
137    /// * `timestamp_secs`– Unix timestamp (seconds).
138    /// * `caller_tag`    – String label for the calling component.
139    #[allow(clippy::too_many_arguments)]
140    pub fn log(
141        &mut self,
142        cid: String,
143        op: AccessOp,
144        size_bytes: Option<u64>,
145        latency_us: u64,
146        success: bool,
147        timestamp_secs: u64,
148        caller_tag: String,
149    ) {
150        let entry_id = self.next_id;
151        self.next_id += 1;
152
153        // Update statistics.
154        self.stats.total_ops += 1;
155        self.stats.total_latency_us += latency_us;
156        if !success {
157            self.stats.failures += 1;
158        }
159        match &op {
160            AccessOp::Get => self.stats.gets += 1,
161            AccessOp::Put => {
162                self.stats.puts += 1;
163                if let Some(bytes) = size_bytes {
164                    self.stats.total_bytes_written += bytes;
165                }
166            }
167            AccessOp::Delete => self.stats.deletes += 1,
168            AccessOp::Exists => {}
169            AccessOp::BatchGet { .. } => self.stats.gets += 1,
170            AccessOp::BatchPut { .. } => {
171                self.stats.puts += 1;
172                if let Some(bytes) = size_bytes {
173                    self.stats.total_bytes_written += bytes;
174                }
175            }
176        }
177
178        let entry = AccessEntry {
179            entry_id,
180            cid,
181            op,
182            size_bytes,
183            latency_us,
184            success,
185            timestamp_secs,
186            caller_tag,
187        };
188
189        // Enforce capacity bound.
190        if self.entries.len() >= self.max_entries {
191            self.entries.pop_front();
192        }
193        self.entries.push_back(entry);
194    }
195
196    /// Return references to the last `n` entries in chronological order.
197    ///
198    /// If `n` exceeds the number of stored entries, all entries are returned.
199    pub fn recent(&self, n: usize) -> Vec<&AccessEntry> {
200        let len = self.entries.len();
201        let skip = len.saturating_sub(n);
202        self.entries.iter().skip(skip).collect()
203    }
204
205    /// Return all entries whose [`AccessEntry::cid`] matches `cid`.
206    pub fn entries_for_cid(&self, cid: &str) -> Vec<&AccessEntry> {
207        self.entries.iter().filter(|e| e.cid == cid).collect()
208    }
209
210    /// Return all entries whose [`AccessEntry::caller_tag`] matches `caller`.
211    pub fn entries_for_caller(&self, caller: &str) -> Vec<&AccessEntry> {
212        self.entries
213            .iter()
214            .filter(|e| e.caller_tag == caller)
215            .collect()
216    }
217
218    /// Analyse the last 10 entries to detect an [`AccessPattern`].
219    ///
220    /// Detection priority:
221    /// 1. **Repeated** — if any single CID appears 3 or more times.
222    /// 2. **Random** — fallback (Sequential detection is deferred).
223    pub fn detect_pattern(&self) -> AccessPattern {
224        let window: Vec<&AccessEntry> = self.recent(10);
225
226        // Check for repeated CID (≥3 occurrences).
227        let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
228        for entry in &window {
229            *counts.entry(entry.cid.as_str()).or_insert(0) += 1;
230        }
231        if let Some((&cid, _)) = counts.iter().find(|(_, &c)| c >= 3) {
232            return AccessPattern::Repeated {
233                cid: cid.to_owned(),
234            };
235        }
236
237        AccessPattern::Random
238    }
239
240    /// Return a reference to the current [`AccessStats`].
241    pub fn stats(&self) -> &AccessStats {
242        &self.stats
243    }
244
245    /// Reset the logger to its initial empty state.
246    ///
247    /// Clears all stored entries, resets statistics to zero, and restarts
248    /// the entry-id counter from zero.
249    pub fn clear(&mut self) {
250        self.entries.clear();
251        self.stats = AccessStats::default();
252        self.next_id = 0;
253    }
254}
255
256// ---------------------------------------------------------------------------
257// Tests
258// ---------------------------------------------------------------------------
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    /// Convenience helper — logs a single Get with default metadata.
265    fn log_get(logger: &mut StorageAccessLogger, cid: &str) {
266        logger.log(
267            cid.to_owned(),
268            AccessOp::Get,
269            None,
270            100,
271            true,
272            1_000_000,
273            "test".to_owned(),
274        );
275    }
276
277    // -----------------------------------------------------------------------
278    // 1. new() produces an empty logger
279    // -----------------------------------------------------------------------
280    #[test]
281    fn test_new_empty() {
282        let logger = StorageAccessLogger::new(5000);
283        assert_eq!(logger.entries.len(), 0);
284        assert_eq!(logger.stats.total_ops, 0);
285        assert_eq!(logger.next_id, 0);
286        assert_eq!(logger.max_entries, 5000);
287    }
288
289    // -----------------------------------------------------------------------
290    // 2. log() Get updates stats.gets
291    // -----------------------------------------------------------------------
292    #[test]
293    fn test_log_get_updates_gets() {
294        let mut logger = StorageAccessLogger::new(100);
295        log_get(&mut logger, "cid1");
296        assert_eq!(logger.stats.gets, 1);
297        assert_eq!(logger.stats.total_ops, 1);
298        assert_eq!(logger.stats.puts, 0);
299        assert_eq!(logger.stats.deletes, 0);
300    }
301
302    // -----------------------------------------------------------------------
303    // 3. log() Put updates stats.puts and total_bytes_written
304    // -----------------------------------------------------------------------
305    #[test]
306    fn test_log_put_updates_puts_and_bytes() {
307        let mut logger = StorageAccessLogger::new(100);
308        logger.log(
309            "cid1".to_owned(),
310            AccessOp::Put,
311            Some(512),
312            200,
313            true,
314            1_000_000,
315            "writer".to_owned(),
316        );
317        assert_eq!(logger.stats.puts, 1);
318        assert_eq!(logger.stats.total_bytes_written, 512);
319        assert_eq!(logger.stats.gets, 0);
320    }
321
322    // -----------------------------------------------------------------------
323    // 4. log() Delete updates stats.deletes
324    // -----------------------------------------------------------------------
325    #[test]
326    fn test_log_delete_updates_deletes() {
327        let mut logger = StorageAccessLogger::new(100);
328        logger.log(
329            "cid1".to_owned(),
330            AccessOp::Delete,
331            None,
332            50,
333            true,
334            1_000_000,
335            "gc".to_owned(),
336        );
337        assert_eq!(logger.stats.deletes, 1);
338        assert_eq!(logger.stats.total_ops, 1);
339    }
340
341    // -----------------------------------------------------------------------
342    // 5. log() failure increments failures
343    // -----------------------------------------------------------------------
344    #[test]
345    fn test_log_failure_increments_failures() {
346        let mut logger = StorageAccessLogger::new(100);
347        logger.log(
348            "cid1".to_owned(),
349            AccessOp::Get,
350            None,
351            300,
352            false,
353            1_000_000,
354            "bitswap".to_owned(),
355        );
356        assert_eq!(logger.stats.failures, 1);
357        assert_eq!(logger.stats.total_ops, 1);
358    }
359
360    // -----------------------------------------------------------------------
361    // 6. log() latency accumulated in total_latency_us
362    // -----------------------------------------------------------------------
363    #[test]
364    fn test_log_latency_accumulated() {
365        let mut logger = StorageAccessLogger::new(100);
366        logger.log(
367            "cid1".to_owned(),
368            AccessOp::Get,
369            None,
370            100,
371            true,
372            1_000_000,
373            "t".to_owned(),
374        );
375        logger.log(
376            "cid2".to_owned(),
377            AccessOp::Get,
378            None,
379            250,
380            true,
381            1_000_001,
382            "t".to_owned(),
383        );
384        assert_eq!(logger.stats.total_latency_us, 350);
385    }
386
387    // -----------------------------------------------------------------------
388    // 7. max_entries cap drops oldest entry
389    // -----------------------------------------------------------------------
390    #[test]
391    fn test_max_entries_cap_drops_oldest() {
392        let mut logger = StorageAccessLogger::new(3);
393        for i in 0..5_u64 {
394            logger.log(
395                format!("cid{}", i),
396                AccessOp::Get,
397                None,
398                10,
399                true,
400                i,
401                "t".to_owned(),
402            );
403        }
404        assert_eq!(logger.entries.len(), 3);
405        // The three remaining entries should be the last three logged.
406        let cids: Vec<&str> = logger.entries.iter().map(|e| e.cid.as_str()).collect();
407        assert_eq!(cids, vec!["cid2", "cid3", "cid4"]);
408    }
409
410    // -----------------------------------------------------------------------
411    // 8. recent() returns the last n entries
412    // -----------------------------------------------------------------------
413    #[test]
414    fn test_recent_returns_last_n() {
415        let mut logger = StorageAccessLogger::new(100);
416        for i in 0..10_u64 {
417            logger.log(
418                format!("cid{}", i),
419                AccessOp::Get,
420                None,
421                10,
422                true,
423                i,
424                "t".to_owned(),
425            );
426        }
427        let recent = logger.recent(3);
428        assert_eq!(recent.len(), 3);
429        assert_eq!(recent[0].cid, "cid7");
430        assert_eq!(recent[1].cid, "cid8");
431        assert_eq!(recent[2].cid, "cid9");
432    }
433
434    // -----------------------------------------------------------------------
435    // 9. recent() n > len returns all entries
436    // -----------------------------------------------------------------------
437    #[test]
438    fn test_recent_n_larger_than_len_returns_all() {
439        let mut logger = StorageAccessLogger::new(100);
440        for i in 0..5_u64 {
441            log_get(&mut logger, &format!("cid{}", i));
442        }
443        let recent = logger.recent(50);
444        assert_eq!(recent.len(), 5);
445    }
446
447    // -----------------------------------------------------------------------
448    // 10. entries_for_cid filters correctly
449    // -----------------------------------------------------------------------
450    #[test]
451    fn test_entries_for_cid_filters_correctly() {
452        let mut logger = StorageAccessLogger::new(100);
453        log_get(&mut logger, "cid_a");
454        log_get(&mut logger, "cid_b");
455        log_get(&mut logger, "cid_a");
456
457        let hits = logger.entries_for_cid("cid_a");
458        assert_eq!(hits.len(), 2);
459        assert!(hits.iter().all(|e| e.cid == "cid_a"));
460
461        let misses = logger.entries_for_cid("cid_x");
462        assert!(misses.is_empty());
463    }
464
465    // -----------------------------------------------------------------------
466    // 11. entries_for_caller filters correctly
467    // -----------------------------------------------------------------------
468    #[test]
469    fn test_entries_for_caller_filters_correctly() {
470        let mut logger = StorageAccessLogger::new(100);
471        logger.log(
472            "cid1".to_owned(),
473            AccessOp::Get,
474            None,
475            10,
476            true,
477            1,
478            "bitswap".to_owned(),
479        );
480        logger.log(
481            "cid2".to_owned(),
482            AccessOp::Put,
483            Some(64),
484            20,
485            true,
486            2,
487            "gc".to_owned(),
488        );
489        logger.log(
490            "cid3".to_owned(),
491            AccessOp::Get,
492            None,
493            15,
494            true,
495            3,
496            "bitswap".to_owned(),
497        );
498
499        let bitswap_entries = logger.entries_for_caller("bitswap");
500        assert_eq!(bitswap_entries.len(), 2);
501        assert!(bitswap_entries.iter().all(|e| e.caller_tag == "bitswap"));
502
503        let gc_entries = logger.entries_for_caller("gc");
504        assert_eq!(gc_entries.len(), 1);
505    }
506
507    // -----------------------------------------------------------------------
508    // 12. detect_pattern: Repeated when same CID accessed >= 3 times in last 10
509    // -----------------------------------------------------------------------
510    #[test]
511    fn test_detect_pattern_repeated() {
512        let mut logger = StorageAccessLogger::new(100);
513        // Fill with unrelated entries first.
514        log_get(&mut logger, "other1");
515        log_get(&mut logger, "other2");
516        // Log "hot_cid" three times within the last 10.
517        log_get(&mut logger, "hot_cid");
518        log_get(&mut logger, "hot_cid");
519        log_get(&mut logger, "hot_cid");
520
521        let pattern = logger.detect_pattern();
522        assert_eq!(
523            pattern,
524            AccessPattern::Repeated {
525                cid: "hot_cid".to_owned()
526            }
527        );
528    }
529
530    // -----------------------------------------------------------------------
531    // 13. detect_pattern: Random when no repetition
532    // -----------------------------------------------------------------------
533    #[test]
534    fn test_detect_pattern_random() {
535        let mut logger = StorageAccessLogger::new(100);
536        for i in 0..10_u64 {
537            log_get(&mut logger, &format!("unique_cid_{}", i));
538        }
539        assert_eq!(logger.detect_pattern(), AccessPattern::Random);
540    }
541
542    // -----------------------------------------------------------------------
543    // 14. avg_latency_us calculation
544    // -----------------------------------------------------------------------
545    #[test]
546    fn test_avg_latency_us_calculation() {
547        let mut logger = StorageAccessLogger::new(100);
548        logger.log(
549            "c1".to_owned(),
550            AccessOp::Get,
551            None,
552            100,
553            true,
554            1,
555            "t".to_owned(),
556        );
557        logger.log(
558            "c2".to_owned(),
559            AccessOp::Get,
560            None,
561            300,
562            true,
563            2,
564            "t".to_owned(),
565        );
566        // avg = (100 + 300) / 2 = 200
567        let avg = logger.stats().avg_latency_us();
568        assert!((avg - 200.0_f64).abs() < f64::EPSILON);
569    }
570
571    // -----------------------------------------------------------------------
572    // 15. error_rate calculation
573    // -----------------------------------------------------------------------
574    #[test]
575    fn test_error_rate_calculation() {
576        let mut logger = StorageAccessLogger::new(100);
577        // 1 success
578        logger.log(
579            "c1".to_owned(),
580            AccessOp::Get,
581            None,
582            10,
583            true,
584            1,
585            "t".to_owned(),
586        );
587        // 1 failure
588        logger.log(
589            "c2".to_owned(),
590            AccessOp::Get,
591            None,
592            10,
593            false,
594            2,
595            "t".to_owned(),
596        );
597        // error_rate = 1/2 = 0.5
598        let rate = logger.stats().error_rate();
599        assert!((rate - 0.5_f64).abs() < f64::EPSILON);
600    }
601
602    // -----------------------------------------------------------------------
603    // 16. stats updated correctly after multiple mixed ops
604    // -----------------------------------------------------------------------
605    #[test]
606    fn test_stats_updated_after_multiple_ops() {
607        let mut logger = StorageAccessLogger::new(100);
608        log_get(&mut logger, "cid1");
609        logger.log(
610            "cid2".to_owned(),
611            AccessOp::Put,
612            Some(1024),
613            50,
614            true,
615            2,
616            "w".to_owned(),
617        );
618        logger.log(
619            "cid3".to_owned(),
620            AccessOp::Delete,
621            None,
622            20,
623            true,
624            3,
625            "gc".to_owned(),
626        );
627        logger.log(
628            "cid4".to_owned(),
629            AccessOp::Exists,
630            None,
631            5,
632            false,
633            4,
634            "t".to_owned(),
635        );
636
637        let s = logger.stats();
638        assert_eq!(s.total_ops, 4);
639        assert_eq!(s.gets, 1);
640        assert_eq!(s.puts, 1);
641        assert_eq!(s.deletes, 1);
642        assert_eq!(s.total_bytes_written, 1024);
643        assert_eq!(s.failures, 1);
644        assert_eq!(s.total_latency_us, 100 + 50 + 20 + 5);
645    }
646
647    // -----------------------------------------------------------------------
648    // 17. clear() resets everything
649    // -----------------------------------------------------------------------
650    #[test]
651    fn test_clear_resets_everything() {
652        let mut logger = StorageAccessLogger::new(100);
653        log_get(&mut logger, "cid1");
654        log_get(&mut logger, "cid2");
655        logger.clear();
656
657        assert_eq!(logger.entries.len(), 0);
658        assert_eq!(logger.stats.total_ops, 0);
659        assert_eq!(logger.stats.gets, 0);
660        assert_eq!(logger.stats.puts, 0);
661        assert_eq!(logger.stats.deletes, 0);
662        assert_eq!(logger.stats.total_bytes_written, 0);
663        assert_eq!(logger.stats.total_latency_us, 0);
664        assert_eq!(logger.stats.failures, 0);
665        assert_eq!(logger.next_id, 0);
666    }
667}