perf: remove HTTP cache middleware, add service-level ETags

- 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
This commit is contained in:
Dionisio
2026-02-24 09:52:22 +01:00
parent 677b3eafa9
commit cba34056dc
4 changed files with 68 additions and 500 deletions
+28 -1
View File
@@ -516,6 +516,7 @@ impl FileHandler {
/// Axum-compatible handler wrapper around [`Self::list_files`].
pub async fn list_files_query(
State(state): State<GlobalState>,
headers: HeaderMap,
Query(params): Query<HashMap<String, String>>,
) -> 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);
+40 -2
View File
@@ -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<GlobalAppState>,
auth_user: AuthUser,
headers: HeaderMap,
Path(id): Path<String>,
) -> 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 {