first pass of merging in warp (doesn't build)
This commit is contained in:
@@ -1,18 +1,23 @@
|
||||
use anyhow::anyhow;
|
||||
use anyhow::{Error, Result};
|
||||
use std::any::{Any, TypeId};
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::hash::Hash;
|
||||
use std::pin::Pin;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
use std::time::SystemTime;
|
||||
|
||||
use anyhow::{anyhow, Error, Result};
|
||||
use async_channel::{self, Receiver, Sender};
|
||||
use bytes::Bytes;
|
||||
use derivative::Derivative;
|
||||
use futures::FutureExt as _;
|
||||
use futures::{future::BoxFuture, Future};
|
||||
use std::any::{Any, TypeId};
|
||||
use std::pin::Pin;
|
||||
use std::{cell::RefCell, collections::HashMap, hash::Hash, rc::Rc, sync::Arc};
|
||||
|
||||
use crate::image_cache::ImageCache;
|
||||
use crate::{r#async::executor, Entity, ModelContext, SingletonEntity};
|
||||
use futures::future::BoxFuture;
|
||||
use futures::{Future, FutureExt as _};
|
||||
|
||||
use super::AssetProvider;
|
||||
use crate::image_cache::ImageCache;
|
||||
use crate::r#async::executor;
|
||||
use crate::{Entity, ModelContext, SingletonEntity};
|
||||
|
||||
pub trait FetchAsset: crate::r#async::Spawnable + Future<Output = Result<Bytes>> {}
|
||||
impl<T: crate::r#async::Spawnable + Future<Output = Result<Bytes>> + ?Sized> FetchAsset for T {}
|
||||
@@ -60,6 +65,41 @@ impl std::fmt::Debug for AsyncAssetId {
|
||||
}
|
||||
}
|
||||
|
||||
/// A content fingerprint for a local file on disk.
|
||||
///
|
||||
/// Used as part of an [`AssetSource::LocalFile`] cache key so that a file whose
|
||||
/// contents change on disk is treated as a distinct asset and re-read, rather
|
||||
/// than served from a now-stale cache entry.
|
||||
#[derive(Clone, Copy, Hash, PartialEq, Eq, Debug)]
|
||||
pub struct LocalFileContentVersion {
|
||||
modified: Option<SystemTime>,
|
||||
file_size: u64,
|
||||
}
|
||||
|
||||
impl LocalFileContentVersion {
|
||||
/// Builds a content version by reading filesystem metadata for `path`.
|
||||
///
|
||||
/// Performs blocking filesystem I/O, so this must only be called off the
|
||||
/// render hot path (for example, once when a view resolves its image
|
||||
/// sources), never on every frame. Returns `None` when metadata cannot be
|
||||
/// read.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn for_path(path: impl AsRef<std::path::Path>) -> Option<Self> {
|
||||
let metadata = std::fs::metadata(path).ok()?;
|
||||
Some(Self {
|
||||
modified: metadata.modified().ok(),
|
||||
file_size: metadata.len(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Filesystem metadata is unavailable on WASM, so a local-file content
|
||||
/// version is never computed there.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn for_path(_path: impl AsRef<std::path::Path>) -> Option<Self> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// A "URI" for some data file. In other words, the location of an asset.
|
||||
#[derive(Derivative)]
|
||||
#[derivative(Clone, Hash, PartialEq, Eq, Debug)]
|
||||
@@ -81,11 +121,39 @@ pub enum AssetSource {
|
||||
path: &'static str,
|
||||
},
|
||||
/// Accessible in the user's local filesystem at the provided path.
|
||||
LocalFile { path: String },
|
||||
LocalFile {
|
||||
path: String,
|
||||
/// Optional content fingerprint. When present, it makes the cache key
|
||||
/// sensitive to on-disk changes so an edited file is re-read instead of
|
||||
/// served stale. `None` preserves path-only caching for callers that do
|
||||
/// not need invalidation.
|
||||
content_version: Option<LocalFileContentVersion>,
|
||||
},
|
||||
/// Image loaded directly with bytes
|
||||
Raw { id: String },
|
||||
}
|
||||
|
||||
impl AssetSource {
|
||||
/// Returns this source with a freshly-read local-file content version
|
||||
/// attached when it is an [`AssetSource::LocalFile`]; all other variants are
|
||||
/// returned unchanged.
|
||||
///
|
||||
/// Reads filesystem metadata, so call this off the render hot path (for
|
||||
/// example, once when a view resolves its image sources), never per frame.
|
||||
pub fn with_local_file_content_version(self) -> Self {
|
||||
match self {
|
||||
AssetSource::LocalFile { path, .. } => {
|
||||
let content_version = LocalFileContentVersion::for_path(&path);
|
||||
AssetSource::LocalFile {
|
||||
path,
|
||||
content_version,
|
||||
}
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The public representation of an asset's current state (i.e., in-memory availability).
|
||||
pub enum AssetState<T> {
|
||||
Loading { handle: AssetHandle },
|
||||
@@ -163,6 +231,7 @@ pub struct AssetCache {
|
||||
inner: Rc<RefCell<HashMap<AssetHandle, AssetStateInternal>>>,
|
||||
|
||||
bundled_asset_provider: Box<dyn AssetProvider>,
|
||||
image_cache: ImageCache,
|
||||
foreground_executor: Rc<executor::Foreground>,
|
||||
background_executor: Arc<executor::Background>,
|
||||
}
|
||||
@@ -192,15 +261,18 @@ impl Asset for String {
|
||||
|
||||
impl AssetCache {
|
||||
const MAX_RAW_ASSET_SIZE: usize = 320 * 1000 * 1000; // 320MB
|
||||
const MAX_VERSIONED_LOCAL_FILE_ASSET_SIZE: usize = 320 * 1000 * 1000; // 320MB
|
||||
|
||||
pub fn new(
|
||||
bundled_asset_provider: Box<dyn AssetProvider>,
|
||||
image_cache: ImageCache,
|
||||
foreground_executor: Rc<executor::Foreground>,
|
||||
background_executor: Arc<executor::Background>,
|
||||
) -> Self {
|
||||
Self {
|
||||
inner: Rc::new(RefCell::new(HashMap::new())),
|
||||
bundled_asset_provider,
|
||||
image_cache,
|
||||
foreground_executor,
|
||||
background_executor,
|
||||
}
|
||||
@@ -275,6 +347,59 @@ impl AssetCache {
|
||||
evicted_image_ids
|
||||
}
|
||||
|
||||
fn evict_versioned_local_file_assets(
|
||||
assets: &mut HashMap<AssetHandle, AssetStateInternal>,
|
||||
max_total_size: usize,
|
||||
) -> Vec<AssetSource> {
|
||||
let mut versioned_local_file_assets: Vec<_> = assets
|
||||
.iter()
|
||||
.filter_map(|(handle, state)| {
|
||||
if matches!(
|
||||
handle.source,
|
||||
AssetSource::LocalFile {
|
||||
content_version: Some(_),
|
||||
..
|
||||
}
|
||||
) {
|
||||
if let AssetStateInternal::Loaded {
|
||||
timestamp,
|
||||
size_in_bytes,
|
||||
..
|
||||
} = state
|
||||
{
|
||||
return Some((handle.clone(), *timestamp, *size_in_bytes));
|
||||
}
|
||||
}
|
||||
None
|
||||
})
|
||||
.collect();
|
||||
let mut total_size = versioned_local_file_assets
|
||||
.iter()
|
||||
.map(|(_, _, size_in_bytes)| size_in_bytes)
|
||||
.sum::<usize>();
|
||||
|
||||
if total_size <= max_total_size {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
versioned_local_file_assets.sort_by_key(|(_, timestamp, _)| *timestamp);
|
||||
let mut evicted_sources = vec![];
|
||||
|
||||
for (handle, _, size_in_bytes) in versioned_local_file_assets {
|
||||
if total_size <= max_total_size {
|
||||
break;
|
||||
}
|
||||
|
||||
if assets.remove(&handle).is_some() {
|
||||
total_size -= size_in_bytes;
|
||||
evicted_sources.push(handle.source.clone());
|
||||
assets.insert(handle, AssetStateInternal::Evicted);
|
||||
}
|
||||
}
|
||||
|
||||
evicted_sources
|
||||
}
|
||||
|
||||
/// The main API of the asset cache. Given the location of an asset, returns an indicator of the
|
||||
/// in-memory availability of the asset. If the asset is not already loaded or loading, a background
|
||||
/// task is spawned to perform the retrieval.
|
||||
@@ -317,7 +442,7 @@ impl AssetCache {
|
||||
};
|
||||
assets.insert(key.clone(), asset_state);
|
||||
}
|
||||
AssetSource::LocalFile { path } => {
|
||||
AssetSource::LocalFile { path, .. } => {
|
||||
assets.insert(key.clone(), AssetStateInternal::loading());
|
||||
self.load_asynchronously::<T>(
|
||||
source.clone(),
|
||||
@@ -434,6 +559,7 @@ impl AssetCache {
|
||||
|
||||
// Spawn a receiver on the foreground executor.
|
||||
let assets = Rc::downgrade(&self.inner);
|
||||
let image_cache = self.image_cache.clone();
|
||||
self.foreground_executor
|
||||
.spawn_boxed(Box::pin(async move {
|
||||
let result = match rx.await {
|
||||
@@ -483,6 +609,15 @@ impl AssetCache {
|
||||
assets.insert(handle, AssetStateInternal::Error(Rc::new(err)));
|
||||
}
|
||||
}
|
||||
|
||||
let evicted_sources = Self::evict_versioned_local_file_assets(
|
||||
&mut assets,
|
||||
Self::MAX_VERSIONED_LOCAL_FILE_ASSET_SIZE,
|
||||
);
|
||||
drop(assets);
|
||||
for source in evicted_sources {
|
||||
image_cache.evict_image(&source);
|
||||
}
|
||||
}))
|
||||
.detach();
|
||||
}
|
||||
@@ -498,3 +633,7 @@ impl Entity for AssetCache {
|
||||
}
|
||||
|
||||
impl SingletonEntity for AssetCache {}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "asset_cache_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
use std::any::{Any, TypeId};
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
use std::time::{Duration, UNIX_EPOCH};
|
||||
|
||||
use super::{AssetCache, AssetHandle, AssetSource, AssetStateInternal, LocalFileContentVersion};
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn unique_temp_path(name: &str) -> std::path::PathBuf {
|
||||
let mut path = std::env::temp_dir();
|
||||
path.push(format!(
|
||||
"warp_asset_cache_test_{}_{name}",
|
||||
std::process::id()
|
||||
));
|
||||
path
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn local_file_content_version_changes_when_file_contents_change() {
|
||||
let path = unique_temp_path("contents_change.png");
|
||||
std::fs::write(&path, b"aaaa").expect("write temp file");
|
||||
let path_string = path.to_string_lossy().to_string();
|
||||
|
||||
let source = AssetSource::LocalFile {
|
||||
path: path_string.clone(),
|
||||
content_version: None,
|
||||
}
|
||||
.with_local_file_content_version();
|
||||
|
||||
match &source {
|
||||
AssetSource::LocalFile {
|
||||
path: resolved_path,
|
||||
content_version,
|
||||
} => {
|
||||
assert_eq!(resolved_path, &path_string);
|
||||
let content_version = content_version
|
||||
.as_ref()
|
||||
.expect("expected a content version for an existing file");
|
||||
assert_eq!(
|
||||
content_version.modified,
|
||||
std::fs::metadata(&path)
|
||||
.expect("read temp file metadata")
|
||||
.modified()
|
||||
.ok()
|
||||
);
|
||||
assert_eq!(content_version.file_size, 4);
|
||||
}
|
||||
other => panic!("expected a local file source, got {other:?}"),
|
||||
}
|
||||
|
||||
let unchanged = AssetSource::LocalFile {
|
||||
path: path_string.clone(),
|
||||
content_version: None,
|
||||
}
|
||||
.with_local_file_content_version();
|
||||
assert_eq!(
|
||||
source, unchanged,
|
||||
"an unmodified file should produce the same cache key"
|
||||
);
|
||||
|
||||
std::fs::write(&path, b"bbbbbbbb").expect("rewrite temp file");
|
||||
let after_change = AssetSource::LocalFile {
|
||||
path: path_string,
|
||||
content_version: None,
|
||||
}
|
||||
.with_local_file_content_version();
|
||||
assert_ne!(
|
||||
source, after_change,
|
||||
"changing file contents should produce a different cache key"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn local_file_content_version_is_none_for_missing_file() {
|
||||
let path = unique_temp_path("definitely_missing.png");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
assert!(
|
||||
LocalFileContentVersion::for_path(&path).is_none(),
|
||||
"a missing file should not produce a content version"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_local_file_content_version_leaves_non_local_sources_unchanged() {
|
||||
let bundled = AssetSource::Bundled { path: "icon.svg" };
|
||||
assert_eq!(bundled.clone().with_local_file_content_version(), bundled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evicts_oldest_versioned_local_file_assets_over_limit() {
|
||||
let oldest_source = AssetSource::LocalFile {
|
||||
path: "oldest.png".to_string(),
|
||||
content_version: Some(LocalFileContentVersion {
|
||||
modified: Some(UNIX_EPOCH + Duration::from_secs(1)),
|
||||
file_size: 4,
|
||||
}),
|
||||
};
|
||||
let newest_source = AssetSource::LocalFile {
|
||||
path: "newest.png".to_string(),
|
||||
content_version: Some(LocalFileContentVersion {
|
||||
modified: Some(UNIX_EPOCH + Duration::from_secs(2)),
|
||||
file_size: 4,
|
||||
}),
|
||||
};
|
||||
let unversioned_source = AssetSource::LocalFile {
|
||||
path: "unversioned.png".to_string(),
|
||||
content_version: None,
|
||||
};
|
||||
let oldest_handle = AssetHandle {
|
||||
source: oldest_source.clone(),
|
||||
asset_type: TypeId::of::<String>(),
|
||||
};
|
||||
let newest_handle = AssetHandle {
|
||||
source: newest_source.clone(),
|
||||
asset_type: TypeId::of::<String>(),
|
||||
};
|
||||
let unversioned_handle = AssetHandle {
|
||||
source: unversioned_source,
|
||||
asset_type: TypeId::of::<String>(),
|
||||
};
|
||||
let mut assets = HashMap::from([
|
||||
(
|
||||
oldest_handle.clone(),
|
||||
AssetStateInternal::Loaded {
|
||||
data: Rc::new(String::new()) as Rc<dyn Any>,
|
||||
timestamp: 1,
|
||||
size_in_bytes: 4,
|
||||
},
|
||||
),
|
||||
(
|
||||
newest_handle.clone(),
|
||||
AssetStateInternal::Loaded {
|
||||
data: Rc::new(String::new()) as Rc<dyn Any>,
|
||||
timestamp: 2,
|
||||
size_in_bytes: 4,
|
||||
},
|
||||
),
|
||||
(
|
||||
unversioned_handle.clone(),
|
||||
AssetStateInternal::Loaded {
|
||||
data: Rc::new(String::new()) as Rc<dyn Any>,
|
||||
timestamp: 0,
|
||||
size_in_bytes: 10,
|
||||
},
|
||||
),
|
||||
]);
|
||||
|
||||
let evicted_sources = AssetCache::evict_versioned_local_file_assets(&mut assets, 4);
|
||||
|
||||
assert_eq!(evicted_sources, vec![oldest_source]);
|
||||
assert!(matches!(
|
||||
assets.get(&oldest_handle),
|
||||
Some(AssetStateInternal::Evicted)
|
||||
));
|
||||
assert!(matches!(
|
||||
assets.get(&newest_handle),
|
||||
Some(AssetStateInternal::Loaded { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
assets.get(&unversioned_handle),
|
||||
Some(AssetStateInternal::Loaded { .. })
|
||||
));
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use std::borrow::Cow;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
pub mod asset_cache;
|
||||
|
||||
impl AssetProvider for () {
|
||||
|
||||
Reference in New Issue
Block a user