Skip to main content

ipfrs_storage/
access_tracker.rs

1//! Access frequency and recency tracking for storage tiering and eviction decisions.
2//!
3//! [`AccessTracker`] maintains per-CID access records and computes a composite
4//! score that balances how often a block is accessed (frequency) against how
5//! recently it was last accessed (recency).  The score drives hot/cold
6//! classification and LRU-style eviction.
7//!
8//! # Score formula
9//!
10//! ```text
11//! score = access_count / (1 + time_since_last_access_secs * decay_factor)
12//! ```
13//!
14//! Blocks with a score above `hot_threshold` are classified as *hot*; blocks
15//! below `cold_threshold` are classified as *cold*.  When the tracker is at
16//! capacity, [`AccessTracker::enforce_capacity`] evicts the coldest blocks
17//! first.
18//!
19//! # Example
20//!
21//! ```rust
22//! use ipfrs_storage::access_tracker::{AccessTracker, TrackerConfig};
23//!
24//! let config = TrackerConfig::default();
25//! let mut tracker = AccessTracker::new(config);
26//!
27//! tracker.record_access("bafybeig", 1024, 0);
28//! tracker.record_access("bafybeig", 1024, 10);
29//!
30//! assert!(tracker.is_hot("bafybeig", 10));
31//! ```
32
33use std::collections::HashMap;
34
35// ─────────────────────────────────────────────────────────────────────────────
36// Configuration
37// ─────────────────────────────────────────────────────────────────────────────
38
39/// Configuration parameters that govern tracker behaviour.
40#[derive(Debug, Clone)]
41pub struct TrackerConfig {
42    /// Maximum number of records held at any time.  When exceeded,
43    /// [`AccessTracker::enforce_capacity`] evicts the coldest entries.
44    pub max_records: usize,
45    /// Multiplicative weight applied to the recency component of the score.
46    /// Higher values reward recently-accessed blocks more strongly.
47    pub recency_weight: f64,
48    /// Time-based decay applied per second of idle time.  Larger values make
49    /// scores fall faster as blocks sit unused.
50    pub decay_factor: f64,
51    /// Score threshold above which a block is classified as *hot*.
52    pub hot_threshold: f64,
53    /// Score threshold below which a block is classified as *cold*.
54    pub cold_threshold: f64,
55}
56
57impl Default for TrackerConfig {
58    fn default() -> Self {
59        Self {
60            max_records: 10_000,
61            recency_weight: 1.0,
62            decay_factor: 0.001,
63            hot_threshold: 5.0,
64            cold_threshold: 0.5,
65        }
66    }
67}
68
69// ─────────────────────────────────────────────────────────────────────────────
70// AccessRecord
71// ─────────────────────────────────────────────────────────────────────────────
72
73/// A single CID's access history and computed temperature score.
74#[derive(Debug, Clone)]
75pub struct AccessRecord {
76    /// The Content Identifier this record belongs to.
77    pub cid: String,
78    /// Total number of times this CID has been accessed.
79    pub access_count: u64,
80    /// Logical timestamp (seconds) of the most recent access.
81    pub last_access: u64,
82    /// Logical timestamp (seconds) of the very first access.
83    pub first_access: u64,
84    /// Size in bytes reported at the most recent access.
85    pub size_bytes: u64,
86    /// Composite temperature score: `frequency * recency_weight`.
87    /// Recomputed by [`AccessTracker::update_score`].
88    pub access_score: f64,
89}
90
91// ─────────────────────────────────────────────────────────────────────────────
92// TrackerStats
93// ─────────────────────────────────────────────────────────────────────────────
94
95/// Aggregate statistics maintained by the tracker.
96#[derive(Debug, Clone, Default)]
97pub struct TrackerStats {
98    /// Cumulative number of calls to [`AccessTracker::record_access`].
99    pub total_accesses: u64,
100    /// Number of distinct CIDs currently tracked.
101    pub unique_cids: u64,
102    /// Number of CIDs currently classified as hot.
103    pub hot_blocks: u64,
104    /// Number of CIDs currently classified as cold.
105    pub cold_blocks: u64,
106    /// Number of records removed by eviction (capacity or explicit).
107    pub evictions: u64,
108}
109
110// ─────────────────────────────────────────────────────────────────────────────
111// AccessTracker
112// ─────────────────────────────────────────────────────────────────────────────
113
114/// Tracks access frequency and recency for storage tiering and eviction.
115pub struct AccessTracker {
116    config: TrackerConfig,
117    records: HashMap<String, AccessRecord>,
118    stats: TrackerStats,
119}
120
121impl AccessTracker {
122    // ── Construction ────────────────────────────────────────────────────────
123
124    /// Create a new tracker with the provided configuration.
125    pub fn new(config: TrackerConfig) -> Self {
126        Self {
127            records: HashMap::new(),
128            stats: TrackerStats::default(),
129            config,
130        }
131    }
132
133    // ── Core recording ──────────────────────────────────────────────────────
134
135    /// Record one access to `cid` at logical time `now` (seconds).
136    ///
137    /// If this is the first access for `cid` a new [`AccessRecord`] is
138    /// created.  Otherwise the existing record is updated in-place and its
139    /// score is recomputed.  After recording, capacity is enforced.
140    pub fn record_access(&mut self, cid: &str, size: u64, now: u64) {
141        self.stats.total_accesses += 1;
142
143        if let Some(record) = self.records.get_mut(cid) {
144            record.access_count += 1;
145            record.last_access = now;
146            if size > 0 {
147                record.size_bytes = size;
148            }
149            let score = Self::compute_score_inner(&self.config, record, now);
150            record.access_score = score;
151        } else {
152            let score_initial = self.config.recency_weight; // count=1 → 1/(1+0)
153            let record = AccessRecord {
154                cid: cid.to_owned(),
155                access_count: 1,
156                last_access: now,
157                first_access: now,
158                size_bytes: size,
159                access_score: score_initial,
160            };
161            self.records.insert(cid.to_owned(), record);
162            self.stats.unique_cids += 1;
163        }
164
165        // Keep within capacity — but do not count forced evictions twice.
166        self.enforce_capacity(now);
167        self.refresh_stats(now);
168    }
169
170    // ── Score computation ────────────────────────────────────────────────────
171
172    /// Compute the temperature score for `record` at logical time `now`.
173    ///
174    /// Formula:
175    /// ```text
176    /// score = access_count / (1 + time_since_last_access * decay_factor)
177    /// ```
178    ///
179    /// `recency_weight` from the config scales the final result so that
180    /// operators can tune how much recency matters relative to raw frequency.
181    pub fn compute_score(&self, record: &AccessRecord, now: u64) -> f64 {
182        Self::compute_score_inner(&self.config, record, now)
183    }
184
185    /// Internal stateless version that accepts the config by reference.
186    fn compute_score_inner(config: &TrackerConfig, record: &AccessRecord, now: u64) -> f64 {
187        let time_since = now.saturating_sub(record.last_access) as f64;
188        let denominator = 1.0 + time_since * config.decay_factor;
189        (record.access_count as f64 / denominator) * config.recency_weight
190    }
191
192    /// Recompute and persist the score for the CID identified by `cid`.
193    ///
194    /// This is useful when time has advanced without any new accesses.
195    pub fn update_score(&mut self, cid: &str, now: u64) {
196        if let Some(record) = self.records.get_mut(cid) {
197            let score = Self::compute_score_inner(&self.config, record, now);
198            record.access_score = score;
199        }
200    }
201
202    // ── Hot / cold classification ────────────────────────────────────────────
203
204    /// Returns `true` when the CID's current score exceeds `hot_threshold`.
205    pub fn is_hot(&self, cid: &str, now: u64) -> bool {
206        self.records
207            .get(cid)
208            .map(|r| Self::compute_score_inner(&self.config, r, now) >= self.config.hot_threshold)
209            .unwrap_or(false)
210    }
211
212    /// Returns `true` when the CID's current score is below `cold_threshold`.
213    pub fn is_cold(&self, cid: &str, now: u64) -> bool {
214        self.records
215            .get(cid)
216            .map(|r| Self::compute_score_inner(&self.config, r, now) < self.config.cold_threshold)
217            .unwrap_or(true)
218    }
219
220    /// All records currently scoring at or above `hot_threshold`.
221    pub fn hot_blocks(&self, now: u64) -> Vec<&AccessRecord> {
222        self.records
223            .values()
224            .filter(|r| {
225                Self::compute_score_inner(&self.config, r, now) >= self.config.hot_threshold
226            })
227            .collect()
228    }
229
230    /// All records currently scoring below `cold_threshold`.
231    pub fn cold_blocks(&self, now: u64) -> Vec<&AccessRecord> {
232        self.records
233            .values()
234            .filter(|r| {
235                Self::compute_score_inner(&self.config, r, now) < self.config.cold_threshold
236            })
237            .collect()
238    }
239
240    // ── Eviction ─────────────────────────────────────────────────────────────
241
242    /// Remove and return the single record with the lowest temperature score.
243    ///
244    /// Returns `None` when the tracker is empty.
245    pub fn evict_coldest(&mut self, now: u64) -> Option<AccessRecord> {
246        let coldest_key = self
247            .records
248            .iter()
249            .min_by(|a, b| {
250                let sa = Self::compute_score_inner(&self.config, a.1, now);
251                let sb = Self::compute_score_inner(&self.config, b.1, now);
252                sa.partial_cmp(&sb).unwrap_or(std::cmp::Ordering::Equal)
253            })
254            .map(|(k, _)| k.clone());
255
256        if let Some(key) = coldest_key {
257            let record = self.records.remove(&key)?;
258            self.stats.evictions += 1;
259            self.stats.unique_cids = self.stats.unique_cids.saturating_sub(1);
260            self.refresh_stats(now);
261            Some(record)
262        } else {
263            None
264        }
265    }
266
267    /// Evict records until `records.len() <= max_records`.
268    ///
269    /// Returns the number of records evicted in this call.
270    pub fn enforce_capacity(&mut self, now: u64) -> usize {
271        let mut evicted = 0usize;
272        while self.records.len() > self.config.max_records {
273            // Identify the key with the lowest score without borrowing self.
274            let coldest_key = self
275                .records
276                .iter()
277                .min_by(|a, b| {
278                    let sa = Self::compute_score_inner(&self.config, a.1, now);
279                    let sb = Self::compute_score_inner(&self.config, b.1, now);
280                    sa.partial_cmp(&sb).unwrap_or(std::cmp::Ordering::Equal)
281                })
282                .map(|(k, _)| k.clone());
283
284            if let Some(key) = coldest_key {
285                if self.records.remove(&key).is_some() {
286                    self.stats.evictions += 1;
287                    self.stats.unique_cids = self.stats.unique_cids.saturating_sub(1);
288                    evicted += 1;
289                }
290            } else {
291                break;
292            }
293        }
294        if evicted > 0 {
295            self.refresh_stats(now);
296        }
297        evicted
298    }
299
300    // ── Queries ───────────────────────────────────────────────────────────────
301
302    /// Look up the record for `cid`, if present.
303    pub fn get_record(&self, cid: &str) -> Option<&AccessRecord> {
304        self.records.get(cid)
305    }
306
307    /// Number of records currently held.
308    pub fn record_count(&self) -> usize {
309        self.records.len()
310    }
311
312    /// Reference to the current aggregate statistics.
313    pub fn stats(&self) -> &TrackerStats {
314        &self.stats
315    }
316
317    /// The `n` most-accessed records, sorted by `access_count` descending.
318    ///
319    /// If `n` exceeds the number of records, all records are returned.
320    pub fn top_accessed(&self, n: usize) -> Vec<&AccessRecord> {
321        let mut records: Vec<&AccessRecord> = self.records.values().collect();
322        records.sort_by_key(|b| std::cmp::Reverse(b.access_count));
323        records.truncate(n);
324        records
325    }
326
327    // ── Internal helpers ─────────────────────────────────────────────────────
328
329    /// Recompute the derived counters (hot_blocks, cold_blocks) in `stats`.
330    fn refresh_stats(&mut self, now: u64) {
331        let hot_threshold = self.config.hot_threshold;
332        let cold_threshold = self.config.cold_threshold;
333        let config = &self.config;
334
335        let mut hot = 0u64;
336        let mut cold = 0u64;
337        for record in self.records.values() {
338            let score = Self::compute_score_inner(config, record, now);
339            if score >= hot_threshold {
340                hot += 1;
341            } else if score < cold_threshold {
342                cold += 1;
343            }
344        }
345        self.stats.hot_blocks = hot;
346        self.stats.cold_blocks = cold;
347        self.stats.unique_cids = self.records.len() as u64;
348    }
349}
350
351// ─────────────────────────────────────────────────────────────────────────────
352// Tests
353// ─────────────────────────────────────────────────────────────────────────────
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    // ── helpers ────────────────────────────────────────────────────────────
360
361    fn default_config() -> TrackerConfig {
362        TrackerConfig {
363            max_records: 5,
364            recency_weight: 1.0,
365            decay_factor: 0.1,
366            hot_threshold: 3.0,
367            cold_threshold: 0.5,
368        }
369    }
370
371    fn tracker() -> AccessTracker {
372        AccessTracker::new(default_config())
373    }
374
375    // ── 1. Record access creates a new entry ───────────────────────────────
376
377    #[test]
378    fn test_record_access_creates_entry() {
379        let mut t = tracker();
380        t.record_access("cid1", 512, 0);
381        assert!(t.get_record("cid1").is_some());
382    }
383
384    // ── 2. Missing CID returns None ────────────────────────────────────────
385
386    #[test]
387    fn test_get_record_missing() {
388        let t = tracker();
389        assert!(t.get_record("nonexistent").is_none());
390    }
391
392    // ── 3. Multiple accesses accumulate ───────────────────────────────────
393
394    #[test]
395    fn test_multiple_accesses_accumulate() {
396        let mut t = tracker();
397        t.record_access("cid1", 512, 0);
398        t.record_access("cid1", 512, 1);
399        t.record_access("cid1", 512, 2);
400        let rec = t.get_record("cid1").expect("record must exist");
401        assert_eq!(rec.access_count, 3);
402    }
403
404    // ── 4. first_access is preserved ──────────────────────────────────────
405
406    #[test]
407    fn test_first_access_preserved() {
408        let mut t = tracker();
409        t.record_access("cid1", 512, 100);
410        t.record_access("cid1", 512, 200);
411        let rec = t.get_record("cid1").expect("must exist");
412        assert_eq!(rec.first_access, 100);
413        assert_eq!(rec.last_access, 200);
414    }
415
416    // ── 5. Score computation at t=0 ───────────────────────────────────────
417
418    #[test]
419    fn test_compute_score_at_access_time() {
420        let t = tracker();
421        let rec = AccessRecord {
422            cid: "c".to_owned(),
423            access_count: 10,
424            last_access: 50,
425            first_access: 0,
426            size_bytes: 1024,
427            access_score: 0.0,
428        };
429        // score = 10 / (1 + 0 * 0.1) * 1.0 = 10.0
430        let score = t.compute_score(&rec, 50);
431        assert!((score - 10.0).abs() < 1e-9);
432    }
433
434    // ── 6. Score decays with elapsed time ─────────────────────────────────
435
436    #[test]
437    fn test_score_decays_with_time() {
438        let t = tracker();
439        let rec = AccessRecord {
440            cid: "c".to_owned(),
441            access_count: 10,
442            last_access: 0,
443            first_access: 0,
444            size_bytes: 1024,
445            access_score: 0.0,
446        };
447        let score_now = t.compute_score(&rec, 0);
448        let score_later = t.compute_score(&rec, 100);
449        assert!(score_later < score_now, "score must decay over time");
450    }
451
452    // ── 7. is_hot — block above threshold ────────────────────────────────
453
454    #[test]
455    fn test_is_hot_true() {
456        let mut t = tracker();
457        // Record 4 times in quick succession → score ≈ 4.0 > 3.0
458        for i in 0u64..4 {
459            t.record_access("hot_cid", 512, i);
460        }
461        assert!(t.is_hot("hot_cid", 3));
462    }
463
464    // ── 8. is_hot — missing CID is not hot ───────────────────────────────
465
466    #[test]
467    fn test_is_hot_missing() {
468        let t = tracker();
469        assert!(!t.is_hot("nope", 0));
470    }
471
472    // ── 9. is_cold — fresh single-access block with age ──────────────────
473
474    #[test]
475    fn test_is_cold_with_old_block() {
476        let mut t = tracker();
477        t.record_access("cold_cid", 512, 0);
478        // After 10 000 seconds the score = 1 / (1 + 1000) ≈ 0.001 < 0.5
479        assert!(t.is_cold("cold_cid", 10_000));
480    }
481
482    // ── 10. is_cold — missing CID defaults to cold ───────────────────────
483
484    #[test]
485    fn test_is_cold_missing() {
486        let t = tracker();
487        assert!(t.is_cold("nope", 0));
488    }
489
490    // ── 11. hot_blocks returns only hot records ───────────────────────────
491
492    #[test]
493    fn test_hot_blocks_list() {
494        let mut t = tracker();
495        for i in 0u64..4 {
496            t.record_access("hot", 512, i);
497        }
498        t.record_access("warm", 512, 0);
499        let hot = t.hot_blocks(3);
500        assert!(hot.iter().any(|r| r.cid == "hot"));
501        assert!(!hot.iter().any(|r| r.cid == "warm"));
502    }
503
504    // ── 12. cold_blocks returns only cold records ─────────────────────────
505
506    #[test]
507    fn test_cold_blocks_list() {
508        let mut t = tracker();
509        t.record_access("cold", 512, 0);
510        let cold = t.cold_blocks(10_000);
511        assert!(cold.iter().any(|r| r.cid == "cold"));
512    }
513
514    // ── 13. evict_coldest removes the lowest-score entry ─────────────────
515
516    #[test]
517    fn test_evict_coldest_removes_lowest() {
518        let mut t = tracker();
519        t.record_access("a", 512, 0); // score ≈ 1.0
520        for i in 0u64..4 {
521            t.record_access("b", 512, i); // score ≈ 4.0
522        }
523        let evicted = t.evict_coldest(3).expect("must evict something");
524        assert_eq!(evicted.cid, "a");
525        assert!(t.get_record("a").is_none());
526        assert!(t.get_record("b").is_some());
527    }
528
529    // ── 14. evict_coldest on empty tracker returns None ───────────────────
530
531    #[test]
532    fn test_evict_coldest_empty() {
533        let mut t = tracker();
534        assert!(t.evict_coldest(0).is_none());
535    }
536
537    // ── 15. enforce_capacity trims to max_records ─────────────────────────
538
539    #[test]
540    fn test_enforce_capacity() {
541        let mut t = tracker(); // max_records = 5
542        for i in 0u64..8 {
543            // Insert 8 distinct CIDs, enforce runs inside record_access
544            // so the last insertion triggers enforcement. We only have 5 slots.
545            t.records.insert(
546                format!("cid{i}"),
547                AccessRecord {
548                    cid: format!("cid{i}"),
549                    access_count: i + 1,
550                    last_access: i,
551                    first_access: 0,
552                    size_bytes: 512,
553                    access_score: (i + 1) as f64,
554                },
555            );
556        }
557        t.stats.unique_cids = t.records.len() as u64;
558        let evicted = t.enforce_capacity(100);
559        assert_eq!(evicted, 3);
560        assert_eq!(t.record_count(), 5);
561    }
562
563    // ── 16. enforce_capacity returns 0 when within limits ────────────────
564
565    #[test]
566    fn test_enforce_capacity_no_op() {
567        let mut t = tracker();
568        t.record_access("only_one", 512, 0);
569        let evicted = t.enforce_capacity(0);
570        assert_eq!(evicted, 0);
571    }
572
573    // ── 17. top_accessed ordering by access_count desc ───────────────────
574
575    #[test]
576    fn test_top_accessed_ordering() {
577        let mut t = tracker();
578        t.record_access("low", 512, 0);
579        for _ in 0..3 {
580            t.record_access("mid", 512, 0);
581        }
582        for _ in 0..5 {
583            t.record_access("high", 512, 0);
584        }
585        let top = t.top_accessed(3);
586        assert_eq!(top[0].cid, "high");
587        assert_eq!(top[1].cid, "mid");
588        assert_eq!(top[2].cid, "low");
589    }
590
591    // ── 18. top_accessed(n) where n > records returns all ─────────────────
592
593    #[test]
594    fn test_top_accessed_more_than_records() {
595        let mut t = tracker();
596        t.record_access("a", 512, 0);
597        t.record_access("b", 512, 0);
598        let top = t.top_accessed(100);
599        assert_eq!(top.len(), 2);
600    }
601
602    // ── 19. top_accessed(0) returns empty slice ────────────────────────────
603
604    #[test]
605    fn test_top_accessed_zero() {
606        let mut t = tracker();
607        t.record_access("a", 512, 0);
608        assert!(t.top_accessed(0).is_empty());
609    }
610
611    // ── 20. stats.total_accesses increments correctly ────────────────────
612
613    #[test]
614    fn test_stats_total_accesses() {
615        let mut t = tracker();
616        t.record_access("x", 512, 0);
617        t.record_access("x", 512, 1);
618        t.record_access("y", 512, 2);
619        assert_eq!(t.stats().total_accesses, 3);
620    }
621
622    // ── 21. stats.unique_cids is accurate ────────────────────────────────
623
624    #[test]
625    fn test_stats_unique_cids() {
626        let mut t = tracker();
627        t.record_access("a", 512, 0);
628        t.record_access("b", 512, 0);
629        t.record_access("a", 512, 1); // duplicate
630        assert_eq!(t.stats().unique_cids, 2);
631    }
632
633    // ── 22. stats.evictions increments on evict_coldest ──────────────────
634
635    #[test]
636    fn test_stats_evictions_on_evict_coldest() {
637        let mut t = tracker();
638        t.record_access("a", 512, 0);
639        t.evict_coldest(0);
640        assert_eq!(t.stats().evictions, 1);
641    }
642
643    // ── 23. record_count returns correct value ────────────────────────────
644
645    #[test]
646    fn test_record_count() {
647        let mut t = tracker();
648        assert_eq!(t.record_count(), 0);
649        t.record_access("a", 512, 0);
650        t.record_access("b", 512, 0);
651        assert_eq!(t.record_count(), 2);
652    }
653
654    // ── 24. update_score refreshes stored score ───────────────────────────
655
656    #[test]
657    fn test_update_score() {
658        let mut t = tracker();
659        t.record_access("a", 512, 0);
660        let initial_score = t.get_record("a").map(|r| r.access_score).unwrap_or(0.0);
661        t.update_score("a", 1000); // much later → score should be smaller
662        let later_score = t.get_record("a").map(|r| r.access_score).unwrap_or(0.0);
663        assert!(later_score < initial_score);
664    }
665
666    // ── 25. hot_blocks and cold_blocks are mutually exclusive ─────────────
667    //
668    // We use a single logical `now` so that the same scoring function is
669    // applied to both lists.  A block cannot simultaneously be >= hot_threshold
670    // and < cold_threshold (assuming hot_threshold > cold_threshold).
671
672    #[test]
673    fn test_hot_and_cold_disjoint() {
674        let mut t = tracker();
675        // Record "hot" 4 times at t=0..3; its score at t=3 ≈ 4/(1+0) = 4.0 (> 3.0).
676        for i in 0u64..4 {
677            t.record_access("hot", 512, i);
678        }
679        // Record "cold" once at t=0; its score at t=3 = 1/(1+0.3) ≈ 0.77 (warm, not cold).
680        // To ensure it is cold we pick a time far in the future: score ≈ 1/(1+1000*0.1)=0.01.
681        t.record_access("cold", 512, 0);
682
683        // Use the same `now` for both queries so scoring is consistent.
684        let query_now = 3u64;
685        let hot_ids: std::collections::HashSet<String> = t
686            .hot_blocks(query_now)
687            .iter()
688            .map(|r| r.cid.clone())
689            .collect();
690        let cold_ids: std::collections::HashSet<String> = t
691            .cold_blocks(query_now)
692            .iter()
693            .map(|r| r.cid.clone())
694            .collect();
695
696        // Intersection must be empty — by definition hot_threshold > cold_threshold.
697        let overlap: Vec<_> = hot_ids.intersection(&cold_ids).collect();
698        assert!(
699            overlap.is_empty(),
700            "hot and cold sets must be disjoint at the same timestamp"
701        );
702    }
703
704    // ── 26. stats hot_blocks count matches hot_blocks() length ────────────
705
706    #[test]
707    fn test_stats_hot_blocks_count() {
708        let mut t = tracker();
709        for i in 0u64..4 {
710            t.record_access("h", 512, i);
711        }
712        t.record_access("c", 512, 0);
713        // Force stats refresh
714        t.refresh_stats(3);
715        assert_eq!(t.stats().hot_blocks, t.hot_blocks(3).len() as u64);
716    }
717
718    // ── 27. size_bytes is updated on subsequent accesses ──────────────────
719
720    #[test]
721    fn test_size_bytes_updated() {
722        let mut t = tracker();
723        t.record_access("a", 512, 0);
724        t.record_access("a", 2048, 1);
725        let rec = t.get_record("a").expect("must exist");
726        assert_eq!(rec.size_bytes, 2048);
727    }
728}