From cace61127f11c669b6abaaf0100f52197780c47c Mon Sep 17 00:00:00 2001 From: Dionisio Date: Tue, 24 Feb 2026 13:22:04 +0100 Subject: [PATCH] perf(cache): use Arc for etag/content_type in ContentCachePort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace String with Arc for etag and content_type fields in the content cache. String::clone() allocates and copies the full string on every cache hit (O(n)), while Arc::clone() is O(1) — just an atomic ref-count increment. This eliminates 2 heap allocations per cache hit on the hottest download path. At 1000 req/s that is 2000 fewer alloc/dealloc cycles per second. Changed files: - cache_ports.rs: trait signatures String → Arc - file_content_cache.rs: CacheEntry fields, get/put methods, tests - stubs.rs: StubContentCachePort signatures - file_retrieval_service.rs: caller creates Arc before put() --- src/application/ports/cache_ports.rs | 8 +++-- .../services/file_retrieval_service.rs | 5 +-- src/common/stubs.rs | 4 +-- .../services/file_content_cache.rs | 35 ++++++++++--------- 4 files changed, 29 insertions(+), 23 deletions(-) diff --git a/src/application/ports/cache_ports.rs b/src/application/ports/cache_ports.rs index f647a57d..6af6a8e2 100644 --- a/src/application/ports/cache_ports.rs +++ b/src/application/ports/cache_ports.rs @@ -12,6 +12,7 @@ use crate::common::errors::DomainError; use async_trait::async_trait; use bytes::Bytes; use std::path::{Path, PathBuf}; +use std::sync::Arc; /// Statistics for monitoring write-behind cache status. #[derive(Debug, Clone, Default)] @@ -111,10 +112,13 @@ pub trait ContentCachePort: Send + Sync + 'static { fn should_cache(&self, size: usize) -> bool; /// Get cached content. Returns `(content, etag, content_type)` on hit. - async fn get(&self, file_id: &str) -> Option<(Bytes, String, String)>; + /// + /// `etag` and `content_type` are `Arc` so cloning on cache hit is O(1) + /// (atomic ref-count increment) instead of O(n) heap allocation. + async fn get(&self, file_id: &str) -> Option<(Bytes, Arc, Arc)>; /// Store content in the cache (may evict older entries). - async fn put(&self, file_id: String, content: Bytes, etag: String, content_type: String); + async fn put(&self, file_id: String, content: Bytes, etag: Arc, content_type: Arc); /// Remove a file from the cache (e.g. on delete/update). async fn invalidate(&self, file_id: &str); diff --git a/src/application/services/file_retrieval_service.rs b/src/application/services/file_retrieval_service.rs index 3832df9a..a9c326d7 100644 --- a/src/application/services/file_retrieval_service.rs +++ b/src/application/services/file_retrieval_service.rs @@ -197,13 +197,14 @@ impl FileRetrievalService { // Store in cache if let Some(cache) = &self.content_cache { - let etag = format!("\"{}-{}\"", id, modified_at); + let etag: Arc = format!("\"{}-{}\"", id, modified_at).into(); + let ct: Arc = Arc::from(&*mime_type); cache .put( id.to_string(), content_bytes.clone(), etag, - mime_type.clone(), + ct, ) .await; } diff --git a/src/common/stubs.rs b/src/common/stubs.rs index aab72b0f..ebcf9c01 100644 --- a/src/common/stubs.rs +++ b/src/common/stubs.rs @@ -790,11 +790,11 @@ impl ContentCachePort for StubContentCachePort { false } - async fn get(&self, _file_id: &str) -> Option<(Bytes, String, String)> { + async fn get(&self, _file_id: &str) -> Option<(Bytes, Arc, Arc)> { None } - async fn put(&self, _file_id: String, _content: Bytes, _etag: String, _content_type: String) {} + async fn put(&self, _file_id: String, _content: Bytes, _etag: Arc, _content_type: Arc) {} async fn invalidate(&self, _file_id: &str) {} diff --git a/src/infrastructure/services/file_content_cache.rs b/src/infrastructure/services/file_content_cache.rs index 865bf6d8..65d514b3 100644 --- a/src/infrastructure/services/file_content_cache.rs +++ b/src/infrastructure/services/file_content_cache.rs @@ -40,8 +40,8 @@ impl FileContentCacheConfig { #[derive(Clone)] struct CacheEntry { content: Bytes, - etag: String, - content_type: String, + etag: Arc, + content_type: Arc, } /// Lock-free concurrent file content cache backed by `moka`. @@ -98,8 +98,9 @@ impl FileContentCache { /// Get file content from cache (lock-free read) /// - /// Returns (content, etag, content_type) if found - pub async fn get(&self, file_id: &str) -> Option<(Bytes, String, String)> { + /// Returns `(content, etag, content_type)` if found. + /// All three clones are O(1): `Bytes` and `Arc` only bump a ref count. + pub async fn get(&self, file_id: &str) -> Option<(Bytes, Arc, Arc)> { if let Some(entry) = self.cache.get(file_id).await { self.hits.fetch_add(1, Ordering::Relaxed); debug!("Cache HIT for file: {}", file_id); @@ -123,7 +124,7 @@ impl FileContentCache { /// Put file content into cache /// /// Moka handles eviction automatically based on weight (content size). - pub async fn put(&self, file_id: String, content: Bytes, etag: String, content_type: String) { + pub async fn put(&self, file_id: String, content: Bytes, etag: Arc, content_type: Arc) { let size = content.len(); // Don't cache if too large @@ -199,11 +200,11 @@ impl ContentCachePort for FileContentCache { FileContentCache::should_cache(self, size) } - async fn get(&self, file_id: &str) -> Option<(Bytes, String, String)> { + async fn get(&self, file_id: &str) -> Option<(Bytes, Arc, Arc)> { FileContentCache::get(self, file_id).await } - async fn put(&self, file_id: String, content: Bytes, etag: String, content_type: String) { + async fn put(&self, file_id: String, content: Bytes, etag: Arc, content_type: Arc) { FileContentCache::put(self, file_id, content, etag, content_type).await } @@ -233,8 +234,8 @@ mod tests { .put( "file1".to_string(), content.clone(), - "etag1".to_string(), - "text/plain".to_string(), + "etag1".into(), + "text/plain".into(), ) .await; @@ -242,8 +243,8 @@ mod tests { assert!(result.is_some()); let (cached_content, etag, content_type) = result.unwrap(); assert_eq!(cached_content, content); - assert_eq!(etag, "etag1"); - assert_eq!(content_type, "text/plain"); + assert_eq!(&*etag, "etag1"); + assert_eq!(&*content_type, "text/plain"); } #[tokio::test] @@ -260,8 +261,8 @@ mod tests { .put( "small".to_string(), small, - "e1".to_string(), - "app/bin".to_string(), + "e1".into(), + "app/bin".into(), ) .await; assert!(cache.get("small").await.is_some()); @@ -272,8 +273,8 @@ mod tests { .put( "big".to_string(), big, - "e2".to_string(), - "app/bin".to_string(), + "e2".into(), + "app/bin".into(), ) .await; assert!( @@ -298,8 +299,8 @@ mod tests { .put( "file1".to_string(), content, - "e".to_string(), - "t".to_string(), + "e".into(), + "t".into(), ) .await;