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}