Skip to main content

gwseq_io/source/
cache.rs

1//! Aligned LRU block cache in front of any [`ByteSource`].
2//!
3//! Reads are aligned to `block_size`, blocks are kept in an LRU capped at
4//! `max_blocks`, and a block already being fetched is **waited on, not fetched
5//! twice**.
6//!
7//! Each slot carries its own mutex, and the two locks are taken in one order
8//! and never together:
9//!
10//! - lock the map, find or insert the slot, clone the `Arc`, **unlock**;
11//! - lock that one slot and fill it if it is empty.
12//!
13//! So the map lock covers a hash lookup and never an I/O, while a second
14//! caller wanting the same block blocks on the slot's mutex and finds the
15//! bytes there when it wakes. There are no workers here at all: the rayon
16//! threads the reader already owns are the ones fetching.
17//!
18//! A fill that fails leaves the slot empty rather than caching the error, so
19//! the next request retries. Errors are not `Clone` and a cached one could not
20//! be handed to a second waiter anyway, but the behaviour is the one worth
21//! having on its own: a transient network failure must not stick to a reader
22//! for its lifetime.
23
24use std::ops::Range;
25use std::sync::Arc;
26
27use bytes::{Bytes, BytesMut};
28use indexmap::IndexMap;
29use parking_lot::Mutex;
30
31use crate::error::Result;
32use crate::source::ByteSource;
33
34/// The most a single `read_at` reserves up front, whatever it was asked for.
35///
36/// Comfortably above every read this crate makes in one call — the largest are
37/// an R-tree leaf and a BAM chunk span — so the growth path is dead code in
38/// practice and live only for a file that lies.
39const MAX_RESERVE: usize = 8 << 20;
40
41/// One cached block, empty until somebody fills it.
42#[derive(Debug, Default)]
43struct Slot {
44    data: Mutex<Option<Bytes>>,
45}
46
47#[derive(Debug)]
48pub struct CachedSource<S: ByteSource> {
49    inner: S,
50    block_size: u64,
51    /// Resident blocks, each with the tick of its last use. See [`Self::slot`].
52    blocks: Mutex<IndexMap<u64, (u64, Arc<Slot>)>>,
53    /// Monotonic counter the ticks come from. Wrapping it would take 2^64
54    /// cache lookups.
55    tick: std::sync::atomic::AtomicU64,
56    max_blocks: usize,
57}
58
59impl<S: ByteSource> CachedSource<S> {
60    pub fn new(inner: S, block_size: u64, max_blocks: usize) -> Self {
61        assert!(block_size > 0, "block_size must be positive");
62        assert!(max_blocks > 0, "max_blocks must be positive");
63        Self {
64            inner,
65            block_size,
66            blocks: Mutex::new(IndexMap::with_capacity(max_blocks.min(1024))),
67            tick: std::sync::atomic::AtomicU64::new(0),
68            max_blocks,
69        }
70    }
71
72    pub fn into_inner(self) -> S {
73        self.inner
74    }
75
76    pub fn block_size(&self) -> u64 {
77        self.block_size
78    }
79
80    /// How many blocks are resident. Tests and benchmarks read this; nothing
81    /// else should.
82    pub fn cached_blocks(&self) -> usize {
83        self.blocks.lock().len()
84    }
85
86    /// The blocks a `[offset, offset + len)` request touches.
87    ///
88    /// `saturating_add` because `len` may be a number a file named: a wrapped
89    /// end would put the last block *before* the first and the range would
90    /// quietly become one block.
91    fn block_range(&self, offset: u64, len: usize) -> Range<u64> {
92        let first = offset / self.block_size;
93        let last = offset.saturating_add(len as u64).div_ceil(self.block_size);
94        first..last.max(first + 1)
95    }
96
97    /// `len`, cut down to what the source can actually hand over from `offset`.
98    ///
99    /// Every reader in the crate goes through this cache, and several of them
100    /// pass a length that came straight out of the file: an R-tree leaf's
101    /// `size`, a chromosome node's `count * item_size`, a BAM chunk's span, a
102    /// HiC block's `size`. `LocalSource` and `MemorySource` clamp such a length
103    /// where they read it, but this layer sits in front of them and used to
104    /// size a buffer from it first — and a failed allocation is an abort, not
105    /// an `Err` anyone can catch.
106    ///
107    /// A source that cannot say how long it is keeps its `len` unclamped rather
108    /// than failing a read that would have worked; the reserve is capped
109    /// separately, so nothing is sized from the number either way. Both
110    /// implementations that reach here memoise their length, so this costs a
111    /// load per read after the header.
112    fn clamped(&self, offset: u64, len: usize) -> usize {
113        match self.inner.len() {
114            Ok(total) => {
115                let left = total.saturating_sub(offset);
116                len.min(usize::try_from(left).unwrap_or(usize::MAX))
117            }
118            Err(_) => len,
119        }
120    }
121
122    /// Find or create the slot for a block, evicting the least recently used if
123    /// the map is full. Never fetches — the caller does that outside the lock.
124    ///
125    /// Recency is a tick per entry rather than the map's order. Keeping the
126    /// order meant `move_index` on every *hit*, which shifts every entry
127    /// between — fine at the default 128 blocks, quadratic for a caller who
128    /// asks for ten thousand. A tick makes a hit two stores, and moves the cost
129    /// to eviction: a scan for the smallest tick, which happens only on a miss
130    /// that overflows, and a miss is about to do I/O anyway.
131    fn slot(&self, block: u64) -> Arc<Slot> {
132        let mut map = self.blocks.lock();
133        let tick = self.tick.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
134        if let Some(entry) = map.get_mut(&block) {
135            entry.0 = tick;
136            return entry.1.clone();
137        }
138        let slot = Arc::new(Slot::default());
139        map.insert(block, (tick, slot.clone()));
140        while map.len() > self.max_blocks {
141            let oldest = map
142                .iter()
143                .enumerate()
144                .min_by_key(|(_, (_, (tick, _)))| *tick)
145                .map(|(index, _)| index)
146                .expect("the map is over its limit, so it is not empty");
147            // `swap_remove` rather than `shift_remove`: the map's order carries
148            // no meaning now that recency does not live in it, and shifting
149            // would put back the cost this removed.
150            map.swap_remove_index(oldest);
151        }
152        slot
153    }
154
155    /// Fill a slot if it is empty, and hand back its bytes.
156    ///
157    /// The slot's lock is held across the read, which is the point: a second
158    /// caller wanting this block waits here instead of issuing its own request.
159    /// The map lock is not held, so the rest of the cache stays live.
160    fn fill(&self, block: u64, slot: &Slot) -> Result<Bytes> {
161        let mut guard = slot.data.lock();
162        if let Some(data) = guard.as_ref() {
163            return Ok(data.clone());
164        }
165        let data = self
166            .inner
167            .read_at(block * self.block_size, self.block_size as usize)?;
168        *guard = Some(data.clone());
169        Ok(data)
170    }
171
172    fn block(&self, block: u64) -> Result<Bytes> {
173        let slot = self.slot(block);
174        self.fill(block, &slot)
175    }
176}
177
178impl<S: ByteSource> ByteSource for CachedSource<S> {
179    fn path(&self) -> &str {
180        self.inner.path()
181    }
182
183    fn len(&self) -> Result<u64> {
184        self.inner.len()
185    }
186
187    fn read_at(&self, offset: u64, len: usize) -> Result<Bytes> {
188        let len = self.clamped(offset, len);
189        if len == 0 {
190            return Ok(Bytes::new());
191        }
192        let range = self.block_range(offset, len);
193
194        // One block: hand back a slice of it. `Bytes::slice` is a refcount
195        // bump, so a request served from cache copies nothing at all.
196        if range.end - range.start == 1 {
197            let data = self.block(range.start)?;
198            let start = (offset - range.start * self.block_size) as usize;
199            if start >= data.len() {
200                return Ok(Bytes::new());
201            }
202            let end = (start + len).min(data.len());
203            return Ok(data.slice(start..end));
204        }
205
206        // Reserved rather than allocated outright: `len` is clamped to the
207        // file above, but a file may honestly be enormous and the loop below
208        // appends only what the blocks really hold. Growth is amortised
209        // doubling, which on the few-hundred-KB reads this path actually sees
210        // never happens at all.
211        let mut out = BytesMut::with_capacity(len.min(MAX_RESERVE));
212        let mut wanted = len;
213        let mut position = offset;
214        for index in range {
215            if wanted == 0 {
216                break;
217            }
218            let data = self.block(index)?;
219            let start = (position - index * self.block_size) as usize;
220            if start >= data.len() {
221                break; // end of file inside this block
222            }
223            let take = wanted.min(data.len() - start);
224            out.extend_from_slice(&data[start..start + take]);
225            position += take as u64;
226            wanted -= take;
227            if data.len() < self.block_size as usize {
228                break; // a short block is the last one
229            }
230        }
231        Ok(out.freeze())
232    }
233
234    fn prefetch(&self, ranges: &[Range<u64>]) {
235        self.inner.prefetch(ranges)
236    }
237
238    fn close(&self) {
239        self.blocks.lock().clear();
240        self.inner.close();
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247    use crate::error::Error;
248    use std::sync::atomic::{AtomicUsize, Ordering};
249
250    /// An in-memory source that counts the reads reaching it, so the tests can
251    /// tell a cache hit from a miss.
252    #[derive(Debug)]
253    struct CountingSource {
254        data: Bytes,
255        reads: AtomicUsize,
256        fail_until: AtomicUsize,
257    }
258
259    impl CountingSource {
260        fn new(data: &[u8]) -> Self {
261            Self {
262                data: Bytes::copy_from_slice(data),
263                reads: AtomicUsize::new(0),
264                fail_until: AtomicUsize::new(0),
265            }
266        }
267    }
268
269    impl ByteSource for CountingSource {
270        fn path(&self) -> &str {
271            "memory"
272        }
273        fn len(&self) -> Result<u64> {
274            Ok(self.data.len() as u64)
275        }
276        fn read_at(&self, offset: u64, len: usize) -> Result<Bytes> {
277            self.reads.fetch_add(1, Ordering::SeqCst);
278            if self.fail_until.load(Ordering::SeqCst) > 0 {
279                self.fail_until.fetch_sub(1, Ordering::SeqCst);
280                return Err(Error::invalid("transient"));
281            }
282            let start = (offset as usize).min(self.data.len());
283            let end = (start + len).min(self.data.len());
284            Ok(self.data.slice(start..end))
285        }
286    }
287
288    fn data(n: usize) -> Vec<u8> {
289        (0..n).map(|i| (i % 251) as u8).collect()
290    }
291
292    #[test]
293    fn cached_reads_match_uncached_ones_for_every_range() {
294        let raw = data(1000);
295        for block_size in [1u64, 7, 64, 512, 4096] {
296            let cache = CachedSource::new(CountingSource::new(&raw), block_size, 64);
297            let plain = CountingSource::new(&raw);
298            for offset in [0u64, 1, 63, 64, 65, 511, 999, 1000, 1500] {
299                for len in [0usize, 1, 5, 64, 200, 1000] {
300                    let a = cache.read_at(offset, len).unwrap();
301                    let b = plain.read_at(offset, len).unwrap();
302                    assert_eq!(a, b, "block_size={block_size} offset={offset} len={len}");
303                }
304            }
305        }
306    }
307
308    /// The one allocation in this file sized from a caller's number, and the
309    /// caller is often a file: a bbi leaf `size`, a chromosome node's
310    /// `count * item_size`, a BAM chunk span, a HiC block `size`. Before the
311    /// clamp, `1 << 60` here aborted the process — no `Err` can carry a failed
312    /// allocation, so this is the only place it can be stopped.
313    #[test]
314    fn a_length_larger_than_the_file_is_not_allocated() {
315        let raw = data(100);
316        for block_size in [4u64, 32, 4096] {
317            let cache = CachedSource::new(CountingSource::new(&raw), block_size, 4);
318            assert_eq!(cache.read_at(0, 1 << 60).unwrap(), &raw[..]);
319            assert_eq!(cache.read_at(50, usize::MAX).unwrap(), &raw[50..]);
320            assert!(cache.read_at(100, 1 << 60).unwrap().is_empty());
321            // An offset past the end plus a length that would wrap `u64`.
322            assert!(cache.read_at(u64::MAX - 1, 1 << 60).unwrap().is_empty());
323        }
324    }
325
326    #[test]
327    fn a_repeated_read_does_not_reach_the_source_again() {
328        let raw = data(1000);
329        let cache = CachedSource::new(CountingSource::new(&raw), 128, 64);
330        cache.read_at(0, 100).unwrap();
331        let after_first = cache.inner.reads.load(Ordering::SeqCst);
332        assert_eq!(after_first, 1);
333        for _ in 0..10 {
334            cache.read_at(0, 100).unwrap();
335            cache.read_at(20, 50).unwrap();
336        }
337        assert_eq!(cache.inner.reads.load(Ordering::SeqCst), after_first);
338    }
339
340    #[test]
341    fn the_lru_evicts_the_oldest_and_a_hit_renews_it() {
342        let raw = data(10_000);
343        let cache = CachedSource::new(CountingSource::new(&raw), 100, 3);
344        for block in 0..3u64 {
345            cache.read_at(block * 100, 10).unwrap();
346        }
347        assert_eq!(cache.cached_blocks(), 3);
348        // Touch block 0 so block 1 becomes the oldest, then force an eviction.
349        cache.read_at(0, 10).unwrap();
350        cache.read_at(300, 10).unwrap();
351        assert_eq!(cache.cached_blocks(), 3);
352        let before = cache.inner.reads.load(Ordering::SeqCst);
353        cache.read_at(0, 10).unwrap(); // still resident
354        assert_eq!(cache.inner.reads.load(Ordering::SeqCst), before);
355        cache.read_at(100, 10).unwrap(); // was evicted
356        assert_eq!(cache.inner.reads.load(Ordering::SeqCst), before + 1);
357    }
358
359    #[test]
360    fn a_failed_fill_is_retried_rather_than_cached() {
361        let raw = data(500);
362        let cache = CachedSource::new(CountingSource::new(&raw), 128, 8);
363        cache.inner.fail_until.store(1, Ordering::SeqCst);
364        assert!(cache.read_at(0, 10).is_err());
365        // The next request must reach the source again and succeed.
366        assert_eq!(&cache.read_at(0, 10).unwrap()[..], &raw[0..10]);
367    }
368
369    #[test]
370    fn concurrent_readers_of_one_block_fetch_it_once() {
371        let raw = data(1 << 16);
372        let cache = Arc::new(CachedSource::new(CountingSource::new(&raw), 1 << 12, 64));
373        let threads: Vec<_> = (0..16)
374            .map(|_| {
375                let cache = cache.clone();
376                let raw = raw.clone();
377                std::thread::spawn(move || {
378                    for _ in 0..200 {
379                        let got = cache.read_at(4096, 4096).unwrap();
380                        assert_eq!(&got[..], &raw[4096..8192]);
381                    }
382                })
383            })
384            .collect();
385        for t in threads {
386            t.join().unwrap();
387        }
388        // Sixteen threads, one block, one read of it.
389        assert_eq!(cache.inner.reads.load(Ordering::SeqCst), 1);
390    }
391
392    #[test]
393    fn close_drops_the_blocks_and_the_handle() {
394        let cache = CachedSource::new(CountingSource::new(&data(500)), 128, 8);
395        cache.read_at(0, 10).unwrap();
396        assert_eq!(cache.cached_blocks(), 1);
397        cache.close();
398        assert_eq!(cache.cached_blocks(), 0);
399    }
400}