Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
242 lines
9.6 KiB
Rust
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());
|
|
}
|