use std::collections::HashMap;
use std::future::Future;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use async_tiff::Array;
use tokio::sync::watch;
use crate::error::AgError;
use crate::io::cache::CacheConfig;
pub(crate) type BlockResult = Result<Arc<DecodedBlock>, Arc<AgError>>;
pub(crate) type PendingBlock = watch::Receiver<Option<BlockResult>>;
pub(crate) enum Claim {
Ready(Arc<DecodedBlock>),
Owned(InflightGuard),
Pending(PendingBlock),
}
pub(crate) struct InflightGuard {
key: BlockKey,
tx: watch::Sender<Option<BlockResult>>,
inflight: Arc<Mutex<HashMap<BlockKey, PendingBlock>>>,
resolved: bool,
}
impl InflightGuard {
pub(crate) fn resolve(mut self, result: BlockResult) {
let _ = self.tx.send(Some(result));
self.inflight.lock().unwrap().remove(&self.key);
self.resolved = true;
}
}
impl Drop for InflightGuard {
fn drop(&mut self) {
if !self.resolved {
self.inflight.lock().unwrap().remove(&self.key);
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum BlockKind {
Data,
Mask,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct BlockKey {
pub source: Arc<str>,
pub level: u32,
pub kind: BlockKind,
pub tx: u32,
pub ty: u32,
}
pub struct DecodedBlock {
array: Array,
weight: u32,
}
impl DecodedBlock {
pub fn new(array: Array) -> Self {
let elems = array.data().len();
let elem_bytes = array.data_type().map(|dt| dt.size()).unwrap_or(1);
let weight = elems
.saturating_mul(elem_bytes)
.try_into()
.unwrap_or(u32::MAX);
Self { array, weight }
}
pub fn weight(&self) -> u32 {
self.weight
}
pub fn array(&self) -> &Array {
&self.array
}
}
#[derive(Debug, Default)]
struct Counters {
hits: AtomicU64,
misses: AtomicU64,
decoded: AtomicU64,
fetched: AtomicU64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BlockCacheStats {
pub hits: u64,
pub misses: u64,
pub decoded: u64,
pub fetched: u64,
pub entries: u64,
pub resident_bytes: u64,
}
#[derive(Clone)]
pub struct BlockCache {
inner: moka::future::Cache<BlockKey, Arc<DecodedBlock>>,
counters: Arc<Counters>,
inflight: Arc<Mutex<HashMap<BlockKey, PendingBlock>>>,
}
impl BlockCache {
pub fn new(cfg: CacheConfig) -> Self {
let mut builder = moka::future::Cache::builder()
.max_capacity(cfg.max_bytes)
.weigher(|_k: &BlockKey, v: &Arc<DecodedBlock>| v.weight());
if let Some(ttl) = cfg.ttl {
builder = builder.time_to_live(ttl);
}
Self {
inner: builder.build(),
counters: Arc::new(Counters::default()),
inflight: Arc::new(Mutex::new(HashMap::new())),
}
}
pub(crate) async fn claim(&self, key: &BlockKey) -> Claim {
let guard = {
let mut map = self.inflight.lock().unwrap();
if let Some(rx) = map.get(key) {
return Claim::Pending(rx.clone());
}
let (tx, rx) = watch::channel(None);
map.insert(key.clone(), rx);
InflightGuard {
key: key.clone(),
tx,
inflight: Arc::clone(&self.inflight),
resolved: false,
}
};
if let Some(block) = self.inner.get(key).await {
guard.resolve(Ok(Arc::clone(&block)));
Claim::Ready(block)
} else {
Claim::Owned(guard)
}
}
pub(crate) async fn await_pending(
mut rx: PendingBlock,
) -> Result<Option<Arc<DecodedBlock>>, AgError> {
if let Some(v) = rx.borrow().clone() {
return v
.map(Some)
.map_err(|e| AgError::Tiff(format!("block decode: {e}")));
}
loop {
if rx.changed().await.is_err() {
return Ok(None);
}
if let Some(v) = rx.borrow().clone() {
return v
.map(Some)
.map_err(|e| AgError::Tiff(format!("block decode: {e}")));
}
}
}
pub fn stats(&self) -> BlockCacheStats {
BlockCacheStats {
hits: self.counters.hits.load(Ordering::Relaxed),
misses: self.counters.misses.load(Ordering::Relaxed),
decoded: self.counters.decoded.load(Ordering::Relaxed),
fetched: self.counters.fetched.load(Ordering::Relaxed),
entries: self.inner.entry_count(),
resident_bytes: self.inner.weighted_size(),
}
}
pub(crate) fn record_fetched(&self, n: u64) {
self.counters.fetched.fetch_add(n, Ordering::Relaxed);
}
pub async fn try_get_with(
&self,
key: BlockKey,
init: impl Future<Output = Result<DecodedBlock, AgError>>,
) -> Result<Arc<DecodedBlock>, AgError> {
let counters = Arc::clone(&self.counters);
self.inner
.try_get_with(key, async move {
let v = init.await.map(Arc::new);
if v.is_ok() {
counters.decoded.fetch_add(1, Ordering::Relaxed);
}
v
})
.await
.map_err(|e: Arc<AgError>| AgError::Tiff(format!("block decode: {e}")))
}
pub(crate) fn peek(&self, key: &BlockKey) -> bool {
self.inner.contains_key(key)
}
pub async fn get(&self, key: &BlockKey) -> Option<Arc<DecodedBlock>> {
let hit = self.inner.get(key).await;
let counter = if hit.is_some() {
&self.counters.hits
} else {
&self.counters.misses
};
counter.fetch_add(1, Ordering::Relaxed);
hit
}
pub async fn sync_count(&self) -> u64 {
self.inner.run_pending_tasks().await;
self.inner.entry_count()
}
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use async_tiff::metadata::{cache::ReadaheadMetadataCache, TiffMetadataReader};
use async_tiff::reader::ObjectReader;
use async_tiff::{DataType, TIFF};
use object_store::local::LocalFileSystem;
use object_store::path::Path;
use super::*;
use crate::error::AgError;
use crate::io::cache::CacheConfig;
async fn sample_array() -> Array {
let tif = std::env::current_dir()
.unwrap()
.join("tests/fixtures/uint16.tif");
let fs = Arc::new(LocalFileSystem::new());
let reader = ObjectReader::new(fs, Path::from_filesystem_path(&tif).unwrap());
let md_cache = ReadaheadMetadataCache::new(reader.clone());
let mut md = TiffMetadataReader::try_open(&md_cache).await.unwrap();
let ifds = md.read_all_ifds(&md_cache).await.unwrap();
let tiff = TIFF::new(ifds, md.endianness());
let ifd = &tiff.ifds()[0];
let tile = ifd.fetch_tile(0, 0, &reader).await.unwrap();
tile.decode(&Default::default()).unwrap()
}
fn make_key(source: &str) -> BlockKey {
BlockKey {
source: Arc::from(source),
level: 0,
kind: BlockKind::Data,
tx: 0,
ty: 0,
}
}
fn default_cache() -> BlockCache {
BlockCache::new(CacheConfig::default())
}
#[tokio::test]
async fn decoded_block_weight_is_byte_size() {
let array = sample_array().await;
assert_eq!(
array.data_type(),
Some(DataType::UInt16),
"fixture must be UInt16 for this test to be meaningful"
);
let elems = array.data().len();
assert!(elems > 0, "fixture tile must contain data");
let block = DecodedBlock::new(array.clone());
let expected_via_formula = elems
.saturating_mul(DataType::UInt16.size())
.try_into()
.unwrap_or(u32::MAX);
assert_eq!(block.weight(), expected_via_formula);
let raw_bytes: &[u8] = array.data().as_ref();
assert_eq!(block.weight() as usize, raw_bytes.len());
}
#[tokio::test]
async fn same_key_returns_same_arc_and_decodes_once() {
let cache = default_cache();
let key = make_key("aot");
let calls = Arc::new(AtomicUsize::new(0));
let array_a = sample_array().await;
let array_b = sample_array().await;
let calls_a = Arc::clone(&calls);
let a = cache
.try_get_with(key.clone(), async move {
calls_a.fetch_add(1, Ordering::SeqCst);
Ok::<_, AgError>(DecodedBlock::new(array_a))
})
.await
.unwrap();
let calls_b = Arc::clone(&calls);
let b = cache
.try_get_with(key, async move {
calls_b.fetch_add(1, Ordering::SeqCst);
Ok::<_, AgError>(DecodedBlock::new(array_b))
})
.await
.unwrap();
assert!(Arc::ptr_eq(&a, &b), "both lookups must return the same Arc");
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"init must be called exactly once"
);
}
#[tokio::test]
async fn distinct_sources_do_not_collide() {
let cache = default_cache();
let array1 = sample_array().await;
let array2 = sample_array().await;
let expected_weight = DecodedBlock::new(array1.clone()).weight();
let key1 = make_key("source-a");
let key2 = make_key("source-b");
let b1 = cache
.try_get_with(
key1,
async move { Ok::<_, AgError>(DecodedBlock::new(array1)) },
)
.await
.unwrap();
let b2 = cache
.try_get_with(
key2,
async move { Ok::<_, AgError>(DecodedBlock::new(array2)) },
)
.await
.unwrap();
assert!(
!Arc::ptr_eq(&b1, &b2),
"different sources must not alias the same Arc"
);
assert_eq!(b2.weight(), expected_weight, "second block weight mismatch");
assert_eq!(cache.sync_count().await, 2, "cache should hold 2 entries");
}
#[tokio::test]
async fn claim_dedups_and_await_pending_receives_result() {
let array = sample_array().await;
let cache = default_cache();
let key = make_key("inflight");
let guard = match cache.claim(&key).await {
Claim::Owned(g) => g,
Claim::Pending(_) | Claim::Ready(_) => panic!("first claim must own the fetch"),
};
let pending = match cache.claim(&key).await {
Claim::Pending(rx) => rx,
Claim::Owned(_) | Claim::Ready(_) => panic!("second concurrent claim must be pending"),
};
let block = Arc::new(DecodedBlock::new(array));
guard.resolve(Ok(Arc::clone(&block)));
let got = BlockCache::await_pending(pending).await.unwrap().unwrap();
assert!(
Arc::ptr_eq(&got, &block),
"waiter must get the owner's block"
);
assert!(
matches!(cache.claim(&key).await, Claim::Owned(_)),
"resolved slot must be freed for re-claim"
);
}
#[tokio::test]
async fn dropped_guard_cancels_waiters_and_frees_slot() {
let cache = default_cache();
let key = make_key("inflight-drop");
let guard = match cache.claim(&key).await {
Claim::Owned(g) => g,
Claim::Pending(_) | Claim::Ready(_) => unreachable!(),
};
let pending = match cache.claim(&key).await {
Claim::Pending(rx) => rx,
Claim::Owned(_) | Claim::Ready(_) => unreachable!(),
};
drop(guard);
assert!(
BlockCache::await_pending(pending).await.unwrap().is_none(),
"waiter must observe Ok(None) when owner drops (signal to retry)"
);
assert!(
matches!(cache.claim(&key).await, Claim::Owned(_)),
"cancelled slot must be freed for re-claim"
);
}
#[tokio::test]
async fn audit_claim_rechecks_cache_after_stale_miss() {
let cache = default_cache();
let key = make_key("race");
assert!(cache.get(&key).await.is_none());
let array = sample_array().await;
cache
.try_get_with(key.clone(), async move { Ok(DecodedBlock::new(array)) })
.await
.unwrap();
assert!(
!matches!(cache.claim(&key).await, Claim::Owned(_)),
"stale miss must not own a duplicate fetch"
);
}
#[tokio::test]
async fn stats_track_hits_misses_decode_and_fetch() {
let array = sample_array().await;
let cache = BlockCache::new(CacheConfig::default());
let key = make_key("s1");
assert!(cache.get(&key).await.is_none());
cache
.try_get_with(key.clone(), async move {
Ok::<_, AgError>(DecodedBlock::new(array))
})
.await
.unwrap();
assert!(cache.get(&key).await.is_some());
assert!(cache.get(&key).await.is_some());
assert!(cache.get(&make_key("absent")).await.is_none());
cache.record_fetched(3);
let _ = cache.sync_count().await;
let s = cache.stats();
assert_eq!(s.hits, 2, "hits");
assert_eq!(s.misses, 2, "misses");
assert_eq!(s.decoded, 1, "decoded once (single-flight init)");
assert_eq!(s.fetched, 3, "fetched");
assert_eq!(s.entries, 1, "one cached entry");
assert!(s.resident_bytes > 0, "resident bytes after one insert");
assert_eq!(cache.clone().stats().hits, 2, "clone shares counters");
}
}