Skip to main content

async_geotiff/io/
block_cache.rs

1//! Shared decoded-block cache.
2//!
3//! A single `BlockCache` can be shared across multiple readers via `Arc<BlockCache>`.
4//! It caches decoded `async_tiff::Array` tiles under a configurable byte budget
5//! using moka's TinyLFU policy.
6
7use std::collections::HashMap;
8use std::future::Future;
9use std::sync::atomic::{AtomicU64, Ordering};
10use std::sync::{Arc, Mutex};
11
12use async_tiff::Array;
13use tokio::sync::watch;
14
15use crate::error::AgError;
16use crate::io::cache::CacheConfig;
17
18/// Result of an in-flight block fetch+decode, cloneable so concurrent waiters
19/// each get a copy. The error is `Arc`-wrapped because `AgError` is not `Clone`.
20pub(crate) type BlockResult = Result<Arc<DecodedBlock>, Arc<AgError>>;
21
22/// A receiver a concurrent caller awaits while another caller fetches the block.
23pub(crate) type PendingBlock = watch::Receiver<Option<BlockResult>>;
24
25/// Outcome of [`BlockCache::claim`]: either this caller owns the fetch (and must
26/// `resolve` the returned guard), or another caller is already fetching it.
27pub(crate) enum Claim {
28    Ready(Arc<DecodedBlock>),
29    Owned(InflightGuard),
30    Pending(PendingBlock),
31}
32
33/// Held by the caller that owns a block's fetch+decode. Dropping it without
34/// [`resolve`](InflightGuard::resolve) closes the channel, so waiters observe a
35/// cancellation and can retry, and the in-flight slot is always cleaned up.
36pub(crate) struct InflightGuard {
37    key: BlockKey,
38    tx: watch::Sender<Option<BlockResult>>,
39    inflight: Arc<Mutex<HashMap<BlockKey, PendingBlock>>>,
40    resolved: bool,
41}
42
43impl InflightGuard {
44    /// Publish the fetched+decoded result to any waiters and free the slot.
45    pub(crate) fn resolve(mut self, result: BlockResult) {
46        let _ = self.tx.send(Some(result));
47        self.inflight.lock().unwrap().remove(&self.key);
48        self.resolved = true;
49    }
50}
51
52impl Drop for InflightGuard {
53    fn drop(&mut self) {
54        if !self.resolved {
55            // Owner gave up (fetch/decode error or task abort): remove the slot;
56            // dropping `tx` makes waiters' `changed()` return Err → they retry.
57            self.inflight.lock().unwrap().remove(&self.key);
58        }
59    }
60}
61
62// ── BlockKind ───────────────────────────────────────────────────────────────
63
64/// Whether a block carries image data or an alpha/validity mask.
65#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
66pub enum BlockKind {
67    /// Samples from a data IFD, including any native extra sample bands.
68    Data,
69    /// Samples from an independent TIFF validity-mask IFD.
70    Mask,
71}
72
73// ── BlockKey ────────────────────────────────────────────────────────────────
74
75/// Cache key identifying a single decoded TIFF block.
76#[derive(Clone, Debug, PartialEq, Eq, Hash)]
77pub struct BlockKey {
78    /// Source URL / path of the COG.
79    pub source: Arc<str>,
80    /// Overview level index (0 = full resolution).
81    pub level: u32,
82    /// Data or mask.
83    pub kind: BlockKind,
84    /// Tile column index.
85    pub tx: u32,
86    /// Tile row index.
87    pub ty: u32,
88}
89
90// ── DecodedBlock ────────────────────────────────────────────────────────────
91
92/// A decoded TIFF block together with its pre-computed resident byte size.
93///
94/// The weight is computed once at construction time so the moka weigher can
95/// call it without re-traversing the array.
96pub struct DecodedBlock {
97    array: Array,
98    weight: u32,
99}
100
101impl DecodedBlock {
102    /// Wrap `array` and pre-compute its byte weight.
103    ///
104    /// Weight = element count × bytes-per-element, saturating into `u32`.
105    pub fn new(array: Array) -> Self {
106        let elems = array.data().len();
107        let elem_bytes = array.data_type().map(|dt| dt.size()).unwrap_or(1);
108        let weight = elems
109            .saturating_mul(elem_bytes)
110            .try_into()
111            .unwrap_or(u32::MAX);
112        Self { array, weight }
113    }
114
115    /// Resident byte size of this block (used by moka weigher).
116    pub fn weight(&self) -> u32 {
117        self.weight
118    }
119
120    /// Access the underlying decoded array.
121    pub fn array(&self) -> &Array {
122        &self.array
123    }
124}
125
126// ── BlockCache ──────────────────────────────────────────────────────────────
127
128// ── Stats ───────────────────────────────────────────────────────────────────
129
130/// Monotonic counters shared by all clones of a [`BlockCache`].
131#[derive(Debug, Default)]
132struct Counters {
133    /// Probe (`get`) calls that found a live entry.
134    hits: AtomicU64,
135    /// Probe (`get`) calls that found nothing.
136    misses: AtomicU64,
137    /// Blocks actually decoded (one per `try_get_with` miss; single-flighted).
138    decoded: AtomicU64,
139    /// Blocks fetched from the object store. May exceed `decoded` when
140    /// concurrent requests fetch the same cold block before either inserts it.
141    fetched: AtomicU64,
142}
143
144/// Point-in-time snapshot of [`BlockCache`] activity, for metrics/monitoring.
145///
146/// `hits`/`misses`/`decoded`/`fetched` are monotonic since the cache was
147/// created; `entries`/`resident_bytes` are the current (eventually-consistent)
148/// cache occupancy. Poll this periodically and export to your metrics backend
149/// (e.g. OpenTelemetry gauges/counters):
150/// - hit ratio = `hits / (hits + misses)`,
151/// - decode-saving = `1 - decoded / misses`,
152/// - fetch amplification = `fetched / decoded` (1.0 = no duplicate fetches),
153/// - `resident_bytes` vs the configured budget = memory pressure.
154#[derive(Debug, Clone, Copy, PartialEq, Eq)]
155pub struct BlockCacheStats {
156    /// Probe hits (block already decoded and resident).
157    pub hits: u64,
158    /// Probe misses (block needed fetch + decode).
159    pub misses: u64,
160    /// Blocks decoded (single-flighted; ~= unique cold blocks).
161    pub decoded: u64,
162    /// Blocks fetched from the object store.
163    pub fetched: u64,
164    /// Current number of cached blocks (eventually consistent).
165    pub entries: u64,
166    /// Current resident byte size of cached blocks (eventually consistent).
167    pub resident_bytes: u64,
168}
169
170/// Shared, byte-bounded cache of decoded TIFF blocks.
171///
172/// `BlockCache` is cheaply cloneable; clones share the same underlying cache
173/// (the inner `moka::future::Cache` is `Arc`-backed) **and** the same stats
174/// counters.
175#[derive(Clone)]
176pub struct BlockCache {
177    inner: moka::future::Cache<BlockKey, Arc<DecodedBlock>>,
178    counters: Arc<Counters>,
179    /// In-flight compressed-block fetches, keyed identically to the decoded
180    /// cache. Lets concurrent requests for the same cold block coalesce the
181    /// network range GET (not just the decode, which moka already single-flights).
182    inflight: Arc<Mutex<HashMap<BlockKey, PendingBlock>>>,
183}
184
185impl BlockCache {
186    /// Build a new cache honoring `cfg`.
187    pub fn new(cfg: CacheConfig) -> Self {
188        let mut builder = moka::future::Cache::builder()
189            .max_capacity(cfg.max_bytes)
190            .weigher(|_k: &BlockKey, v: &Arc<DecodedBlock>| v.weight());
191        if let Some(ttl) = cfg.ttl {
192            builder = builder.time_to_live(ttl);
193        }
194        Self {
195            inner: builder.build(),
196            counters: Arc::new(Counters::default()),
197            inflight: Arc::new(Mutex::new(HashMap::new())),
198        }
199    }
200
201    /// Claim the right to fetch+decode `key`, or get a handle to await whoever
202    /// already claimed it. The first caller for a cold block receives
203    /// [`Claim::Owned`]; concurrent callers receive [`Claim::Pending`] and avoid
204    /// issuing a duplicate network fetch.
205    pub(crate) async fn claim(&self, key: &BlockKey) -> Claim {
206        let guard = {
207            let mut map = self.inflight.lock().unwrap();
208            if let Some(rx) = map.get(key) {
209                return Claim::Pending(rx.clone());
210            }
211            let (tx, rx) = watch::channel(None);
212            map.insert(key.clone(), rx);
213            InflightGuard {
214                key: key.clone(),
215                tx,
216                inflight: Arc::clone(&self.inflight),
217                resolved: false,
218            }
219        };
220        // A prior owner may have completed since the caller's initial probe.
221        // Recheck only after reserving the slot, so no other owner can race us.
222        if let Some(block) = self.inner.get(key).await {
223            guard.resolve(Ok(Arc::clone(&block)));
224            Claim::Ready(block)
225        } else {
226            Claim::Owned(guard)
227        }
228    }
229
230    /// Await an in-flight block another caller is fetching.
231    ///
232    /// Returns `Ok(Some(block))` on success, `Ok(None)` when the owner aborted
233    /// without publishing (caller should retry with a fresh `claim`), or `Err`
234    /// for decode errors forwarded by the owner.
235    pub(crate) async fn await_pending(
236        mut rx: PendingBlock,
237    ) -> Result<Option<Arc<DecodedBlock>>, AgError> {
238        if let Some(v) = rx.borrow().clone() {
239            return v
240                .map(Some)
241                .map_err(|e| AgError::Tiff(format!("block decode: {e}")));
242        }
243        loop {
244            if rx.changed().await.is_err() {
245                // Sender dropped without publishing: owner was aborted (client
246                // disconnect or task cancellation). Signal caller to retry.
247                return Ok(None);
248            }
249            if let Some(v) = rx.borrow().clone() {
250                return v
251                    .map(Some)
252                    .map_err(|e| AgError::Tiff(format!("block decode: {e}")));
253            }
254        }
255    }
256
257    /// Snapshot the cache's activity counters and current occupancy.
258    ///
259    /// Cheap and non-blocking; intended to be polled periodically by a metrics
260    /// exporter. `entries`/`resident_bytes` come from moka and are eventually
261    /// consistent.
262    pub fn stats(&self) -> BlockCacheStats {
263        BlockCacheStats {
264            hits: self.counters.hits.load(Ordering::Relaxed),
265            misses: self.counters.misses.load(Ordering::Relaxed),
266            decoded: self.counters.decoded.load(Ordering::Relaxed),
267            fetched: self.counters.fetched.load(Ordering::Relaxed),
268            entries: self.inner.entry_count(),
269            resident_bytes: self.inner.weighted_size(),
270        }
271    }
272
273    /// Record that `n` blocks were fetched from the object store. Called by the
274    /// reader after a coalesced fetch of cache misses.
275    pub(crate) fn record_fetched(&self, n: u64) {
276        self.counters.fetched.fetch_add(n, Ordering::Relaxed);
277    }
278
279    /// Return the cached block for `key`, calling `init` exactly once when
280    /// there is no live entry (concurrent requests are coalesced).
281    ///
282    /// `init` should produce a raw `DecodedBlock`; this method wraps it in
283    /// `Arc` before insertion so that all callers receive `Arc`-equal
284    /// pointers on a cache hit.
285    pub async fn try_get_with(
286        &self,
287        key: BlockKey,
288        init: impl Future<Output = Result<DecodedBlock, AgError>>,
289    ) -> Result<Arc<DecodedBlock>, AgError> {
290        // The init future runs only on a miss (moka single-flights it), so
291        // counting here counts actual decodes, deduped across concurrent races.
292        let counters = Arc::clone(&self.counters);
293        self.inner
294            .try_get_with(key, async move {
295                let v = init.await.map(Arc::new);
296                if v.is_ok() {
297                    counters.decoded.fetch_add(1, Ordering::Relaxed);
298                }
299                v
300            })
301            .await
302            .map_err(|e: Arc<AgError>| AgError::Tiff(format!("block decode: {e}")))
303    }
304
305    /// Existence check that does NOT touch the hit/miss counters. Used by the
306    /// background prefetcher, whose probes would otherwise skew the cache
307    /// stats that operators tune capacity against.
308    pub(crate) fn peek(&self, key: &BlockKey) -> bool {
309        self.inner.contains_key(key)
310    }
311
312    /// Probe the cache without populating it. Records a hit or miss.
313    pub async fn get(&self, key: &BlockKey) -> Option<Arc<DecodedBlock>> {
314        let hit = self.inner.get(key).await;
315        let counter = if hit.is_some() {
316            &self.counters.hits
317        } else {
318            &self.counters.misses
319        };
320        counter.fetch_add(1, Ordering::Relaxed);
321        hit
322    }
323
324    /// Flush pending internal tasks and return the number of live entries.
325    ///
326    /// `moka`'s `entry_count` is eventually consistent; calling
327    /// `run_pending_tasks` first makes it deterministic in unit tests.
328    ///
329    /// Intended for tests and diagnostics only; do not call on hot paths.
330    pub async fn sync_count(&self) -> u64 {
331        self.inner.run_pending_tasks().await;
332        self.inner.entry_count()
333    }
334}
335
336// ── Tests ───────────────────────────────────────────────────────────────────
337
338#[cfg(test)]
339mod tests {
340    use std::sync::atomic::{AtomicUsize, Ordering};
341    use std::sync::Arc;
342
343    use async_tiff::metadata::{cache::ReadaheadMetadataCache, TiffMetadataReader};
344    use async_tiff::reader::ObjectReader;
345    use async_tiff::{DataType, TIFF};
346    use object_store::local::LocalFileSystem;
347    use object_store::path::Path;
348
349    use super::*;
350    use crate::error::AgError;
351    use crate::io::cache::CacheConfig;
352
353    /// Open the checked-in UInt16 fixture and decode tile (0, 0).
354    ///
355    /// This is the only public path to an `async_tiff::Array` since
356    /// `Array::try_new` is `pub(crate)` in async-tiff 0.3.x.
357    ///
358    /// A missing required fixture must fail the test.
359    async fn sample_array() -> Array {
360        let tif = std::env::current_dir()
361            .unwrap()
362            .join("tests/fixtures/uint16.tif");
363        let fs = Arc::new(LocalFileSystem::new());
364        let reader = ObjectReader::new(fs, Path::from_filesystem_path(&tif).unwrap());
365        let md_cache = ReadaheadMetadataCache::new(reader.clone());
366        let mut md = TiffMetadataReader::try_open(&md_cache).await.unwrap();
367        let ifds = md.read_all_ifds(&md_cache).await.unwrap();
368        let tiff = TIFF::new(ifds, md.endianness());
369        let ifd = &tiff.ifds()[0];
370        let tile = ifd.fetch_tile(0, 0, &reader).await.unwrap();
371        tile.decode(&Default::default()).unwrap()
372    }
373
374    fn make_key(source: &str) -> BlockKey {
375        BlockKey {
376            source: Arc::from(source),
377            level: 0,
378            kind: BlockKind::Data,
379            tx: 0,
380            ty: 0,
381        }
382    }
383
384    fn default_cache() -> BlockCache {
385        BlockCache::new(CacheConfig::default())
386    }
387
388    // ── Test 1 ──────────────────────────────────────────────────────────────
389
390    /// `DecodedBlock::weight` must equal the raw byte count of the array data.
391    ///
392    /// We verify this two independent ways:
393    ///  (a) `weight == elements × dtype_size` (the formula under test)
394    ///  (b) `weight == TypedArray::as_ref().len()` (the actual byte buffer)
395    ///
396    /// With a UInt16 fixture the multiply is exercised: dropping the
397    /// `× dtype.size()` factor would make (a) half of (b) and the test fails.
398    #[tokio::test]
399    async fn decoded_block_weight_is_byte_size() {
400        let array = sample_array().await;
401
402        // Verify we got a non-trivial UInt16 tile.
403        assert_eq!(
404            array.data_type(),
405            Some(DataType::UInt16),
406            "fixture must be UInt16 for this test to be meaningful"
407        );
408        let elems = array.data().len();
409        assert!(elems > 0, "fixture tile must contain data");
410
411        let block = DecodedBlock::new(array.clone());
412
413        // (a) formula: elements × bytes-per-element
414        let expected_via_formula = elems
415            .saturating_mul(DataType::UInt16.size())
416            .try_into()
417            .unwrap_or(u32::MAX);
418        assert_eq!(block.weight(), expected_via_formula);
419
420        // (b) raw byte view (different code path)
421        let raw_bytes: &[u8] = array.data().as_ref();
422        assert_eq!(block.weight() as usize, raw_bytes.len());
423    }
424
425    // ── Test 2 ──────────────────────────────────────────────────────────────
426
427    /// Two `try_get_with` calls with the same key must share one `Arc` and
428    /// call the init closure exactly once.
429    #[tokio::test]
430    async fn same_key_returns_same_arc_and_decodes_once() {
431        let cache = default_cache();
432        let key = make_key("aot");
433        let calls = Arc::new(AtomicUsize::new(0));
434
435        let array_a = sample_array().await;
436        let array_b = sample_array().await;
437
438        let calls_a = Arc::clone(&calls);
439        let a = cache
440            .try_get_with(key.clone(), async move {
441                calls_a.fetch_add(1, Ordering::SeqCst);
442                Ok::<_, AgError>(DecodedBlock::new(array_a))
443            })
444            .await
445            .unwrap();
446
447        let calls_b = Arc::clone(&calls);
448        let b = cache
449            .try_get_with(key, async move {
450                calls_b.fetch_add(1, Ordering::SeqCst);
451                Ok::<_, AgError>(DecodedBlock::new(array_b))
452            })
453            .await
454            .unwrap();
455
456        assert!(Arc::ptr_eq(&a, &b), "both lookups must return the same Arc");
457        assert_eq!(
458            calls.load(Ordering::SeqCst),
459            1,
460            "init must be called exactly once"
461        );
462    }
463
464    // ── Test 3 ──────────────────────────────────────────────────────────────
465
466    /// Two keys differing only in `source` must produce distinct `Arc`s, each
467    /// carrying data that matches the weight of its own array.
468    #[tokio::test]
469    async fn distinct_sources_do_not_collide() {
470        let cache = default_cache();
471
472        let array1 = sample_array().await;
473        let array2 = sample_array().await;
474        let expected_weight = DecodedBlock::new(array1.clone()).weight();
475
476        let key1 = make_key("source-a");
477        let key2 = make_key("source-b");
478
479        let b1 = cache
480            .try_get_with(
481                key1,
482                async move { Ok::<_, AgError>(DecodedBlock::new(array1)) },
483            )
484            .await
485            .unwrap();
486
487        let b2 = cache
488            .try_get_with(
489                key2,
490                async move { Ok::<_, AgError>(DecodedBlock::new(array2)) },
491            )
492            .await
493            .unwrap();
494
495        assert!(
496            !Arc::ptr_eq(&b1, &b2),
497            "different sources must not alias the same Arc"
498        );
499        // Both tiles come from the same fixture so their weights should match.
500        assert_eq!(b2.weight(), expected_weight, "second block weight mismatch");
501
502        assert_eq!(cache.sync_count().await, 2, "cache should hold 2 entries");
503    }
504
505    #[tokio::test]
506    async fn claim_dedups_and_await_pending_receives_result() {
507        let array = sample_array().await;
508        let cache = default_cache();
509        let key = make_key("inflight");
510
511        // First claim owns the fetch; a concurrent claim is pending.
512        let guard = match cache.claim(&key).await {
513            Claim::Owned(g) => g,
514            Claim::Pending(_) | Claim::Ready(_) => panic!("first claim must own the fetch"),
515        };
516        let pending = match cache.claim(&key).await {
517            Claim::Pending(rx) => rx,
518            Claim::Owned(_) | Claim::Ready(_) => panic!("second concurrent claim must be pending"),
519        };
520
521        let block = Arc::new(DecodedBlock::new(array));
522        guard.resolve(Ok(Arc::clone(&block)));
523
524        // The waiter receives the exact Arc the owner produced.
525        let got = BlockCache::await_pending(pending).await.unwrap().unwrap();
526        assert!(
527            Arc::ptr_eq(&got, &block),
528            "waiter must get the owner's block"
529        );
530
531        // Slot is freed after resolve → a later claim owns again.
532        assert!(
533            matches!(cache.claim(&key).await, Claim::Owned(_)),
534            "resolved slot must be freed for re-claim"
535        );
536    }
537
538    #[tokio::test]
539    async fn dropped_guard_cancels_waiters_and_frees_slot() {
540        let cache = default_cache();
541        let key = make_key("inflight-drop");
542        let guard = match cache.claim(&key).await {
543            Claim::Owned(g) => g,
544            Claim::Pending(_) | Claim::Ready(_) => unreachable!(),
545        };
546        let pending = match cache.claim(&key).await {
547            Claim::Pending(rx) => rx,
548            Claim::Owned(_) | Claim::Ready(_) => unreachable!(),
549        };
550        // Owner gives up without resolving (e.g. fetch error / task abort).
551        drop(guard);
552        assert!(
553            BlockCache::await_pending(pending).await.unwrap().is_none(),
554            "waiter must observe Ok(None) when owner drops (signal to retry)"
555        );
556        assert!(
557            matches!(cache.claim(&key).await, Claim::Owned(_)),
558            "cancelled slot must be freed for re-claim"
559        );
560    }
561
562    #[tokio::test]
563    async fn audit_claim_rechecks_cache_after_stale_miss() {
564        let cache = default_cache();
565        let key = make_key("race");
566        assert!(cache.get(&key).await.is_none());
567        // Another request completes between our initial probe and claim.
568        let array = sample_array().await;
569        cache
570            .try_get_with(key.clone(), async move { Ok(DecodedBlock::new(array)) })
571            .await
572            .unwrap();
573        assert!(
574            !matches!(cache.claim(&key).await, Claim::Owned(_)),
575            "stale miss must not own a duplicate fetch"
576        );
577    }
578
579    #[tokio::test]
580    async fn stats_track_hits_misses_decode_and_fetch() {
581        let array = sample_array().await;
582        let cache = BlockCache::new(CacheConfig::default());
583        let key = make_key("s1");
584
585        // Initial probe → miss.
586        assert!(cache.get(&key).await.is_none());
587        // Decode + insert (one decode).
588        cache
589            .try_get_with(key.clone(), async move {
590                Ok::<_, AgError>(DecodedBlock::new(array))
591            })
592            .await
593            .unwrap();
594        // Two probes → two hits.
595        assert!(cache.get(&key).await.is_some());
596        assert!(cache.get(&key).await.is_some());
597        // A probe for an absent key → miss.
598        assert!(cache.get(&make_key("absent")).await.is_none());
599        cache.record_fetched(3);
600
601        // `entries`/`resident_bytes` are eventually consistent in moka; flush
602        // pending tasks so they reflect the single insert deterministically.
603        let _ = cache.sync_count().await;
604        let s = cache.stats();
605        assert_eq!(s.hits, 2, "hits");
606        assert_eq!(s.misses, 2, "misses");
607        assert_eq!(s.decoded, 1, "decoded once (single-flight init)");
608        assert_eq!(s.fetched, 3, "fetched");
609        assert_eq!(s.entries, 1, "one cached entry");
610        assert!(s.resident_bytes > 0, "resident bytes after one insert");
611
612        // Stats are shared across clones.
613        assert_eq!(cache.clone().stats().hits, 2, "clone shares counters");
614    }
615}