//! The disk cache (Settings ▸ Disk ▸ Disk Cache) in the browser: viewer frames kept in the //! Origin Private File System between visits, with the size limit from the settings and //! least-recently-used eviction. //! //! - **Writes** happen in the frame workers: a render request carries the frame's disk-cache key //! (`effectcraft_engine::remote::FrameMsg::Render`), the worker posts the frame, then writes //! the sealed entry (`disk_cache::frame_entry`: LZ4, checksum) with a synchronous access //! handle into a temporary file moved into place (`js/host.js` `cacheWrite`), and reports //! `FrameReply::Stored`. //! - **The index** lives here, on the page ([`DiskIndex`], shared with the desktop's cache): //! rebuilt at startup from the folder listing (oldest files first), updated by the workers' //! `Stored` replies and by reads; eviction deletes the least recently used files. //! - **Reads** are asynchronous: a frame whose key is indexed is read and decoded here instead //! of rendering it (`crate::frames`); a damaged or missing file is a miss and is forgotten. //! //! - **Layer buffers** too (`layers/<32 hex>.ecc`): a layer lookup happens in the middle of a //! synchronous render, where the browser's file system can't be read, so the frame workers //! back their layer caches with a `PrefetchStore`. They report each frame's layer-cache //! misses; before the next request the page names the recent misses that are on disk //! ([`layer_prefetch`], `disk_cache::LayerPrefetch`), and the worker reads those entries //! into memory before rendering, so the lookups find them. Slow buffers are written by the //! worker after its frame and indexed here like frames (one least-recently-used order for //! both kinds). use std::cell::RefCell; use effectcraft_engine::render::disk_cache::{self, DiskIndex, Kind, LayerPrefetch}; use serde_json::{Value, json}; use wasm_bindgen::prelude::*; use wasm_bindgen_futures::JsFuture; #[wasm_bindgen(module = "/js/host.js")] extern "C" { #[wasm_bindgen(js_name = cacheList)] fn cache_list() -> js_sys::Promise; #[wasm_bindgen(js_name = cacheRead)] pub(crate) fn cache_read(name: &str) -> js_sys::Promise; #[wasm_bindgen(js_name = cacheWrite)] pub(crate) fn cache_write(name: &str, bytes: js_sys::Uint8Array) -> js_sys::Promise; #[wasm_bindgen(js_name = cacheDelete)] fn cache_delete(names: js_sys::Array) -> js_sys::Promise; #[wasm_bindgen(js_name = cacheClear)] fn cache_clear() -> js_sys::Promise; } #[derive(Default)] struct State { index: DiskIndex, /// The folder was listed (lookups before then miss). loaded: bool, /// The Origin Private File System works here. available: bool, /// Settings ▸ Disk ▸ Enable Disk Cache. enabled: bool, max_bytes: u64, hits: u64, misses: u64, writes: u64, evictions: u64, error: Option, /// Recent layer-cache misses of the frame workers (what to prefetch). prefetch: LayerPrefetch, layer_hits: u64, layer_writes: u64, } thread_local! { static ST: RefCell = RefCell::new(State { enabled: true, max_bytes: 1 << 30, ..Default::default() }); } fn js_err(e: JsValue) -> String { e.as_string().or_else(|| js_sys::Reflect::get(&e, &"message".into()).ok().and_then(|m| m.as_string())).unwrap_or_else(|| format!("{e:?}")) } /// List the cache folder and build the index (in the background). pub fn init() { wasm_bindgen_futures::spawn_local(async { match JsFuture::from(cache_list()).await { Ok(list) => { let mut entries: Vec<(f64, Kind, u128, u64)> = js_sys::Array::from(&list) .iter() .filter_map(|e| { let get = |k: &str| js_sys::Reflect::get(&e, &k.into()).ok(); let (kind, key) = parse_name(&get("name")?.as_string()?)?; Some((get("modified")?.as_f64().unwrap_or(0.0), kind, key, get("size")?.as_f64().unwrap_or(0.0) as u64)) }) .collect(); entries.sort_by(|a, b| a.0.total_cmp(&b.0)); ST.with(|s| { let mut s = s.borrow_mut(); for (_, kind, key, bytes) in entries { s.index.insert(kind, key, bytes); } s.loaded = true; s.available = true; }); evict(); } Err(e) => { let e = js_err(e); log::info!("disk cache unavailable: {e}"); ST.with(|s| s.borrow_mut().error = Some(e)); } } }); } /// An entry's name in `js/host.js`'s cache folders (`layers/` for layer buffers). pub(crate) fn name_of(kind: Kind, key: u128) -> String { match kind { Kind::Frame => disk_cache::entry_name(key), Kind::Layer => format!("layers/{}", disk_cache::entry_name(key)), } } fn parse_name(name: &str) -> Option<(Kind, u128)> { match name.strip_prefix("layers/") { Some(n) => Some((Kind::Layer, disk_cache::parse_entry_name(n)?)), None => Some((Kind::Frame, disk_cache::parse_entry_name(name)?)), } } /// Layer entries (32 hex digits) a frame worker should read before its next frame: recent /// misses that are on disk. pub fn layer_prefetch(max: usize) -> Vec { if !active() { return vec![]; } ST.with(|s| { let s = s.borrow(); s.prefetch.plan(&s.index, max).into_iter().map(|k| format!("{k:032x}")).collect() }) } /// A frame worker's layer-cache misses and the buffers it served from what it fetched. pub fn note_layers(misses: &[String], hits: &[String]) { let parse = |v: &[String]| v.iter().filter_map(|k| u128::from_str_radix(k, 16).ok()).collect::>(); ST.with(|s| { let mut s = s.borrow_mut(); s.prefetch.note_misses(parse(misses)); for k in parse(hits) { s.index.touch(Kind::Layer, k); s.layer_hits += 1; } }); } /// Apply the settings: on/off and the limit (`max_gb`, at most half the origin's quota when it /// is known). pub fn configure(enabled: bool, max_gb: u32, quota: Option) { let mut max = (max_gb.max(1) as u64) << 30; if let Some(q) = quota.filter(|q| *q > 0) { max = max.min(q / 2); } let changed = ST.with(|s| { let mut s = s.borrow_mut(); let changed = s.enabled != enabled || s.max_bytes != max; s.enabled = enabled; s.max_bytes = max; changed }); if changed { evict(); } } /// The cache is usable: listed, available and enabled. pub fn active() -> bool { ST.with(|s| { let s = s.borrow(); s.loaded && s.available && s.enabled }) } /// The frame of `key` is cached. pub fn contains(key: u128) -> bool { active() && ST.with(|s| s.borrow().index.contains(Kind::Frame, key)) } /// A lookup missed (counted for the statistics). pub fn note_miss() { ST.with(|s| s.borrow_mut().misses += 1); } /// Read and decode a cached frame; `None` (forgotten and deleted) when the file is missing or /// damaged. pub async fn read(key: u128) -> Option { let name = disk_cache::entry_name(key); let bytes = JsFuture::from(cache_read(&name)).await.ok().and_then(|b| b.dyn_into::().ok()).map(|b| b.to_vec()); let frame = bytes.as_deref().and_then(disk_cache::read_frame_entry); ST.with(|s| { let mut s = s.borrow_mut(); if frame.is_some() { s.hits += 1; s.index.touch(Kind::Frame, key); } else { s.misses += 1; s.index.remove(Kind::Frame, key); } }); if frame.is_none() && bytes.is_some() { delete(vec![name]); } frame } /// A worker stored the frame (or, with `layer`, the layer buffer) of `key` (`bytes` on disk). pub fn stored(key: u128, bytes: u64, layer: bool) { ST.with(|s| { let mut s = s.borrow_mut(); s.index.insert(if layer { Kind::Layer } else { Kind::Frame }, key, bytes); s.writes += 1; if layer { s.layer_writes += 1; } }); evict(); } fn evict() { let victims = ST.with(|s| { let mut s = s.borrow_mut(); let max = if s.enabled { s.max_bytes } else { u64::MAX }; let v = s.index.evict(max); s.evictions += v.len() as u64; v }); if !victims.is_empty() { delete(victims.into_iter().map(|(kind, k)| name_of(kind, k)).collect()); } } fn delete(names: Vec) { let arr = js_sys::Array::new(); for n in names { arr.push(&JsValue::from_str(&n)); } wasm_bindgen_futures::spawn_local(async move { let _ = JsFuture::from(cache_delete(arr)).await; }); } /// Delete every cached frame; returns (entries, bytes) removed. pub fn clear() -> (u64, u64) { let r = ST.with(|s| { let mut s = s.borrow_mut(); let r = (s.index.len() as u64, s.index.total()); s.index.clear(); s.prefetch.clear(); r }); wasm_bindgen_futures::spawn_local(async { if let Err(e) = JsFuture::from(cache_clear()).await { log::warn!("disk cache: clearing failed: {}", js_err(e)); } }); r } /// `cache.diskStats` / `storage.info().diskCache`. pub fn stats() -> Value { ST.with(|s| { let s = s.borrow(); json!({ "enabled": s.enabled && s.available, "available": s.available, "loaded": s.loaded, "folder": "Origin Private File System: effectcraft-cache/v1 (frames, layers)", "entries": s.index.len(), "frames": s.index.keys(Kind::Frame).len(), "layers": s.index.keys(Kind::Layer).len(), "layerHits": s.layer_hits, "layerWrites": s.layer_writes, "bytes": s.index.total(), "maxBytes": s.max_bytes, "hits": s.hits, "misses": s.misses, "writes": s.writes, "evictions": s.evictions, "error": s.error, }) }) }