use super::*;
use crate::range_pull::encode_verified_range;
use std::future::Future;
use std::pin::Pin;
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use crate::origin::{Origin, OriginFetch, OriginKind};
use std::assert_matches;
#[derive(Debug)]
struct StubOrigin {
data: Bytes,
hash: Hash,
}
impl StubOrigin {
fn new(payload: &[u8]) -> Self {
Self {
hash: Hash::new(payload),
data: Bytes::from(payload.to_vec()),
}
}
}
impl Origin for StubOrigin {
fn kind(&self) -> OriginKind {
OriginKind::Http
}
fn fetch(
&self,
hash: Hash,
_max_bytes: u64,
) -> Pin<Box<dyn Future<Output = Result<OriginFetch, crate::OriginPullError>> + Send + '_>>
{
let result = if hash == self.hash {
Ok(OriginFetch::found_one_shot(self.data.clone()))
} else {
Ok(OriginFetch::NotFound)
};
Box::pin(async move { result })
}
}
#[derive(Debug)]
struct OutboardStubOrigin {
data: Bytes,
hash: Hash,
outboard: Option<Bytes>,
}
impl OutboardStubOrigin {
fn new(payload: &[u8], outboard: Option<Bytes>) -> Self {
Self {
hash: Hash::new(payload),
data: Bytes::from(payload.to_vec()),
outboard,
}
}
}
impl Origin for OutboardStubOrigin {
fn kind(&self) -> OriginKind {
OriginKind::Http
}
fn fetch(
&self,
hash: Hash,
_max_bytes: u64,
) -> Pin<Box<dyn Future<Output = Result<OriginFetch, crate::OriginPullError>> + Send + '_>>
{
let result = if hash == self.hash {
Ok(OriginFetch::found_one_shot(self.data.clone()))
} else {
Ok(OriginFetch::NotFound)
};
Box::pin(async move { result })
}
fn size(
&self,
hash: Hash,
) -> Pin<Box<dyn Future<Output = Result<Option<u64>, crate::OriginPullError>> + Send + '_>>
{
let matches = hash == self.hash;
let len = u64::try_from(self.data.len()).unwrap_or(u64::MAX);
Box::pin(async move { Ok(matches.then_some(len)) })
}
fn fetch_outboard(
&self,
hash: Hash,
_outboard_max_bytes: u64,
) -> Pin<Box<dyn Future<Output = Result<OutboardFetch, crate::OriginPullError>> + Send + '_>>
{
let result = if hash == self.hash {
match &self.outboard {
Some(ob) => OutboardFetch::Found(ob.clone()),
None => OutboardFetch::NotFound,
}
} else {
OutboardFetch::NotFound
};
Box::pin(async move { Ok(result) })
}
}
#[derive(Debug)]
struct MultiStubOrigin {
blobs: std::collections::HashMap<Hash, Bytes>,
}
impl MultiStubOrigin {
fn new(payloads: &[&[u8]]) -> Self {
let blobs = payloads
.iter()
.map(|p| (Hash::new(p), Bytes::from(p.to_vec())))
.collect();
Self { blobs }
}
}
impl Origin for MultiStubOrigin {
fn kind(&self) -> OriginKind {
OriginKind::Http
}
fn fetch(
&self,
hash: Hash,
_max_bytes: u64,
) -> Pin<Box<dyn Future<Output = Result<OriginFetch, crate::OriginPullError>> + Send + '_>>
{
let result = self
.blobs
.get(&hash)
.map_or(Ok(OriginFetch::NotFound), |b| {
Ok(OriginFetch::found_one_shot(b.clone()))
});
Box::pin(async move { result })
}
}
#[derive(Debug)]
struct CountingSizeOrigin {
hash: Hash,
size: u64,
calls: Arc<AtomicUsize>,
delay: Option<Duration>,
}
impl CountingSizeOrigin {
fn new(hash: Hash, size: u64) -> (Self, Arc<AtomicUsize>) {
let calls = Arc::new(AtomicUsize::new(0));
let origin = Self {
hash,
size,
calls: Arc::clone(&calls),
delay: None,
};
(origin, calls)
}
fn slow(hash: Hash, size: u64, delay: Duration) -> (Self, Arc<AtomicUsize>) {
let (mut origin, calls) = Self::new(hash, size);
origin.delay = Some(delay);
(origin, calls)
}
}
impl Origin for CountingSizeOrigin {
fn kind(&self) -> OriginKind {
OriginKind::Http
}
fn fetch(
&self,
_hash: Hash,
_max_bytes: u64,
) -> Pin<Box<dyn Future<Output = Result<OriginFetch, crate::OriginPullError>> + Send + '_>>
{
Box::pin(async { Ok(OriginFetch::NotFound) })
}
fn size(
&self,
hash: Hash,
) -> Pin<Box<dyn Future<Output = Result<Option<u64>, crate::OriginPullError>> + Send + '_>>
{
let matches = hash == self.hash;
let size = self.size;
let calls = Arc::clone(&self.calls);
let delay = self.delay;
Box::pin(async move {
calls.fetch_add(1, Ordering::SeqCst);
if let Some(d) = delay {
tokio::time::sleep(d).await;
}
Ok(matches.then_some(size))
})
}
}
#[derive(Debug)]
struct FailingSizeOrigin {
calls: Arc<AtomicUsize>,
}
impl FailingSizeOrigin {
fn new() -> (Self, Arc<AtomicUsize>) {
let calls = Arc::new(AtomicUsize::new(0));
(
Self {
calls: Arc::clone(&calls),
},
calls,
)
}
}
impl Origin for FailingSizeOrigin {
fn kind(&self) -> OriginKind {
OriginKind::Http
}
fn fetch(
&self,
_hash: Hash,
_max_bytes: u64,
) -> Pin<Box<dyn Future<Output = Result<OriginFetch, crate::OriginPullError>> + Send + '_>>
{
Box::pin(async { Ok(OriginFetch::NotFound) })
}
fn size(
&self,
_hash: Hash,
) -> Pin<Box<dyn Future<Output = Result<Option<u64>, crate::OriginPullError>> + Send + '_>>
{
let calls = Arc::clone(&self.calls);
Box::pin(async move {
calls.fetch_add(1, Ordering::SeqCst);
Err(crate::OriginPullError::Transient(anyhow::anyhow!(
"synthetic HEAD outage (connection refused)"
)))
})
}
}
#[tokio::test]
async fn origin_probe_presence_transport_error_is_fault_not_absent() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let hash = Hash::new(b"faulting origin probe");
let (origin, calls) = FailingSizeOrigin::new();
let engine =
CacheEngine::open(tmp.path(), vec![Arc::new(origin) as Arc<dyn Origin>], 10).await?;
assert_eq!(
engine.origin_probe_presence(hash).await,
OriginPresence::Fault,
"a per-origin transport error must be Fault, not Absent",
);
assert_eq!(calls.load(Ordering::SeqCst), 1, "one backend HEAD attempt");
assert_eq!(
engine.origin_probe_presence(hash).await,
OriginPresence::Fault,
"a fault is memoised under the fault TTL (#1789 item 6)",
);
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"the second probe served the cached fault rather than re-walking \
the origin chain — a steady state re-probes once per fault TTL, \
not once per request",
);
assert_eq!(
engine.origin_probe_size(hash).await,
None,
"the thin size wrapper still folds Fault to None",
);
Ok(())
}
#[tokio::test]
async fn origin_probe_size_hits_origin_then_memoises_positive() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let hash = Hash::new(b"remote object");
let (origin, calls) = CountingSizeOrigin::new(hash, 4096);
let engine =
CacheEngine::open(tmp.path(), vec![Arc::new(origin) as Arc<dyn Origin>], 10).await?;
assert_eq!(
engine.origin_probe_size(hash).await,
Some(4096),
"live HEAD finds it"
);
assert_eq!(calls.load(Ordering::SeqCst), 1, "one backend HEAD");
assert_eq!(
engine.origin_probe_size(hash).await,
Some(4096),
"served from memo"
);
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"memo hit issues no second HEAD"
);
Ok(())
}
#[tokio::test]
async fn origin_probe_size_memoises_absent() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let present = Hash::new(b"present");
let absent = Hash::new(b"absent");
let (origin, calls) = CountingSizeOrigin::new(present, 100);
let engine =
CacheEngine::open(tmp.path(), vec![Arc::new(origin) as Arc<dyn Origin>], 10).await?;
assert_eq!(
engine.origin_probe_size(absent).await,
None,
"not in origin"
);
assert_eq!(engine.origin_probe_size(absent).await, None, "still absent");
assert_eq!(calls.load(Ordering::SeqCst), 1, "negative answer is cached");
Ok(())
}
#[tokio::test]
async fn origin_probe_size_refused_never_probes() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let hash = Hash::new(b"denied object");
let (origin, calls) = CountingSizeOrigin::new(hash, 100);
let engine =
CacheEngine::open(tmp.path(), vec![Arc::new(origin) as Arc<dyn Origin>], 10).await?;
engine.set_chain_denied_one(hash, true);
assert_eq!(
engine.origin_probe_size(hash).await,
None,
"refused stays hidden"
);
assert_eq!(
calls.load(Ordering::SeqCst),
0,
"no HEAD for a refused hash"
);
Ok(())
}
#[tokio::test]
async fn origin_probe_size_times_out_to_absent() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let hash = Hash::new(b"slow object");
let (origin, _calls) = CountingSizeOrigin::slow(hash, 100, Duration::from_millis(400));
let engine =
CacheEngine::open(tmp.path(), vec![Arc::new(origin) as Arc<dyn Origin>], 10).await?;
engine.set_origin_probe_config(OriginProbePolicy {
positive_ttl: Duration::from_secs(15),
negative_ttl: Duration::from_secs(2),
fault_ttl: Duration::from_secs(5),
timeout: Duration::from_millis(20),
capacity: 16,
});
assert_eq!(
engine.origin_probe_size(hash).await,
None,
"a HEAD slower than the ceiling folds to absent",
);
Ok(())
}
#[tokio::test]
async fn origin_probe_presence_distinguishes_present_absent_and_fault() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let present = Hash::new(b"present for presence");
let absent = Hash::new(b"absent for presence");
let (present_origin, _present_calls) = CountingSizeOrigin::new(present, 777);
let engine = CacheEngine::open(
tmp.path(),
vec![Arc::new(present_origin) as Arc<dyn Origin>],
10,
)
.await?;
assert_eq!(
engine.origin_probe_presence(present).await,
OriginPresence::Present(777),
"a live HEAD hit is Present, not folded away",
);
assert_eq!(
engine.origin_probe_presence(absent).await,
OriginPresence::Absent,
"a genuine miss is Absent",
);
let tmp2 = tempfile::tempdir()?;
let slow = Hash::new(b"slow for presence");
let (slow_origin, slow_calls) = CountingSizeOrigin::slow(slow, 100, Duration::from_millis(400));
let fault_engine = CacheEngine::open(
tmp2.path(),
vec![Arc::new(slow_origin) as Arc<dyn Origin>],
10,
)
.await?;
fault_engine.set_origin_probe_config(OriginProbePolicy {
positive_ttl: Duration::from_secs(15),
negative_ttl: Duration::from_secs(2),
fault_ttl: Duration::from_secs(5),
timeout: Duration::from_millis(20),
capacity: 16,
});
assert_eq!(
fault_engine.origin_probe_presence(slow).await,
OriginPresence::Fault,
"a HEAD slower than the ceiling is Fault, not Absent",
);
assert_eq!(
slow_calls.load(Ordering::SeqCst),
1,
"one backend attempt for the first faulting probe",
);
assert_eq!(
fault_engine.origin_probe_presence(slow).await,
OriginPresence::Fault,
"a memoised fault still answers Fault",
);
assert_eq!(
slow_calls.load(Ordering::SeqCst),
1,
"the second probe served the cached fault instead of re-hitting \
the backend (#1789 item 6)",
);
Ok(())
}
#[tokio::test]
async fn origin_probe_size_folds_fault_to_none_like_absent() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let hash = Hash::new(b"slow object for size wrapper");
let (origin, _calls) = CountingSizeOrigin::slow(hash, 100, Duration::from_millis(400));
let engine =
CacheEngine::open(tmp.path(), vec![Arc::new(origin) as Arc<dyn Origin>], 10).await?;
engine.set_origin_probe_config(OriginProbePolicy {
positive_ttl: Duration::from_secs(15),
negative_ttl: Duration::from_secs(2),
fault_ttl: Duration::from_secs(5),
timeout: Duration::from_millis(20),
capacity: 16,
});
assert_eq!(
engine.origin_probe_size(hash).await,
None,
"a fault still folds to None through the size wrapper",
);
Ok(())
}
#[tokio::test]
async fn total_bytes_sums_populated_blobs() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let payload = b"total-bytes payload";
let hash = Hash::new(payload);
let engine = CacheEngine::open(
tmp.path(),
vec![Arc::new(StubOrigin::new(payload)) as Arc<dyn Origin>],
10,
)
.await?;
anyhow::ensure!(engine.total_bytes().await? == 0, "empty cache is 0 bytes");
let _ = engine.get(hash).await?;
let total = engine.total_bytes().await?;
anyhow::ensure!(
total >= payload.len() as u64,
"total_bytes {total} should cover the {}-byte blob",
payload.len()
);
let sizes = engine.size_snapshot().await?;
anyhow::ensure!(
sizes.get(&hash).copied() == Some(payload.len() as u64),
"size_snapshot should report the blob's exact size"
);
Ok(())
}
#[tokio::test]
async fn release_for_eviction_does_not_write_the_evicted_log() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let payload = b"soft evict payload";
let hash = Hash::new(payload);
let engine = CacheEngine::open(
tmp.path(),
vec![Arc::new(StubOrigin::new(payload)) as Arc<dyn Origin>],
10,
)
.await?;
let _ = engine.get(hash).await?;
engine.release_for_eviction(hash).await?;
anyhow::ensure!(
!engine.is_evicted(hash),
"soft evict must not enter the logical-evicted set"
);
anyhow::ensure!(
!tmp.path().join("evicted.log").exists(),
"soft evict must not create the durable takedown log"
);
anyhow::ensure!(
engine.last_accessed(hash).is_none(),
"soft evict should forget the access-time entry"
);
anyhow::ensure!(
!engine.eviction_candidates().contains_key(&hash),
"released hash should leave the candidate set"
);
Ok(())
}
#[tokio::test]
async fn rescan_origins_indexes_fs_and_present_pins() -> anyhow::Result<()> {
use crate::origin::FilesystemOrigin;
async fn seed(base: &Path, payload: Vec<u8>) -> anyhow::Result<(Hash, u64)> {
let hash = Hash::new(&payload);
let hex = hash.to_hex();
let shard = hex.get(..2).unwrap_or("");
let dir = base.join(shard);
tokio::fs::create_dir_all(&dir).await?;
tokio::fs::write(dir.join(hex.as_str()), &payload).await?;
let len = u64::try_from(payload.len()).unwrap_or(u64::MAX);
Ok((hash, len))
}
let origin_dir = tempfile::tempdir()?;
let base = tokio::fs::canonicalize(origin_dir.path()).await?;
let (h1, len1) = seed(&base, vec![1u8; 5000]).await?;
let (h2, len2) = seed(&base, vec![2u8; 9000]).await?;
let cache_dir = tempfile::tempdir()?;
let origin = Arc::new(FilesystemOrigin::new(&base).await?) as Arc<dyn Origin>;
let engine = CacheEngine::open(cache_dir.path(), vec![origin], 10).await?;
let absent = Hash::new(b"never-on-disk");
engine.set_pinned(&PinnedHashes::new(
[from_store_hash(h1), from_store_hash(absent)]
.into_iter()
.collect(),
));
engine.rescan_origins().await;
let held: HashSet<Hash> = engine.origin_held_snapshot().hashes;
anyhow::ensure!(
held.contains(&h1) && held.contains(&h2),
"fs blobs must be enumerated into the held index",
);
anyhow::ensure!(!held.contains(&absent), "absent pin must not be held");
anyhow::ensure!(engine.origin_held_size(h1) == Some(len1), "h1 size wrong");
anyhow::ensure!(engine.origin_held_size(h2) == Some(len2), "h2 size wrong");
anyhow::ensure!(
engine.origin_held_size(absent).is_none(),
"absent hash must not be servable from origin",
);
engine.set_denied(&crate::DeniedHashes::new(
[from_store_hash(h1)].into_iter().collect(),
));
anyhow::ensure!(
engine.origin_held_size(h1).is_none(),
"a hash denied since the last rescan must not be servable from origin",
);
anyhow::ensure!(
!engine.origin_held_snapshot().hashes.contains(&h1),
"a hash denied since the last rescan must not be announced",
);
engine.set_denied(&crate::DeniedHashes::new(
[from_store_hash(h1), from_store_hash(h2)]
.into_iter()
.collect(),
));
engine.rescan_origins().await;
anyhow::ensure!(
engine.origin_held_size(h2).is_none(),
"denied hash must not be advertised",
);
Ok(())
}
#[derive(Debug)]
struct ThrottlableOrigin {
held: Vec<(Hash, u64)>,
faulting: Arc<AtomicBool>,
permanent: Arc<AtomicBool>,
enumerate_fails: Arc<AtomicBool>,
holds: Arc<AtomicBool>,
slow_ms: Arc<AtomicU64>,
size_calls: Arc<AtomicUsize>,
}
impl ThrottlableOrigin {
fn new(held: Vec<(Hash, u64)>) -> (Self, ThrottleControls) {
let controls = ThrottleControls {
faulting: Arc::new(AtomicBool::new(false)),
permanent: Arc::new(AtomicBool::new(false)),
enumerate_fails: Arc::new(AtomicBool::new(false)),
holds: Arc::new(AtomicBool::new(true)),
slow_ms: Arc::new(AtomicU64::new(0)),
size_calls: Arc::new(AtomicUsize::new(0)),
};
(
Self {
held,
faulting: Arc::clone(&controls.faulting),
permanent: Arc::clone(&controls.permanent),
enumerate_fails: Arc::clone(&controls.enumerate_fails),
holds: Arc::clone(&controls.holds),
slow_ms: Arc::clone(&controls.slow_ms),
size_calls: Arc::clone(&controls.size_calls),
},
controls,
)
}
}
struct ThrottleControls {
faulting: Arc<AtomicBool>,
permanent: Arc<AtomicBool>,
enumerate_fails: Arc<AtomicBool>,
holds: Arc<AtomicBool>,
slow_ms: Arc<AtomicU64>,
size_calls: Arc<AtomicUsize>,
}
impl Origin for ThrottlableOrigin {
fn kind(&self) -> OriginKind {
OriginKind::S3
}
fn fetch(
&self,
_hash: Hash,
_max_bytes: u64,
) -> Pin<Box<dyn Future<Output = Result<OriginFetch, crate::OriginPullError>> + Send + '_>>
{
Box::pin(async { Ok(OriginFetch::NotFound) })
}
fn size(
&self,
hash: Hash,
) -> Pin<Box<dyn Future<Output = Result<Option<u64>, crate::OriginPullError>> + Send + '_>>
{
Box::pin(async move {
self.size_calls.fetch_add(1, Ordering::SeqCst);
let slow = self.slow_ms.load(Ordering::SeqCst);
if slow > 0 {
tokio::time::sleep(Duration::from_millis(slow)).await;
}
if self.faulting.load(Ordering::SeqCst) {
return Err(if self.permanent.load(Ordering::SeqCst) {
crate::OriginPullError::Permanent(anyhow::anyhow!(
"synthetic HeadObject access denied"
))
} else {
crate::OriginPullError::Transient(anyhow::anyhow!(
"synthetic HeadObject throttle (503 SlowDown)"
))
});
}
if !self.holds.load(Ordering::SeqCst) {
return Ok(None);
}
Ok(self
.held
.iter()
.find(|(h, _)| *h == hash)
.map(|(_, len)| *len))
})
}
fn enumerate(
&self,
) -> Pin<Box<dyn Future<Output = Result<Vec<Hash>, crate::OriginPullError>> + Send + '_>> {
Box::pin(async {
if self.enumerate_fails.load(Ordering::SeqCst) {
return Err(crate::OriginPullError::Transient(anyhow::anyhow!(
"synthetic ListObjectsV2 outage"
)));
}
Ok(self.held.iter().map(|(h, _)| *h).collect())
})
}
}
#[tokio::test]
async fn rescan_origins_carries_a_faulted_probe_forward() -> anyhow::Result<()> {
let indexed = Hash::new(b"throttle-indexed");
let fresh = Hash::new(b"throttle-fresh");
let (origin, controls) = ThrottlableOrigin::new(vec![(indexed, 5000), (fresh, 9000)]);
let origin = Arc::new(origin) as Arc<dyn Origin>;
let tmp = tempfile::tempdir()?;
let cm = Arc::new(CacheMetrics::default());
let engine = CacheEngine::open_full(
tmp.path(),
vec![origin],
10,
crate::PinnedHashes::new([from_store_hash(fresh)].into_iter().collect()),
crate::RetryPolicy::default(),
CircuitBreakerPolicy::default(),
Some(Arc::clone(&cm)),
Duration::ZERO,
)
.await?;
engine.set_denied(&crate::DeniedHashes::new(
[from_store_hash(fresh)].into_iter().collect(),
));
engine.rescan_origins().await;
anyhow::ensure!(
engine.origin_held_size(indexed) == Some(5000),
"the healthy rescan must index the enumerated hash",
);
anyhow::ensure!(
engine.origin_held_snapshot().probe_faults == 0,
"a healthy rescan reports no faults",
);
engine.set_denied(&crate::DeniedHashes::new(HashSet::new()));
controls.faulting.store(true, Ordering::SeqCst);
controls.size_calls.store(0, Ordering::SeqCst);
engine.rescan_origins().await;
anyhow::ensure!(
controls.size_calls.load(Ordering::SeqCst) == 2,
"each candidate is probed once however many times it is listed, or a \
duplicate inflates the fault count: {} probes for 2 candidates",
controls.size_calls.load(Ordering::SeqCst),
);
anyhow::ensure!(
engine.origin_held_size(indexed) == Some(5000),
"a faulted probe must keep the previous index entry, not drop it",
);
anyhow::ensure!(
engine.origin_held_size(fresh).is_none(),
"a candidate first seen inside the fault window has nothing to carry forward",
);
anyhow::ensure!(
engine.origin_held_snapshot().probe_faults == 2,
"both faulted probes must be reported to the DHT seed paths, got {}",
engine.origin_held_snapshot().probe_faults,
);
anyhow::ensure!(
cm.origin_probe_failures.get() == 2,
"faulted probes must surface on their counter, got {}",
cm.origin_probe_failures.get(),
);
engine.set_denied(&crate::DeniedHashes::new(
[from_store_hash(indexed)].into_iter().collect(),
));
anyhow::ensure!(
!engine.origin_held_snapshot().hashes.contains(&indexed),
"a hash denied since the last rescan must not be announced",
);
engine.set_denied(&crate::DeniedHashes::new(HashSet::new()));
controls.faulting.store(false, Ordering::SeqCst);
engine.rescan_origins().await;
anyhow::ensure!(
engine.origin_held_size(fresh) == Some(9000),
"a recovered origin must index what the fault window missed",
);
anyhow::ensure!(
engine.origin_held_snapshot().probe_faults == 0,
"a recovered rescan clears the fault report",
);
Ok(())
}
#[tokio::test]
async fn rescan_origins_evicts_a_hash_the_origin_no_longer_holds() -> anyhow::Result<()> {
let goes_away = Hash::new(b"origin-drops-it");
let (origin, controls) = ThrottlableOrigin::new(vec![(goes_away, 4242)]);
let origin = Arc::new(origin) as Arc<dyn Origin>;
let tmp = tempfile::tempdir()?;
let engine = CacheEngine::open(tmp.path(), vec![origin], 10).await?;
engine.rescan_origins().await;
anyhow::ensure!(
engine.origin_held_size(goes_away) == Some(4242),
"the healthy rescan must index it",
);
controls.holds.store(false, Ordering::SeqCst);
engine.rescan_origins().await;
anyhow::ensure!(
engine.origin_held_size(goes_away).is_none(),
"an authoritative absence must drop the entry, not carry it",
);
anyhow::ensure!(
engine.origin_held_snapshot().probe_faults == 0,
"a clean `not held` answer is not a fault",
);
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn concurrent_rescans_collapse_into_one_rerun() -> anyhow::Result<()> {
let listed = Hash::new(b"rescan-single-flight");
let (origin, controls) = ThrottlableOrigin::new(vec![(listed, 11)]);
let origin = Arc::new(origin) as Arc<dyn Origin>;
let tmp = tempfile::tempdir()?;
let engine = Arc::new(CacheEngine::open(tmp.path(), vec![origin], 10).await?);
controls.slow_ms.store(50, Ordering::SeqCst);
controls.size_calls.store(0, Ordering::SeqCst);
let mut joins = Vec::new();
for _ in 0..4 {
let engine = Arc::clone(&engine);
joins.push(tokio::spawn(async move { engine.rescan_origins().await }));
}
for j in joins {
j.await?;
}
let probes = controls.size_calls.load(Ordering::SeqCst);
anyhow::ensure!(
(1..=2).contains(&probes),
"four triggers must collapse into at most one rerun, so at most two \
passes probe the single candidate; saw {probes}",
);
anyhow::ensure!(
engine.origin_held_size(listed) == Some(11),
"and the index is still published",
);
controls.slow_ms.store(0, Ordering::SeqCst);
controls.size_calls.store(0, Ordering::SeqCst);
engine.rescan_origins().await;
anyhow::ensure!(
controls.size_calls.load(Ordering::SeqCst) == 1,
"a rescan after the burst must still run, or the slot is stranded",
);
Ok(())
}
#[tokio::test]
async fn rescan_origins_does_not_carry_a_permanent_fault() -> anyhow::Result<()> {
let revoked = Hash::new(b"origin-acl-revoked");
let (origin, controls) = ThrottlableOrigin::new(vec![(revoked, 777)]);
let origin = Arc::new(origin) as Arc<dyn Origin>;
let tmp = tempfile::tempdir()?;
let cm = Arc::new(CacheMetrics::default());
let engine = CacheEngine::open_full(
tmp.path(),
vec![origin],
10,
crate::PinnedHashes::empty(),
crate::RetryPolicy::default(),
CircuitBreakerPolicy::default(),
Some(Arc::clone(&cm)),
Duration::ZERO,
)
.await?;
engine.rescan_origins().await;
anyhow::ensure!(
engine.origin_held_size(revoked) == Some(777),
"the healthy rescan must index it",
);
controls.faulting.store(true, Ordering::SeqCst);
controls.permanent.store(true, Ordering::SeqCst);
engine.rescan_origins().await;
let report = engine.origin_held_snapshot();
anyhow::ensure!(
!report.hashes.contains(&revoked),
"a permanent fault must drop the entry rather than advertise content \
the serve path will refuse for the process lifetime",
);
anyhow::ensure!(
report.probe_faults == 1,
"it is still a probe that could not answer, so it still counts",
);
Ok(())
}
#[tokio::test]
async fn rescan_origins_reports_an_origin_it_could_not_list() -> anyhow::Result<()> {
let listed = Hash::new(b"listing-outage");
let (origin, controls) = ThrottlableOrigin::new(vec![(listed, 1234)]);
let origin = Arc::new(origin) as Arc<dyn Origin>;
let tmp = tempfile::tempdir()?;
let cm = Arc::new(CacheMetrics::default());
let engine = CacheEngine::open_full(
tmp.path(),
vec![origin],
10,
crate::PinnedHashes::empty(),
crate::RetryPolicy::default(),
CircuitBreakerPolicy::default(),
Some(Arc::clone(&cm)),
Duration::ZERO,
)
.await?;
engine.rescan_origins().await;
anyhow::ensure!(
engine.origin_held_size(listed) == Some(1234),
"the healthy rescan must index the listed hash",
);
controls.enumerate_fails.store(true, Ordering::SeqCst);
engine.rescan_origins().await;
let report = engine.origin_held_snapshot();
anyhow::ensure!(
report.enumerate_failures == 1,
"the failed listing must be reported, got {}",
report.enumerate_failures,
);
anyhow::ensure!(
report.probe_faults == 0,
"no candidate was produced, so there is no probe to fault",
);
anyhow::ensure!(
!report.hashes.contains(&listed),
"an unlisted, unpinned hash genuinely leaves the announce set — the \
point is that the report says so",
);
anyhow::ensure!(
cm.origin_enumerate_failures.get() == 1,
"the failed listing must surface on its counter, got {}",
cm.origin_enumerate_failures.get(),
);
Ok(())
}
#[tokio::test]
async fn release_for_eviction_refuses_pinned_hash() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let payload = b"pinned payload";
let hash = Hash::new(payload);
let engine = CacheEngine::open(
tmp.path(),
vec![Arc::new(StubOrigin::new(payload)) as Arc<dyn Origin>],
10,
)
.await?;
let _ = engine.get(hash).await?;
engine.set_pinned(&PinnedHashes::new(
[from_store_hash(hash)].into_iter().collect(),
));
let released = engine.release_for_eviction(hash).await?;
anyhow::ensure!(released == 0, "pinned hash must report 0 released");
anyhow::ensure!(
engine.last_accessed(hash).is_some(),
"pinned refusal must not forget the access-time entry"
);
anyhow::ensure!(engine.has(hash).await?, "pinned blob must still be present");
Ok(())
}
#[tokio::test]
async fn release_for_eviction_reclaims_a_deny_listed_pinned_hash() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let payload = b"pinned then denied";
let hash = Hash::new(payload);
let engine = CacheEngine::open(
tmp.path(),
vec![Arc::new(StubOrigin::new(payload)) as Arc<dyn Origin>],
10,
)
.await?;
let _ = engine.get(hash).await?;
engine.set_pinned(&PinnedHashes::new(
[from_store_hash(hash)].into_iter().collect(),
));
anyhow::ensure!(
engine.release_for_eviction(hash).await? == 0,
"a pinned clean hash stays exempt"
);
anyhow::ensure!(
engine.last_accessed(hash).is_some(),
"the clean exemption must keep the access-time entry"
);
anyhow::ensure!(
engine.set_chain_denied_one(hash, true),
"deny must change the set"
);
engine.release_for_eviction(hash).await?;
anyhow::ensure!(
engine.last_accessed(hash).is_none(),
"a pinned + governance-denied hash must go through the reclaim path (which \
forgets the access-time entry), not the pin early-return"
);
Ok(())
}
#[tokio::test]
async fn get_cache_hit_records_access_time() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let payload = b"hello cache hit";
let hash = Hash::new(payload);
let origin = StubOrigin::new(payload);
let engine =
CacheEngine::open(tmp.path(), vec![Arc::new(origin) as Arc<dyn Origin>], 10).await?;
let _ = engine.get(hash).await?;
engine.inner.access_times.clear();
let _ = engine.get(hash).await?;
anyhow::ensure!(
engine.last_accessed(hash).is_some(),
"expected Some(Instant) after cache-hit get"
);
Ok(())
}
#[tokio::test]
async fn pull_through_records_access_time() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let payload = b"hello pull-through";
let hash = Hash::new(payload);
let origin = StubOrigin::new(payload);
let engine =
CacheEngine::open(tmp.path(), vec![Arc::new(origin) as Arc<dyn Origin>], 10).await?;
let _ = engine.get(hash).await?;
anyhow::ensure!(
engine.last_accessed(hash).is_some(),
"expected Some(Instant) after pull-through get"
);
Ok(())
}
#[tokio::test]
async fn fill_mode_determines_the_return_shape_in_both_directions() -> anyhow::Result<()> {
let payload = b"drained, not streamed";
let hash = Hash::new(payload);
let tmp_fill = tempfile::tempdir()?;
let engine = CacheEngine::open(
tmp_fill.path(),
vec![Arc::new(StubOrigin::new(payload)) as Arc<dyn Origin>],
10,
)
.await?;
let committed = engine
.pull_through(hash, false, FillMode::CommitOnly)
.await?;
anyhow::ensure!(
committed.is_none(),
"CommitOnly must yield no payload, got {committed:?}"
);
let tmp_bytes = tempfile::tempdir()?;
let engine = CacheEngine::open(
tmp_bytes.path(),
vec![Arc::new(StubOrigin::new(payload)) as Arc<dyn Origin>],
10,
)
.await?;
let returned = engine
.pull_through(hash, false, FillMode::ReturnBytes)
.await?;
anyhow::ensure!(
returned.as_deref() == Some(payload.as_slice()),
"ReturnBytes must yield the payload, got {returned:?}"
);
Ok(())
}
#[tokio::test]
async fn subscribe_inserts_emits_on_pull_through_success() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let payload = b"hello dht hook";
let hash = Hash::new(payload);
let origin = StubOrigin::new(payload);
let engine =
CacheEngine::open(tmp.path(), vec![Arc::new(origin) as Arc<dyn Origin>], 10).await?;
let mut rx = engine.subscribe_inserts();
let _ = engine.get(hash).await?;
let announced = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv())
.await
.map_err(|_| anyhow::anyhow!("timed out waiting for subscribe_inserts emit"))?
.map_err(|e| anyhow::anyhow!("recv: {e}"))?;
anyhow::ensure!(
announced == hash,
"expected announced hash to equal committed hash"
);
Ok(())
}
#[tokio::test]
async fn subscribe_inserts_does_not_emit_on_cache_hit() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let payload = b"hello cached-hit";
let hash = Hash::new(payload);
let origin = StubOrigin::new(payload);
let engine =
CacheEngine::open(tmp.path(), vec![Arc::new(origin) as Arc<dyn Origin>], 10).await?;
let mut rx = engine.subscribe_inserts();
let _ = engine.get(hash).await?;
let _ = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv())
.await
.map_err(|_| anyhow::anyhow!("first pull-through emission missing"))?
.map_err(|e| anyhow::anyhow!("recv first: {e}"))?;
let _ = engine.get(hash).await?;
let r = tokio::time::timeout(std::time::Duration::from_millis(100), rx.recv()).await;
anyhow::ensure!(
r.is_err(),
"cache hit must not emit on subscribe_inserts; got {r:?}"
);
Ok(())
}
#[tokio::test]
async fn second_get_updates_access_time() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let payload = b"hello update";
let hash = Hash::new(payload);
let origin = StubOrigin::new(payload);
let engine =
CacheEngine::open(tmp.path(), vec![Arc::new(origin) as Arc<dyn Origin>], 10).await?;
let _ = engine.get(hash).await?;
let first = engine
.last_accessed(hash)
.ok_or_else(|| anyhow::anyhow!("expected Some after first get"))?;
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
let _ = engine.get(hash).await?;
let second = engine
.last_accessed(hash)
.ok_or_else(|| anyhow::anyhow!("expected Some after second get"))?;
anyhow::ensure!(
second > first,
"access time should advance: first={first:?}, second={second:?}"
);
Ok(())
}
#[tokio::test]
async fn observe_hit_forwards_to_frequency_estimator() -> anyhow::Result<()> {
use crate::policy::FrequencyEstimator;
use std::sync::atomic::{AtomicU32, Ordering};
#[derive(Debug, Default)]
struct Counter(AtomicU32);
impl FrequencyEstimator for Counter {
fn observe(&self, _h: Hash) {
self.0.fetch_add(1, Ordering::Relaxed);
}
fn estimate(&self, _h: Hash) -> u32 {
self.0.load(Ordering::Relaxed)
}
}
let tmp = tempfile::tempdir()?;
let payload = b"hello frequency";
let hash = Hash::new(payload);
let origin = StubOrigin::new(payload);
let engine =
CacheEngine::open(tmp.path(), vec![Arc::new(origin) as Arc<dyn Origin>], 10).await?;
let counter = Arc::new(Counter::default());
engine.set_frequency_estimator(counter.clone());
let _ = engine.get(hash).await?;
anyhow::ensure!(counter.estimate(hash) >= 1, "observe should fire on access");
Ok(())
}
#[tokio::test]
async fn serve_hit_signal_fires_once_per_serve_and_promotes_a_hot_resident_blob()
-> anyhow::Result<()> {
use crate::policy::{FrequencyEstimator, ProbationAdmission, Segment, TinyLfuEstimator};
let tmp = tempfile::tempdir()?;
let payload = b"hot resident blob";
let hash = Hash::new(payload);
let engine = CacheEngine::open(
tmp.path(),
vec![Arc::new(StubOrigin::new(payload)) as Arc<dyn Origin>],
10,
)
.await?;
let promotion_threshold = 3u32;
let freq: Arc<dyn FrequencyEstimator> = Arc::new(TinyLfuEstimator::new(4096));
engine.set_frequency_estimator(freq.clone());
engine.set_admission_policy(Arc::new(ProbationAdmission {
freq: freq.clone(),
promotion_threshold,
}));
engine.populate_local(hash).await?;
anyhow::ensure!(
freq.estimate(hash) == 0,
"the fill alone must not observe (no double-count with the serve)"
);
anyhow::ensure!(
engine.segment_of(hash) == Segment::Probation,
"first sight lands in probation"
);
for i in 1..=promotion_threshold {
engine.observe_hit(hash);
anyhow::ensure!(
freq.estimate(hash) == i,
"each serve must be exactly one sighting"
);
}
anyhow::ensure!(
freq.estimate(hash) >= promotion_threshold,
"a hot resident blob served repeatedly must become promotable"
);
Ok(())
}
#[tokio::test]
async fn default_admission_is_main_segment() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let payload = b"hello admission";
let hash = Hash::new(payload);
let origin = StubOrigin::new(payload);
let engine =
CacheEngine::open(tmp.path(), vec![Arc::new(origin) as Arc<dyn Origin>], 10).await?;
let ctx = crate::policy::AdmissionContext {
hash,
known_size: None,
};
assert_eq!(
engine.admission_segment_for_test(&ctx),
crate::policy::Segment::Main
);
Ok(())
}
#[tokio::test]
async fn access_times_snapshot_contains_accessed_hash() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let payload = b"hello snapshot";
let hash = Hash::new(payload);
let origin = StubOrigin::new(payload);
let engine =
CacheEngine::open(tmp.path(), vec![Arc::new(origin) as Arc<dyn Origin>], 10).await?;
let _ = engine.get(hash).await?;
let snap = engine.access_times_snapshot();
anyhow::ensure!(
snap.contains_key(&hash),
"snapshot should contain the accessed hash"
);
Ok(())
}
#[tokio::test]
async fn record_access_does_not_wait_on_a_scan_of_another_shard() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let engine = CacheEngine::open(tmp.path(), Vec::new(), 10).await?;
let scanned = Hash::new(b"the shard under scan");
engine.inner.access_times.insert(scanned, Instant::now());
let Some(scan_guard) = engine.inner.access_times.get_mut(&scanned) else {
anyhow::bail!("the seeded access-time entry must be present");
};
let completing = (0..1024u32).map(|i| Hash::new(i.to_le_bytes())).find(|h| {
!matches!(
engine.inner.access_times.try_get(h),
dashmap::try_result::TryResult::Locked
)
});
let Some(completing) = completing else {
anyhow::bail!("no candidate hash landed outside the pinned shard");
};
let (tx, rx) = std::sync::mpsc::channel::<()>();
let recorder = {
let engine = engine.clone();
std::thread::spawn(move || {
engine.observe_hit(completing);
let _ = tx.send(());
})
};
let landed = rx.recv_timeout(Duration::from_secs(10)).is_ok();
drop(scan_guard);
let joined = recorder.join().is_ok();
anyhow::ensure!(
landed,
"a serve completion's access record waited on a scan of another shard"
);
anyhow::ensure!(joined, "the recording thread panicked");
anyhow::ensure!(
engine.last_accessed(completing).is_some(),
"the recorded access must be readable once the scan releases"
);
Ok(())
}
#[tokio::test]
async fn a_scan_never_loses_a_candidate_to_a_concurrent_record() -> anyhow::Result<()> {
const WRITER_BASE: u32 = 1_000_000;
const ROUND_STRIDE: u32 = 10_000_000;
let tmp = tempfile::tempdir()?;
let engine = CacheEngine::open(tmp.path(), Vec::new(), 10).await?;
let seeded: Vec<Hash> = (0..4096u32).map(|i| Hash::new(i.to_le_bytes())).collect();
for h in &seeded {
engine.inner.access_times.insert(*h, Instant::now());
}
let mut overlapped = false;
for round in 0..16u32 {
let base = WRITER_BASE.saturating_add(round.saturating_mul(ROUND_STRIDE));
let stop = Arc::new(AtomicBool::new(false));
let written = Arc::new(AtomicU64::new(0));
let writer = {
let engine = engine.clone();
let stop = Arc::clone(&stop);
let written = Arc::clone(&written);
std::thread::spawn(move || {
let mut i = base;
while !stop.load(Ordering::Relaxed) {
engine.observe_hit(Hash::new(i.to_le_bytes()));
written.fetch_add(1, Ordering::Relaxed);
i = i.saturating_add(1);
}
})
};
let spin_deadline = Instant::now() + Duration::from_secs(10);
while written.load(Ordering::Relaxed) == 0 {
anyhow::ensure!(
Instant::now() < spin_deadline,
"the recording thread never recorded an access"
);
std::thread::yield_now();
}
let before = written.load(Ordering::Relaxed);
let candidates = engine.eviction_candidates();
let after = written.load(Ordering::Relaxed);
stop.store(true, Ordering::Relaxed);
anyhow::ensure!(writer.join().is_ok(), "the recording thread panicked");
let missing = seeded
.iter()
.filter(|h| !candidates.contains_key(h))
.count();
anyhow::ensure!(
missing == 0,
"the sweep dropped {missing} of {} pre-existing candidates",
seeded.len()
);
anyhow::ensure!(
after < u64::from(ROUND_STRIDE),
"round wrote {after} keys, overrunning its {ROUND_STRIDE}-key range"
);
let during: HashSet<Hash> = (before.saturating_add(1)..after)
.filter_map(|n| u32::try_from(n).ok())
.map(|n| Hash::new(base.saturating_add(n).to_le_bytes()))
.collect();
overlapped |= candidates.iter().any(|(h, _)| during.contains(h));
if overlapped {
break;
}
}
anyhow::ensure!(
overlapped,
"no round overlapped the writer, so the walk was never concurrent"
);
Ok(())
}
#[tokio::test]
async fn last_accessed_returns_none_for_unknown_hash() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let engine = CacheEngine::open(tmp.path(), Vec::new(), 10).await?;
let unknown = Hash::new(b"never accessed");
anyhow::ensure!(
engine.last_accessed(unknown).is_none(),
"expected None for a hash that was never accessed"
);
Ok(())
}
#[tokio::test]
async fn iter_hashes_returns_empty_on_empty_store() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let engine = CacheEngine::open(tmp.path(), Vec::new(), 10).await?;
let hashes = engine.iter_hashes().await?;
anyhow::ensure!(
hashes.is_empty(),
"expected empty iter_hashes on a fresh store, got {hashes:?}"
);
Ok(())
}
#[tokio::test]
async fn iter_hashes_returns_all_committed_blobs() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let payloads: &[&[u8]] = &[b"iter-a", b"iter-b", b"iter-c"];
let origin = MultiStubOrigin::new(payloads);
let engine =
CacheEngine::open(tmp.path(), vec![Arc::new(origin) as Arc<dyn Origin>], 10).await?;
let mut expected: HashSet<Hash> = HashSet::new();
for p in payloads {
let h = Hash::new(*p);
let _ = engine.get(h).await?;
expected.insert(h);
}
let actual: HashSet<Hash> = engine.iter_hashes().await?.into_iter().collect();
anyhow::ensure!(actual == expected, "expected {expected:?}, got {actual:?}");
Ok(())
}
#[tokio::test]
async fn iter_hashes_excludes_evicted_blobs() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let payloads: &[&[u8]] = &[b"evict-a", b"evict-b", b"evict-c", b"evict-d"];
let origin = MultiStubOrigin::new(payloads);
let engine =
CacheEngine::open(tmp.path(), vec![Arc::new(origin) as Arc<dyn Origin>], 10).await?;
let hashes: Vec<Hash> = payloads.iter().map(|p| Hash::new(*p)).collect();
for h in &hashes {
let _ = engine.get(*h).await?;
}
let mut kept: Vec<Hash> = Vec::new();
let mut evicted: Vec<Hash> = Vec::new();
for (i, h) in hashes.iter().enumerate() {
if i % 2 == 0 {
kept.push(*h);
} else {
evicted.push(*h);
}
}
for h in &evicted {
engine.evict(*h).await?;
}
let actual: HashSet<Hash> = engine.iter_hashes().await?.into_iter().collect();
let kept_set: HashSet<Hash> = kept.iter().copied().collect();
anyhow::ensure!(
actual == kept_set,
"iter_hashes must return exactly the non-evicted set; expected {kept_set:?}, got {actual:?}"
);
Ok(())
}
#[tokio::test]
async fn iter_hashes_includes_pinned_blobs() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let payload_pinned: &[u8] = b"pin-a";
let payload_plain: &[u8] = b"pin-b";
let origin = MultiStubOrigin::new(&[payload_pinned, payload_plain]);
let engine =
CacheEngine::open(tmp.path(), vec![Arc::new(origin) as Arc<dyn Origin>], 10).await?;
let h_pinned = Hash::new(payload_pinned);
let h_plain = Hash::new(payload_plain);
let _ = engine.get(h_pinned).await?;
let _ = engine.get(h_plain).await?;
let s = [from_store_hash(h_pinned)].into_iter().collect();
let diff = engine.set_pinned(&PinnedHashes::new(s));
anyhow::ensure!(
diff.added == 1 && diff.removed == 0,
"set_pinned must apply the pin to make this test meaningful, got {diff:?}"
);
let actual: HashSet<Hash> = engine.iter_hashes().await?.into_iter().collect();
let expected: HashSet<Hash> = [h_pinned, h_plain].into_iter().collect();
anyhow::ensure!(
actual == expected,
"iter_hashes must include pinned blobs (they're the highest-value DHT advertisements); expected {expected:?}, got {actual:?}"
);
Ok(())
}
#[derive(Debug)]
struct SlowCountingOrigin {
data: Bytes,
hash: Hash,
fetch_count: AtomicUsize,
delay: std::time::Duration,
}
impl SlowCountingOrigin {
fn new(payload: &[u8], delay: std::time::Duration) -> Self {
Self {
hash: Hash::new(payload),
data: Bytes::from(payload.to_vec()),
fetch_count: AtomicUsize::new(0),
delay,
}
}
}
impl Origin for SlowCountingOrigin {
fn kind(&self) -> OriginKind {
OriginKind::Http
}
fn fetch(
&self,
hash: Hash,
_max_bytes: u64,
) -> Pin<Box<dyn Future<Output = Result<OriginFetch, crate::OriginPullError>> + Send + '_>>
{
self.fetch_count.fetch_add(1, Ordering::SeqCst);
let result = if hash == self.hash {
Ok(OriginFetch::found_one_shot(self.data.clone()))
} else {
Ok(OriginFetch::NotFound)
};
let delay = self.delay;
Box::pin(async move {
tokio::time::sleep(delay).await;
result
})
}
}
#[tokio::test]
async fn concurrent_gets_coalesce_into_single_origin_fetch() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let payload = b"coalesce me";
let hash = Hash::new(payload);
let origin = Arc::new(SlowCountingOrigin::new(
payload,
std::time::Duration::from_millis(50),
));
let cm = Arc::new(CacheMetrics::default());
let engine = CacheEngine::open_full(
tmp.path(),
vec![origin.clone() as Arc<dyn Origin>],
10,
crate::PinnedHashes::empty(),
crate::RetryPolicy::default(),
CircuitBreakerPolicy::default(),
Some(Arc::clone(&cm)),
Duration::ZERO,
)
.await?;
let mut handles = Vec::new();
for _ in 0..5 {
let e = engine.clone();
handles.push(tokio::spawn(async move { e.get(hash).await }));
}
for handle in handles {
let result = handle
.await
.map_err(|e| anyhow::anyhow!("task join: {e}"))?;
anyhow::ensure!(result.is_ok(), "expected Ok, got {result:?}");
}
let count = origin.fetch_count.load(Ordering::SeqCst);
anyhow::ensure!(count == 1, "expected exactly 1 origin fetch, got {count}");
anyhow::ensure!(
cm.misses.get() == 1,
"owner pulls once → exactly 1 miss, got {}",
cm.misses.get()
);
anyhow::ensure!(
cm.hits.get() == 4,
"4 waiters retry into a hit, got {} hits",
cm.hits.get()
);
anyhow::ensure!(
cm.pull_through_bytes.get() == payload.len() as u64,
"single origin fetch → pull_through_bytes == payload.len()"
);
anyhow::ensure!(
cm.bytes_returned.get() == (payload.len() as u64) * 5,
"all 5 callers got the bytes back, so bytes_returned == 5 * payload.len()"
);
Ok(())
}
#[tokio::test]
async fn inflight_map_is_empty_after_pull_completes() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let payload = b"cleanup check";
let hash = Hash::new(payload);
let origin = StubOrigin::new(payload);
let engine =
CacheEngine::open(tmp.path(), vec![Arc::new(origin) as Arc<dyn Origin>], 10).await?;
let _ = engine.get(hash).await?;
let inflight_len = inflight_len(&engine);
anyhow::ensure!(
inflight_len == 0,
"inflight map should be empty after pull, had {inflight_len} entries"
);
Ok(())
}
#[tokio::test]
async fn cancelled_owner_does_not_orphan_inflight_entry() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let payload = b"cancel test";
let hash = Hash::new(payload);
let origin = SlowCountingOrigin::new(payload, std::time::Duration::from_secs(10));
let engine =
CacheEngine::open(tmp.path(), vec![Arc::new(origin) as Arc<dyn Origin>], 10).await?;
let owner_engine = engine.clone();
let owner = tokio::spawn(async move {
tokio::time::timeout(std::time::Duration::from_millis(50), owner_engine.get(hash)).await
});
let _ = owner.await?;
let inflight_len = inflight_len(&engine);
anyhow::ensure!(
inflight_len == 0,
"inflight map should be empty after cancellation, had {inflight_len} entries"
);
Ok(())
}
#[allow(clippy::panic)]
fn poison_inflight(engine: &CacheEngine) {
let inner = Arc::clone(&engine.inner);
let handle = std::thread::spawn(move || {
let _guard = inner.inflight.lock();
panic!("deliberately poisoning the inflight mutex");
});
drop(handle.join());
}
fn inflight_len(engine: &CacheEngine) -> usize {
engine
.inner
.inflight
.lock()
.unwrap_or_else(PoisonError::into_inner)
.len()
}
#[tokio::test]
async fn poisoned_inflight_mutex_still_coalesces_and_is_counted() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let payload = b"coalesce under poison";
let hash = Hash::new(payload);
let origin = Arc::new(SlowCountingOrigin::new(
payload,
std::time::Duration::from_millis(50),
));
let cm = Arc::new(CacheMetrics::default());
let engine = CacheEngine::open_full(
tmp.path(),
vec![origin.clone() as Arc<dyn Origin>],
10,
crate::PinnedHashes::empty(),
crate::RetryPolicy::default(),
CircuitBreakerPolicy::default(),
Some(Arc::clone(&cm)),
Duration::ZERO,
)
.await?;
poison_inflight(&engine);
let mut handles = Vec::new();
for _ in 0..5 {
let e = engine.clone();
handles.push(tokio::spawn(async move { e.get(hash).await }));
}
for handle in handles {
let result = handle
.await
.map_err(|e| anyhow::anyhow!("task join: {e}"))?;
anyhow::ensure!(result.is_ok(), "expected Ok, got {result:?}");
}
let count = origin.fetch_count.load(Ordering::SeqCst);
anyhow::ensure!(
count == 1,
"coalescing must survive poison — expected exactly 1 origin fetch, got {count}"
);
let bumps = cm.inflight_mutex_poisoned.get();
anyhow::ensure!(
bumps == 1,
"one poisoning must count exactly once, got {bumps}"
);
anyhow::ensure!(
inflight_len(&engine) == 0,
"the owner's guard must still clear its entry under poison"
);
Ok(())
}
#[tokio::test]
async fn separate_poisonings_each_count_while_the_log_latches() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let payload = b"latch check";
let hash = Hash::new(payload);
let origin = StubOrigin::new(payload);
let cm = Arc::new(CacheMetrics::default());
let engine = CacheEngine::open_full(
tmp.path(),
vec![Arc::new(origin) as Arc<dyn Origin>],
10,
crate::PinnedHashes::empty(),
crate::RetryPolicy::default(),
CircuitBreakerPolicy::default(),
Some(Arc::clone(&cm)),
Duration::ZERO,
)
.await?;
poison_inflight(&engine);
anyhow::ensure!(
!engine.inner.inflight_poison_logged.load(Ordering::Relaxed),
"the latch must not be set before any lock is taken"
);
let _ = engine.get(hash).await?;
let after_first = cm.inflight_mutex_poisoned.get();
anyhow::ensure!(
after_first == 1,
"one poisoning, one bump — the poison must have been cleared; got {after_first}"
);
anyhow::ensure!(
engine.inner.inflight_poison_logged.load(Ordering::Relaxed),
"the first poisoned lock must latch the log"
);
poison_inflight(&engine);
drop(engine.inner.lock_inflight());
let after_second = cm.inflight_mutex_poisoned.get();
anyhow::ensure!(
after_second == 2,
"the counter must count the second poisoning too, got {after_second}"
);
anyhow::ensure!(
engine.inner.inflight_poison_logged.load(Ordering::Relaxed),
"the latch must stay set — the second poisoning must not re-log"
);
Ok(())
}
#[tokio::test]
async fn poisoned_populate_still_coalesces_into_one_fill() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let payload = b"populate under poison";
let hash = Hash::new(payload);
let origin = Arc::new(SlowCountingOrigin::new(
payload,
std::time::Duration::from_millis(50),
));
let cm = Arc::new(CacheMetrics::default());
let engine = CacheEngine::open_full(
tmp.path(),
vec![origin.clone() as Arc<dyn Origin>],
10,
crate::PinnedHashes::empty(),
crate::RetryPolicy::default(),
CircuitBreakerPolicy::default(),
Some(Arc::clone(&cm)),
Duration::ZERO,
)
.await?;
poison_inflight(&engine);
let mut handles = Vec::new();
for _ in 0..5 {
let e = engine.clone();
handles.push(tokio::spawn(async move { e.populate(hash).await }));
}
for handle in handles {
let result = handle
.await
.map_err(|e| anyhow::anyhow!("task join: {e}"))?;
anyhow::ensure!(result.is_ok(), "expected Ok, got {result:?}");
}
let count = origin.fetch_count.load(Ordering::SeqCst);
anyhow::ensure!(
count == 1,
"populate must coalesce under poison — expected 1 origin fetch, got {count}"
);
let bumps = cm.inflight_mutex_poisoned.get();
anyhow::ensure!(bumps == 1, "one poisoning, one bump; got {bumps}");
anyhow::ensure!(inflight_len(&engine) == 0, "the claim must be released");
Ok(())
}
#[tokio::test]
async fn poisoned_mutex_does_not_orphan_inflight_entry() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let payload = b"poisoned cancel";
let hash = Hash::new(payload);
let origin_handle = Arc::new(SlowCountingOrigin::new(
payload,
std::time::Duration::from_secs(10),
));
let engine = CacheEngine::open(
tmp.path(),
vec![origin_handle.clone() as Arc<dyn Origin>],
10,
)
.await?;
poison_inflight(&engine);
let owner_engine = engine.clone();
let owner = tokio::spawn(async move {
tokio::time::timeout(std::time::Duration::from_millis(50), owner_engine.get(hash)).await
});
let _ = owner.await?;
let claimed = origin_handle.fetch_count.load(Ordering::SeqCst);
anyhow::ensure!(
claimed == 1,
"owner must have reached the pull (and so held the entry), got {claimed} fetches"
);
let len = inflight_len(&engine);
anyhow::ensure!(
len == 0,
"InflightGuard::drop must clear the entry under poison, had {len}"
);
Ok(())
}
#[tokio::test]
async fn pinned_hash_is_excluded_from_eviction_candidates() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let pinned_payload = b"pinned blob";
let evictable_payload = b"evictable blob";
let pinned_hash = Hash::new(pinned_payload);
let evictable_hash = Hash::new(evictable_payload);
let pinned_set = [from_store_hash(pinned_hash)].into_iter().collect();
let engine =
CacheEngine::open_with_pinned(tmp.path(), Vec::new(), 10, PinnedHashes::new(pinned_set))
.await?;
engine
.inner
.access_times
.insert(pinned_hash, Instant::now());
engine
.inner
.access_times
.insert(evictable_hash, Instant::now());
let raw = engine.access_times_snapshot();
anyhow::ensure!(raw.len() == 2, "raw snapshot must include pinned");
let candidates = engine.eviction_candidates();
anyhow::ensure!(
candidates.len() == 1,
"candidates should exclude pinned, got {} entries",
candidates.len()
);
anyhow::ensure!(
candidates.contains_key(&evictable_hash),
"evictable hash should be a candidate"
);
anyhow::ensure!(
!candidates.contains_key(&pinned_hash),
"pinned hash must NOT be a candidate"
);
anyhow::ensure!(engine.is_pinned(pinned_hash));
anyhow::ensure!(!engine.is_pinned(evictable_hash));
Ok(())
}
#[tokio::test]
async fn deny_listed_pinned_hash_is_an_eviction_candidate() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let clean_hash = Hash::new(b"pinned clean blob");
let denied_hash = Hash::new(b"pinned denied blob");
let pinned_set = [from_store_hash(clean_hash), from_store_hash(denied_hash)]
.into_iter()
.collect();
let engine =
CacheEngine::open_with_pinned(tmp.path(), Vec::new(), 10, PinnedHashes::new(pinned_set))
.await?;
engine.inner.access_times.insert(clean_hash, Instant::now());
engine
.inner
.access_times
.insert(denied_hash, Instant::now());
anyhow::ensure!(
engine.set_chain_denied_one(denied_hash, true),
"deny must change the set"
);
let candidates = engine.eviction_candidates();
anyhow::ensure!(
candidates.contains_key(&denied_hash),
"a pinned + governance-denied hash must remain an eviction candidate"
);
anyhow::ensure!(
!candidates.contains_key(&clean_hash),
"a pinned clean hash must still be excluded"
);
anyhow::ensure!(
engine.is_pinned(denied_hash) && engine.is_pinned(clean_hash),
"the pin flag itself is unchanged — only the eviction carve-out differs"
);
Ok(())
}
#[tokio::test]
async fn local_denied_pinned_hash_is_also_an_eviction_candidate() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let hash = Hash::new(b"pinned + locally denied");
let engine = CacheEngine::open_with_pinned(
tmp.path(),
Vec::new(),
10,
PinnedHashes::new([from_store_hash(hash)].into_iter().collect()),
)
.await?;
engine.inner.access_times.insert(hash, Instant::now());
engine.set_denied(&denied(&[hash]));
anyhow::ensure!(
engine.eviction_candidates().contains_key(&hash),
"a pinned + locally-denied hash must also be an eviction candidate"
);
Ok(())
}
#[tokio::test]
async fn set_pinned_atomically_updates_filter() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let h1 = Hash::new(b"one");
let h2 = Hash::new(b"two");
let engine = CacheEngine::open(tmp.path(), Vec::new(), 10).await?;
engine.inner.access_times.insert(h1, Instant::now());
engine.inner.access_times.insert(h2, Instant::now());
anyhow::ensure!(engine.eviction_candidates().len() == 2);
let s = [from_store_hash(h1)].into_iter().collect();
let diff = engine.set_pinned(&PinnedHashes::new(s));
anyhow::ensure!(
diff.added == 1 && diff.removed == 0,
"expected diff (added=1, removed=0), got {diff:?}"
);
let candidates = engine.eviction_candidates();
anyhow::ensure!(candidates.len() == 1, "h1 should now be excluded");
anyhow::ensure!(candidates.contains_key(&h2));
let diff2 = engine.set_pinned(&PinnedHashes::empty());
anyhow::ensure!(
diff2.added == 0 && diff2.removed == 1,
"expected diff (added=0, removed=1), got {diff2:?}"
);
anyhow::ensure!(engine.eviction_candidates().len() == 2);
Ok(())
}
#[tokio::test]
async fn evict_blocks_subsequent_serve() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let payload = b"evict me";
let hash = Hash::new(payload);
let origin = StubOrigin::new(payload);
let engine =
CacheEngine::open(tmp.path(), vec![Arc::new(origin) as Arc<dyn Origin>], 10).await?;
let _ = engine.get(hash).await?;
anyhow::ensure!(
engine.has(hash).await?,
"expected blob present before evict"
);
engine.evict(hash).await?;
anyhow::ensure!(engine.is_evicted(hash), "evict flag not set");
anyhow::ensure!(
!engine.has(hash).await?,
"has() should report absent after evict"
);
match engine.get(hash).await {
Err(CacheError::NotFound { .. }) => Ok(()),
other => Err(anyhow::anyhow!(
"expected NotFound after evict, got {other:?}"
)),
}
}
#[tokio::test]
async fn evict_persists_across_open() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let payload = b"persist me";
let hash = Hash::new(payload);
{
let origin = Arc::new(StubOrigin::new(payload));
let engine = CacheEngine::open(tmp.path(), vec![origin as Arc<dyn Origin>], 10).await?;
let _ = engine.get(hash).await?;
engine.evict(hash).await?;
engine.shutdown().await?;
}
let engine2 = CacheEngine::open(tmp.path(), Vec::new(), 10).await?;
anyhow::ensure!(
engine2.is_evicted(hash),
"evict flag should reload from <cache_dir>/evicted.log"
);
anyhow::ensure!(
!engine2.has(hash).await?,
"has() should report absent after restart"
);
Ok(())
}
async fn commit_then_close(dir: &Path, payloads: &[&[u8]]) -> anyhow::Result<()> {
let origin = Arc::new(MultiStubOrigin::new(payloads)) as Arc<dyn Origin>;
let engine = CacheEngine::open(dir, vec![origin], 10).await?;
for payload in payloads {
let _ = engine.get(Hash::new(payload)).await?;
}
engine.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn a_blob_on_disk_before_open_is_an_eviction_candidate() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let payload: &[u8] = b"cold before restart";
let hash = Hash::new(payload);
commit_then_close(tmp.path(), &[payload]).await?;
let engine = CacheEngine::open(tmp.path(), Vec::new(), 10).await?;
anyhow::ensure!(
engine.eviction_candidates().contains_key(&hash),
"a pre-open blob must be an eviction candidate without a touch"
);
anyhow::ensure!(
engine.release_for_eviction(hash).await? > 0,
"a seeded pre-open blob must be releasable"
);
anyhow::ensure!(
!engine.eviction_candidates().contains_key(&hash),
"a released blob must leave the candidate set"
);
Ok(())
}
#[tokio::test]
async fn an_evicted_pre_open_blob_is_not_seeded() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let evicted_payload: &[u8] = b"evicted before restart";
let kept_payload: &[u8] = b"kept across restart";
let evicted = Hash::new(evicted_payload);
let kept = Hash::new(kept_payload);
{
let origin =
Arc::new(MultiStubOrigin::new(&[evicted_payload, kept_payload])) as Arc<dyn Origin>;
let engine = CacheEngine::open(tmp.path(), vec![origin], 10).await?;
let _ = engine.get(evicted).await?;
let _ = engine.get(kept).await?;
engine.evict(evicted).await?;
engine.shutdown().await?;
}
let engine = CacheEngine::open(tmp.path(), Vec::new(), 10).await?;
anyhow::ensure!(
!engine.access_times_snapshot().contains_key(&evicted),
"an evicted hash must not enter access_times through the seed"
);
let candidates = engine.eviction_candidates();
anyhow::ensure!(
!candidates.contains_key(&evicted),
"an evicted hash must not be a candidate"
);
anyhow::ensure!(
candidates.contains_key(&kept),
"a kept hash must still be seeded"
);
Ok(())
}
#[tokio::test]
async fn a_touched_blob_ranks_after_an_untouched_pre_open_blob() -> anyhow::Result<()> {
use crate::policy::{EvictionContext, EvictionPolicy, LruEviction};
let tmp = tempfile::tempdir()?;
let cold_payload: &[u8] = b"untouched after restart";
let hot_payload: &[u8] = b"touched after restart";
let cold = Hash::new(cold_payload);
let hot = Hash::new(hot_payload);
commit_then_close(tmp.path(), &[cold_payload, hot_payload]).await?;
let engine = CacheEngine::open(tmp.path(), Vec::new(), 10).await?;
engine.observe_hit(hot);
let candidates = engine.eviction_candidates();
let cold_at = candidates
.iter()
.find_map(|(h, t)| (*h == cold).then_some(*t))
.ok_or_else(|| anyhow::anyhow!("cold blob missing from candidates"))?;
let hot_at = candidates
.iter()
.find_map(|(h, t)| (*h == hot).then_some(*t))
.ok_or_else(|| anyhow::anyhow!("hot blob missing from candidates"))?;
anyhow::ensure!(
cold_at < hot_at,
"the untouched pre-open blob must rank older than the touched one"
);
let sizes = HashMap::new();
let segments = HashMap::new();
let plan = LruEviction.plan(&EvictionContext {
candidates: &candidates,
sizes: &sizes,
segments: &segments,
total_bytes: 1,
target_bytes: 0,
budget: 1,
cache_bytes: 0,
});
anyhow::ensure!(
plan.evict == vec![cold],
"LRU must release the untouched pre-open blob first, got {:?}",
plan.evict
);
Ok(())
}
#[tokio::test]
async fn a_seeded_blob_reports_no_last_access() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let payload: &[u8] = b"seeded, not accessed";
let hash = Hash::new(payload);
commit_then_close(tmp.path(), &[payload]).await?;
let engine = CacheEngine::open(tmp.path(), Vec::new(), 10).await?;
anyhow::ensure!(
engine.last_accessed(hash).is_none(),
"a seeded entry must not read as an access"
);
anyhow::ensure!(
engine.inspect(hash).await?.last_accessed_us_ago.is_none(),
"the preview must not report the seed as an access"
);
engine.observe_hit(hash);
anyhow::ensure!(
engine.last_accessed(hash).is_some(),
"a touch after open must read as an access"
);
anyhow::ensure!(
engine.inspect(hash).await?.last_accessed_us_ago.is_some(),
"the preview must report a touch after open"
);
Ok(())
}
#[tokio::test]
async fn a_pinned_pre_open_blob_is_not_a_candidate() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let pinned_payload: &[u8] = b"pinned across restart";
let free_payload: &[u8] = b"unpinned across restart";
let pinned = Hash::new(pinned_payload);
let free = Hash::new(free_payload);
commit_then_close(tmp.path(), &[pinned_payload, free_payload]).await?;
let engine = CacheEngine::open_with_pinned(
tmp.path(),
Vec::new(),
10,
crate::PinnedHashes::new([from_store_hash(pinned)].into_iter().collect()),
)
.await?;
let candidates = engine.eviction_candidates();
anyhow::ensure!(
!candidates.contains_key(&pinned),
"a pinned pre-open blob must not be an eviction candidate"
);
anyhow::ensure!(
candidates.contains_key(&free),
"an unpinned pre-open blob must still be an eviction candidate"
);
Ok(())
}
#[tokio::test]
async fn tinylfu_ranks_a_touched_blob_after_an_untouched_pre_open_blob() -> anyhow::Result<()> {
use crate::policy::{EvictionContext, EvictionPolicy, TinyLfuEstimator, TinyLfuEviction};
let tmp = tempfile::tempdir()?;
let cold_payload: &[u8] = b"tinylfu: untouched after restart";
let hot_payload: &[u8] = b"tinylfu: touched after restart";
let cold = Hash::new(cold_payload);
let hot = Hash::new(hot_payload);
commit_then_close(tmp.path(), &[cold_payload, hot_payload]).await?;
let engine = CacheEngine::open(tmp.path(), Vec::new(), 10).await?;
let freq: Arc<dyn crate::policy::FrequencyEstimator> = Arc::new(TinyLfuEstimator::new(4096));
engine.set_frequency_estimator(Arc::clone(&freq));
engine.touch_recency(hot);
let candidates = engine.eviction_candidates();
let sizes = HashMap::new();
let segments = HashMap::new();
let plan = TinyLfuEviction::new(freq, 2, 10).plan(&EvictionContext {
candidates: &candidates,
sizes: &sizes,
segments: &segments,
total_bytes: 1,
target_bytes: 0,
budget: 1,
cache_bytes: 0,
});
anyhow::ensure!(
plan.evict == vec![cold],
"TinyLFU must release the untouched pre-open blob first, got {:?}",
plan.evict
);
Ok(())
}
#[tokio::test]
async fn a_partial_blob_on_disk_before_open_is_an_eviction_candidate() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let group = crate::CHUNK_GROUP_BYTES;
let total = 4 * group;
let (root, plaintext, outboard) = synth_blob(usize::try_from(total)?);
let (hash, ranges, bao) = bao_for(root, &plaintext, outboard, group, group, total);
{
let engine = CacheEngine::open(tmp.path(), Vec::new(), 16).await?;
engine.admit_bao(hash, ranges, bao).await?;
anyhow::ensure!(
!engine.present_ranges(hash).await?.is_complete(),
"the admitted blob must be partial"
);
engine.shutdown().await?;
}
let engine = CacheEngine::open(tmp.path(), Vec::new(), 16).await?;
anyhow::ensure!(
engine.eviction_candidates().contains_key(&hash),
"a pre-open partial must be an eviction candidate without a touch"
);
anyhow::ensure!(
engine.last_accessed(hash).is_none(),
"a seeded partial must not read as accessed"
);
anyhow::ensure!(
engine.release_for_eviction(hash).await? > 0,
"a seeded pre-open partial must be releasable"
);
Ok(())
}
#[tokio::test]
async fn evicted_log_skips_malformed_lines() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let log_path = tmp.path().join("evicted.log");
let good = Hash::new(b"good entry");
std::fs::write(
&log_path,
format!("\n# operator note\n{good}\nnot-a-hash\n{good}\n"),
)?;
let engine = CacheEngine::open(tmp.path(), Vec::new(), 10).await?;
anyhow::ensure!(engine.is_evicted(good), "valid hash line not loaded");
Ok(())
}
#[tokio::test]
async fn evict_unknown_hash_is_a_no_op() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let engine = CacheEngine::open(tmp.path(), Vec::new(), 10).await?;
let unknown = Hash::new(b"never seen");
engine.evict(unknown).await?;
anyhow::ensure!(engine.is_evicted(unknown), "evict flag not set");
Ok(())
}
#[tokio::test]
async fn serve_audit_reports_presence_size_and_eviction() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let payload = b"a served blob";
let origin = StubOrigin::new(payload);
let engine =
CacheEngine::open(tmp.path(), vec![Arc::new(origin) as Arc<dyn Origin>], 10).await?;
let hash = Hash::new(payload);
let unseen = Hash::new(b"never cached");
anyhow::ensure!(
engine.serve_audit(unseen).await? == ServeAudit::Unavailable { withdrawn: false },
"an absent hash is unavailable and not evicted"
);
engine.populate(hash).await?;
anyhow::ensure!(
engine.serve_audit(hash).await?
== ServeAudit::Serveable {
size: payload.len() as u64
},
"a complete blob is serveable and carries its size"
);
engine.evict(hash).await?;
anyhow::ensure!(
engine.serve_audit(hash).await? == ServeAudit::Unavailable { withdrawn: true },
"evicted content is unavailable with the eviction surfaced"
);
Ok(())
}
#[tokio::test]
async fn serve_audit_withholds_the_size_of_a_denied_blob() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let payload = b"denied but on disk";
let origin = StubOrigin::new(payload);
let engine =
CacheEngine::open(tmp.path(), vec![Arc::new(origin) as Arc<dyn Origin>], 10).await?;
let hash = Hash::new(payload);
engine.populate(hash).await?;
engine.set_chain_denied_one(hash, true);
let audit = engine.serve_audit(hash).await?;
anyhow::ensure!(
audit == ServeAudit::Unavailable { withdrawn: false },
"a chain-denied blob is unavailable, un-evicted, and sizeless"
);
anyhow::ensure!(
audit.hit_size().is_none(),
"a refused blob never yields a wire size"
);
Ok(())
}
#[tokio::test]
async fn serve_audit_reports_a_zero_length_blob_as_serveable() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let origin = StubOrigin::new(b"");
let engine =
CacheEngine::open(tmp.path(), vec![Arc::new(origin) as Arc<dyn Origin>], 10).await?;
let hash = Hash::new(b"");
engine.populate(hash).await?;
let audit = engine.serve_audit(hash).await?;
anyhow::ensure!(
audit == ServeAudit::Serveable { size: 0 },
"an empty blob is serveable at size 0"
);
anyhow::ensure!(
audit.hit_size() == Some(0),
"the delivery path reads 0, not a missing size"
);
Ok(())
}
#[tokio::test]
async fn evict_is_idempotent_and_does_not_grow_log() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let log_path = tmp.path().join("evicted.log");
let engine = CacheEngine::open(tmp.path(), Vec::new(), 10).await?;
let hash = Hash::new(b"dup-evict");
engine.evict(hash).await?;
let after_first = std::fs::read_to_string(&log_path)?;
let lines_first = after_first.lines().count();
engine.evict(hash).await?;
engine.evict(hash).await?;
let after_third = std::fs::read_to_string(&log_path)?;
let lines_third = after_third.lines().count();
anyhow::ensure!(
lines_first == 1 && lines_third == 1,
"expected 1 log line both times, got first={lines_first}, third={lines_third}"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn evicted_set_stays_consistent_under_concurrent_evict() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let engine = Arc::new(empty_engine(tmp.path()).await?);
let hashes: Vec<Hash> = (0..64)
.map(|i| Hash::new(format!("concurrent-evict-{i}").as_bytes()))
.collect();
let stop = Arc::new(AtomicBool::new(false));
let mut writers = Vec::new();
for chunk in hashes.chunks(8) {
let engine = Arc::clone(&engine);
let chunk: Vec<Hash> = chunk.to_vec();
writers.push(tokio::spawn(async move {
for h in &chunk {
engine.evict(*h).await?;
}
Ok::<_, anyhow::Error>(())
}));
}
let mut readers = Vec::new();
for _ in 0..8 {
let engine = Arc::clone(&engine);
let hashes = hashes.clone();
let stop = Arc::clone(&stop);
readers.push(tokio::spawn(async move {
let mut saw_evicted = HashSet::new();
for _ in 0..50_000 {
if stop.load(Ordering::Relaxed) {
break;
}
for h in &hashes {
if engine.is_evicted(*h) {
saw_evicted.insert(*h);
}
}
tokio::task::yield_now().await;
}
Ok::<_, anyhow::Error>(saw_evicted)
}));
}
for w in writers {
w.await
.map_err(|e| anyhow::anyhow!("writer task panicked: {e}"))??;
}
stop.store(true, Ordering::Relaxed);
for h in &hashes {
anyhow::ensure!(
engine.is_evicted(*h),
"{h} was evicted by a concurrent writer but is not in the set — lost update"
);
}
let mut any_observed = false;
for r in readers {
let saw = r
.await
.map_err(|e| anyhow::anyhow!("reader task panicked: {e}"))??;
any_observed |= !saw.is_empty();
for h in &saw {
anyhow::ensure!(
engine.is_evicted(*h),
"reader once saw {h} evicted but it is not evicted at the end — torn view"
);
}
}
anyhow::ensure!(
any_observed,
"no reader observed any eviction — the monotonicity half asserted nothing"
);
Ok(())
}
#[tokio::test]
async fn evicted_log_accepts_uppercase_hex() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let log_path = tmp.path().join("evicted.log");
let hash = Hash::new(b"upper-hex");
let upper = hash.to_string().to_uppercase();
std::fs::write(&log_path, format!("{upper}\n"))?;
let engine = CacheEngine::open(tmp.path(), Vec::new(), 10).await?;
anyhow::ensure!(
engine.is_evicted(hash),
"uppercase hex line should load as the same hash",
);
Ok(())
}
#[tokio::test]
async fn evict_returns_err_when_persistence_fails() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let engine = CacheEngine::open(tmp.path(), Vec::new(), 10).await?;
std::fs::create_dir(tmp.path().join("evicted.log"))?;
let hash = Hash::new(b"persist-fail");
match engine.evict(hash).await {
Err(CacheError::Store(_)) => Ok(()),
other => Err(anyhow::anyhow!(
"expected Store error from persistence failure, got {other:?}"
)),
}?;
anyhow::ensure!(
!engine.is_evicted(hash),
"in-memory set must not commit when persistence fails",
);
Ok(())
}
#[tokio::test]
async fn inspect_unknown_hash_reports_absent() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let engine = CacheEngine::open(tmp.path(), Vec::new(), 10).await?;
let unknown = Hash::new(b"never seen");
let preview = engine.inspect(unknown).await?;
anyhow::ensure!(preview.size_bytes.is_none(), "expected size_bytes=None");
anyhow::ensure!(
preview.last_accessed_us_ago.is_none(),
"expected last_accessed_us_ago=None"
);
anyhow::ensure!(!preview.pinned, "expected pinned=false");
anyhow::ensure!(!preview.already_evicted, "expected already_evicted=false");
anyhow::ensure!(!preview.served, "expected served=false");
Ok(())
}
#[tokio::test]
async fn inspect_after_get_reports_size_and_served() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let payload = b"inspect me";
let hash = Hash::new(payload);
let origin = StubOrigin::new(payload);
let engine =
CacheEngine::open(tmp.path(), vec![Arc::new(origin) as Arc<dyn Origin>], 10).await?;
let _ = engine.get(hash).await?;
let preview = engine.inspect(hash).await?;
anyhow::ensure!(
preview.size_bytes == Some(payload.len() as u64),
"expected size_bytes={:?}, got {:?}",
Some(payload.len() as u64),
preview.size_bytes,
);
anyhow::ensure!(
preview.last_accessed_us_ago.is_some(),
"expected Some(last_accessed_us_ago) after get()"
);
anyhow::ensure!(preview.served, "expected served=true");
anyhow::ensure!(!preview.already_evicted, "expected already_evicted=false");
anyhow::ensure!(!preview.pinned, "expected pinned=false");
Ok(())
}
#[tokio::test]
async fn inspect_after_evict_keeps_size_but_flips_served() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let payload = b"evicted blob";
let hash = Hash::new(payload);
let origin = StubOrigin::new(payload);
let engine =
CacheEngine::open(tmp.path(), vec![Arc::new(origin) as Arc<dyn Origin>], 10).await?;
let _ = engine.get(hash).await?;
engine.evict(hash).await?;
let preview = engine.inspect(hash).await?;
anyhow::ensure!(
preview.size_bytes == Some(payload.len() as u64),
"expected size_bytes still reported post-evict, got {:?}",
preview.size_bytes,
);
anyhow::ensure!(preview.already_evicted, "expected already_evicted=true");
anyhow::ensure!(!preview.served, "expected served=false post-evict");
anyhow::ensure!(
preview.last_accessed_us_ago.is_none(),
"expected last_accessed cleared by evict, got {:?}",
preview.last_accessed_us_ago,
);
Ok(())
}
#[tokio::test]
async fn inspect_reports_pinned_flag() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let pinned_hash = Hash::new(b"pinned");
let other_hash = Hash::new(b"other");
let set = [from_store_hash(pinned_hash)].into_iter().collect();
let engine =
CacheEngine::open_with_pinned(tmp.path(), Vec::new(), 10, PinnedHashes::new(set)).await?;
let pinned_preview = engine.inspect(pinned_hash).await?;
anyhow::ensure!(pinned_preview.pinned, "expected pinned=true");
anyhow::ensure!(
!pinned_preview.served,
"pinned-but-uncached blob should not be served"
);
let other_preview = engine.inspect(other_hash).await?;
anyhow::ensure!(!other_preview.pinned, "unrelated hash must not be pinned");
Ok(())
}
#[tokio::test]
async fn inspect_reports_origin_kinds_when_origin_configured() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let origin = StubOrigin::new(b"egress-cost preview");
let engine =
CacheEngine::open(tmp.path(), vec![Arc::new(origin) as Arc<dyn Origin>], 10).await?;
let preview = engine.inspect(Hash::new(b"never-fetched")).await?;
anyhow::ensure!(
preview.origin_kinds == vec![OriginKind::Http],
"expected [Http], got {:?}",
preview.origin_kinds,
);
Ok(())
}
#[tokio::test]
async fn inspect_reports_no_origin_kinds_when_cache_only() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let engine = CacheEngine::open(tmp.path(), Vec::new(), 10).await?;
let preview = engine.inspect(Hash::new(b"absent")).await?;
anyhow::ensure!(
preview.origin_kinds.is_empty(),
"expected empty Vec for cache-only mode, got {:?}",
preview.origin_kinds,
);
Ok(())
}
#[tokio::test]
async fn hits_plus_misses_equals_total_gets() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let payload = b"hello invariant";
let hash = Hash::new(payload);
let unknown = Hash::new(b"never present");
let origin = StubOrigin::new(payload);
let cm = Arc::new(CacheMetrics::default());
let engine = CacheEngine::open_full(
tmp.path(),
vec![Arc::new(origin) as Arc<dyn Origin>],
10,
crate::PinnedHashes::empty(),
crate::RetryPolicy::default(),
CircuitBreakerPolicy::default(),
Some(Arc::clone(&cm)),
Duration::ZERO,
)
.await?;
let _ = engine.get(hash).await?; let _ = engine.get(hash).await?; let _ = engine.get(hash).await?; let _ = engine.get(unknown).await; engine.evict(hash).await?;
let _ = engine.get(hash).await;
anyhow::ensure!(cm.hits.get() == 2, "hits = {}", cm.hits.get());
anyhow::ensure!(cm.misses.get() == 3, "misses = {}", cm.misses.get());
anyhow::ensure!(
cm.hits.get() + cm.misses.get() == 5,
"across cache-domain outcomes (no store-I/O errors), every get bumps exactly one of hits/misses"
);
anyhow::ensure!(
cm.pull_through_bytes.get() == payload.len() as u64,
"pull_through_bytes = {}, expected {}",
cm.pull_through_bytes.get(),
payload.len()
);
Ok(())
}
#[tokio::test]
async fn pull_through_success_bumps_bytes_returned() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let payload = b"hello pull-through bytes returned";
let hash = Hash::new(payload);
let origin = StubOrigin::new(payload);
let cm = Arc::new(CacheMetrics::default());
let engine = CacheEngine::open_full(
tmp.path(),
vec![Arc::new(origin) as Arc<dyn Origin>],
10,
crate::PinnedHashes::empty(),
crate::RetryPolicy::default(),
CircuitBreakerPolicy::default(),
Some(Arc::clone(&cm)),
Duration::ZERO,
)
.await?;
let bytes = engine.get(hash).await?;
anyhow::ensure!(
cm.bytes_returned.get() == bytes.len() as u64,
"bytes_returned should match payload length after a successful pull-through"
);
Ok(())
}
#[tokio::test]
async fn pull_through_bumps_pull_through_bytes() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let payload = b"hello pull-through bytes";
let hash = Hash::new(payload);
let origin = StubOrigin::new(payload);
let cm = Arc::new(CacheMetrics::default());
let engine = CacheEngine::open_full(
tmp.path(),
vec![Arc::new(origin) as Arc<dyn Origin>],
10,
crate::PinnedHashes::empty(),
crate::RetryPolicy::default(),
CircuitBreakerPolicy::default(),
Some(Arc::clone(&cm)),
Duration::ZERO,
)
.await?;
let bytes = engine.get(hash).await?;
anyhow::ensure!(cm.misses.get() == 1, "first get is a miss");
anyhow::ensure!(cm.hits.get() == 0, "no hits on first get");
anyhow::ensure!(
cm.pull_through_bytes.get() == bytes.len() as u64,
"pull_through_bytes should equal payload length on a Found origin"
);
Ok(())
}
#[tokio::test]
async fn populate_fills_without_bumping_bytes_returned() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let payload = b"populate must not count as bytes returned";
let hash = Hash::new(payload);
let origin = StubOrigin::new(payload);
let cm = Arc::new(CacheMetrics::default());
let engine = CacheEngine::open_full(
tmp.path(),
vec![Arc::new(origin) as Arc<dyn Origin>],
10,
crate::PinnedHashes::empty(),
crate::RetryPolicy::default(),
CircuitBreakerPolicy::default(),
Some(Arc::clone(&cm)),
Duration::ZERO,
)
.await?;
engine.populate(hash).await?;
anyhow::ensure!(engine.has(hash).await?, "populate must fill the store");
anyhow::ensure!(
cm.pull_through_bytes.get() == payload.len() as u64,
"populate must still bump origin-egress pull_through_bytes"
);
anyhow::ensure!(
cm.bytes_returned.get() == 0,
"populate must NOT bump bytes_returned (#831: internal fill, not client egress)"
);
anyhow::ensure!(cm.hits.get() == 0, "populate must not count a hit");
engine.populate(hash).await?;
anyhow::ensure!(
cm.pull_through_bytes.get() == payload.len() as u64,
"a populate for an already-present blob must not re-pull"
);
anyhow::ensure!(cm.bytes_returned.get() == 0, "still no bytes_returned");
Ok(())
}
#[tokio::test]
async fn no_origin_increments_misses_only() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let cm = Arc::new(CacheMetrics::default());
let engine = CacheEngine::open_full(
tmp.path(),
Vec::new(),
10,
crate::PinnedHashes::empty(),
crate::RetryPolicy::default(),
CircuitBreakerPolicy::default(),
Some(Arc::clone(&cm)),
Duration::ZERO,
)
.await?;
let hash = Hash::new(b"missing payload");
let Err(err) = engine.get(hash).await else {
anyhow::bail!("expected NoOrigin, got Ok");
};
anyhow::ensure!(
matches!(err, CacheError::NoOrigin { .. }),
"expected NoOrigin"
);
anyhow::ensure!(cm.misses.get() == 1, "exactly one miss for a NoOrigin get");
anyhow::ensure!(cm.hits.get() == 0, "no hits");
anyhow::ensure!(
cm.pull_through_bytes.get() == 0,
"no origin bytes since origin not configured"
);
anyhow::ensure!(
cm.bytes_returned.get() == 0,
"no bytes returned on error path"
);
Ok(())
}
#[tokio::test]
async fn evicted_hash_increments_misses() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let payload = b"hello evicted miss";
let hash = Hash::new(payload);
let origin = StubOrigin::new(payload);
let cm = Arc::new(CacheMetrics::default());
let engine = CacheEngine::open_full(
tmp.path(),
vec![Arc::new(origin) as Arc<dyn Origin>],
10,
crate::PinnedHashes::empty(),
crate::RetryPolicy::default(),
CircuitBreakerPolicy::default(),
Some(Arc::clone(&cm)),
Duration::ZERO,
)
.await?;
let _ = engine.get(hash).await?;
engine.evict(hash).await?;
let misses_before = cm.misses.get();
let Err(err) = engine.get(hash).await else {
anyhow::bail!("expected NotFound, got Ok");
};
anyhow::ensure!(
matches!(err, CacheError::NotFound { .. }),
"evicted get must surface NotFound"
);
anyhow::ensure!(
cm.misses.get() == misses_before + 1,
"misses should bump by exactly 1 on an evicted-hash get"
);
Ok(())
}
async fn empty_engine(tmp: &std::path::Path) -> anyhow::Result<CacheEngine> {
Ok(CacheEngine::open_with_pinned(tmp, Vec::new(), 10, crate::PinnedHashes::empty()).await?)
}
#[tokio::test]
async fn chain_denied_hash_is_refused() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let engine = empty_engine(tmp.path()).await?;
let hash = Hash::new(b"governance takedown");
anyhow::ensure!(!engine.refuses(hash), "nothing refused before the deny");
anyhow::ensure!(engine.set_chain_denied_one(hash, true), "set changed");
anyhow::ensure!(engine.is_chain_denied(hash));
anyhow::ensure!(engine.refuses(hash), "a governance deny must refuse");
anyhow::ensure!(
!engine.is_denied(hash),
"and must NOT masquerade as a local denylist entry — the two feed \
different operator metrics"
);
anyhow::ensure!(!engine.has(hash).await?, "refused hashes report absent");
Ok(())
}
#[tokio::test]
async fn set_chain_denied_one_reports_whether_it_changed_anything() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let engine = empty_engine(tmp.path()).await?;
let hash = Hash::new(b"replayed");
anyhow::ensure!(engine.set_chain_denied_one(hash, true));
anyhow::ensure!(
!engine.set_chain_denied_one(hash, true),
"replay is a no-op"
);
anyhow::ensure!(engine.set_chain_denied_one(hash, false));
anyhow::ensure!(!engine.set_chain_denied_one(hash, false));
anyhow::ensure!(
!engine.refuses(hash),
"a de-listed hash stops being refused"
);
Ok(())
}
#[tokio::test]
async fn local_and_chain_deny_sets_do_not_clobber_each_other() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let engine = empty_engine(tmp.path()).await?;
let local = Hash::new(b"local entry");
let governance = Hash::new(b"governance entry");
engine.set_denied(&denied(&[local]));
engine.set_chain_denied_one(governance, true);
engine.set_denied(&crate::DeniedHashes::empty());
anyhow::ensure!(!engine.refuses(local), "local entry lifted by the reload");
anyhow::ensure!(
engine.refuses(governance),
"a config reload must not lift a governance takedown"
);
engine.set_denied(&denied(&[local]));
engine.set_chain_denied_one(governance, false);
anyhow::ensure!(engine.refuses(local));
anyhow::ensure!(!engine.refuses(governance));
Ok(())
}
#[tokio::test]
async fn hash_on_both_deny_sets_survives_removal_from_one() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let engine = empty_engine(tmp.path()).await?;
let hash = Hash::new(b"both lists");
engine.set_denied(&denied(&[hash]));
engine.set_chain_denied_one(hash, true);
engine.set_chain_denied_one(hash, false);
anyhow::ensure!(engine.refuses(hash), "the local entry still stands");
Ok(())
}
#[tokio::test]
async fn set_chain_denied_replaces_wholesale() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let engine = empty_engine(tmp.path()).await?;
let stale = Hash::new(b"stale");
let restored = Hash::new(b"restored");
engine.set_chain_denied_one(stale, true);
engine.set_chain_denied([restored].into_iter().collect());
anyhow::ensure!(!engine.refuses(stale));
anyhow::ensure!(engine.refuses(restored));
Ok(())
}
fn denied(hashes: &[Hash]) -> crate::DeniedHashes {
crate::DeniedHashes::new(hashes.iter().map(|h| from_store_hash(*h)).collect())
}
#[tokio::test]
async fn try_probe_hold_unavailable_when_blob_absent() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let engine = CacheEngine::open(tmp.path(), vec![], 10).await?;
let absent = Hash::new(b"never fetched");
anyhow::ensure!(
engine.try_probe_hold(absent).await? == ProbeHoldOutcome::Unavailable,
"absent blob must not be holdable (an absent blob is never advertised)"
);
Ok(())
}
#[tokio::test]
async fn try_probe_hold_true_when_cached_and_excluded_from_eviction() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let payload = b"holdable blob";
let hash = Hash::new(payload);
let engine = CacheEngine::open(
tmp.path(),
vec![Arc::new(StubOrigin::new(payload)) as Arc<dyn Origin>],
10,
)
.await?;
let _ = engine.get(hash).await?;
anyhow::ensure!(
engine.try_probe_hold(hash).await? == ProbeHoldOutcome::Held,
"cached blob should hold"
);
anyhow::ensure!(engine.probe_hold_slots_used() == 1, "one slot used");
anyhow::ensure!(
!engine
.eviction_candidates()
.into_inner()
.contains_key(&hash),
"held hash must be invisible to the LRU driver"
);
Ok(())
}
#[tokio::test]
async fn probe_hold_is_shared_per_blob_not_per_probe() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let payload = b"popular blob";
let hash = Hash::new(payload);
let engine = CacheEngine::open(
tmp.path(),
vec![Arc::new(StubOrigin::new(payload)) as Arc<dyn Origin>],
10,
)
.await?;
let _ = engine.get(hash).await?;
engine.set_max_probe_holds(1);
for _ in 0..3 {
anyhow::ensure!(engine.try_probe_hold(hash).await? == ProbeHoldOutcome::Held);
}
anyhow::ensure!(
engine.probe_hold_slots_used() == 1,
"shared per-blob slot must not grow with probe volume"
);
Ok(())
}
#[tokio::test]
async fn probe_hold_budget_exhaustion_reports_exhausted() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let a: &[u8] = b"blob a";
let b: &[u8] = b"blob bee";
let (ha, hb) = (Hash::new(a), Hash::new(b));
let origin = Arc::new(MultiStubOrigin::new(&[a, b]));
let engine = CacheEngine::open(tmp.path(), vec![origin as Arc<dyn Origin>], 10).await?;
let _ = engine.get(ha).await?;
let _ = engine.get(hb).await?;
engine.set_max_probe_holds(1);
anyhow::ensure!(
engine.try_probe_hold(ha).await? == ProbeHoldOutcome::Held,
"first hold fits budget"
);
anyhow::ensure!(
engine.try_probe_hold(hb).await? == ProbeHoldOutcome::BudgetExhausted,
"second distinct hold must be refused when budget is exhausted"
);
Ok(())
}
#[tokio::test]
async fn probe_hold_disabled_when_budget_zero() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let payload = b"unhold me";
let hash = Hash::new(payload);
let engine = CacheEngine::open(
tmp.path(),
vec![Arc::new(StubOrigin::new(payload)) as Arc<dyn Origin>],
10,
)
.await?;
let _ = engine.get(hash).await?;
engine.set_max_probe_holds(0);
anyhow::ensure!(
engine.try_probe_hold(hash).await? == ProbeHoldOutcome::HoldsDisabled,
"max_probe_holds=0 must disable has_blob:true entirely"
);
Ok(())
}
#[tokio::test]
async fn runtime_disable_overrides_existing_probe_hold() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let payload = b"held then disabled";
let hash = Hash::new(payload);
let engine = CacheEngine::open(
tmp.path(),
vec![Arc::new(StubOrigin::new(payload)) as Arc<dyn Origin>],
10,
)
.await?;
let _ = engine.get(hash).await?;
engine.set_max_probe_holds(1);
anyhow::ensure!(
engine.try_probe_hold(hash).await? == ProbeHoldOutcome::Held,
"hold should be granted while the budget is positive"
);
engine.set_max_probe_holds(0);
anyhow::ensure!(
engine.try_probe_hold(hash).await? == ProbeHoldOutcome::HoldsDisabled,
"a runtime disable must override an existing live hold"
);
Ok(())
}
#[tokio::test]
async fn absent_blob_is_unavailable_even_when_holds_disabled() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let absent = Hash::new(b"never fetched");
let engine = CacheEngine::open(tmp.path(), vec![], 10).await?;
engine.set_max_probe_holds(0);
anyhow::ensure!(
engine.try_probe_hold(absent).await? == ProbeHoldOutcome::Unavailable,
"absent blob must win over holds-disabled"
);
Ok(())
}
#[tokio::test]
async fn operator_evict_overrides_probe_hold() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let payload = b"dmca target";
let hash = Hash::new(payload);
let engine = CacheEngine::open(
tmp.path(),
vec![Arc::new(StubOrigin::new(payload)) as Arc<dyn Origin>],
10,
)
.await?;
let _ = engine.get(hash).await?;
anyhow::ensure!(
engine.try_probe_hold(hash).await? == ProbeHoldOutcome::Held,
"held before evict"
);
engine.evict(hash).await?;
anyhow::ensure!(
engine.try_probe_hold(hash).await? == ProbeHoldOutcome::Unavailable,
"operator evict (DMCA) must win over a probe hold"
);
Ok(())
}
#[tokio::test]
async fn expired_probe_hold_is_swept_and_re_evictable() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let payload = b"expiring blob";
let hash = Hash::new(payload);
let engine = CacheEngine::open(
tmp.path(),
vec![Arc::new(StubOrigin::new(payload)) as Arc<dyn Origin>],
10,
)
.await?;
let _ = engine.get(hash).await?;
engine.observe_hit(hash);
{
let mut g = engine
.inner
.probe_holds
.lock()
.unwrap_or_else(PoisonError::into_inner);
let past = tokio::time::Instant::now()
.checked_sub(Duration::from_secs(1))
.unwrap_or_else(tokio::time::Instant::now);
g.insert(hash, past);
}
anyhow::ensure!(
engine.probe_hold_slots_used() == 0,
"expired hold must be swept from the slot count"
);
anyhow::ensure!(
engine
.eviction_candidates()
.into_inner()
.contains_key(&hash),
"an expired hold must no longer shield the hash from LRU"
);
Ok(())
}
#[tokio::test]
async fn hit_increments_hits_and_bytes_returned() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let payload = b"hello hit metrics";
let hash = Hash::new(payload);
let origin = StubOrigin::new(payload);
let cm = Arc::new(CacheMetrics::default());
let engine = CacheEngine::open_full(
tmp.path(),
vec![Arc::new(origin) as Arc<dyn Origin>],
10,
crate::PinnedHashes::empty(),
crate::RetryPolicy::default(),
CircuitBreakerPolicy::default(),
Some(Arc::clone(&cm)),
Duration::ZERO,
)
.await?;
let _ = engine.get(hash).await?;
let hits_before = cm.hits.get();
let bytes_before = cm.bytes_returned.get();
let bytes = engine.get(hash).await?;
anyhow::ensure!(
cm.hits.get() == hits_before + 1,
"hits should increment by 1 on a cache hit"
);
anyhow::ensure!(
cm.bytes_returned.get() == bytes_before + bytes.len() as u64,
"bytes_returned should increase by bytes.len() on a cache hit"
);
Ok(())
}
fn local_outboard_pull_test_blob() -> Vec<u8> {
let size = 5 * crate::CHUNK_GROUP_BYTES + 123;
(0..size).map(|i| (i % 251) as u8).collect()
}
#[derive(Debug)]
struct RangeStubOrigin {
hash: Hash,
data: Bytes,
outboard: Option<Bytes>,
size: u64,
support_range: bool,
max_req: AtomicU64,
short_from: u64,
decline_from: u64,
fail_from: u64,
panic_from: u64,
gate_from: u64,
gate: Arc<tokio::sync::Semaphore>,
fault_outboard: bool,
outboard_fetches: AtomicUsize,
gate_outboard: bool,
range_fetches: Arc<AtomicUsize>,
}
impl RangeStubOrigin {
fn serving(hash: Hash, payload: &[u8], outboard: Bytes) -> Self {
Self {
hash,
data: Bytes::from(payload.to_vec()),
outboard: Some(outboard),
size: u64::try_from(payload.len()).unwrap_or(u64::MAX),
support_range: true,
max_req: AtomicU64::new(0),
short_from: u64::MAX,
decline_from: u64::MAX,
fail_from: u64::MAX,
panic_from: u64::MAX,
gate_from: u64::MAX,
gate: Arc::new(tokio::sync::Semaphore::new(0)),
fault_outboard: false,
outboard_fetches: AtomicUsize::new(0),
gate_outboard: false,
range_fetches: Arc::new(AtomicUsize::new(0)),
}
}
fn outboard_faulting(hash: Hash, size: u64) -> Self {
Self {
hash,
data: Bytes::new(),
outboard: None,
size,
support_range: false,
max_req: AtomicU64::new(0),
short_from: u64::MAX,
decline_from: u64::MAX,
fail_from: u64::MAX,
panic_from: u64::MAX,
gate_from: u64::MAX,
gate: Arc::new(tokio::sync::Semaphore::new(0)),
fault_outboard: true,
outboard_fetches: AtomicUsize::new(0),
gate_outboard: false,
range_fetches: Arc::new(AtomicUsize::new(0)),
}
}
}
impl Origin for RangeStubOrigin {
fn kind(&self) -> OriginKind {
OriginKind::Http
}
fn fetch(
&self,
hash: Hash,
_max_bytes: u64,
) -> Pin<Box<dyn Future<Output = Result<OriginFetch, crate::OriginPullError>> + Send + '_>>
{
let result = if hash == self.hash {
Ok(OriginFetch::found_one_shot(self.data.clone()))
} else {
Ok(OriginFetch::NotFound)
};
Box::pin(async move { result })
}
fn size(
&self,
hash: Hash,
) -> Pin<Box<dyn Future<Output = Result<Option<u64>, crate::OriginPullError>> + Send + '_>>
{
let out = (hash == self.hash).then_some(self.size);
Box::pin(async move { Ok(out) })
}
fn fetch_outboard(
&self,
hash: Hash,
_outboard_max_bytes: u64,
) -> Pin<Box<dyn Future<Output = Result<OutboardFetch, crate::OriginPullError>> + Send + '_>>
{
self.outboard_fetches.fetch_add(1, Ordering::SeqCst);
if self.fault_outboard && hash == self.hash {
return Box::pin(async move {
Err(crate::OriginPullError::Transient(anyhow::anyhow!(
"stub outboard transport fault"
)))
});
}
let result = match (&self.outboard, hash == self.hash) {
(Some(ob), true) => OutboardFetch::Found(ob.clone()),
_ => OutboardFetch::NotFound,
};
let gated = self.gate_outboard;
Box::pin(async move {
if gated {
let _held = self.gate.acquire().await;
}
Ok(result)
})
}
fn fetch_range_data(
&self,
hash: Hash,
req: crate::OriginRangeRequest,
) -> Pin<
Box<
dyn Future<Output = Result<crate::OriginRangeFetch, crate::OriginPullError>>
+ Send
+ '_,
>,
> {
self.max_req.fetch_max(req.len(), Ordering::SeqCst);
self.range_fetches.fetch_add(1, Ordering::SeqCst);
assert!(
req.fetch_start < self.panic_from,
"stub origin panics on request"
);
if req.fetch_start >= self.fail_from {
return Box::pin(async {
Err(crate::OriginPullError::Transient(anyhow::anyhow!(
"stub range transport fault"
)))
});
}
let gated = req.fetch_start >= self.gate_from;
let result = if req.fetch_start >= self.decline_from {
crate::OriginRangeFetch::Unsupported
} else if hash == self.hash && self.support_range {
let s = usize::try_from(req.fetch_start).unwrap_or(usize::MAX);
let mut e = usize::try_from(req.fetch_end).unwrap_or(usize::MAX);
if req.fetch_start >= self.short_from {
e = e.saturating_sub(1);
}
match self.data.get(s..e) {
Some(span) => crate::OriginRangeFetch::Ranged {
data: Bytes::copy_from_slice(span),
},
None => crate::OriginRangeFetch::NotFound,
}
} else {
crate::OriginRangeFetch::Unsupported
};
Box::pin(async move {
if gated {
let _held = self.gate.acquire().await;
}
Ok(result)
})
}
}
fn multi_window_test_blob() -> Vec<u8> {
let size = 2 * crate::RANGE_PULL_WINDOW_BYTES + 5 * crate::CHUNK_GROUP_BYTES + 123;
(0..size).map(|i| (i % 251) as u8).collect()
}
async fn drain_wire(mut wire: OriginRangeWire) -> (Vec<u8>, Option<CacheError>) {
let mut out = Vec::new();
while let Some(item) = wire.next_chunk().await {
match item {
Ok(chunk) => out.extend_from_slice(&chunk),
Err(fault) => {
assert!(wire.next_chunk().await.is_none(), "a fault is terminal");
return (out, Some(fault));
}
}
}
(out, None)
}
fn multi_window_stub() -> (Vec<u8>, Hash, RangeStubOrigin, AlignedRange) {
use bao_tree::io::outboard::PreOrderMemOutboard;
let data = multi_window_test_blob();
let ob = PreOrderMemOutboard::create(&data, crate::range_pull::IROH_BLOCK_SIZE);
let hash = Hash::from(*ob.root.as_bytes());
let total = u64::try_from(data.len()).unwrap_or(u64::MAX);
let origin = RangeStubOrigin::serving(hash, &data, Bytes::from(ob.data));
let aligned = crate::range_pull::align_range(0, 0, total).expect("aligns");
(data, hash, origin, aligned)
}
async fn stub_engine(
origins: Vec<RangeStubOrigin>,
) -> anyhow::Result<(CacheEngine, tempfile::TempDir)> {
let tmp = tempfile::tempdir()?;
let origins = origins
.into_iter()
.map(|o| Arc::new(o) as Arc<dyn Origin>)
.collect();
let engine = CacheEngine::open(tmp.path(), origins, 64).await?;
Ok((engine, tmp))
}
#[tokio::test]
async fn origin_range_serviceable_probes_one_group_then_latches() -> anyhow::Result<()> {
let (data, hash, origin, _) = multi_window_stub();
let total = u64::try_from(data.len())?;
let fetches = Arc::clone(&origin.range_fetches);
let (engine, _tmp) = stub_engine(vec![origin]).await?;
anyhow::ensure!(engine.origin_range_serviceable(hash, total).await?);
anyhow::ensure!(
fetches.load(Ordering::SeqCst) == 1,
"the first probe reads one window"
);
anyhow::ensure!(engine.origin_range_serviceable(hash, total).await?);
anyhow::ensure!(
fetches.load(Ordering::SeqCst) == 1,
"a confirmed origin is not probed again"
);
Ok(())
}
#[tokio::test]
async fn origin_range_serviceable_is_false_without_range_support() -> anyhow::Result<()> {
let (data, hash, mut origin, _) = multi_window_stub();
let total = u64::try_from(data.len())?;
origin.support_range = false;
let (engine, _tmp) = stub_engine(vec![origin]).await?;
anyhow::ensure!(!engine.origin_range_serviceable(hash, total).await?);
Ok(())
}
#[tokio::test]
async fn origin_range_serviceable_faults_on_a_short_first_window() -> anyhow::Result<()> {
let (data, hash, mut origin, _) = multi_window_stub();
let total = u64::try_from(data.len())?;
origin.short_from = 0;
let (engine, _tmp) = stub_engine(vec![origin]).await?;
let probed = engine.origin_range_serviceable(hash, total).await;
anyhow::ensure!(
matches!(probed, Err(CacheError::VerifyFailed { .. })),
"a short first window must fault, got {probed:?}"
);
Ok(())
}
#[tokio::test]
async fn origin_range_serviceable_latches_a_small_blob() -> anyhow::Result<()> {
use bao_tree::io::outboard::PreOrderMemOutboard;
let data: Vec<u8> = (0..1000u32).map(|i| (i % 251) as u8).collect();
let ob = PreOrderMemOutboard::create(&data, crate::range_pull::IROH_BLOCK_SIZE);
anyhow::ensure!(ob.data.is_empty(), "one chunk group has an empty outboard");
let hash = Hash::from(*ob.root.as_bytes());
let origin = RangeStubOrigin::serving(hash, &data, Bytes::from(ob.data));
let fetches = Arc::clone(&origin.range_fetches);
let (engine, _tmp) = stub_engine(vec![origin]).await?;
let total = u64::try_from(data.len())?;
anyhow::ensure!(engine.origin_range_serviceable(hash, total).await?);
anyhow::ensure!(engine.origin_range_serviceable(hash, total).await?);
anyhow::ensure!(
fetches.load(Ordering::SeqCst) == 1,
"a confirmed origin is probed once, got {}",
fetches.load(Ordering::SeqCst)
);
Ok(())
}
#[tokio::test]
async fn origin_range_serviceable_walks_past_a_range_declining_origin() -> anyhow::Result<()> {
let (data, hash, mut declining, _) = multi_window_stub();
let total = u64::try_from(data.len())?;
declining.support_range = false;
let (_, _, serving, _) = multi_window_stub();
let declined = Arc::clone(&declining.range_fetches);
let served = Arc::clone(&serving.range_fetches);
let (engine, _tmp) = stub_engine(vec![declining, serving]).await?;
anyhow::ensure!(engine.origin_range_serviceable(hash, total).await?);
anyhow::ensure!(
served.load(Ordering::SeqCst) == 1,
"the second origin serves the probe"
);
let before = declined.load(Ordering::SeqCst);
anyhow::ensure!(engine.origin_range_serviceable(hash, total).await?);
anyhow::ensure!(
declined.load(Ordering::SeqCst) == before && served.load(Ordering::SeqCst) == 1,
"a second probe reads no range from either origin"
);
Ok(())
}
#[tokio::test]
async fn a_short_first_window_advances_to_a_healthy_origin() -> anyhow::Result<()> {
let (data, hash, mut short, aligned) = multi_window_stub();
let total = u64::try_from(data.len())?;
short.short_from = 0;
let (_, _, healthy, _) = multi_window_stub();
let (engine, _tmp) = stub_engine(vec![short, healthy]).await?;
anyhow::ensure!(engine.origin_range_serviceable(hash, total).await?);
let wire = engine
.origin_range_wire(hash, &aligned)
.await?
.expect("opens on the healthy origin");
let (_, fault) = drain_wire(wire).await;
anyhow::ensure!(
fault.is_none(),
"the healthy origin's wire is whole: {fault:?}"
);
Ok(())
}
#[tokio::test]
async fn a_permit_wait_past_the_warning_keeps_waiting() -> anyhow::Result<()> {
use std::time::Duration;
let (_, hash, mut origin, aligned) = multi_window_stub();
origin.gate_from = crate::RANGE_PULL_WINDOW_BYTES;
let (engine, _tmp) = stub_engine(vec![origin]).await?;
let mut wires = Vec::new();
for _ in 0..crate::MAX_CONCURRENT_RANGE_PULLS {
let mut wire = engine
.origin_range_wire(hash, &aligned)
.await?
.expect("opens");
anyhow::ensure!(matches!(wire.next_chunk().await, Some(Ok(_))));
wires.push(wire);
}
tokio::time::pause();
let waiting = engine.origin_range_wire(hash, &aligned);
tokio::pin!(waiting);
anyhow::ensure!(
tokio::time::timeout(RANGE_PULL_PERMIT_WARN_AFTER * 2, &mut waiting)
.await
.is_err(),
"the warning must not end the wait"
);
tokio::time::resume();
drop(wires.pop());
let opened = tokio::time::timeout(Duration::from_secs(10), waiting).await??;
anyhow::ensure!(
opened.is_some(),
"the waiting draw opens once a permit frees"
);
Ok(())
}
#[tokio::test]
async fn origin_range_wire_mid_stream_decline_is_origin_error() -> anyhow::Result<()> {
let (_, hash, mut origin, aligned) = multi_window_stub();
origin.decline_from = crate::RANGE_PULL_WINDOW_BYTES;
let (engine, _tmp) = stub_engine(vec![origin]).await?;
let wire = engine
.origin_range_wire(hash, &aligned)
.await?
.expect("opens");
let (_, fault) = drain_wire(wire).await;
anyhow::ensure!(
matches!(fault, Some(CacheError::OriginError { .. })),
"a mid-stream decline must be an OriginError, got {fault:?}"
);
Ok(())
}
#[tokio::test]
async fn origin_range_wire_mid_stream_transport_fault_is_origin_error() -> anyhow::Result<()> {
let (_, hash, mut origin, aligned) = multi_window_stub();
origin.fail_from = crate::RANGE_PULL_WINDOW_BYTES;
let (engine, _tmp) = stub_engine(vec![origin]).await?;
let wire = engine
.origin_range_wire(hash, &aligned)
.await?
.expect("opens");
let (_, fault) = drain_wire(wire).await;
anyhow::ensure!(
matches!(fault, Some(CacheError::OriginError { .. })),
"a mid-stream transport fault must be an OriginError, got {fault:?}"
);
Ok(())
}
#[tokio::test]
async fn origin_range_wire_panicking_encode_ends_on_an_internal_fault() -> anyhow::Result<()> {
use std::time::Duration;
let (_, hash, mut origin, aligned) = multi_window_stub();
origin.panic_from = crate::RANGE_PULL_WINDOW_BYTES;
let (engine, _tmp) = stub_engine(vec![origin]).await?;
for _ in 0..=crate::MAX_CONCURRENT_RANGE_PULLS {
let wire = tokio::time::timeout(
Duration::from_secs(10),
engine.origin_range_wire(hash, &aligned),
)
.await
.map_err(|_| anyhow::anyhow!("a panicked encode must release its permit"))??
.expect("opens");
let (_, fault) = drain_wire(wire).await;
let Some(CacheError::Internal(source)) = &fault else {
anyhow::bail!("a panicked encode must end on an Internal fault, got {fault:?}");
};
anyhow::ensure!(
format!("{source:#}").contains("stub origin panics on request"),
"the fault must carry the panic message, got {source:#}"
);
}
Ok(())
}
#[tokio::test]
async fn origin_range_wire_releases_its_permit_on_drop() -> anyhow::Result<()> {
use std::time::Duration;
let (_, hash, mut origin, aligned) = multi_window_stub();
origin.gate_from = crate::RANGE_PULL_WINDOW_BYTES;
let tmp = tempfile::tempdir()?;
let cm = Arc::new(CacheMetrics::default());
let engine = CacheEngine::open_full(
tmp.path(),
vec![Arc::new(origin) as Arc<dyn Origin>],
64,
PinnedHashes::empty(),
crate::RetryPolicy::default(),
CircuitBreakerPolicy::default(),
Some(Arc::clone(&cm)),
Duration::ZERO,
)
.await?;
let mut wires = Vec::new();
for _ in 0..crate::MAX_CONCURRENT_RANGE_PULLS {
let mut wire = engine
.origin_range_wire(hash, &aligned)
.await?
.expect("opens");
anyhow::ensure!(matches!(wire.next_chunk().await, Some(Ok(_))));
wires.push(wire);
}
anyhow::ensure!(
tokio::time::timeout(
Duration::from_millis(200),
engine.origin_range_wire(hash, &aligned)
)
.await
.is_err(),
"a full own-origin pool must make the next open wait",
);
anyhow::ensure!(
cm.range_pull_permit_waits.get() == 1,
"the waiting draw is counted once, got {}",
cm.range_pull_permit_waits.get()
);
drop(wires);
let reopened = tokio::time::timeout(
Duration::from_secs(10),
engine.origin_range_wire(hash, &aligned),
)
.await??;
anyhow::ensure!(
reopened.is_some(),
"dropped wires must release their permits"
);
Ok(())
}
#[tokio::test]
async fn dropping_a_wire_parked_in_an_origin_fetch_frees_its_permit() -> anyhow::Result<()> {
use std::time::Duration;
let (_, hash, mut origin, aligned) = multi_window_stub();
origin.gate_from = crate::RANGE_PULL_WINDOW_BYTES;
let (engine, _tmp) = stub_engine(vec![origin]).await?;
let pool = Arc::clone(&engine.inner.own_origin_range_pulls);
let mut wire = engine
.origin_range_wire(hash, &aligned)
.await?
.expect("opens");
while tokio::time::timeout(Duration::from_millis(200), wire.next_chunk())
.await
.is_ok()
{}
anyhow::ensure!(
pool.available_permits() == crate::MAX_CONCURRENT_RANGE_PULLS - 1,
"the parked encode must hold its permit"
);
drop(wire);
tokio::time::timeout(Duration::from_secs(10), async {
while pool.available_permits() < crate::MAX_CONCURRENT_RANGE_PULLS {
tokio::task::yield_now().await;
}
})
.await
.map_err(|_| anyhow::anyhow!("a dropped wire must abort its parked encode"))?;
Ok(())
}
#[tokio::test]
async fn origin_range_wire_empty_blob_ends_cleanly() -> anyhow::Result<()> {
use bao_tree::io::outboard::PreOrderMemOutboard;
let ob = PreOrderMemOutboard::create([], crate::range_pull::IROH_BLOCK_SIZE);
let root: [u8; 32] = *ob.root.as_bytes();
let hash = Hash::from(root);
let outboard = Bytes::from(ob.data.clone());
let origin = RangeStubOrigin::serving(hash, &[], outboard.clone());
let (engine, _tmp) = stub_engine(vec![origin]).await?;
let aligned = crate::range_pull::align_range(0, 0, 0)?;
let wire = engine
.origin_range_wire(hash, &aligned)
.await?
.expect("opens");
let (wire, fault) = drain_wire(wire).await;
anyhow::ensure!(fault.is_none(), "the empty blob must not fault: {fault:?}");
let reference = encode_verified_range(root, &aligned, &[], outboard)?;
anyhow::ensure!(wire.as_slice() == &reference[8..]);
Ok(())
}
#[tokio::test]
async fn origin_range_wire_falls_back_to_a_serving_origin() -> anyhow::Result<()> {
let (data, hash, serving, aligned) = multi_window_stub();
let (_, _, mut declining, _) = multi_window_stub();
declining.support_range = false;
let faulting = RangeStubOrigin::outboard_faulting(hash, aligned.blob_size());
let (engine, _tmp) = stub_engine(vec![faulting, declining, serving]).await?;
let wire = engine
.origin_range_wire(hash, &aligned)
.await?
.expect("opens");
let (wire, fault) = drain_wire(wire).await;
anyhow::ensure!(
fault.is_none(),
"the serving origin must not fault: {fault:?}"
);
let root = *hash.as_bytes();
let ob = bao_tree::io::outboard::PreOrderMemOutboard::create(
&data,
crate::range_pull::IROH_BLOCK_SIZE,
);
let reference = encode_verified_range(root, &aligned, &data, Bytes::from(ob.data))?;
anyhow::ensure!(wire.as_slice() == &reference[8..], "the wire is byte-exact");
Ok(())
}
#[tokio::test]
async fn origin_range_wire_reports_a_transport_fault_when_no_origin_serves() -> anyhow::Result<()> {
let (_, hash, _, aligned) = multi_window_stub();
let faulting = RangeStubOrigin::outboard_faulting(hash, aligned.blob_size());
let (engine, _tmp) = stub_engine(vec![faulting]).await?;
let err = engine
.origin_range_wire(hash, &aligned)
.await
.err()
.ok_or_else(|| anyhow::anyhow!("expected an OriginError"))?;
anyhow::ensure!(matches!(err, CacheError::OriginError { .. }), "got {err:?}");
Ok(())
}
#[tokio::test]
async fn origin_range_wire_streams_admittable_wire_in_windows() -> anyhow::Result<()> {
use bao_tree::io::outboard::PreOrderMemOutboard;
let data = multi_window_test_blob();
let ob = PreOrderMemOutboard::create(&data, crate::range_pull::IROH_BLOCK_SIZE);
let root: [u8; 32] = *ob.root.as_bytes();
let outboard = Bytes::from(ob.data.clone());
let hash = Hash::from(root);
let total = u64::try_from(data.len()).unwrap_or(u64::MAX);
let origin = Arc::new(RangeStubOrigin::serving(hash, &data, outboard.clone()));
let tmp = tempfile::tempdir()?;
let engine =
CacheEngine::open(tmp.path(), vec![Arc::clone(&origin) as Arc<dyn Origin>], 64).await?;
let aligned = crate::range_pull::align_range(3 * crate::CHUNK_GROUP_BYTES, 0, total)
.map_err(|e| anyhow::anyhow!("align: {e}"))?;
let Some(wire) = engine.origin_range_wire(hash, &aligned).await? else {
anyhow::bail!("expected Some(wire) — the origin serves the range + outboard");
};
let (wire, fault) = drain_wire(wire).await;
anyhow::ensure!(
fault.is_none(),
"a correct origin must not fault: {fault:?}"
);
let start = usize::try_from(aligned.fetch_start())?;
let reference = encode_verified_range(root, &aligned, &data[start..], outboard)?;
anyhow::ensure!(
wire.as_slice() == &reference[8..],
"the streamed wire must equal the header-less whole-span encode"
);
anyhow::ensure!(
origin.max_req.load(Ordering::SeqCst) <= crate::RANGE_PULL_WINDOW_BYTES,
"each origin read must be at most one window"
);
let tmp2 = tempfile::tempdir()?;
let engine2 = CacheEngine::open(tmp2.path(), vec![], 64).await?;
let drained = engine2
.admit_bao_stream(
hash,
aligned.chunk_ranges().clone(),
total,
Bytes::from(wire),
None,
)
.await
.map_err(|(_reader, e)| e)?;
anyhow::ensure!(drained.is_empty(), "the wire is fully drained by admit");
Ok(())
}
#[tokio::test]
async fn origin_range_wire_hard_faults_on_mismatch() -> anyhow::Result<()> {
use bao_tree::io::outboard::PreOrderMemOutboard;
let genuine = multi_window_test_blob();
let ob = PreOrderMemOutboard::create(&genuine, crate::range_pull::IROH_BLOCK_SIZE);
let root: [u8; 32] = *ob.root.as_bytes();
let outboard = Bytes::from(ob.data.clone());
let hash = Hash::from(root);
let total = u64::try_from(genuine.len()).unwrap_or(u64::MAX);
let window = usize::try_from(crate::RANGE_PULL_WINDOW_BYTES)?;
let mut corrupt = genuine.clone();
for b in &mut corrupt[window..window + 1024] {
*b ^= 0xFF;
}
let mut origin = RangeStubOrigin::serving(hash, &genuine, outboard);
origin.data = Bytes::from(corrupt);
let tmp = tempfile::tempdir()?;
let engine =
CacheEngine::open(tmp.path(), vec![Arc::new(origin) as Arc<dyn Origin>], 64).await?;
let aligned =
crate::range_pull::align_range(0, 0, total).map_err(|e| anyhow::anyhow!("align: {e}"))?;
let Some(wire) = engine.origin_range_wire(hash, &aligned).await? else {
anyhow::bail!("expected Some(wire) — the first window serves");
};
let (_, fault) = drain_wire(wire).await;
anyhow::ensure!(
matches!(fault, Some(CacheError::VerifyFailed { expected }) if expected == hash),
"a corrupt own origin must be a hard VerifyFailed, got {fault:?}"
);
Ok(())
}
#[tokio::test]
async fn origin_range_wire_hard_faults_on_short_later_window() -> anyhow::Result<()> {
use bao_tree::io::outboard::PreOrderMemOutboard;
let data = multi_window_test_blob();
let ob = PreOrderMemOutboard::create(&data, crate::range_pull::IROH_BLOCK_SIZE);
let hash = Hash::from(*ob.root.as_bytes());
let total = u64::try_from(data.len()).unwrap_or(u64::MAX);
let aligned =
crate::range_pull::align_range(0, 0, total).map_err(|e| anyhow::anyhow!("align: {e}"))?;
let mut origin = RangeStubOrigin::serving(hash, &data, Bytes::from(ob.data.clone()));
origin.short_from = crate::RANGE_PULL_WINDOW_BYTES;
let tmp = tempfile::tempdir()?;
let engine =
CacheEngine::open(tmp.path(), vec![Arc::new(origin) as Arc<dyn Origin>], 64).await?;
let Some(wire) = engine.origin_range_wire(hash, &aligned).await? else {
anyhow::bail!("expected Some(wire) — the first window serves");
};
let (_, fault) = drain_wire(wire).await;
anyhow::ensure!(
matches!(fault, Some(CacheError::VerifyFailed { expected }) if expected == hash),
"a short later window must be a hard VerifyFailed, got {fault:?}"
);
Ok(())
}
#[tokio::test]
async fn origin_range_wire_hard_faults_on_short_first_window() -> anyhow::Result<()> {
use bao_tree::io::outboard::PreOrderMemOutboard;
let data = multi_window_test_blob();
let ob = PreOrderMemOutboard::create(&data, crate::range_pull::IROH_BLOCK_SIZE);
let hash = Hash::from(*ob.root.as_bytes());
let total = u64::try_from(data.len()).unwrap_or(u64::MAX);
let aligned =
crate::range_pull::align_range(0, 0, total).map_err(|e| anyhow::anyhow!("align: {e}"))?;
let mut origin = RangeStubOrigin::serving(hash, &data, Bytes::from(ob.data.clone()));
origin.short_from = 0;
let tmp = tempfile::tempdir()?;
let engine =
CacheEngine::open(tmp.path(), vec![Arc::new(origin) as Arc<dyn Origin>], 64).await?;
let err = engine
.origin_range_wire(hash, &aligned)
.await
.err()
.ok_or_else(|| anyhow::anyhow!("expected VerifyFailed, got Ok"))?;
anyhow::ensure!(
matches!(err, CacheError::VerifyFailed { expected } if expected == hash),
"a short first window must be a hard VerifyFailed, got {err:?}"
);
Ok(())
}
#[tokio::test]
async fn origin_range_wire_hard_faults_on_wrong_length_outboard() -> anyhow::Result<()> {
let data = local_outboard_pull_test_blob();
let hash = Hash::new(&data);
let total = u64::try_from(data.len()).unwrap_or(u64::MAX);
let origin = RangeStubOrigin::serving(hash, &data, Bytes::from_static(&[0u8; 64]));
let tmp = tempfile::tempdir()?;
let engine =
CacheEngine::open(tmp.path(), vec![Arc::new(origin) as Arc<dyn Origin>], 64).await?;
let aligned =
crate::range_pull::align_range(0, 0, total).map_err(|e| anyhow::anyhow!("align: {e}"))?;
let err = engine
.origin_range_wire(hash, &aligned)
.await
.err()
.ok_or_else(|| anyhow::anyhow!("expected VerifyFailed, got Ok"))?;
anyhow::ensure!(
matches!(err, CacheError::VerifyFailed { expected } if expected == hash),
"a wrong-length outboard must be a hard VerifyFailed, got {err:?}"
);
anyhow::ensure!(
engine.cached_outboard(hash, total).is_none(),
"a wrong-length outboard must not be cached"
);
anyhow::ensure!(
engine
.origin_fetch_outboard_bytes(hash, total)
.await?
.is_none(),
"the serviceability probe declines a wrong-length outboard"
);
Ok(())
}
#[tokio::test]
async fn origin_range_wire_reads_the_outboard_once_per_hash() -> anyhow::Result<()> {
let (data, hash, origin, aligned) = multi_window_stub();
let origin = Arc::new(origin);
let tmp = tempfile::tempdir()?;
let engine =
CacheEngine::open(tmp.path(), vec![Arc::clone(&origin) as Arc<dyn Origin>], 64).await?;
let total = u64::try_from(data.len())?;
anyhow::ensure!(
engine
.origin_fetch_outboard_bytes(hash, total)
.await?
.is_some()
);
for _ in 0..3 {
let Some(wire) = engine.origin_range_wire(hash, &aligned).await? else {
anyhow::bail!("expected Some(wire)");
};
let (_, fault) = drain_wire(wire).await;
anyhow::ensure!(fault.is_none(), "a genuine origin ends cleanly: {fault:?}");
}
anyhow::ensure!(
origin.outboard_fetches.load(Ordering::SeqCst) == 1,
"the probe and three draws must read the outboard once, read {}",
origin.outboard_fetches.load(Ordering::SeqCst)
);
Ok(())
}
#[tokio::test]
async fn concurrent_cold_probes_read_the_outboard_once() -> anyhow::Result<()> {
let (data, hash, mut origin, _) = multi_window_stub();
origin.gate_outboard = true;
let origin = Arc::new(origin);
let gate = Arc::clone(&origin.gate);
let tmp = tempfile::tempdir()?;
let engine =
CacheEngine::open(tmp.path(), vec![Arc::clone(&origin) as Arc<dyn Origin>], 64).await?;
let total = u64::try_from(data.len())?;
let probes: Vec<_> = (0..8)
.map(|_| {
let engine = engine.clone();
tokio::spawn(async move { engine.origin_fetch_outboard_bytes(hash, total).await })
})
.collect();
tokio::time::sleep(Duration::from_millis(50)).await;
gate.add_permits(64);
for probe in probes {
anyhow::ensure!(probe.await??.is_some(), "every probe gets the outboard");
}
anyhow::ensure!(
origin.outboard_fetches.load(Ordering::SeqCst) == 1,
"eight concurrent probes must read the outboard once, read {}",
origin.outboard_fetches.load(Ordering::SeqCst)
);
Ok(())
}
#[tokio::test]
async fn pull_through_bytes_counts_the_outboard_once() -> anyhow::Result<()> {
let (data, hash, origin, aligned) = multi_window_stub();
let outboard_len = u64::try_from(origin.outboard.as_ref().map_or(0, Bytes::len))?;
let tmp = tempfile::tempdir()?;
let cm = Arc::new(CacheMetrics::default());
let engine = CacheEngine::open_full(
tmp.path(),
vec![Arc::new(origin) as Arc<dyn Origin>],
64,
crate::PinnedHashes::default(),
crate::RetryPolicy::default(),
CircuitBreakerPolicy::default(),
Some(Arc::clone(&cm)),
Duration::ZERO,
)
.await?;
let total = u64::try_from(data.len())?;
anyhow::ensure!(
engine
.origin_fetch_outboard_bytes(hash, total)
.await?
.is_some()
);
let draws = 2u64;
for _ in 0..draws {
let Some(wire) = engine.origin_range_wire(hash, &aligned).await? else {
anyhow::bail!("expected Some(wire)");
};
let (_, fault) = drain_wire(wire).await;
anyhow::ensure!(fault.is_none(), "clean drain: {fault:?}");
}
let span = aligned.fetch_end() - aligned.fetch_start();
let got = cm.pull_through_bytes.get();
anyhow::ensure!(
got == outboard_len + draws * span,
"want one outboard ({outboard_len}) plus {draws} spans of {span}, got {got}"
);
Ok(())
}
#[tokio::test]
async fn a_verify_fault_evicts_the_cached_outboard() -> anyhow::Result<()> {
let (genuine, hash, mut origin, aligned) = multi_window_stub();
let window = usize::try_from(crate::RANGE_PULL_WINDOW_BYTES)?;
let mut corrupt = genuine;
for b in &mut corrupt[window..window + 1024] {
*b ^= 0xFF;
}
origin.data = Bytes::from(corrupt);
let origin = Arc::new(origin);
let tmp = tempfile::tempdir()?;
let engine =
CacheEngine::open(tmp.path(), vec![Arc::clone(&origin) as Arc<dyn Origin>], 64).await?;
for round in 1..=2 {
let Some(wire) = engine.origin_range_wire(hash, &aligned).await? else {
anyhow::bail!("expected Some(wire) — the first window serves");
};
let (_, fault) = drain_wire(wire).await;
anyhow::ensure!(
matches!(fault, Some(CacheError::VerifyFailed { .. })),
"a corrupt window is a hard VerifyFailed, got {fault:?}"
);
anyhow::ensure!(
origin.outboard_fetches.load(Ordering::SeqCst) == round,
"each draw after a verify fault re-reads the outboard"
);
}
Ok(())
}
#[tokio::test]
async fn a_stuck_outboard_fetch_times_out_and_advances_the_chain() -> anyhow::Result<()> {
let (_, hash, serving, aligned) = multi_window_stub();
let total = aligned.blob_size();
let mut stuck =
RangeStubOrigin::serving(hash, &[], serving.outboard.clone().unwrap_or_default());
stuck.gate_outboard = true;
let (engine, _tmp) = stub_engine(vec![stuck, serving]).await?;
engine.set_origin_read_budget(Duration::from_millis(50), u64::MAX);
anyhow::ensure!(
engine
.origin_fetch_outboard_bytes(hash, total)
.await?
.is_some(),
"the second origin serves the outboard after the first times out"
);
let (_, hash, serving, _) = multi_window_stub();
let mut stuck = RangeStubOrigin::serving(hash, &[], serving.outboard.unwrap_or_default());
stuck.gate_outboard = true;
let (engine, _tmp) = stub_engine(vec![stuck]).await?;
engine.set_origin_read_budget(Duration::from_millis(50), u64::MAX);
let err = engine.origin_fetch_outboard_bytes(hash, total).await.err();
anyhow::ensure!(
matches!(&err, Some(CacheError::OriginError { source, .. })
if source.to_string().contains("budget")),
"a lone stuck origin is a timeout fault, got {err:?}"
);
Ok(())
}
#[tokio::test]
async fn a_stuck_first_window_advances_the_chain() -> anyhow::Result<()> {
let (data, hash, serving, aligned) = multi_window_stub();
let mut stuck =
RangeStubOrigin::serving(hash, &data, serving.outboard.clone().unwrap_or_default());
stuck.gate_from = 0;
let (engine, _tmp) = stub_engine(vec![stuck, serving]).await?;
engine.set_origin_read_budget(Duration::from_millis(50), u64::MAX);
let Some(wire) = engine.origin_range_wire(hash, &aligned).await? else {
anyhow::bail!("expected Some(wire) from the second origin");
};
let (_, fault) = drain_wire(wire).await;
anyhow::ensure!(
fault.is_none(),
"the second origin serves cleanly: {fault:?}"
);
Ok(())
}
#[tokio::test]
async fn an_origin_without_data_does_not_lend_its_outboard() -> anyhow::Result<()> {
let (_, hash, serving, aligned) = multi_window_stub();
let total = aligned.blob_size();
let genuine = serving.outboard.clone().unwrap_or_default();
let corrupt = Bytes::from(vec![0u8; genuine.len()]);
let mut orphan = RangeStubOrigin::serving(hash, &[], corrupt);
orphan.support_range = false;
let (engine, _tmp) = stub_engine(vec![orphan, serving]).await?;
anyhow::ensure!(
engine
.origin_fetch_outboard_bytes(hash, total)
.await?
.is_some()
);
for _ in 0..2 {
let Some(wire) = engine.origin_range_wire(hash, &aligned).await? else {
anyhow::bail!("expected Some(wire) from the second origin");
};
let (_, fault) = drain_wire(wire).await;
anyhow::ensure!(
fault.is_none(),
"the second origin serves cleanly: {fault:?}"
);
}
anyhow::ensure!(
engine.cached_outboard(hash, total) == Some(genuine),
"the serving origin's outboard replaces the orphan's in the cache"
);
Ok(())
}
#[tokio::test]
async fn a_stuck_window_fetch_times_out() -> anyhow::Result<()> {
let (_, hash, mut origin, aligned) = multi_window_stub();
origin.gate_from = crate::RANGE_PULL_WINDOW_BYTES;
let (engine, _tmp) = stub_engine(vec![origin]).await?;
engine.set_origin_read_budget(Duration::from_millis(50), u64::MAX);
let Some(wire) = engine.origin_range_wire(hash, &aligned).await? else {
anyhow::bail!("expected Some(wire) — the first window serves");
};
let (_, fault) = tokio::time::timeout(Duration::from_secs(10), drain_wire(wire)).await?;
anyhow::ensure!(
matches!(&fault, Some(CacheError::OriginError { source, .. })
if source.to_string().contains("budget")),
"a stuck window must end on a timeout OriginError, got {fault:?}"
);
Ok(())
}
#[tokio::test]
async fn origin_range_wire_none_when_unsupported() -> anyhow::Result<()> {
let data = local_outboard_pull_test_blob();
let hash = Hash::new(&data);
let total = u64::try_from(data.len()).unwrap_or(u64::MAX);
let mut origin = RangeStubOrigin::serving(hash, &data, Bytes::new());
origin.outboard = None;
origin.support_range = false;
let tmp = tempfile::tempdir()?;
let engine =
CacheEngine::open(tmp.path(), vec![Arc::new(origin) as Arc<dyn Origin>], 64).await?;
let aligned =
crate::range_pull::align_range(0, 0, total).map_err(|e| anyhow::anyhow!("align: {e}"))?;
anyhow::ensure!(
engine.origin_range_wire(hash, &aligned).await?.is_none(),
"an unsupported origin must degrade origin_range_wire to Ok(None)"
);
Ok(())
}
#[tokio::test]
async fn origin_fetch_outboard_bytes_found_and_absent() -> anyhow::Result<()> {
use bao_tree::io::outboard::PreOrderMemOutboard;
let data = local_outboard_pull_test_blob();
let ob = PreOrderMemOutboard::create(&data, crate::range_pull::IROH_BLOCK_SIZE);
let outboard = Bytes::from(ob.data.clone());
let hash = Hash::new(&data);
let total = u64::try_from(data.len()).unwrap_or(u64::MAX);
let serving = RangeStubOrigin::serving(hash, &data, outboard.clone());
let tmp = tempfile::tempdir()?;
let engine =
CacheEngine::open(tmp.path(), vec![Arc::new(serving) as Arc<dyn Origin>], 64).await?;
anyhow::ensure!(
engine.origin_fetch_outboard_bytes(hash, total).await? == Some(outboard),
"a publishing origin must return its outboard bytes"
);
let bare = OutboardStubOrigin::new(&data, None);
let tmp2 = tempfile::tempdir()?;
let engine2 =
CacheEngine::open(tmp2.path(), vec![Arc::new(bare) as Arc<dyn Origin>], 64).await?;
anyhow::ensure!(
engine2
.origin_fetch_outboard_bytes(hash, total)
.await?
.is_none(),
"no origin publishes the outboard; must be Ok(None)"
);
Ok(())
}
#[tokio::test]
async fn origin_fetch_outboard_bytes_surfaces_a_transport_fault() -> anyhow::Result<()> {
let data = local_outboard_pull_test_blob();
let hash = Hash::new(&data);
let total = u64::try_from(data.len()).unwrap_or(u64::MAX);
let faulting = RangeStubOrigin::outboard_faulting(hash, total);
let tmp = tempfile::tempdir()?;
let engine =
CacheEngine::open(tmp.path(), vec![Arc::new(faulting) as Arc<dyn Origin>], 64).await?;
anyhow::ensure!(
engine
.origin_fetch_outboard_bytes(hash, total)
.await
.is_err(),
"an outboard transport fault must surface as Err, not a clean Ok(None)"
);
Ok(())
}
fn synth_blob(len: usize) -> ([u8; 32], Vec<u8>, bytes::Bytes) {
let mut plaintext = vec![0u8; len];
let mut x: u32 = 0x9e37_79b9;
for b in &mut plaintext {
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
*b = x.to_le_bytes()[0];
}
let ob = bao_tree::io::outboard::PreOrderMemOutboard::create(
&plaintext,
crate::range_pull::IROH_BLOCK_SIZE,
);
(*ob.root.as_bytes(), plaintext, bytes::Bytes::from(ob.data))
}
fn bao_for(
root: [u8; 32],
plaintext: &[u8],
outboard: bytes::Bytes,
off: u64,
len: u64,
total: u64,
) -> (Hash, bao_tree::ChunkRanges, bytes::Bytes) {
let aligned = crate::range_pull::align_range(off, len, total).unwrap();
let s = aligned.fetch_start() as usize;
let e = aligned.fetch_end() as usize;
let encoded =
crate::range_pull::encode_verified_range(root, &aligned, &plaintext[s..e], outboard)
.unwrap();
(Hash::from(root), aligned.chunk_ranges().clone(), encoded)
}
async fn count_tags_for(engine: &CacheEngine, hash: Hash) -> usize {
let mut stream = engine.inner.store.tags().list().await.unwrap();
let mut n = 0usize;
while let Some(info) = stream.next().await {
if info.unwrap().hash == hash {
n += 1;
}
}
n
}
#[tokio::test]
async fn admit_bao_tags_the_partial() {
let tmp = tempfile::tempdir().unwrap();
let engine = CacheEngine::open(tmp.path(), vec![], 16).await.unwrap();
let group = crate::CHUNK_GROUP_BYTES;
let total = 4 * group;
let (root, plaintext, outboard) = synth_blob(total as usize);
let (hash, ranges, bao) = bao_for(root, &plaintext, outboard.clone(), group, group, total);
engine.admit_bao(hash, ranges, bao).await.unwrap();
assert!(
!engine.present_ranges(hash).await.unwrap().is_complete(),
"still partial"
);
assert_eq!(
count_tags_for(&engine, hash).await,
1,
"partial admit creates exactly one protecting tag"
);
let (h2, r2, b2) = bao_for(root, &plaintext, outboard, 2 * group, group, total);
engine.admit_bao(h2, r2, b2).await.unwrap();
assert_eq!(
count_tags_for(&engine, hash).await,
1,
"re-admit does not proliferate tags"
);
}
#[tokio::test]
async fn protect_partial_skips_store_write_when_memoized() {
let tmp = tempfile::tempdir().unwrap();
let engine = CacheEngine::open(tmp.path(), vec![], 16).await.unwrap();
let group = crate::CHUNK_GROUP_BYTES;
let total = 4 * group;
let (root, plaintext, outboard) = synth_blob(total as usize);
let (hash, ranges, bao) = bao_for(root, &plaintext, outboard, group, group, total);
engine.admit_bao(hash, ranges, bao).await.unwrap();
assert_eq!(count_tags_for(&engine, hash).await, 1);
assert!(engine.inner.partial_protected.contains_key(&hash));
let name = format!("decdn-partial-{hash}");
engine
.inner
.store
.tags()
.delete(name.as_bytes())
.await
.unwrap();
assert_eq!(count_tags_for(&engine, hash).await, 0);
engine.protect_partial(hash).await.unwrap();
assert_eq!(
count_tags_for(&engine, hash).await,
0,
"a memoized protect_partial must skip the store write"
);
}
#[tokio::test]
async fn dropping_tags_reinvalidates_protect_memo() {
let tmp = tempfile::tempdir().unwrap();
let engine = CacheEngine::open(tmp.path(), vec![], 16).await.unwrap();
let group = crate::CHUNK_GROUP_BYTES;
let total = 4 * group;
let (root, plaintext, outboard) = synth_blob(total as usize);
let (hash, ranges, bao) = bao_for(root, &plaintext, outboard, group, group, total);
engine.admit_bao(hash, ranges, bao).await.unwrap();
assert!(engine.inner.partial_protected.contains_key(&hash));
engine.drop_named_tags_for(hash).await.unwrap();
assert_eq!(count_tags_for(&engine, hash).await, 0);
assert!(
!engine.inner.partial_protected.contains_key(&hash),
"dropping the tag must invalidate the memo"
);
engine.protect_partial(hash).await.unwrap();
assert_eq!(
count_tags_for(&engine, hash).await,
1,
"protect_partial re-creates the tag after the memo is invalidated"
);
}
#[tokio::test]
async fn admit_bao_stream_admits_a_partial_range() {
let tmp = tempfile::tempdir().unwrap();
let engine = CacheEngine::open(tmp.path(), vec![], 16).await.unwrap();
let group = crate::CHUNK_GROUP_BYTES;
let total = 4 * group;
let (root, plaintext, outboard) = synth_blob(total as usize);
let (hash, ranges, bao) = bao_for(root, &plaintext, outboard, group, group, total);
assert!(bao.len() > 8, "bao_for output must carry the 8-byte header");
let header_less = bao.slice(8..);
let reader = engine
.admit_bao_stream(hash, ranges.clone(), total, header_less, None)
.await
.map_err(|(_reader, e)| e)
.unwrap();
assert_eq!(reader.len(), 0, "the reader is fully drained");
let present = engine.present_ranges(hash).await.unwrap();
assert!(!present.is_complete(), "still partial");
assert!(!present.is_empty(), "the admitted range is present");
assert_eq!(
count_tags_for(&engine, hash).await,
1,
"streaming admit creates exactly one protecting tag"
);
assert!(
engine.eviction_candidates().contains_key(&hash),
"a streamed partial is an eviction candidate with no serve after it (#2157)"
);
}
#[tokio::test]
async fn admit_bao_stream_captures_proof_into_the_session_during_import() {
use bao_tree::io::fsm::Outboard;
let tmp = tempfile::tempdir().unwrap();
let engine = CacheEngine::open(tmp.path(), vec![], 16).await.unwrap();
let group = crate::CHUNK_GROUP_BYTES;
let total = 4 * group;
let (root, plaintext, outboard) = synth_blob(total as usize);
let (hash, ranges, bao) = bao_for(root, &plaintext, outboard, group, group, total);
let header_less = bao.slice(8..);
let session = crate::FillSession::new(bao_tree::blake3::Hash::from(root), total);
engine
.admit_bao_stream(hash, ranges.clone(), total, header_less, Some(&session))
.await
.map_err(|(_reader, e)| e)
.unwrap();
let expected = engine.outboard_pairs(hash, &ranges).await.unwrap();
assert!(
!expected.is_empty(),
"the admitted range spans interior proof nodes"
);
let mut reader = session.outboard_reader();
for (node, pair) in expected {
assert_eq!(
reader.load(node).await.unwrap(),
Some(pair),
"node {node:?} was captured during import"
);
}
}
#[tokio::test]
async fn a_dead_reader_reads_a_node_over_held_content_from_the_store() {
use bao_tree::io::fsm::Outboard;
let tmp = tempfile::tempdir().unwrap();
let engine = CacheEngine::open(tmp.path(), vec![], 16).await.unwrap();
let group = crate::CHUNK_GROUP_BYTES;
let total = 8 * group;
let (root, plaintext, outboard) = synth_blob(total as usize);
let (hash, held, bao) = bao_for(root, &plaintext, outboard, group, group, total);
engine
.admit_bao_stream(hash, held.clone(), total, bao.slice(8..), None)
.await
.map_err(|(_reader, e)| e)
.unwrap();
let session = crate::FillSession::new(bao_tree::blake3::Hash::from(root), total);
session.mark_ended(Err(crate::FillError::new("upstream died")));
let truth = bao_tree::io::outboard::PreOrderMemOutboard::create(
&plaintext,
crate::range_pull::IROH_BLOCK_SIZE,
);
let tree = truth.tree;
let internal: Vec<_> = tree
.pre_order_nodes_iter()
.filter(|n| tree.pre_order_offset(*n).is_some())
.collect();
let over_held = |n: &bao_tree::TreeNode| {
!(&bao_tree::ChunkRanges::from(n.chunk_range()) & &held).is_empty()
};
assert!(internal.iter().any(over_held) && !internal.iter().all(over_held));
let mut bare = session.outboard_reader();
assert!(
bare.load(tree.root()).await.is_err(),
"without the fallback a dead node fails"
);
let mut reader = session
.outboard_reader()
.with_store_fallback(engine.clone());
for node in internal {
let loaded = reader.load(node).await;
if over_held(&node) {
let want = bao_tree::io::sync::Outboard::load(&truth, node).unwrap();
assert_eq!(loaded.unwrap(), want, "node {node:?} reads from the store");
} else {
let err = loaded.expect_err("the store holds nothing under this node");
assert!(err.to_string().contains("upstream pull failed"), "{err}");
}
}
}
#[tokio::test]
async fn admit_bao_stream_captures_a_leafs_proof_before_the_admit_finishes() {
use bao_tree::io::fsm::Outboard;
struct StallingReader(bytes::Bytes);
impl iroh_io::AsyncStreamReader for StallingReader {
async fn read_bytes(&mut self, len: usize) -> std::io::Result<bytes::Bytes> {
if self.0.is_empty() {
std::future::pending::<()>().await;
}
Ok(self.0.split_to(self.0.len().min(len)))
}
async fn read<const L: usize>(&mut self) -> std::io::Result<[u8; L]> {
if self.0.len() < L {
std::future::pending::<()>().await;
}
let g = self.0.split_to(L);
let mut out = [0u8; L];
out.copy_from_slice(&g);
Ok(out)
}
}
let tmp = tempfile::tempdir().unwrap();
let engine = CacheEngine::open(tmp.path(), vec![], 16).await.unwrap();
let group = crate::CHUNK_GROUP_BYTES;
let total = 4 * group;
let (root, plaintext, outboard) = synth_blob(total as usize);
let (hash, ranges, bao) = bao_for(root, &plaintext, outboard, 0, total, total);
let wire = bao.slice(8..);
let first_leaf_end = 2 * 64 + usize::try_from(group).unwrap();
let reader = StallingReader(wire.slice(..first_leaf_end));
let root_pair = (
bao_tree::blake3::Hash::from_bytes(wire[..32].try_into().unwrap()),
bao_tree::blake3::Hash::from_bytes(wire[32..64].try_into().unwrap()),
);
let root_node = bao_tree::BaoTree::new(total, crate::range_pull::IROH_BLOCK_SIZE).root();
let session = crate::FillSession::new(bao_tree::blake3::Hash::from(root), total);
let mut outboard_reader = session.outboard_reader();
let admit = engine.admit_bao_stream(hash, ranges, total, reader, Some(&session));
tokio::pin!(admit);
let loaded = tokio::select! {
_ = &mut admit => panic!("a stalled feed cannot finish the admit"),
loaded = tokio::time::timeout(
std::time::Duration::from_secs(5),
outboard_reader.load(root_node),
) => loaded,
};
assert_eq!(
loaded
.expect("the root proof node is captured while the admit is in flight")
.unwrap(),
Some(root_pair),
);
}
#[tokio::test]
async fn admit_bao_stream_handles_zero_total_bytes() {
let tmp = tempfile::tempdir().unwrap();
let engine = CacheEngine::open(tmp.path(), vec![], 16).await.unwrap();
engine
.admit_bao_stream(Hash::EMPTY, ChunkRanges::empty(), 0, Bytes::new(), None)
.await
.map_err(|(_reader, e)| e)
.expect("admitting the empty blob is a no-op success");
let (_reader, err) = engine
.admit_bao_stream(
Hash::from([9u8; 32]),
ChunkRanges::empty(),
0,
Bytes::new(),
None,
)
.await
.expect_err("zero size under a non-empty hash is rejected");
assert_matches!(err, CacheError::Feed(_), "expected Feed, got {err:?}");
}
#[tokio::test]
async fn admit_bao_stream_rejects_corrupt_bao() {
let tmp = tempfile::tempdir().unwrap();
let engine = CacheEngine::open(tmp.path(), vec![], 16).await.unwrap();
let group = crate::CHUNK_GROUP_BYTES;
let total = 4 * group;
let (root, plaintext, outboard) = synth_blob(total as usize);
let (hash, ranges, bao) = bao_for(root, &plaintext, outboard, group, group, total);
let mut corrupt = bao.slice(8..).to_vec();
let flip_at = corrupt.len() / 2;
let byte = corrupt.get_mut(flip_at).expect("non-empty header-less bao");
*byte ^= 0xFF;
let (_reader, err) = engine
.admit_bao_stream(hash, ranges, total, Bytes::from(corrupt), None)
.await
.unwrap_err();
assert_matches!(
err,
CacheError::VerifyFailed { expected } if expected == hash,
"expected VerifyFailed, got {err:?}"
);
assert!(
engine.present_ranges(hash).await.unwrap().is_empty(),
"nothing admitted from a corrupt bao"
);
assert_eq!(
count_tags_for(&engine, hash).await,
0,
"a rejected import must not tag a partial"
);
}
#[tokio::test]
async fn coverage_of_absent_hash_is_empty() {
let tmp = tempfile::tempdir().unwrap();
let engine = CacheEngine::open(tmp.path(), vec![], 16).await.unwrap();
let cov = engine.coverage(Hash::from([7u8; 32])).await.unwrap();
assert!(cov.is_empty(), "an absent hash has no covered blocks");
let (_, size) = engine.coverage_sized(Hash::from([7u8; 32])).await.unwrap();
assert_eq!(size, None, "an absent hash has no size to advertise");
}
#[tokio::test]
async fn coverage_of_complete_blob_is_full() {
let tmp = tempfile::tempdir().unwrap();
let engine = CacheEngine::open(tmp.path(), vec![], 16).await.unwrap();
let group = crate::CHUNK_GROUP_BYTES;
let total = 3 * group;
let (root, plaintext, outboard) = synth_blob(total as usize);
let (hash, ranges, bao) = bao_for(root, &plaintext, outboard, 0, total, total);
engine.admit_bao(hash, ranges, bao).await.unwrap();
assert!(
engine.present_ranges(hash).await.unwrap().is_complete(),
"whole blob admitted in one range"
);
let cov = engine.coverage(hash).await.unwrap();
assert_eq!(
cov,
decdn_protocol::Coverage::full(decdn_protocol::num_blocks(total)),
"a complete blob covers every discovery block it spans"
);
}
#[tokio::test]
async fn coverage_of_partial_blob_covers_only_present_blocks() {
let tmp = tempfile::tempdir().unwrap();
let engine = CacheEngine::open(tmp.path(), vec![], 16).await.unwrap();
let group = crate::CHUNK_GROUP_BYTES;
let total = decdn_protocol::DISCOVERY_BLOCK_BYTES + 3 * group;
let (root, plaintext, outboard) = synth_blob(total as usize);
let (hash, block0_ranges, block0_bao) = bao_for(
root,
&plaintext,
outboard.clone(),
0,
decdn_protocol::DISCOVERY_BLOCK_BYTES,
total,
);
engine
.admit_bao(hash, block0_ranges, block0_bao)
.await
.unwrap();
let (_, tail_ranges, tail_bao) =
bao_for(root, &plaintext, outboard, total - group, group, total);
engine.admit_bao(hash, tail_ranges, tail_bao).await.unwrap();
assert!(
!engine.present_ranges(hash).await.unwrap().is_complete(),
"block 1's middle group was never admitted"
);
let cov = engine.coverage(hash).await.unwrap();
assert!(cov.covers(0), "block 0 was admitted in full");
assert!(!cov.covers(1), "block 1's middle group is missing");
}
#[tokio::test]
async fn spike_bitfield_size_known_before_status_size_on_front_partial() {
let tmp = tempfile::tempdir().unwrap();
let engine = CacheEngine::open(tmp.path(), vec![], 16).await.unwrap();
let total = decdn_protocol::DISCOVERY_BLOCK_BYTES + 3 * crate::CHUNK_GROUP_BYTES;
let (root, plaintext, outboard) = synth_blob(total as usize);
let (hash, block0_ranges, block0_bao) = bao_for(
root,
&plaintext,
outboard,
0,
decdn_protocol::DISCOVERY_BLOCK_BYTES,
total,
);
engine
.admit_bao(hash, block0_ranges, block0_bao)
.await
.unwrap();
let status = engine.inner.store.blobs().status(hash).await.unwrap();
assert_matches!(
status,
iroh_blobs::api::blobs::BlobStatus::Partial { size: None },
"front-only partial must NOT have a status()-known size yet, got {status:?}"
);
let bitfield = engine.inner.store.blobs().observe(hash).await.unwrap();
assert_eq!(
bitfield.size(),
total,
"observe()'s bitfield must already know the full synthetic blob size"
);
}
#[tokio::test]
async fn coverage_of_front_partial_covers_block_zero_before_status_knows_size() {
let tmp = tempfile::tempdir().unwrap();
let engine = CacheEngine::open(tmp.path(), vec![], 16).await.unwrap();
let total = decdn_protocol::DISCOVERY_BLOCK_BYTES + 3 * crate::CHUNK_GROUP_BYTES;
let (root, plaintext, outboard) = synth_blob(total as usize);
let (hash, block0_ranges, block0_bao) = bao_for(
root,
&plaintext,
outboard,
0,
decdn_protocol::DISCOVERY_BLOCK_BYTES,
total,
);
engine
.admit_bao(hash, block0_ranges, block0_bao)
.await
.unwrap();
let status = engine.inner.store.blobs().status(hash).await.unwrap();
assert_matches!(
status,
iroh_blobs::api::blobs::BlobStatus::Partial { size: None },
"front-only partial must NOT have a status()-known size yet, got {status:?}"
);
let cov = engine.coverage(hash).await.unwrap();
assert!(
cov.covers(0),
"block 0 is fully present; coverage must not depend on status()'s size"
);
let (sized_cov, size) = engine.coverage_sized(hash).await.unwrap();
assert_eq!(sized_cov, cov, "the sized read reports the same blocks");
assert_eq!(
size,
Some(total),
"a front partial reports the full blob size"
);
}
fn next_insert(rx: &mut broadcast::Receiver<Hash>) -> Option<Hash> {
rx.try_recv().ok()
}
#[tokio::test]
async fn subscribe_inserts_emits_when_admit_bao_completes_a_discovery_block() {
let tmp = tempfile::tempdir().unwrap();
let engine = CacheEngine::open(tmp.path(), vec![], 16).await.unwrap();
let mut rx = engine.subscribe_inserts();
let total = decdn_protocol::DISCOVERY_BLOCK_BYTES + 3 * crate::CHUNK_GROUP_BYTES;
let (root, plaintext, outboard) = synth_blob(total as usize);
let (hash, ranges, bao) = bao_for(
root,
&plaintext,
outboard,
0,
decdn_protocol::DISCOVERY_BLOCK_BYTES,
total,
);
engine.admit_bao(hash, ranges, bao).await.unwrap();
assert_eq!(
next_insert(&mut rx),
Some(hash),
"an admit that completes block 0 must announce the hash"
);
}
#[tokio::test]
async fn subscribe_inserts_emits_when_admit_bao_stream_completes_a_discovery_block() {
let tmp = tempfile::tempdir().unwrap();
let engine = CacheEngine::open(tmp.path(), vec![], 16).await.unwrap();
let mut rx = engine.subscribe_inserts();
let total = decdn_protocol::DISCOVERY_BLOCK_BYTES + 3 * crate::CHUNK_GROUP_BYTES;
let (root, plaintext, outboard) = synth_blob(total as usize);
let (hash, ranges, bao) = bao_for(
root,
&plaintext,
outboard,
0,
decdn_protocol::DISCOVERY_BLOCK_BYTES,
total,
);
engine
.admit_bao_stream(hash, ranges, total, bao.slice(8..), None)
.await
.map_err(|(_reader, e)| e)
.unwrap();
assert_eq!(
next_insert(&mut rx),
Some(hash),
"a streamed admit that completes block 0 must announce the hash"
);
}
#[tokio::test]
async fn subscribe_inserts_silent_for_an_admit_that_completes_no_block() {
let tmp = tempfile::tempdir().unwrap();
let engine = CacheEngine::open(tmp.path(), vec![], 16).await.unwrap();
let mut rx = engine.subscribe_inserts();
let group = crate::CHUNK_GROUP_BYTES;
let total = decdn_protocol::DISCOVERY_BLOCK_BYTES + 3 * group;
let (root, plaintext, outboard) = synth_blob(total as usize);
let (hash, ranges, bao) = bao_for(root, &plaintext, outboard, group, group, total);
engine.admit_bao(hash, ranges, bao).await.unwrap();
assert_eq!(
next_insert(&mut rx),
None,
"one interior group completes no discovery block"
);
}
#[tokio::test]
async fn subscribe_inserts_emits_for_a_small_blob_only_once_it_completes() {
let tmp = tempfile::tempdir().unwrap();
let engine = CacheEngine::open(tmp.path(), vec![], 16).await.unwrap();
let mut rx = engine.subscribe_inserts();
let group = crate::CHUNK_GROUP_BYTES;
let total = 4 * group;
let (root, plaintext, outboard) = synth_blob(total as usize);
let (hash, front, front_bao) = bao_for(root, &plaintext, outboard.clone(), 0, 2 * group, total);
engine.admit_bao(hash, front, front_bao).await.unwrap();
assert_eq!(
next_insert(&mut rx),
None,
"half a single-block blob completes nothing"
);
let (_, back, back_bao) = bao_for(root, &plaintext, outboard, 2 * group, 2 * group, total);
engine.admit_bao(hash, back, back_bao).await.unwrap();
assert_eq!(
next_insert(&mut rx),
Some(hash),
"the admit that completes the only block must announce the hash"
);
}
#[tokio::test]
async fn subscribe_inserts_emits_when_an_admit_completes_the_short_last_block() {
let tmp = tempfile::tempdir().unwrap();
let engine = CacheEngine::open(tmp.path(), vec![], 16).await.unwrap();
let mut rx = engine.subscribe_inserts();
let group = crate::CHUNK_GROUP_BYTES;
let block = decdn_protocol::DISCOVERY_BLOCK_BYTES;
let total = block + 3 * group;
let (root, plaintext, outboard) = synth_blob(total as usize);
let (hash, tail, tail_bao) = bao_for(root, &plaintext, outboard, block, 3 * group, total);
engine.admit_bao(hash, tail, tail_bao).await.unwrap();
assert_eq!(
next_insert(&mut rx),
Some(hash),
"the admit that completes block 1 must announce the hash"
);
}
#[tokio::test]
async fn subscribe_inserts_emits_when_an_admit_completes_an_interior_block() {
let tmp = tempfile::tempdir().unwrap();
let engine = CacheEngine::open(tmp.path(), vec![], 16).await.unwrap();
let mut rx = engine.subscribe_inserts();
let block = decdn_protocol::DISCOVERY_BLOCK_BYTES;
let total = 2 * block + crate::CHUNK_GROUP_BYTES;
let (root, plaintext, outboard) = synth_blob(total as usize);
let (hash, middle, middle_bao) = bao_for(root, &plaintext, outboard, block, block, total);
engine.admit_bao(hash, middle, middle_bao).await.unwrap();
assert_eq!(
next_insert(&mut rx),
Some(hash),
"the admit that completes interior block 1 must announce the hash"
);
}
#[tokio::test]
async fn admit_bao_stream_announces_a_block_it_completed_before_failing() {
let tmp = tempfile::tempdir().unwrap();
let engine = CacheEngine::open(tmp.path(), vec![], 16).await.unwrap();
let mut rx = engine.subscribe_inserts();
let group = crate::CHUNK_GROUP_BYTES;
let block = decdn_protocol::DISCOVERY_BLOCK_BYTES;
let total = block + 3 * group;
let (root, plaintext, outboard) = synth_blob(total as usize);
let (hash, ranges, bao) = bao_for(root, &plaintext, outboard, 0, total, total);
let wire = bao.slice(8..);
let truncated = wire.slice(..wire.len() - usize::try_from(group).unwrap());
let failed = engine
.admit_bao_stream(hash, ranges, total, truncated, None)
.await;
let Err((_reader, err)) = failed else {
panic!("a truncated feed must fail the admit");
};
assert_matches!(err, CacheError::Feed(_), "{err:?}");
assert!(
engine.coverage(hash).await.unwrap().covers(0),
"fixture precondition: block 0 landed before the feed ended"
);
assert_eq!(
next_insert(&mut rx),
Some(hash),
"a block that landed before the failure must still be announced"
);
}
#[tokio::test]
async fn iter_hashes_includes_partial_blobs() {
let tmp = tempfile::tempdir().unwrap();
let engine = CacheEngine::open(tmp.path(), vec![], 16).await.unwrap();
let group = crate::CHUNK_GROUP_BYTES;
let total = 4 * group;
let (root, plaintext, outboard) = synth_blob(total as usize);
let (hash, ranges, bao) = bao_for(root, &plaintext, outboard, group, group, total);
engine.admit_bao(hash, ranges, bao).await.unwrap();
assert!(!engine.present_ranges(hash).await.unwrap().is_complete());
let hashes = engine.iter_hashes().await.unwrap();
assert_eq!(hashes, vec![hash], "a held partial is a held blob");
}
#[tokio::test]
async fn size_snapshot_counts_middle_range_partial_by_present_bytes() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let engine = CacheEngine::open(tmp.path(), vec![], 16).await?;
let group = crate::CHUNK_GROUP_BYTES;
let total = 4 * group;
let (root, plaintext, outboard) = synth_blob(usize::try_from(total)?);
let (hash, ranges, bao) = bao_for(root, &plaintext, outboard, group, 2 * group, total);
engine.admit_bao(hash, ranges, bao).await?;
let status = engine.inner.store.blobs().status(hash).await?;
anyhow::ensure!(
matches!(
status,
iroh_blobs::api::blobs::BlobStatus::Partial { size: None }
),
"a middle-range partial must leave status()'s size unknown, got {status:?}"
);
let sizes = engine.size_snapshot().await?;
anyhow::ensure!(
sizes.get(&hash).copied() == Some(2 * group),
"size_snapshot must count the middle partial's present bytes, got {:?}",
sizes.get(&hash)
);
anyhow::ensure!(
engine.total_bytes().await? == 2 * group,
"total_bytes must agree with size_snapshot"
);
anyhow::ensure!(
engine.eviction_candidates().contains_key(&hash),
"a counted partial must be an eviction candidate with no serve after it"
);
Ok(())
}
#[tokio::test]
async fn size_snapshot_counts_tail_bearing_partial_by_present_bytes_not_total() -> anyhow::Result<()>
{
let tmp = tempfile::tempdir()?;
let engine = CacheEngine::open(tmp.path(), vec![], 16).await?;
let group = crate::CHUNK_GROUP_BYTES;
let tail_start = 3 * group;
let tail_len = 123;
let total = tail_start + tail_len;
let (root, plaintext, outboard) = synth_blob(usize::try_from(total)?);
let (hash, head_ranges, head_bao) =
bao_for(root, &plaintext, outboard.clone(), 0, group, total);
engine.admit_bao(hash, head_ranges, head_bao).await?;
let (_, tail_ranges, tail_bao) =
bao_for(root, &plaintext, outboard, tail_start, tail_len, total);
engine.admit_bao(hash, tail_ranges, tail_bao).await?;
let status = engine.inner.store.blobs().status(hash).await?;
anyhow::ensure!(
matches!(
status,
iroh_blobs::api::blobs::BlobStatus::Partial { size: Some(s) } if s == total
),
"the tail validates the size, got {status:?}"
);
let sizes = engine.size_snapshot().await?;
anyhow::ensure!(
sizes.get(&hash).copied() == Some(group + tail_len),
"size_snapshot must count present bytes, not the validated total {total}, got {:?}",
sizes.get(&hash)
);
Ok(())
}
#[tokio::test]
async fn snapshot_reuses_a_partial_count_within_the_ttl() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let engine = CacheEngine::open(tmp.path(), vec![], 16).await?;
let group = crate::CHUNK_GROUP_BYTES;
let total = 4 * group;
let (root, plaintext, outboard) = synth_blob(usize::try_from(total)?);
let store = &engine.inner.store;
let memo: PartialSizeMemo = Mutex::new(HashMap::new());
let t0 = Instant::now();
let (hash, ranges, bao) = bao_for(root, &plaintext, outboard.clone(), group, group, total);
engine.admit_bao(hash, ranges, bao).await?;
let first = snapshot_blob_sizes(store, &memo, t0, None).await?;
anyhow::ensure!(
first.get(&hash).copied() == Some(group),
"first walk observes"
);
let (_, ranges, bao) = bao_for(root, &plaintext, outboard, 2 * group, group, total);
engine.admit_bao(hash, ranges, bao).await?;
let within = snapshot_blob_sizes(store, &memo, t0 + PARTIAL_SIZE_TTL / 2, None).await?;
anyhow::ensure!(
within.get(&hash).copied() == Some(group),
"within the TTL the walk reuses the memoized count, got {:?}",
within.get(&hash)
);
let after = snapshot_blob_sizes(store, &memo, t0 + PARTIAL_SIZE_TTL, None).await?;
anyhow::ensure!(
after.get(&hash).copied() == Some(2 * group),
"after the TTL the walk re-observes, got {:?}",
after.get(&hash)
);
memo.lock()
.unwrap_or_else(PoisonError::into_inner)
.insert(Hash::new(b"no longer partial"), (1, t0));
snapshot_blob_sizes(store, &memo, t0 + PARTIAL_SIZE_TTL, None).await?;
let kept: Vec<Hash> = memo
.lock()
.unwrap_or_else(PoisonError::into_inner)
.keys()
.copied()
.collect();
anyhow::ensure!(
kept == vec![hash],
"the walk drops non-partial memo entries, got {kept:?}"
);
Ok(())
}
#[tokio::test]
async fn observed_present_bytes_of_an_absent_hash_is_zero() -> anyhow::Result<()> {
let tmp = tempfile::tempdir()?;
let engine = CacheEngine::open(tmp.path(), vec![], 16).await?;
let bytes = observed_present_bytes(engine.inner.store.blobs(), Hash::new(b"absent")).await?;
anyhow::ensure!(bytes == 0, "an absent hash holds no bytes, got {bytes}");
Ok(())
}
#[test]
fn present_byte_count_clamps_to_size() {
use bao_tree::ChunkNum;
assert_eq!(present_byte_count(&ChunkRanges::empty(), 5000), 0);
assert_eq!(
present_byte_count(&ChunkRanges::all(), 5000),
5000,
"a complete bitfield counts the whole blob, short last chunk included"
);
assert_eq!(
present_byte_count(&ChunkRanges::from(ChunkNum(1)..ChunkNum(3)), 5000),
2048,
"a closed span counts 1 KiB per chunk"
);
assert_eq!(
present_byte_count(&ChunkRanges::from(ChunkNum(4)..), 5000),
5000 - 4096,
"an open tail ends at the blob size"
);
assert_eq!(
present_byte_count(&ChunkRanges::from(ChunkNum(4)..ChunkNum(5)), 5000),
904,
"a closed span over the short last chunk counts its true length"
);
assert_eq!(
present_byte_count(&ChunkRanges::from(ChunkNum(10)..ChunkNum(12)), 5000),
0,
"a span wholly past the size counts nothing"
);
assert_eq!(
present_byte_count(
&(ChunkRanges::from(ChunkNum(0)..ChunkNum(1)) | ChunkRanges::from(ChunkNum(4)..)),
5000
),
1024 + 904,
"disjoint spans sum"
);
assert_eq!(
present_byte_count(&ChunkRanges::from(ChunkNum(1)..), 0),
0,
"an unknown size contributes nothing past it"
);
}
#[tokio::test]
async fn partial_hit_size_source_agrees_with_coverage_on_front_partial() {
let tmp = tempfile::tempdir().unwrap();
let engine = CacheEngine::open(tmp.path(), vec![], 16).await.unwrap();
let total = decdn_protocol::DISCOVERY_BLOCK_BYTES + 3 * crate::CHUNK_GROUP_BYTES;
let (root, plaintext, outboard) = synth_blob(total as usize);
let (hash, block0_ranges, block0_bao) = bao_for(
root,
&plaintext,
outboard,
0,
decdn_protocol::DISCOVERY_BLOCK_BYTES,
total,
);
engine
.admit_bao(hash, block0_ranges, block0_bao)
.await
.unwrap();
let status = engine.inner.store.blobs().status(hash).await.unwrap();
assert_matches!(
status,
iroh_blobs::api::blobs::BlobStatus::Partial { size: None },
"front-only partial must NOT have a status()-known size yet, got {status:?}"
);
let cov = engine.coverage(hash).await.unwrap();
assert!(cov.covers(0), "advertise: block 0 is fully present");
let present = engine.present_ranges(hash).await.unwrap();
let size = present.size();
assert_ne!(size, 0, "serve: the bitfield must know the blob's size");
let byte_offset = 0;
let byte_len = decdn_protocol::DISCOVERY_BLOCK_BYTES;
let missing = engine
.missing_ranges(hash, byte_offset, byte_len, size)
.await
.unwrap();
assert!(
missing.is_empty(),
"serve: block 0's byte range is fully present, so partial_hit_size must return \
Some(size), agreeing with advertise's coverage(0)"
);
}
#[tokio::test]
async fn already_admitted_short_circuits_and_serves_from_store() -> anyhow::Result<()> {
struct AdmitReader(bytes::Bytes);
impl iroh_io::AsyncStreamReader for AdmitReader {
async fn read_bytes(&mut self, len: usize) -> std::io::Result<bytes::Bytes> {
Ok(self.0.split_to(self.0.len().min(len)))
}
async fn read<const L: usize>(&mut self) -> std::io::Result<[u8; L]> {
if self.0.len() < L {
return Err(std::io::Error::new(
std::io::ErrorKind::UnexpectedEof,
"short",
));
}
let g = self.0.split_to(L);
let mut out = [0u8; L];
out.copy_from_slice(&g);
Ok(out)
}
}
#[derive(Debug)]
struct AdmittingOrigin {
engine: std::sync::Arc<std::sync::OnceLock<CacheEngine>>,
hash: Hash,
total: u64,
wire: bytes::Bytes,
ranges: bao_tree::ChunkRanges,
}
impl Origin for AdmittingOrigin {
fn kind(&self) -> OriginKind {
OriginKind::Peer
}
fn fetch(
&self,
hash: Hash,
_max_bytes: u64,
) -> Pin<Box<dyn Future<Output = Result<OriginFetch, crate::OriginPullError>> + Send + '_>>
{
Box::pin(async move {
if hash != self.hash {
return Ok(OriginFetch::NotFound);
}
let engine = self
.engine
.get()
.expect("engine set by the caller right after open");
engine
.admit_bao_stream(
self.hash,
self.ranges.clone(),
self.total,
AdmitReader(self.wire.clone()),
None,
)
.await
.map_err(|(_reader, e)| {
crate::OriginPullError::Permanent(anyhow::Error::from(e))
})?;
Ok(OriginFetch::AlreadyAdmitted)
})
}
}
let group = crate::CHUNK_GROUP_BYTES;
let total = 5 * group + 321;
let (root, plaintext, outboard) = synth_blob(total as usize);
let (hash, ranges, wire) = bao_for(root, &plaintext, outboard, 0, total, total);
assert!(
wire.len() > 8,
"bao_for output must carry the 8-byte header"
);
let wire = wire.slice(8..);
let engine_cell = std::sync::Arc::new(std::sync::OnceLock::<CacheEngine>::new());
let origin = std::sync::Arc::new(AdmittingOrigin {
engine: engine_cell.clone(),
hash,
total,
wire,
ranges: ranges.clone(),
}) as Arc<dyn Origin>;
let tmp = tempfile::tempdir()?;
let engine = CacheEngine::open(tmp.path(), vec![origin], 16).await?;
engine_cell
.set(engine.clone())
.map_err(|_| anyhow::anyhow!("engine cell already set"))?;
engine.populate(hash).await?;
anyhow::ensure!(
engine.has(hash).await?,
"blob must be present after AlreadyAdmitted populate"
);
let got = engine.get(hash).await?;
anyhow::ensure!(
got.as_ref() == plaintext.as_slice(),
"served bytes must equal the blob"
);
Ok(())
}
#[tokio::test]
async fn tagged_partial_survives_gc_untagged_is_reclaimed() {
use iroh_blobs::api::blobs::BlobStatus;
let group = crate::CHUNK_GROUP_BYTES;
let total = 4 * group;
let tmp = tempfile::tempdir().unwrap();
let engine = CacheEngine::open_full(
tmp.path(),
vec![],
16,
PinnedHashes::empty(),
RetryPolicy::disabled(),
CircuitBreakerPolicy::default(),
Some(std::sync::Arc::new(CacheMetrics::default())),
std::time::Duration::from_millis(200),
)
.await
.unwrap();
let (root_a, pt_a, ob_a) = synth_blob(total as usize);
let (ha, ra, ba) = bao_for(root_a, &pt_a, ob_a, group, group, total);
engine.admit_bao(ha, ra, ba).await.unwrap();
let (root_b, pt_b, ob_b) = synth_blob((total + group) as usize); let (hb, rb, bb) = bao_for(root_b, &pt_b, ob_b, group, group, total + group);
engine
.inner
.store
.blobs()
.import_bao_bytes(hb, rb, bb)
.await
.unwrap();
assert_matches!(
engine.inner.store.blobs().status(ha).await.unwrap(),
BlobStatus::Partial { .. }
);
assert_matches!(
engine.inner.store.blobs().status(hb).await.unwrap(),
BlobStatus::Partial { .. }
);
let deadline = std::time::Duration::from_secs(15);
let poll = std::time::Duration::from_millis(50);
let start = std::time::Instant::now();
loop {
let reclaimed = matches!(
engine.inner.store.blobs().status(hb).await.unwrap(),
BlobStatus::NotFound
);
if reclaimed {
break;
}
assert!(
start.elapsed() < deadline,
"control never reclaimed within {deadline:?}: GC did not run"
);
tokio::time::sleep(poll).await;
}
assert_matches!(
engine.inner.store.blobs().status(ha).await.unwrap(),
BlobStatus::Partial { .. },
"tagged partial survives GC"
);
}
async fn drain_serve_leg(
first: Bytes,
mut stream: Pin<Box<dyn futures_util::Stream<Item = CacheResult<Bytes>> + Send>>,
) -> Bytes {
let mut out = bytes::BytesMut::from(&first[..]);
while let Some(item) = stream.next().await {
let bytes = item.expect("in-flight serve-leg reader must deliver bytes despite evict + GC");
out.extend_from_slice(&bytes);
}
out.freeze()
}
async fn wait_reclaimed(engine: &CacheEngine, hash: Hash, deadline: Duration) {
use iroh_blobs::api::blobs::BlobStatus;
let start = std::time::Instant::now();
loop {
if matches!(
engine.inner.store.blobs().status(hash).await.unwrap(),
BlobStatus::NotFound
) {
return;
}
assert!(
start.elapsed() < deadline,
"blob {hash} never reclaimed within {deadline:?}: GC did not run"
);
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
#[tokio::test]
async fn evict_mid_serve_lets_coalesced_readers_finish_then_reclaims() {
let group = crate::CHUNK_GROUP_BYTES;
let total = 8 * group;
let tmp = tempfile::tempdir().unwrap();
let engine = CacheEngine::open_full(
tmp.path(),
vec![],
16,
PinnedHashes::empty(),
RetryPolicy::disabled(),
CircuitBreakerPolicy::default(),
Some(Arc::new(CacheMetrics::default())),
Duration::from_millis(200),
)
.await
.unwrap();
let (root, plaintext, outboard) = synth_blob(total as usize);
let (hash, ranges, bao) = bao_for(root, &plaintext, outboard, 0, 6 * group, total);
engine.admit_bao(hash, ranges, bao).await.unwrap();
assert!(
!engine.present_ranges(hash).await.unwrap().is_complete(),
"the fill target is a genuine partial"
);
assert_eq!(
count_tags_for(&engine, hash).await,
1,
"the partial carries its protecting tag"
);
let expected = engine
.export_bao_range(hash, 0, 6 * group, total)
.await
.unwrap();
let mut leg_a = engine
.export_bao_range_stream(hash, 0, 6 * group, total)
.await
.unwrap();
let mut leg_b = engine
.export_bao_range_stream(hash, 0, 6 * group, total)
.await
.unwrap();
let first_a = leg_a.next().await.expect("leg A first frame").unwrap();
let first_b = leg_b.next().await.expect("leg B first frame").unwrap();
engine.evict(hash).await.unwrap();
assert!(engine.is_evicted(hash), "evict flag set");
assert_eq!(
count_tags_for(&engine, hash).await,
0,
"evict drops the protecting tag"
);
assert!(
!engine.has(hash).await.unwrap(),
"a NEW serve is blocked immediately after evict"
);
wait_reclaimed(&engine, hash, Duration::from_secs(15)).await;
let served_a = drain_serve_leg(first_a, leg_a).await;
let served_b = drain_serve_leg(first_b, leg_b).await;
assert_eq!(
served_a, expected,
"serve leg A delivered the full range intact"
);
assert_eq!(
served_b, expected,
"serve leg B delivered the full range intact"
);
}