feat(core): add blob cache wiring and avatar download orchestration

- Add chanora_cache dependency to Cargo.toml
- Add blob_cache field to ChanoraSession (Arc<Mutex<Option<BlobCache>>>)
- Add init_cache() method: creates BlobCache, runs eviction
- Add get_avatar() method: cache-first, download on miss, store in cache
- Add clear_cache() and cache_size() methods for cache management
- Add CoreError::Cache variant for BlobCacheError conversion
- Add avatar_cache integration test
This commit is contained in:
Edison Jwa
2026-06-10 07:09:40 +09:00
parent 6c380fc7db
commit 8c6538aa41
3 changed files with 115 additions and 0 deletions
+1
View File
@@ -10,6 +10,7 @@ repository.workspace = true
publish.workspace = true
[dependencies]
chanora_cache = { path = "../../crates/chanora_cache" }
chanora_protocol = { path = "../../crates/chanora_protocol" }
chanora_state = { path = "../../crates/chanora_state" }
chanora_audio = { path = "../../crates/chanora_audio" }
+64
View File
@@ -92,6 +92,9 @@ pub enum CoreError {
/// Storage error.
#[error("storage: {0}")]
Storage(#[from] chanora_storage::StorageError),
/// Blob-cache failure.
#[error("cache: {0}")]
Cache(#[from] chanora_cache::BlobCacheError),
/// Diagnostics error.
#[error("diagnostics: {0}")]
Diagnostics(#[from] chanora_diagnostics::DiagnosticsError),
@@ -197,6 +200,7 @@ pub struct ChanoraSession {
/// extension). Lives alongside the identity file. Wired by
/// [`Self::init_storage`].
bookmark_store: Arc<Mutex<Option<BookmarkRepository>>>,
blob_cache: Arc<Mutex<Option<chanora_cache::BlobCache>>>,
/// Invisible server-address prefetch cache. Warmed by Flutter typing
/// but validated by Rust before Connect can reuse it.
server_prefetch: ServerPrefetcher,
@@ -252,6 +256,7 @@ impl ChanoraSession {
network_tx,
identity_store: Arc::new(Mutex::new(None)),
bookmark_store: Arc::new(Mutex::new(None)),
blob_cache: Arc::new(Mutex::new(None)),
server_prefetch: ServerPrefetcher::new(),
voice_selector: selector,
release_tail,
@@ -337,6 +342,65 @@ impl ChanoraSession {
Ok(())
}
/// Configure the blob cache root.
pub async fn init_cache(&self, dir: &str) -> Result<(), CoreError> {
let cache = chanora_cache::BlobCache::new(dir, 100 * 1024 * 1024)?;
cache.evict().await?;
let mut guard = self.blob_cache.lock().await;
*guard = Some(cache);
Ok(())
}
/// Resolve avatar bytes.
pub async fn get_avatar(
&self,
avatar_hash: &str,
client_uid: &str,
) -> Result<Option<Vec<u8>>, CoreError> {
{
let guard = self.blob_cache.lock().await;
if let Some(ref cache) = *guard {
if let Some(bytes) = cache.get(chanora_cache::PREFIX_AVATAR, avatar_hash).await? {
return Ok(Some(bytes));
}
}
}
let guard = self.inner.lock().await;
let state = guard.as_ref().ok_or(CoreError::NotConnected)?;
let bytes = state.protocol.download_avatar(client_uid).await?;
{
let cache_guard = self.blob_cache.lock().await;
if let Some(ref cache) = *cache_guard {
let _ = cache
.put(chanora_cache::PREFIX_AVATAR, avatar_hash, &bytes)
.await;
}
}
Ok(Some(bytes))
}
/// Purge cached protocol-owned assets.
pub async fn clear_cache(&self) -> Result<(), CoreError> {
let guard = self.blob_cache.lock().await;
if let Some(ref cache) = *guard {
cache.clear().await?;
}
Ok(())
}
/// Report the configured blob-cache size.
pub async fn cache_size(&self) -> Result<u64, CoreError> {
let guard = self.blob_cache.lock().await;
if let Some(ref cache) = *guard {
Ok(cache.total_size().await?)
} else {
Ok(0)
}
}
/// List persisted bookmarks. Returns an empty list if the store
/// has not been wired or has no entries.
pub async fn list_bookmarks(&self) -> Result<Vec<Bookmark>, CoreError> {
+50
View File
@@ -0,0 +1,50 @@
use std::env;
use std::path::PathBuf;
use std::process;
use std::time::{SystemTime, UNIX_EPOCH};
#[tokio::test]
async fn get_avatar_returns_cached_bytes_without_connection() {
let tmp = mktemp("chanora_core_avatar_cache_test");
let session = chanora_core::ChanoraSession::new();
session.init_cache(tmp.to_str().unwrap()).await.unwrap();
let cache = chanora_cache::BlobCache::new(&tmp, 100 * 1024 * 1024).unwrap();
let hash = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6";
cache
.put(chanora_cache::PREFIX_AVATAR, hash, b"avatar-bytes")
.await
.unwrap();
let bytes = session
.get_avatar(hash, "client-uid")
.await
.unwrap()
.unwrap();
assert_eq!(bytes, b"avatar-bytes");
let _ = std::fs::remove_dir_all(&tmp);
}
#[tokio::test]
async fn get_avatar_without_cache_or_connection_returns_not_connected() {
let session = chanora_core::ChanoraSession::new();
let err = session
.get_avatar("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", "client-uid")
.await
.unwrap_err();
assert!(matches!(err, chanora_core::CoreError::NotConnected));
}
fn mktemp(label: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let p = env::temp_dir()
.join(label)
.join(format!("{}-{nanos}", process::id()));
std::fs::create_dir_all(&p).unwrap();
p
}