makepad/libs/asset/client/tests/cache.rs
2026-09-03 14:58:38 +02:00

242 lines
9.6 KiB
Rust

//! Cache semantics: atomic verified commits, resumable partials, pinning,
//! deterministic eviction under injected time, and fail-closed refusals.
mod common;
use common::{payload, test_root};
use makepad_asset_client::cache::{CacheBudgets, ContentCache};
use makepad_asset_client::ClientError;
use makepad_asset_data::Sha256;
const NOW: u64 = 1_700_000_000_000;
fn digest_of(bytes: &[u8]) -> [u8; 32] {
let mut h = Sha256::new();
h.update(bytes);
h.finalize()
}
fn small_budgets() -> CacheBudgets {
CacheBudgets {
max_total_bytes: 10_000,
max_object_bytes: 4_000,
max_partial_bytes: 8_000,
stale_partial_ms: 1_000_000,
max_ram_bytes: 64_000,
}
}
#[test]
fn put_resolve_roundtrip_verified() {
let root = test_root("roundtrip");
let mut cache = ContentCache::open(&root, small_budgets(), NOW).unwrap();
let bytes = payload(1, 1000);
let digest = cache.put_bytes(&bytes, None, NOW).unwrap();
assert_eq!(digest, digest_of(&bytes));
let path = cache.resolve(&digest, NOW + 1).unwrap().expect("resolves");
assert_eq!(std::fs::read(&path).unwrap(), bytes);
assert_eq!(cache.read_verified(&digest, NOW + 2).unwrap().unwrap(), bytes);
// Dedup keeps one object.
cache.put_bytes(&bytes, Some(&digest), NOW + 3).unwrap();
assert_eq!(cache.stats().object_count, 1);
assert_eq!(cache.stats().total_bytes, 1000);
// Wrong expectation refuses before anything lands.
let err = cache.put_bytes(&bytes, Some(&[0u8; 32]), NOW).unwrap_err();
assert!(matches!(err, ClientError::DigestMismatch { .. }));
}
#[test]
fn absent_is_none_not_a_guess() {
let root = test_root("absent");
let mut cache = ContentCache::open(&root, small_budgets(), NOW).unwrap();
assert!(cache.resolve(&[9u8; 32], NOW).unwrap().is_none());
assert!(cache.read_verified(&[9u8; 32], NOW).unwrap().is_none());
}
#[test]
fn corruption_self_heals_on_resolve() {
let root = test_root("corrupt");
let mut cache = ContentCache::open(&root, small_budgets(), NOW).unwrap();
let bytes = payload(2, 500);
let digest = cache.put_bytes(&bytes, None, NOW).unwrap();
let path = cache.resolve(&digest, NOW).unwrap().unwrap();
// Flip a byte on disk behind the cache's back.
let mut on_disk = std::fs::read(&path).unwrap();
on_disk[0] ^= 0xff;
std::fs::write(&path, &on_disk).unwrap();
// The corrupt object is never served: removed and reported absent.
assert!(cache.resolve(&digest, NOW + 1).unwrap().is_none());
assert!(!path.exists());
let stats = cache.stats();
assert_eq!(stats.corruption_evictions, 1);
assert_eq!(stats.object_count, 0);
assert_eq!(stats.total_bytes, 0);
}
#[test]
fn eviction_is_lru_and_never_touches_pins() {
let root = test_root("evict");
let mut cache = ContentCache::open(&root, small_budgets(), NOW).unwrap();
let a = payload(10, 4_000);
let b = payload(11, 4_000);
let c = payload(12, 4_000);
let da = cache.put_bytes(&a, None, NOW).unwrap(); // oldest
let db = cache.put_bytes(&b, None, NOW + 10).unwrap();
cache.pin(&da).unwrap(); // a is pinned despite being oldest
// 8000 + 4000 > 10000: eviction must take b (oldest unpinned), not a.
let dc = cache.put_bytes(&c, None, NOW + 20).unwrap();
assert!(cache.resolve(&da, NOW + 30).unwrap().is_some(), "pinned survived");
assert!(cache.resolve(&db, NOW + 30).unwrap().is_none(), "unpinned LRU evicted");
assert!(cache.resolve(&dc, NOW + 30).unwrap().is_some());
assert_eq!(cache.stats().evictions, 1);
// Only unpinned content can go: adding d evicts c (the sole unpinned
// object) even though it was just used.
let d = payload(13, 4_000);
let dd = cache.put_bytes(&d, None, NOW + 40).unwrap();
assert!(cache.resolve(&dc, NOW + 50).unwrap().is_none());
assert!(cache.resolve(&dd, NOW + 50).unwrap().is_some());
assert!(cache.resolve(&da, NOW + 50).unwrap().is_some(), "pin still holds");
}
#[test]
fn admission_refusals_are_typed() {
let root = test_root("admission");
let mut cache = ContentCache::open(&root, small_budgets(), NOW).unwrap();
// Over per-object budget.
let big = payload(20, 5_000);
let err = cache.put_bytes(&big, None, NOW).unwrap_err();
assert!(matches!(err, ClientError::CacheAdmission { .. }));
// Pinned bytes leave no room: pin 3 objects of 3000, then admit 3000.
for seed in 30..33u64 {
let bytes = payload(seed, 3_000);
let d = cache.put_bytes(&bytes, None, NOW + seed).unwrap();
cache.pin(&d).unwrap();
}
let more = payload(40, 3_000);
let err = cache.put_bytes(&more, None, NOW + 100).unwrap_err();
assert!(matches!(err, ClientError::CacheAdmission { .. }));
// Nothing was evicted to satisfy the refused admission.
assert_eq!(cache.stats().evictions, 0);
assert_eq!(cache.stats().object_count, 3);
// Unpinning one frees the room.
let victim = digest_of(&payload(30, 3_000));
cache.unpin(&victim).unwrap();
cache.put_bytes(&more, None, NOW + 200).unwrap();
}
#[test]
fn partial_resume_across_writer_drop_and_reopen() {
let root = test_root("partial");
let full = payload(50, 2_000);
let digest = digest_of(&full);
{
let mut cache = ContentCache::open(&root, small_budgets(), NOW).unwrap();
let mut w = cache.open_partial_at(&digest, NOW).unwrap();
assert_eq!(w.resumed_bytes(), 0);
w.write(&full[..700]).unwrap();
// Dropping the writer KEEPS the partial (that is the resume point).
drop(w);
assert_eq!(cache.partial_len(&digest), 700);
}
// A whole new cache instance (process restart) resumes from byte 700.
let mut cache = ContentCache::open(&root, small_budgets(), NOW + 1).unwrap();
let mut w = cache.open_partial_at(&digest, NOW + 1).unwrap();
assert_eq!(w.resumed_bytes(), 700);
w.write(&full[700..]).unwrap();
let path = cache.commit_partial(w, NOW + 2).unwrap();
assert_eq!(std::fs::read(path).unwrap(), full);
assert_eq!(cache.partial_len(&digest), 0, "partial consumed by commit");
assert!(cache.resolve(&digest, NOW + 3).unwrap().is_some());
}
#[test]
fn partial_digest_mismatch_deletes_partial() {
let root = test_root("partial_bad");
let mut cache = ContentCache::open(&root, small_budgets(), NOW).unwrap();
let digest = digest_of(&payload(60, 100));
let mut w = cache.open_partial_at(&digest, NOW).unwrap();
w.write(b"wrong bytes entirely").unwrap();
let err = cache.commit_partial(w, NOW).unwrap_err();
assert!(matches!(err, ClientError::DigestMismatch { .. }));
assert_eq!(cache.partial_len(&digest), 0, "poisoned partial removed");
assert!(cache.resolve(&digest, NOW).unwrap().is_none());
}
#[test]
fn partial_reset_restarts_hash_state() {
let root = test_root("partial_reset");
let mut cache = ContentCache::open(&root, small_budgets(), NOW).unwrap();
let full = payload(61, 300);
let digest = digest_of(&full);
let mut w = cache.open_partial_at(&digest, NOW).unwrap();
w.write(b"garbage prefix").unwrap();
w.reset().unwrap();
assert_eq!(w.resumed_bytes(), 0);
w.write(&full).unwrap();
cache.commit_partial(w, NOW).unwrap();
assert!(cache.resolve(&digest, NOW).unwrap().is_some());
}
#[test]
fn stale_partials_swept_at_open_fresh_kept() {
let root = test_root("partial_sweep");
let digest = digest_of(&payload(70, 100));
{
let mut cache = ContentCache::open(&root, small_budgets(), NOW).unwrap();
let mut w = cache.open_partial_at(&digest, NOW).unwrap();
w.write(b"resume me").unwrap();
}
// Reopen "now": fresh partial survives.
{
let cache = ContentCache::open(&root, small_budgets(), NOW).unwrap();
assert_eq!(cache.partial_len(&digest), 9);
}
// Reopen far in the future: swept.
let far = NOW + small_budgets().stale_partial_ms + 60 * 60 * 1000;
let cache = ContentCache::open(&root, small_budgets(), far).unwrap();
assert_eq!(cache.partial_len(&digest), 0, "stale partial swept");
}
#[test]
fn tmp_never_survives_open_and_foreign_partials_removed() {
let root = test_root("sweep_tmp");
{
let _ = ContentCache::open(&root, small_budgets(), NOW).unwrap();
}
std::fs::write(root.join("tmp").join("w999-0.part"), b"orphan").unwrap();
std::fs::write(root.join("partial").join("not-a-digest.part"), b"foreign").unwrap();
let _ = ContentCache::open(&root, small_budgets(), NOW + 1).unwrap();
assert!(std::fs::read_dir(root.join("tmp")).unwrap().next().is_none());
assert!(std::fs::read_dir(root.join("partial")).unwrap().next().is_none());
}
#[test]
fn index_and_pins_survive_reopen() {
let root = test_root("reopen");
let bytes = payload(80, 1_200);
let digest;
{
let mut cache = ContentCache::open(&root, small_budgets(), NOW).unwrap();
digest = cache.put_bytes(&bytes, None, NOW).unwrap();
cache.pin(&digest).unwrap();
}
let mut cache = ContentCache::open(&root, small_budgets(), NOW + 10).unwrap();
assert!(cache.is_pinned(&digest));
assert_eq!(cache.stats().object_count, 1);
assert_eq!(cache.stats().total_bytes, 1_200);
assert_eq!(cache.stats().pinned_bytes, 1_200);
assert_eq!(cache.read_verified(&digest, NOW + 11).unwrap().unwrap(), bytes);
}
#[test]
fn budgets_validate() {
let root = test_root("budget_validate");
let mut b = small_budgets();
b.max_object_bytes = b.max_total_bytes + 1;
let err = ContentCache::open(&root, b, NOW).err().expect("refused");
assert!(matches!(err, ClientError::InvalidInput { .. }));
let mut z = small_budgets();
z.max_total_bytes = 0;
assert!(ContentCache::open(&root, z, NOW).is_err());
}