perf: fix issues #1,#2,#3,#13 from performance audit
- #1 image_transcode: dedicated rayon pool + moka cache (no tokio blocking) - #2 ltree materialized paths: eliminate N+1 folder/file queries - #3 compute_content_hash: read blob_hash column instead of loading file into RAM - #13 event delegation + DocumentFragment: ~15 delegated listeners replace ~19k per-item listeners Backend: 82/82 tests pass, cargo check clean. Frontend: ui.js and app.js syntax-validated via Node.js.
This commit is contained in:
@@ -3,22 +3,20 @@
|
||||
//! Automatically transcodes images to WebP format when the browser supports it,
|
||||
//! reducing bandwidth by 30-50% compared to JPEG/PNG.
|
||||
//!
|
||||
//! Features:
|
||||
//! - Detects browser WebP support via Accept header
|
||||
//! - Caches transcoded versions to avoid re-conversion
|
||||
//! Architecture:
|
||||
//! - **Dedicated `rayon` thread pool** for CPU-bound transcoding (never blocks Tokio)
|
||||
//! - **`moka` lock-free cache** for hot transcoded images (no write-lock on reads)
|
||||
//! - Disk cache for persistence across restarts
|
||||
//! - Supports JPEG, PNG, GIF → WebP conversion
|
||||
//! - Configurable quality settings
|
||||
//! - Falls back to original if conversion fails
|
||||
//! - Falls back to original if conversion fails or result is larger
|
||||
|
||||
use async_trait::async_trait;
|
||||
use bytes::Bytes;
|
||||
use image::{DynamicImage, ImageFormat};
|
||||
use lru::LruCache;
|
||||
use std::num::NonZeroUsize;
|
||||
use image::ImageFormat;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use tokio::fs;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::application::ports::transcode_ports::{
|
||||
ImageTranscodePort, OutputFormat as PortOutputFormat, TranscodeStatsDto,
|
||||
@@ -28,11 +26,20 @@ use crate::domain::errors::{DomainError, ErrorKind};
|
||||
/// Maximum file size for transcoding (5MB - larger files stream directly)
|
||||
pub const MAX_TRANSCODE_SIZE: u64 = 5 * 1024 * 1024;
|
||||
|
||||
/// Cache key for transcoded images
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
struct TranscodeKey {
|
||||
file_id: String,
|
||||
format: OutputFormat,
|
||||
/// Number of threads in the dedicated transcoding pool
|
||||
const TRANSCODE_POOL_THREADS: usize = 2;
|
||||
|
||||
/// Dedicated rayon thread pool for CPU-bound image transcoding.
|
||||
/// Isolated from Tokio's blocking pool to prevent starvation of other I/O.
|
||||
fn transcode_pool() -> &'static rayon::ThreadPool {
|
||||
static POOL: OnceLock<rayon::ThreadPool> = OnceLock::new();
|
||||
POOL.get_or_init(|| {
|
||||
rayon::ThreadPoolBuilder::new()
|
||||
.num_threads(TRANSCODE_POOL_THREADS)
|
||||
.thread_name(|idx| format!("transcode-{idx}"))
|
||||
.build()
|
||||
.expect("Failed to create transcode thread pool")
|
||||
})
|
||||
}
|
||||
|
||||
/// Supported output formats
|
||||
@@ -75,7 +82,6 @@ impl BrowserCapabilities {
|
||||
|
||||
/// Get the best output format for this browser
|
||||
pub fn best_format(&self) -> Option<OutputFormat> {
|
||||
// WebP has best support currently
|
||||
if self.supports_webp {
|
||||
Some(OutputFormat::WebP)
|
||||
} else {
|
||||
@@ -84,21 +90,17 @@ impl BrowserCapabilities {
|
||||
}
|
||||
}
|
||||
|
||||
/// Image Transcoding Service
|
||||
pub struct ImageTranscodeService {
|
||||
/// Cache directory for transcoded images
|
||||
cache_dir: PathBuf,
|
||||
/// In-memory LRU cache for hot transcoded images
|
||||
memory_cache: Arc<RwLock<LruCache<TranscodeKey, Bytes>>>,
|
||||
/// Maximum memory cache size in bytes
|
||||
max_memory_bytes: usize,
|
||||
/// Current memory usage
|
||||
current_memory_bytes: Arc<RwLock<usize>>,
|
||||
/// Statistics
|
||||
stats: Arc<RwLock<TranscodeStats>>,
|
||||
/// Lock-free transcoding statistics using atomics (no RwLock needed)
|
||||
#[derive(Debug, Default)]
|
||||
struct AtomicTranscodeStats {
|
||||
cache_hits: AtomicU64,
|
||||
disk_hits: AtomicU64,
|
||||
transcodes: AtomicU64,
|
||||
bytes_saved: AtomicU64,
|
||||
transcode_errors: AtomicU64,
|
||||
}
|
||||
|
||||
/// Transcoding statistics
|
||||
/// Snapshot of transcoding statistics
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct TranscodeStats {
|
||||
pub cache_hits: u64,
|
||||
@@ -108,19 +110,58 @@ pub struct TranscodeStats {
|
||||
pub transcode_errors: u64,
|
||||
}
|
||||
|
||||
impl AtomicTranscodeStats {
|
||||
fn snapshot(&self) -> TranscodeStats {
|
||||
TranscodeStats {
|
||||
cache_hits: self.cache_hits.load(Ordering::Relaxed),
|
||||
disk_hits: self.disk_hits.load(Ordering::Relaxed),
|
||||
transcodes: self.transcodes.load(Ordering::Relaxed),
|
||||
bytes_saved: self.bytes_saved.load(Ordering::Relaxed),
|
||||
transcode_errors: self.transcode_errors.load(Ordering::Relaxed),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Image Transcoding Service
|
||||
///
|
||||
/// Uses a dedicated `rayon` thread pool for CPU-bound work and `moka` for
|
||||
/// lock-free concurrent caching with automatic weight-based eviction.
|
||||
pub struct ImageTranscodeService {
|
||||
/// Cache directory for transcoded images on disk
|
||||
cache_dir: PathBuf,
|
||||
/// Lock-free concurrent cache (moka) — no write-lock on reads
|
||||
memory_cache: moka::future::Cache<String, Bytes>,
|
||||
/// Lock-free statistics
|
||||
stats: Arc<AtomicTranscodeStats>,
|
||||
}
|
||||
|
||||
impl ImageTranscodeService {
|
||||
/// Create new transcoding service
|
||||
///
|
||||
/// - `storage_root`: base path for disk cache
|
||||
/// - `max_cache_entries`: max number of transcoded images in memory
|
||||
/// - `max_memory_bytes`: max total bytes for in-memory cache
|
||||
pub fn new(storage_root: &Path, max_cache_entries: usize, max_memory_bytes: usize) -> Self {
|
||||
let cache_dir = storage_root.join(".transcoded");
|
||||
|
||||
// Build moka cache with weight-based eviction (by content size)
|
||||
let memory_cache = moka::future::Cache::builder()
|
||||
.max_capacity(max_memory_bytes as u64)
|
||||
.weigher(|_key: &String, value: &Bytes| -> u32 {
|
||||
// Weight = byte size, capped to u32::MAX
|
||||
value.len().min(u32::MAX as usize) as u32
|
||||
})
|
||||
.time_to_live(std::time::Duration::from_secs(600)) // 10 min TTL for freshness
|
||||
.build();
|
||||
|
||||
// Ignore max_cache_entries — moka uses weight-based eviction, which is
|
||||
// more accurate than entry-count limits for variable-size images.
|
||||
let _ = max_cache_entries;
|
||||
|
||||
Self {
|
||||
cache_dir,
|
||||
memory_cache: Arc::new(RwLock::new(LruCache::new(
|
||||
NonZeroUsize::new(max_cache_entries).unwrap_or(NonZeroUsize::new(1000).unwrap()),
|
||||
))),
|
||||
max_memory_bytes,
|
||||
current_memory_bytes: Arc::new(RwLock::new(0)),
|
||||
stats: Arc::new(RwLock::new(TranscodeStats::default())),
|
||||
memory_cache,
|
||||
stats: Arc::new(AtomicTranscodeStats::default()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,7 +170,8 @@ impl ImageTranscodeService {
|
||||
fs::create_dir_all(&self.cache_dir).await?;
|
||||
fs::create_dir_all(self.cache_dir.join("webp")).await?;
|
||||
tracing::info!(
|
||||
"🖼️ Image transcode service initialized at {:?}",
|
||||
"🖼️ Image transcode service initialized (rayon pool: {} threads, cache dir: {:?})",
|
||||
TRANSCODE_POOL_THREADS,
|
||||
self.cache_dir
|
||||
);
|
||||
Ok(())
|
||||
@@ -148,8 +190,8 @@ impl ImageTranscodeService {
|
||||
Self::can_transcode(mime_type) && file_size <= MAX_TRANSCODE_SIZE
|
||||
}
|
||||
|
||||
/// Get transcoded version of an image
|
||||
/// Returns (content, mime_type, was_transcoded)
|
||||
/// Get transcoded version of an image.
|
||||
/// Returns `(content, mime_type, was_transcoded)`.
|
||||
pub async fn get_transcoded(
|
||||
&self,
|
||||
file_id: &str,
|
||||
@@ -157,34 +199,25 @@ impl ImageTranscodeService {
|
||||
original_mime: &str,
|
||||
target_format: OutputFormat,
|
||||
) -> Result<(Bytes, String, bool), String> {
|
||||
let key = TranscodeKey {
|
||||
file_id: file_id.to_string(),
|
||||
format: target_format,
|
||||
};
|
||||
let cache_key = format!("{}:{}", file_id, target_format.extension());
|
||||
|
||||
// Check memory cache first
|
||||
{
|
||||
let mut cache = self.memory_cache.write().await;
|
||||
if let Some(cached) = cache.get(&key) {
|
||||
let mut stats = self.stats.write().await;
|
||||
stats.cache_hits += 1;
|
||||
tracing::debug!("🔥 Transcode memory cache HIT: {}", file_id);
|
||||
return Ok((cached.clone(), target_format.mime_type().to_string(), true));
|
||||
}
|
||||
// ── 1. Check moka memory cache (lock-free read) ──
|
||||
if let Some(cached) = self.memory_cache.get(&cache_key).await {
|
||||
self.stats.cache_hits.fetch_add(1, Ordering::Relaxed);
|
||||
tracing::debug!("🔥 Transcode memory cache HIT: {}", file_id);
|
||||
return Ok((cached, target_format.mime_type().to_string(), true));
|
||||
}
|
||||
|
||||
// Check disk cache
|
||||
// ── 2. Check disk cache (async fs) ──
|
||||
let cache_path = self.get_cache_path(file_id, target_format);
|
||||
if cache_path.exists() {
|
||||
if tokio::fs::try_exists(&cache_path).await.unwrap_or(false) {
|
||||
match fs::read(&cache_path).await {
|
||||
Ok(data) => {
|
||||
let content = Bytes::from(data);
|
||||
|
||||
// Store in memory cache
|
||||
self.cache_in_memory(&key, content.clone()).await;
|
||||
|
||||
let mut stats = self.stats.write().await;
|
||||
stats.disk_hits += 1;
|
||||
self.memory_cache
|
||||
.insert(cache_key.clone(), content.clone())
|
||||
.await;
|
||||
self.stats.disk_hits.fetch_add(1, Ordering::Relaxed);
|
||||
tracing::debug!("💾 Transcode disk cache HIT: {}", file_id);
|
||||
return Ok((content, target_format.mime_type().to_string(), true));
|
||||
}
|
||||
@@ -194,16 +227,27 @@ impl ImageTranscodeService {
|
||||
}
|
||||
}
|
||||
|
||||
// Need to transcode
|
||||
let transcoded = self.transcode_image(original_content, original_mime, target_format)?;
|
||||
let transcoded_bytes = Bytes::from(transcoded.clone());
|
||||
// ── 3. Transcode on dedicated rayon pool (never blocks Tokio) ──
|
||||
let content_owned = original_content.to_vec();
|
||||
let mime_owned = original_mime.to_string();
|
||||
|
||||
// Calculate savings
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
|
||||
transcode_pool().spawn(move || {
|
||||
let result = transcode_image_blocking(&content_owned, &mime_owned, target_format);
|
||||
let _ = tx.send(result);
|
||||
});
|
||||
|
||||
let transcoded = rx
|
||||
.await
|
||||
.map_err(|_| "Transcode task was cancelled".to_string())??;
|
||||
|
||||
let transcoded_bytes = Bytes::from(transcoded);
|
||||
|
||||
// ── 4. Evaluate savings ──
|
||||
let original_size = original_content.len();
|
||||
let transcoded_size = transcoded_bytes.len();
|
||||
let saved = original_size.saturating_sub(transcoded_size);
|
||||
|
||||
// Only use transcoded if it's actually smaller
|
||||
if transcoded_size >= original_size {
|
||||
tracing::debug!(
|
||||
"⚠️ Transcode not beneficial for {}: {} -> {} bytes",
|
||||
@@ -218,27 +262,30 @@ impl ImageTranscodeService {
|
||||
));
|
||||
}
|
||||
|
||||
// Save to disk cache (async, don't wait)
|
||||
let saved = original_size - transcoded_size;
|
||||
|
||||
// ── 5. Persist to disk cache (fire-and-forget) ──
|
||||
let cache_path_clone = cache_path.clone();
|
||||
let transcoded_clone = transcoded.clone();
|
||||
let transcoded_for_disk = transcoded_bytes.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Some(parent) = cache_path_clone.parent() {
|
||||
let _ = fs::create_dir_all(parent).await;
|
||||
}
|
||||
if let Err(e) = fs::write(&cache_path_clone, &transcoded_clone).await {
|
||||
if let Err(e) = fs::write(&cache_path_clone, &transcoded_for_disk).await {
|
||||
tracing::warn!("Failed to cache transcoded image: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
// Store in memory cache
|
||||
self.cache_in_memory(&key, transcoded_bytes.clone()).await;
|
||||
// ── 6. Store in moka memory cache (lock-free) ──
|
||||
self.memory_cache
|
||||
.insert(cache_key, transcoded_bytes.clone())
|
||||
.await;
|
||||
|
||||
// Update stats
|
||||
{
|
||||
let mut stats = self.stats.write().await;
|
||||
stats.transcodes += 1;
|
||||
stats.bytes_saved += saved as u64;
|
||||
}
|
||||
// ── 7. Update stats (lock-free atomics) ──
|
||||
self.stats.transcodes.fetch_add(1, Ordering::Relaxed);
|
||||
self.stats
|
||||
.bytes_saved
|
||||
.fetch_add(saved as u64, Ordering::Relaxed);
|
||||
|
||||
tracing::info!(
|
||||
"✨ Transcoded {}: {} -> {} bytes ({:.1}% smaller)",
|
||||
@@ -255,43 +302,6 @@ impl ImageTranscodeService {
|
||||
))
|
||||
}
|
||||
|
||||
/// Perform actual image transcoding
|
||||
fn transcode_image(
|
||||
&self,
|
||||
content: &[u8],
|
||||
original_mime: &str,
|
||||
target_format: OutputFormat,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
// Determine input format
|
||||
let input_format = match original_mime {
|
||||
"image/jpeg" | "image/jpg" => ImageFormat::Jpeg,
|
||||
"image/png" => ImageFormat::Png,
|
||||
"image/gif" => ImageFormat::Gif,
|
||||
_ => return Err(format!("Unsupported input format: {}", original_mime)),
|
||||
};
|
||||
|
||||
// Load image
|
||||
let img = image::load_from_memory_with_format(content, input_format)
|
||||
.map_err(|e| format!("Failed to decode image: {}", e))?;
|
||||
|
||||
// Encode to target format
|
||||
match target_format {
|
||||
OutputFormat::WebP => self.encode_webp(&img),
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode image to WebP
|
||||
fn encode_webp(&self, img: &DynamicImage) -> Result<Vec<u8>, String> {
|
||||
let mut buffer = Vec::new();
|
||||
let mut cursor = std::io::Cursor::new(&mut buffer);
|
||||
|
||||
// Use image crate's WebP encoder
|
||||
img.write_to(&mut cursor, ImageFormat::WebP)
|
||||
.map_err(|e| format!("Failed to encode WebP: {}", e))?;
|
||||
|
||||
Ok(buffer)
|
||||
}
|
||||
|
||||
/// Get path for cached transcoded file
|
||||
fn get_cache_path(&self, file_id: &str, format: OutputFormat) -> PathBuf {
|
||||
self.cache_dir
|
||||
@@ -299,67 +309,28 @@ impl ImageTranscodeService {
|
||||
.join(format!("{}.{}", file_id, format.extension()))
|
||||
}
|
||||
|
||||
/// Store transcoded image in memory cache
|
||||
async fn cache_in_memory(&self, key: &TranscodeKey, content: Bytes) {
|
||||
let size = content.len();
|
||||
|
||||
let mut current = self.current_memory_bytes.write().await;
|
||||
|
||||
// Evict if needed
|
||||
while *current + size > self.max_memory_bytes {
|
||||
let mut cache = self.memory_cache.write().await;
|
||||
if let Some((_, evicted)) = cache.pop_lru() {
|
||||
*current = current.saturating_sub(evicted.len());
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Add to cache
|
||||
if *current + size <= self.max_memory_bytes {
|
||||
let mut cache = self.memory_cache.write().await;
|
||||
cache.put(key.clone(), content);
|
||||
*current += size;
|
||||
}
|
||||
}
|
||||
|
||||
/// Invalidate cached transcodes for a file
|
||||
pub async fn invalidate(&self, file_id: &str) {
|
||||
// Remove from memory cache
|
||||
{
|
||||
let mut cache = self.memory_cache.write().await;
|
||||
let key = TranscodeKey {
|
||||
file_id: file_id.to_string(),
|
||||
format: OutputFormat::WebP,
|
||||
};
|
||||
if let Some(removed) = cache.pop(&key) {
|
||||
let mut current = self.current_memory_bytes.write().await;
|
||||
*current = current.saturating_sub(removed.len());
|
||||
}
|
||||
}
|
||||
let cache_key = format!("{}:{}", file_id, OutputFormat::WebP.extension());
|
||||
self.memory_cache.invalidate(&cache_key).await;
|
||||
|
||||
// Remove disk cache
|
||||
let cache_path = self.get_cache_path(file_id, OutputFormat::WebP);
|
||||
let _ = fs::remove_file(&cache_path).await;
|
||||
}
|
||||
|
||||
/// Get transcoding statistics
|
||||
pub async fn get_stats(&self) -> TranscodeStats {
|
||||
self.stats.read().await.clone()
|
||||
self.stats.snapshot()
|
||||
}
|
||||
|
||||
/// Clear all caches
|
||||
pub async fn clear_cache(&self) -> std::io::Result<()> {
|
||||
// Clear memory
|
||||
{
|
||||
let mut cache = self.memory_cache.write().await;
|
||||
cache.clear();
|
||||
let mut current = self.current_memory_bytes.write().await;
|
||||
*current = 0;
|
||||
}
|
||||
self.memory_cache.invalidate_all();
|
||||
|
||||
// Clear disk
|
||||
if self.cache_dir.exists() {
|
||||
if tokio::fs::try_exists(&self.cache_dir)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
fs::remove_dir_all(&self.cache_dir).await?;
|
||||
fs::create_dir_all(&self.cache_dir).await?;
|
||||
fs::create_dir_all(self.cache_dir.join("webp")).await?;
|
||||
@@ -369,6 +340,36 @@ impl ImageTranscodeService {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── CPU-bound transcoding (runs on rayon, never on Tokio) ───────────────────
|
||||
|
||||
/// Perform actual image transcoding. This is a pure CPU function — safe to call
|
||||
/// from `rayon::spawn` or `spawn_blocking`.
|
||||
fn transcode_image_blocking(
|
||||
content: &[u8],
|
||||
original_mime: &str,
|
||||
target_format: OutputFormat,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let input_format = match original_mime {
|
||||
"image/jpeg" | "image/jpg" => ImageFormat::Jpeg,
|
||||
"image/png" => ImageFormat::Png,
|
||||
"image/gif" => ImageFormat::Gif,
|
||||
_ => return Err(format!("Unsupported input format: {}", original_mime)),
|
||||
};
|
||||
|
||||
let img = image::load_from_memory_with_format(content, input_format)
|
||||
.map_err(|e| format!("Failed to decode image: {}", e))?;
|
||||
|
||||
match target_format {
|
||||
OutputFormat::WebP => {
|
||||
let mut buffer = Vec::new();
|
||||
let mut cursor = std::io::Cursor::new(&mut buffer);
|
||||
img.write_to(&mut cursor, ImageFormat::WebP)
|
||||
.map_err(|e| format!("Failed to encode WebP: {}", e))?;
|
||||
Ok(buffer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Port implementation ─────────────────────────────────────────────────────
|
||||
|
||||
/// Convert port OutputFormat to infra OutputFormat.
|
||||
@@ -481,4 +482,11 @@ mod tests {
|
||||
1024 * 1024
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transcode_pool_initializes() {
|
||||
// Verify the pool can be created without panic
|
||||
let pool = transcode_pool();
|
||||
assert_eq!(pool.current_num_threads(), TRANSCODE_POOL_THREADS);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user