Skip to main content

ipfrs_storage/
read_ahead.rs

1//! Read-Ahead Scheduler for sequential block prefetching
2//!
3//! This module implements a lightweight, single-threaded read-ahead scheduler
4//! that tracks raw block offset access sequences and issues prefetch hints
5//! based on detected access patterns (sequential, strided, repeated, random).
6//!
7//! # Design Notes
8//!
9//! - `ReadAheadScheduler` is intentionally `!Send + !Sync` (uses `Instant` in a
10//!   `HashMap` without any atomic synchronization).  All state is owned by a
11//!   single thread.
12//! - History is capped at 64 entries (ring-buffer semantics via `VecDeque`).
13//! - Prefetch cache entries are deduplicated within their TTL (default 30 s).
14
15use std::collections::{HashMap, VecDeque};
16use std::time::{Duration, Instant};
17
18// ──────────────────────────────────────────────────────────────
19// AccessPattern
20// ──────────────────────────────────────────────────────────────
21
22/// Classification of block offset access patterns.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum ReadAheadPattern {
25    /// Every access targets the same offset.
26    Repeated,
27    /// Each access advances by exactly 1 block offset.
28    Sequential,
29    /// Each access advances by the same constant stride (> 1).
30    Strided {
31        /// The constant stride between consecutive accesses.
32        stride: u64,
33    },
34    /// No discernible pattern.
35    Random,
36}
37
38impl ReadAheadPattern {
39    /// Analyse a history slice and return the best-fitting pattern.
40    ///
41    /// Requires at least 2 entries; a single-entry (or empty) slice returns
42    /// `Random` because there is insufficient evidence for any pattern.
43    pub fn detect(history: &[u64]) -> Self {
44        if history.len() < 2 {
45            return Self::Random;
46        }
47
48        // Compute successive deltas.
49        let deltas: Vec<u64> = history
50            .windows(2)
51            .map(|w| w[1].wrapping_sub(w[0]))
52            .collect();
53
54        let first = deltas[0];
55
56        // All deltas must equal `first` for any of the structured patterns.
57        if deltas.iter().all(|&d| d == first) {
58            match first {
59                0 => Self::Repeated,
60                1 => Self::Sequential,
61                s => Self::Strided { stride: s },
62            }
63        } else {
64            Self::Random
65        }
66    }
67}
68
69// Keep the public-API name the spec requires (`AccessPattern`) as an alias so
70// callers that use `read_ahead::AccessPattern` compile without ambiguity.
71/// Public alias for [`ReadAheadPattern`].
72pub type AccessPattern = ReadAheadPattern;
73
74// ──────────────────────────────────────────────────────────────
75// PrefetchHint
76// ──────────────────────────────────────────────────────────────
77
78/// A set of block offsets that the scheduler recommends prefetching.
79#[derive(Debug, Clone)]
80pub struct PrefetchHint {
81    /// Ordered list of block offsets to prefetch.
82    pub block_offsets: Vec<u64>,
83    /// Pattern that generated this hint.
84    pub pattern: ReadAheadPattern,
85    /// Confidence score in `[0.0, 1.0]` that the pattern will continue.
86    pub confidence: f32,
87}
88
89// ──────────────────────────────────────────────────────────────
90// ReadAheadStats
91// ──────────────────────────────────────────────────────────────
92
93/// Cumulative statistics for a [`ReadAheadScheduler`].
94///
95/// Plain struct — no atomics; the scheduler is single-threaded by design.
96#[derive(Debug, Clone, Default)]
97pub struct ReadAheadStats {
98    /// Total number of `record_access` calls.
99    pub total_accesses: u64,
100    /// Total number of non-`None` hints returned by `next_hints`.
101    pub total_hints_issued: u64,
102    /// Total number of offsets skipped due to recent prefetch cache hits.
103    pub total_deduped: u64,
104}
105
106// ──────────────────────────────────────────────────────────────
107// ReadAheadScheduler
108// ──────────────────────────────────────────────────────────────
109
110/// Maximum number of offset entries kept in history.
111const HISTORY_CAP: usize = 64;
112
113/// Single-threaded read-ahead scheduler.
114///
115/// # Example
116/// ```rust
117/// use ipfrs_storage::read_ahead::{ReadAheadScheduler, ReadAheadPattern};
118///
119/// let mut sched = ReadAheadScheduler::new();
120/// for offset in 0u64..8 {
121///     sched.record_access(offset);
122/// }
123/// if let Some(hint) = sched.next_hints() {
124///     assert_eq!(hint.pattern, ReadAheadPattern::Sequential);
125/// }
126/// ```
127pub struct ReadAheadScheduler {
128    /// Ring-buffer of the last `HISTORY_CAP` accessed offsets.
129    history: VecDeque<u64>,
130    /// Number of blocks to look ahead when generating hints.
131    lookahead: usize,
132    /// Tracks recently prefetched offsets → time they were issued.
133    prefetch_cache: HashMap<u64, Instant>,
134    /// How long a prefetch cache entry remains valid before eviction.
135    cache_ttl: Duration,
136    /// Accumulated statistics.
137    stats: ReadAheadStats,
138}
139
140impl Default for ReadAheadScheduler {
141    fn default() -> Self {
142        Self::new()
143    }
144}
145
146impl ReadAheadScheduler {
147    /// Create a scheduler with default settings (`lookahead = 8`, `ttl = 30 s`).
148    pub fn new() -> Self {
149        Self {
150            history: VecDeque::with_capacity(HISTORY_CAP),
151            lookahead: 8,
152            prefetch_cache: HashMap::new(),
153            cache_ttl: Duration::from_secs(30),
154            stats: ReadAheadStats::default(),
155        }
156    }
157
158    /// Create a scheduler with custom lookahead and TTL.
159    pub fn with_config(lookahead: usize, cache_ttl: Duration) -> Self {
160        Self {
161            history: VecDeque::with_capacity(HISTORY_CAP),
162            lookahead,
163            prefetch_cache: HashMap::new(),
164            cache_ttl,
165            stats: ReadAheadStats::default(),
166        }
167    }
168
169    // ──────────────────────────────────────────────────────────
170    // Public API
171    // ──────────────────────────────────────────────────────────
172
173    /// Record a block access at the given offset.
174    ///
175    /// Trims history to the last `HISTORY_CAP` entries.
176    pub fn record_access(&mut self, offset: u64) {
177        if self.history.len() == HISTORY_CAP {
178            self.history.pop_front();
179        }
180        self.history.push_back(offset);
181        self.stats.total_accesses += 1;
182    }
183
184    /// Compute and return the next prefetch hints, or `None` if the current
185    /// pattern is `Random` or history is too short to classify.
186    ///
187    /// Offsets that are already in the prefetch cache (within TTL) are excluded
188    /// from `block_offsets` and counted in `stats.total_deduped`.
189    pub fn next_hints(&mut self) -> Option<PrefetchHint> {
190        // Need at least 2 data points to detect a pattern.
191        if self.history.len() < 2 {
192            return None;
193        }
194
195        let history_slice: Vec<u64> = self.history.iter().copied().collect();
196        let pattern = ReadAheadPattern::detect(&history_slice);
197
198        let last = *self.history.back()?;
199
200        let (raw_offsets, confidence) = match pattern {
201            ReadAheadPattern::Sequential => {
202                let offsets: Vec<u64> = (1..=(self.lookahead as u64))
203                    .map(|i| last.wrapping_add(i))
204                    .collect();
205                (offsets, 0.95_f32)
206            }
207            ReadAheadPattern::Strided { stride } => {
208                let offsets: Vec<u64> = (1..=(self.lookahead as u64))
209                    .map(|i| last.wrapping_add(stride.wrapping_mul(i)))
210                    .collect();
211                (offsets, 0.85_f32)
212            }
213            ReadAheadPattern::Repeated => {
214                // Hint the same offset — useful for keeping it warm.
215                (vec![last], 0.5_f32)
216            }
217            ReadAheadPattern::Random => {
218                return None;
219            }
220        };
221
222        // Deduplicate against prefetch cache.
223        let now = Instant::now();
224        let mut deduped_count: u64 = 0;
225        let mut block_offsets = Vec::with_capacity(raw_offsets.len());
226
227        for offset in raw_offsets {
228            let cached = self
229                .prefetch_cache
230                .get(&offset)
231                .is_some_and(|&issued| now.duration_since(issued) < self.cache_ttl);
232            if cached {
233                deduped_count += 1;
234            } else {
235                block_offsets.push(offset);
236                self.prefetch_cache.insert(offset, now);
237            }
238        }
239
240        self.stats.total_deduped += deduped_count;
241
242        // If every offset was deduped, still return `Some` but with an empty
243        // list so callers know the hint existed but was fully satisfied.
244        let hint = PrefetchHint {
245            block_offsets,
246            pattern,
247            confidence,
248        };
249        self.stats.total_hints_issued += 1;
250        Some(hint)
251    }
252
253    /// Remove prefetch cache entries whose age exceeds the TTL.
254    pub fn evict_stale_cache(&mut self) {
255        let ttl = self.cache_ttl;
256        let now = Instant::now();
257        self.prefetch_cache
258            .retain(|_offset, &mut issued| now.duration_since(issued) < ttl);
259    }
260
261    /// Return the current access pattern based on recorded history.
262    ///
263    /// Returns `Random` if fewer than 2 accesses have been recorded.
264    pub fn pattern(&self) -> ReadAheadPattern {
265        if self.history.len() < 2 {
266            return ReadAheadPattern::Random;
267        }
268        let slice: Vec<u64> = self.history.iter().copied().collect();
269        ReadAheadPattern::detect(&slice)
270    }
271
272    /// Immutable reference to accumulated statistics.
273    pub fn stats(&self) -> &ReadAheadStats {
274        &self.stats
275    }
276
277    /// Mutable reference to accumulated statistics (for resetting etc.).
278    pub fn stats_mut(&mut self) -> &mut ReadAheadStats {
279        &mut self.stats
280    }
281
282    /// Current number of entries in the prefetch cache (including stale ones).
283    pub fn prefetch_cache_len(&self) -> usize {
284        self.prefetch_cache.len()
285    }
286}
287
288// ──────────────────────────────────────────────────────────────
289// Tests
290// ──────────────────────────────────────────────────────────────
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295    use std::thread;
296    use std::time::Duration;
297
298    // ── AccessPattern::detect ──────────────────────────────────
299
300    #[test]
301    fn test_detect_sequential() {
302        let history = vec![10, 11, 12, 13, 14];
303        assert_eq!(
304            ReadAheadPattern::detect(&history),
305            ReadAheadPattern::Sequential
306        );
307    }
308
309    #[test]
310    fn test_detect_strided() {
311        let history = vec![0, 4, 8, 12, 16];
312        assert_eq!(
313            ReadAheadPattern::detect(&history),
314            ReadAheadPattern::Strided { stride: 4 }
315        );
316    }
317
318    #[test]
319    fn test_detect_repeated() {
320        let history = vec![7, 7, 7, 7, 7];
321        assert_eq!(
322            ReadAheadPattern::detect(&history),
323            ReadAheadPattern::Repeated
324        );
325    }
326
327    #[test]
328    fn test_detect_random() {
329        let history = vec![1, 5, 2, 9, 3];
330        assert_eq!(ReadAheadPattern::detect(&history), ReadAheadPattern::Random);
331    }
332
333    #[test]
334    fn test_detect_single_entry_is_random() {
335        let history = vec![42];
336        assert_eq!(ReadAheadPattern::detect(&history), ReadAheadPattern::Random);
337    }
338
339    #[test]
340    fn test_detect_empty_is_random() {
341        assert_eq!(ReadAheadPattern::detect(&[]), ReadAheadPattern::Random);
342    }
343
344    #[test]
345    fn test_detect_two_entry_sequential() {
346        let history = vec![100, 101];
347        assert_eq!(
348            ReadAheadPattern::detect(&history),
349            ReadAheadPattern::Sequential
350        );
351    }
352
353    // ── Sequential prefetch ────────────────────────────────────
354
355    #[test]
356    fn test_sequential_prefetch_offsets() {
357        let mut sched = ReadAheadScheduler::new();
358        for i in 0u64..4 {
359            sched.record_access(i);
360        }
361        let hint = sched.next_hints().expect("expected a hint");
362        assert_eq!(hint.pattern, ReadAheadPattern::Sequential);
363        // Last recorded offset = 3; next 8 should be 4..=11
364        let expected: Vec<u64> = (4..=11).collect();
365        assert_eq!(hint.block_offsets, expected);
366    }
367
368    #[test]
369    fn test_sequential_confidence_high() {
370        let mut sched = ReadAheadScheduler::new();
371        for i in 0u64..4 {
372            sched.record_access(i);
373        }
374        let hint = sched.next_hints().expect("expected a hint");
375        assert!(
376            hint.confidence >= 0.9,
377            "sequential confidence should be high"
378        );
379    }
380
381    // ── Strided prefetch ───────────────────────────────────────
382
383    #[test]
384    fn test_strided_prefetch_offsets() {
385        let mut sched = ReadAheadScheduler::new();
386        for i in 0u64..4 {
387            sched.record_access(i * 8);
388        }
389        let hint = sched.next_hints().expect("expected a hint");
390        assert_eq!(hint.pattern, ReadAheadPattern::Strided { stride: 8 });
391        // Last = 24; next 8 strided = 32, 40, 48, 56, 64, 72, 80, 88
392        let expected: Vec<u64> = (1..=8).map(|i| 24 + i * 8).collect();
393        assert_eq!(hint.block_offsets, expected);
394    }
395
396    // ── Random returns None ────────────────────────────────────
397
398    #[test]
399    fn test_random_returns_none() {
400        let mut sched = ReadAheadScheduler::new();
401        for &offset in &[1u64, 100, 5, 77, 42] {
402            sched.record_access(offset);
403        }
404        assert!(
405            sched.next_hints().is_none(),
406            "random pattern should produce no hint"
407        );
408    }
409
410    // ── Repeated hint ──────────────────────────────────────────
411
412    #[test]
413    fn test_repeated_returns_same_offset_hint() {
414        let mut sched = ReadAheadScheduler::new();
415        for _ in 0..5 {
416            sched.record_access(99);
417        }
418        let hint = sched
419            .next_hints()
420            .expect("expected a hint for repeated pattern");
421        assert_eq!(hint.pattern, ReadAheadPattern::Repeated);
422        assert_eq!(hint.block_offsets, vec![99]);
423        assert!(
424            (hint.confidence - 0.5).abs() < f32::EPSILON,
425            "repeated confidence should be 0.5"
426        );
427    }
428
429    // ── Dedup / prefetch cache ─────────────────────────────────
430
431    #[test]
432    fn test_dedup_skips_recently_prefetched() {
433        let mut sched = ReadAheadScheduler::new();
434        // Seed sequential pattern starting at 0.
435        for i in 0u64..4 {
436            sched.record_access(i);
437        }
438
439        // First call — should return 8 fresh offsets.
440        let hint1 = sched.next_hints().expect("expected hint");
441        assert_eq!(hint1.block_offsets.len(), 8);
442        assert_eq!(sched.stats().total_deduped, 0);
443
444        // Record one more sequential offset so the window advances by 1.
445        sched.record_access(4);
446
447        // Second call — new "last" is 4, raw hints = 5..12 (8 offsets).
448        // Offsets 5..11 were already cached from first call; only 12 is new.
449        let hint2 = sched.next_hints().expect("expected hint");
450        // 7 of the 8 offsets (5–11) should be deduped.
451        assert_eq!(
452            hint2.block_offsets.len(),
453            1,
454            "only offset 12 should be fresh"
455        );
456        assert_eq!(sched.stats().total_deduped, 7);
457    }
458
459    // ── evict_stale_cache ──────────────────────────────────────
460
461    #[test]
462    fn test_evict_stale_cache_removes_expired() {
463        // Use a very short TTL so we can expire entries without sleeping long.
464        let mut sched = ReadAheadScheduler::with_config(4, Duration::from_millis(50));
465
466        // Drive a sequential pattern to populate the cache.
467        for i in 0u64..4 {
468            sched.record_access(i);
469        }
470        let _hint = sched.next_hints();
471        assert!(sched.prefetch_cache_len() > 0, "cache should be populated");
472
473        // Wait for entries to expire.
474        thread::sleep(Duration::from_millis(80));
475
476        sched.evict_stale_cache();
477        assert_eq!(
478            sched.prefetch_cache_len(),
479            0,
480            "all entries should be evicted"
481        );
482    }
483
484    // ── History capped at 64 ───────────────────────────────────
485
486    #[test]
487    fn test_history_capped_at_64() {
488        let mut sched = ReadAheadScheduler::new();
489        for i in 0u64..200 {
490            sched.record_access(i);
491        }
492        // The VecDeque should never exceed HISTORY_CAP.
493        assert_eq!(sched.history.len(), 64);
494        // Last entry should be 199.
495        assert_eq!(*sched.history.back().expect("non-empty"), 199);
496        // First entry should be 200 - 64 = 136.
497        assert_eq!(*sched.history.front().expect("non-empty"), 136);
498    }
499
500    // ── Stats accumulate ──────────────────────────────────────
501
502    #[test]
503    fn test_stats_accumulate() {
504        let mut sched = ReadAheadScheduler::new();
505
506        // 10 accesses.
507        for i in 0u64..10 {
508            sched.record_access(i);
509        }
510        assert_eq!(sched.stats().total_accesses, 10);
511
512        // Two separate hint calls on a sequential pattern.
513        let h1 = sched.next_hints();
514        let h2 = sched.next_hints();
515
516        assert!(h1.is_some());
517        assert!(h2.is_some());
518        assert_eq!(sched.stats().total_hints_issued, 2);
519    }
520
521    // ── pattern() accessor ────────────────────────────────────
522
523    #[test]
524    fn test_pattern_accessor_sequential() {
525        let mut sched = ReadAheadScheduler::new();
526        for i in 0u64..6 {
527            sched.record_access(i);
528        }
529        assert_eq!(sched.pattern(), ReadAheadPattern::Sequential);
530    }
531
532    #[test]
533    fn test_pattern_accessor_insufficient_history() {
534        let mut sched = ReadAheadScheduler::new();
535        sched.record_access(5);
536        assert_eq!(sched.pattern(), ReadAheadPattern::Random);
537    }
538}