The asset store now uses libs/sqlite_query as its ONLY engine — not a feature
flag, not a fallback. That closes the Windows gap (the embedded store starts
there now, and a SHARED->EXCLUSIVE upgrade is handled rather than assumed
free) and takes the C dependency out of the build everywhere else.
Around it:
- store: a garbage collector, catalogued content that is referenced in place
instead of copied, the `vjeffect` kind, and host/chat routes that keep up
with the chat wire below.
- importer: the unified map contract reaches quake2, quake3, doom and duke —
world placement, nav, welding, prelit maps and glTF node handling shared
rather than reimplemented per game. Music import, billboards and stateful
props move to the data crate so readers stop linking the importer.
- ai: the serving side of multi-lane chat — per-lane conversations, honest
progress and acceptance reporting, penalties and a watchdog, context as a
per-box number that compacts instead of erupting, a realtime session mode,
and inpaint/flux2 backends. `chat_bench` measures the rate the way the
client meter computes it.
- client / chat / chat_ui: a publication can NAME a file instead of carrying
it; the wire says whether a turn is warm and whether it is thinking, so a
client stops guessing; transcript and feed widgets render history the way
the model wrote it. `SessionConfig::catalog_runtime` lets a host size the
catalog runtime's lanes itself — a browsing UI puts every listing, every
per-tile resolve and every thumbnail blob through that one runtime and
wants a wider fast lane than the shared default, while media lanes keep
it (a few big transfers, not a thousand small ones).
- widgets: the shared asset widgets — one video view (knobbed seek,
transport, bracket trim, rail playback) used everywhere, plus thumb,
preview, scene view, walk-world and the lyric reader.
251 lines
9.9 KiB
Rust
251 lines
9.9 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(&digest).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(&digest).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(&digest).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(&digest).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(&digest).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;
|
|
// The file's real mtime is "now" (wall clock), so measure staleness from
|
|
// real wall time plus the budget.
|
|
let wall_now = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_millis() as u64;
|
|
let cache =
|
|
ContentCache::open(&root, small_budgets(), wall_now + small_budgets().stale_partial_ms + 10)
|
|
.unwrap();
|
|
let _ = far;
|
|
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());
|
|
}
|