From cba34056dcf87f5937029eafe37eb86794854b0a Mon Sep 17 00:00:00 2001 From: Dionisio Date: Tue, 24 Feb 2026 09:52:22 +0100 Subject: [PATCH] perf: remove HTTP cache middleware, add service-level ETags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Delete cache.rs middleware that buffered entire response bodies (up to 10MB) in RAM on every cache miss, defeating streaming and causing memory spikes - Also buffered non-GET responses unnecessarily via response_map_body() - Add lightweight ETag support (based on max modified_at + count) to: - FolderHandler::list_folder_listing (combined folder+files endpoint) - FileHandler::list_files_query (file listing endpoint) - Both support If-None-Match / 304 Not Modified without any body buffering - File downloads already had ETag/304 support at handler level - Service-level caches (FileContentCache, SearchService, ThumbnailService) remain unchanged — they handle caching without HTTP body materialization --- src/interfaces/api/handlers/file_handler.rs | 29 +- src/interfaces/api/handlers/folder_handler.rs | 42 +- src/interfaces/middleware/cache.rs | 496 ------------------ src/interfaces/middleware/mod.rs | 1 - 4 files changed, 68 insertions(+), 500 deletions(-) delete mode 100644 src/interfaces/middleware/cache.rs diff --git a/src/interfaces/api/handlers/file_handler.rs b/src/interfaces/api/handlers/file_handler.rs index 57947ad6..d0569b8e 100644 --- a/src/interfaces/api/handlers/file_handler.rs +++ b/src/interfaces/api/handlers/file_handler.rs @@ -516,6 +516,7 @@ impl FileHandler { /// Axum-compatible handler wrapper around [`Self::list_files`]. pub async fn list_files_query( State(state): State, + headers: HeaderMap, Query(params): Query>, ) -> impl IntoResponse { let folder_id = params.get("folder_id").map(|id| id.as_str()); @@ -524,8 +525,34 @@ impl FileHandler { let retrieval = &state.applications.file_retrieval_service; match retrieval.list_files(folder_id).await { Ok(files) => { + // Compute lightweight ETag from max modified_at + count + let max_mod = files.iter().map(|f| f.modified_at).max().unwrap_or(0); + let count = files.len(); + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + std::hash::Hash::hash(&max_mod, &mut hasher); + std::hash::Hash::hash(&count, &mut hasher); + let etag = format!("\"{:x}\"", std::hash::Hasher::finish(&hasher)); + + // 304 Not Modified if client already has this version + if let Some(inm) = headers.get(header::IF_NONE_MATCH) + && let Ok(client_etag) = inm.to_str() + && client_etag == etag + { + return Response::builder() + .status(StatusCode::NOT_MODIFIED) + .header(header::ETAG, &etag) + .body(Body::empty()) + .unwrap() + .into_response(); + } + tracing::info!("Found {} files", files.len()); - (StatusCode::OK, Json(files)).into_response() + let mut resp = (StatusCode::OK, Json(files)).into_response(); + resp.headers_mut().insert( + header::ETAG, + header::HeaderValue::from_str(&etag).unwrap(), + ); + resp } Err(err) => { tracing::error!("Error listing files: {}", err); diff --git a/src/interfaces/api/handlers/folder_handler.rs b/src/interfaces/api/handlers/folder_handler.rs index 925de2e6..bdf92a98 100644 --- a/src/interfaces/api/handlers/folder_handler.rs +++ b/src/interfaces/api/handlers/folder_handler.rs @@ -1,10 +1,12 @@ use axum::{ Json, + body::Body, extract::{Path, Query, State}, - http::{Response, StatusCode, header}, + http::{HeaderMap, Response, StatusCode, header}, response::IntoResponse, }; use std::collections::HashMap; +use std::hash::{Hash, Hasher}; use std::sync::Arc; use tokio_util::io::ReaderStream; @@ -194,13 +196,29 @@ impl FolderHandler { } } + /// Compute a lightweight ETag from the maximum `modified_at` timestamp + /// and item count. No body buffering required. + fn compute_listing_etag(folders: &[crate::application::dtos::folder_dto::FolderDto], files: &[crate::application::dtos::file_dto::FileDto]) -> String { + let max_mod = folders.iter().map(|f| f.modified_at) + .chain(files.iter().map(|f| f.modified_at)) + .max() + .unwrap_or(0); + let count = folders.len() + files.len(); + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + max_mod.hash(&mut hasher); + count.hash(&mut hasher); + format!("\"{:x}\"", hasher.finish()) + } + /// Returns both sub-folders and files for a given folder in a single /// response, eliminating the double-fetch the frontend used to make. /// /// Both queries run concurrently via `tokio::join!`. + /// Supports `If-None-Match` / ETag for conditional responses (304). pub async fn list_folder_listing( State(state): State, auth_user: AuthUser, + headers: HeaderMap, Path(id): Path, ) -> axum::response::Response { let folder_service = &state.applications.folder_service; @@ -214,8 +232,28 @@ impl FolderHandler { match (folders_result, files_result) { (Ok(folders), Ok(files)) => { + let etag = Self::compute_listing_etag(&folders, &files); + + // 304 Not Modified if the client already has this version + if let Some(inm) = headers.get(header::IF_NONE_MATCH) + && let Ok(client_etag) = inm.to_str() + && client_etag == etag + { + return Response::builder() + .status(StatusCode::NOT_MODIFIED) + .header(header::ETAG, &etag) + .body(Body::empty()) + .unwrap() + .into_response(); + } + let listing = FolderListingDto { folders, files }; - (StatusCode::OK, Json(listing)).into_response() + let mut resp = (StatusCode::OK, Json(listing)).into_response(); + resp.headers_mut().insert( + header::ETAG, + header::HeaderValue::from_str(&etag).unwrap(), + ); + resp } (Err(err), _) | (_, Err(err)) => { let status = match err.kind { diff --git a/src/interfaces/middleware/cache.rs b/src/interfaces/middleware/cache.rs deleted file mode 100644 index d162e0be..00000000 --- a/src/interfaces/middleware/cache.rs +++ /dev/null @@ -1,496 +0,0 @@ -use axum::{ - body::Body, - http::{HeaderMap, HeaderValue, Method, Request, Response, StatusCode}, - middleware::Next, -}; -use bytes::Bytes; -use chrono::{DateTime, Utc}; -use serde::Serialize; -use std::collections::hash_map::DefaultHasher; -use std::future::Future; -use std::hash::{Hash, Hasher}; -use std::pin::Pin; -use std::task::{Context, Poll}; -use std::time::Duration; -use tower::{Layer, Service}; -use tracing::debug; - -const MAX_CACHE_ENTRIES: u64 = 1000; // Maximum number of cache entries -const DEFAULT_MAX_AGE: u64 = 60; // Default time-to-live in seconds - -// Type definitions for clarity -type CacheKey = String; -type EntityTag = String; - -/// A cached value -#[derive(Clone)] -struct CacheEntry { - /// The ETag calculated for this value - etag: EntityTag, - /// The serialized data in bytes - data: Option, - /// The original headers - headers: HeaderMap, -} - -/// Lock-free HTTP response cache with ETag support. -/// -/// Backed by `moka::sync::Cache` — all reads and writes are lock-free and -/// safe to call from async Tokio tasks without risking worker-thread stalls. -/// TTL expiration and LRU eviction are handled automatically. -#[derive(Clone)] -pub struct HttpCache { - /// Concurrent cache (lock-free, automatic TTL + LRU) - cache: moka::sync::Cache, - /// Default max-age value used in HTTP Cache-Control headers - default_max_age: u64, -} - -impl Default for HttpCache { - fn default() -> Self { - Self::new() - } -} - -impl HttpCache { - /// Creates a new cache instance with the default TTL - pub fn new() -> Self { - Self { - cache: moka::sync::Cache::builder() - .max_capacity(MAX_CACHE_ENTRIES) - .time_to_live(Duration::from_secs(DEFAULT_MAX_AGE)) - .build(), - default_max_age: DEFAULT_MAX_AGE, - } - } - - /// Creates a new instance with a specified time-to-live - pub fn with_max_age(max_age: u64) -> Self { - Self { - cache: moka::sync::Cache::builder() - .max_capacity(MAX_CACHE_ENTRIES) - .time_to_live(Duration::from_secs(max_age)) - .build(), - default_max_age: max_age, - } - } - - /// Sets an entry in the cache - fn set( - &self, - key: &str, - etag: EntityTag, - data: Option, - headers: HeaderMap, - ) { - self.cache.insert( - key.to_string(), - CacheEntry { - etag, - data, - headers, - }, - ); - } - - /// Gets an entry from the cache (returns None for expired / missing) - fn get(&self, key: &str) -> Option { - self.cache.get(key) - } - - /// Generates a simple ETag for a block of bytes - fn calculate_etag_for_bytes(&self, bytes: &[u8]) -> EntityTag { - let mut hasher = DefaultHasher::new(); - bytes.hash(&mut hasher); - let hash = hasher.finish(); - format!("\"{}\"", hash) - } -} - -/// HTTP cache middleware -pub async fn cache_middleware( - cache: HttpCache, - cache_key: &str, - max_age: Option, - req: Request, - next: Next, -) -> Result, (StatusCode, String)> -where - T: Serialize, -{ - // Only apply cache for GET requests - if req.method() != Method::GET { - return Ok(next.run(req).await); - } - - // Check if the response is cached - let if_none_match = req - .headers() - .get("if-none-match") - .and_then(|v| v.to_str().ok()); - - // If there is a cache entry - if let Some(cache_entry) = cache.get(cache_key) { - // Check if the client already has the updated version - if let Some(client_etag) = if_none_match - && client_etag == cache_entry.etag - { - // The client has the most recent version, send 304 Not Modified - debug!("Cache hit (304) for key: {}", cache_key); - return Ok(create_not_modified_response(&cache_entry)); - } - - // The client needs the updated version - if let Some(data) = &cache_entry.data { - debug!("Cache hit (200) for key: {}", cache_key); - - // Create response with cached data - let mut response = Response::new(Body::from(data.clone())); - - // Copy original headers - for (key, value) in &cache_entry.headers { - if !key.as_str().eq_ignore_ascii_case("transfer-encoding") { - response.headers_mut().insert(key.clone(), value.clone()); - } - } - - // Add cache headers - set_cache_headers( - &mut response, - &cache_entry.etag, - max_age.unwrap_or(cache.default_max_age), - ); - - return Ok(response); - } - } - - // Not cached or expired, continue with the middleware - debug!("Cache miss for key: {}", cache_key); - let response = next.run(req).await; - - // Don't cache errors - if !response.status().is_success() { - return Ok(response); - } - - // Convert the response to calculate the ETag - let (parts, _body) = response.into_parts(); - let bytes = axum::body::to_bytes(_body, 1024 * 1024 * 10) - .await - .unwrap_or_default(); - - // Calculate ETag - let etag = cache.calculate_etag_for_bytes(&bytes); - - // Save to cache - cache.set( - cache_key, - etag.clone(), - Some(bytes.clone()), - parts.headers.clone(), - ); - - // Create the response with ETag - let mut response = Response::from_parts(parts, Body::from(bytes)); - set_cache_headers( - &mut response, - &etag, - max_age.unwrap_or(cache.default_max_age), - ); - - Ok(response) -} - -/// Creates a 304 Not Modified response -fn create_not_modified_response(entry: &CacheEntry) -> Response { - let mut response = Response::builder() - .status(StatusCode::NOT_MODIFIED) - .body(Body::empty()) - .unwrap(); - - // Copy cache headers - if let Some(cache_control) = entry.headers.get("cache-control") { - response - .headers_mut() - .insert("cache-control", cache_control.clone()); - } - - // Add ETag - response.headers_mut().insert( - "etag", - HeaderValue::from_str(&entry.etag).unwrap_or(HeaderValue::from_static("")), - ); - - response -} - -/// Configures cache headers for a response -fn set_cache_headers(response: &mut Response, etag: &str, max_age: u64) { - // Add ETag - response.headers_mut().insert( - "etag", - HeaderValue::from_str(etag).unwrap_or(HeaderValue::from_static("")), - ); - - // Configure Cache-Control - let cache_control = format!("public, max-age={}", max_age); - response.headers_mut().insert( - "cache-control", - HeaderValue::from_str(&cache_control).unwrap_or(HeaderValue::from_static("")), - ); - - // Add Last-Modified header - let now: DateTime = Utc::now(); - let last_modified = now.format("%a, %d %b %Y %H:%M:%S GMT").to_string(); - response.headers_mut().insert( - "last-modified", - HeaderValue::from_str(&last_modified).unwrap_or(HeaderValue::from_static("")), - ); -} - -/// Layer for applying cache middleware -#[derive(Clone)] -pub struct HttpCacheLayer { - cache: HttpCache, - max_age: Option, -} - -impl HttpCacheLayer { - /// Creates a new cache layer - pub fn new(cache: HttpCache) -> Self { - Self { - cache, - max_age: None, - } - } - - /// Sets the maximum time-to-live - pub fn with_max_age(mut self, max_age: u64) -> Self { - self.max_age = Some(max_age); - self - } -} - -impl Layer for HttpCacheLayer { - type Service = HttpCacheService; - - fn layer(&self, service: S) -> Self::Service { - HttpCacheService { - inner: service, - cache: self.cache.clone(), - max_age: self.max_age, - } - } -} - -/// Service that implements cache logic -#[derive(Clone)] -pub struct HttpCacheService { - inner: S, - cache: HttpCache, - max_age: Option, -} - -impl Service> for HttpCacheService -where - S: Service, Response = Response>, - S::Future: Send + 'static, - S::Error: Into>, - ReqBody: Send + 'static, - ResBody: http_body::Body + Send + 'static, - ResBody::Data: Send + 'static, - ResBody::Error: Into>, -{ - type Response = Response; - type Error = Box; - type Future = Pin> + Send>>; - - fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { - self.inner.poll_ready(cx).map_err(|e| e.into()) - } - - fn call(&mut self, req: Request) -> Self::Future { - // Generate cache key - let cache_key = req.uri().path().to_string(); - - // Only apply cache for GET requests - if req.method() != Method::GET { - let future = self.inner.call(req); - return Box::pin(async move { - let response = future.await.map_err(|e| e.into())?; - Ok(response_map_body(response).await) - }); - } - - // Get client ETag - let if_none_match = req - .headers() - .get("if-none-match") - .and_then(|v| v.to_str().ok()); - - // Check if there is a cache entry - let cache_clone = self.cache.clone(); - let max_age = self.max_age; - let entry = cache_clone.get(&cache_key); - - match entry { - Some(cache_entry) if if_none_match == Some(&cache_entry.etag) => { - // The client has the correct version, send 304 - debug!("Cache HIT (304): {}", cache_key); - let response = create_not_modified_response(&cache_entry); - Box::pin(async move { Ok(response) }) - } - Some(cache_entry) if cache_entry.data.is_some() => { - // The client needs the updated version - debug!("Cache HIT (200): {}", cache_key); - let mut response = Response::new(Body::from(cache_entry.data.clone().unwrap())); - - // Copy original headers - for (key, value) in &cache_entry.headers { - if !key.as_str().eq_ignore_ascii_case("transfer-encoding") { - response.headers_mut().insert(key.clone(), value.clone()); - } - } - - // Add cache headers - set_cache_headers( - &mut response, - &cache_entry.etag, - max_age.unwrap_or(cache_clone.default_max_age), - ); - - Box::pin(async move { Ok(response) }) - } - _ => { - // Not cached or expired - debug!("Cache MISS: {}", cache_key); - let future = self.inner.call(req); - let cache_clone = self.cache.clone(); - let max_age = self.max_age; - let cache_key = cache_key.clone(); - - Box::pin(async move { - let response = future.await.map_err(|e| e.into())?; - let response = response_map_body(response).await; - - // Don't cache errors - if !response.status().is_success() { - return Ok(response); - } - - // Get the body and calculate ETag - let (parts, body) = response.into_parts(); - let bytes = axum::body::to_bytes(body, 1024 * 1024 * 10).await?; - - // Calculate ETag - let etag = cache_clone.calculate_etag_for_bytes(&bytes); - - // Save to cache - cache_clone.set( - &cache_key, - etag.clone(), - Some(bytes.clone()), - parts.headers.clone(), - ); - - // Create the response with ETag - let mut response = Response::from_parts(parts, Body::from(bytes)); - set_cache_headers( - &mut response, - &etag, - max_age.unwrap_or(cache_clone.default_max_age), - ); - - Ok(response) - }) - } - } - } -} - -// Helper function to convert any body into Body preserving its content. -// Previously this function discarded the body with Body::empty(), causing -// data loss in non-cached responses. -async fn response_map_body(response: Response) -> Response -where - B: http_body::Body + Send + 'static, - B::Data: Send + 'static, - B::Error: Into>, -{ - use http_body_util::BodyExt; - - let (parts, body) = response.into_parts(); - - // Collect the full body into Bytes, preserving all response data - let collected = body - .collect() - .await - .map(|c| c.to_bytes()) - .unwrap_or_default(); - - Response::from_parts(parts, Body::from(collected)) -} - -#[cfg(test)] -mod tests { - use super::*; - use serde::{Deserialize, Serialize}; - - #[derive(Debug, Serialize, Deserialize, Hash)] - struct TestData { - id: u32, - name: String, - } - - #[tokio::test] - async fn test_etag_generation() { - let cache = HttpCache::new(); - - let data1 = serde_json::to_vec(&TestData { - id: 1, - name: "Test".to_string(), - }) - .unwrap(); - let data2 = serde_json::to_vec(&TestData { - id: 1, - name: "Test".to_string(), - }) - .unwrap(); - let data3 = serde_json::to_vec(&TestData { - id: 2, - name: "Test".to_string(), - }) - .unwrap(); - - let etag1 = cache.calculate_etag_for_bytes(&data1); - let etag2 = cache.calculate_etag_for_bytes(&data2); - let etag3 = cache.calculate_etag_for_bytes(&data3); - - // Same data should generate the same ETag - assert_eq!(etag1, etag2); - - // Different data should generate different ETags - assert_ne!(etag1, etag3); - } - - #[tokio::test] - async fn test_cache_hit_miss() { - let cache = HttpCache::new(); - - // Create test data directly as Bytes - let bytes1 = Bytes::from(r#"{"id":1,"name":"Test"}"#); - let headers1 = HeaderMap::new(); - - let etag1 = cache.calculate_etag_for_bytes(&bytes1); - cache.set("test", etag1.clone(), Some(bytes1.clone()), headers1); - - // Verify cache hit - let entry = cache.get("test").unwrap(); - assert_eq!(entry.etag, etag1); - assert_eq!(entry.data.unwrap(), bytes1); - - // Verify cache miss - assert!(cache.get("nonexistent").is_none()); - } -} diff --git a/src/interfaces/middleware/mod.rs b/src/interfaces/middleware/mod.rs index 58873880..41aa0f5f 100644 --- a/src/interfaces/middleware/mod.rs +++ b/src/interfaces/middleware/mod.rs @@ -1,3 +1,2 @@ pub mod auth; -pub mod cache; pub mod redirect; // Add redirect middleware for API to Axum transition