1use 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
18pub(crate) type BlockResult = Result<Arc<DecodedBlock>, Arc<AgError>>;
21
22pub(crate) type PendingBlock = watch::Receiver<Option<BlockResult>>;
24
25pub(crate) enum Claim {
28 Ready(Arc<DecodedBlock>),
29 Owned(InflightGuard),
30 Pending(PendingBlock),
31}
32
33pub(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 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 self.inflight.lock().unwrap().remove(&self.key);
58 }
59 }
60}
61
62#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
66pub enum BlockKind {
67 Data,
69 Mask,
71}
72
73#[derive(Clone, Debug, PartialEq, Eq, Hash)]
77pub struct BlockKey {
78 pub source: Arc<str>,
80 pub level: u32,
82 pub kind: BlockKind,
84 pub tx: u32,
86 pub ty: u32,
88}
89
90pub struct DecodedBlock {
97 array: Array,
98 weight: u32,
99}
100
101impl DecodedBlock {
102 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 pub fn weight(&self) -> u32 {
117 self.weight
118 }
119
120 pub fn array(&self) -> &Array {
122 &self.array
123 }
124}
125
126#[derive(Debug, Default)]
132struct Counters {
133 hits: AtomicU64,
135 misses: AtomicU64,
137 decoded: AtomicU64,
139 fetched: AtomicU64,
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
155pub struct BlockCacheStats {
156 pub hits: u64,
158 pub misses: u64,
160 pub decoded: u64,
162 pub fetched: u64,
164 pub entries: u64,
166 pub resident_bytes: u64,
168}
169
170#[derive(Clone)]
176pub struct BlockCache {
177 inner: moka::future::Cache<BlockKey, Arc<DecodedBlock>>,
178 counters: Arc<Counters>,
179 inflight: Arc<Mutex<HashMap<BlockKey, PendingBlock>>>,
183}
184
185impl BlockCache {
186 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 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 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 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 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 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 pub(crate) fn record_fetched(&self, n: u64) {
276 self.counters.fetched.fetch_add(n, Ordering::Relaxed);
277 }
278
279 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 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 pub(crate) fn peek(&self, key: &BlockKey) -> bool {
309 self.inner.contains_key(key)
310 }
311
312 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 pub async fn sync_count(&self) -> u64 {
331 self.inner.run_pending_tasks().await;
332 self.inner.entry_count()
333 }
334}
335
336#[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 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 #[tokio::test]
399 async fn decoded_block_weight_is_byte_size() {
400 let array = sample_array().await;
401
402 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 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 let raw_bytes: &[u8] = array.data().as_ref();
422 assert_eq!(block.weight() as usize, raw_bytes.len());
423 }
424
425 #[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 #[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 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 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 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 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 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 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 assert!(cache.get(&key).await.is_none());
587 cache
589 .try_get_with(key.clone(), async move {
590 Ok::<_, AgError>(DecodedBlock::new(array))
591 })
592 .await
593 .unwrap();
594 assert!(cache.get(&key).await.is_some());
596 assert!(cache.get(&key).await.is_some());
597 assert!(cache.get(&make_key("absent")).await.is_none());
599 cache.record_fetched(3);
600
601 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 assert_eq!(cache.clone().stats().hits, 2, "clone shares counters");
614 }
615}