perf(thumbnails): cache-first + ETag 304 eliminates DB queries on GET

Solution A — Cache-first path:
- get_thumbnail now tries moka (RAM) → disk BEFORE any DB query
- Ownership was verified at creation time; UUIDv4 prevents enumeration
- 146-photo timeline: 0 SQL queries instead of 146 per page load

Solution C — ETag / If-None-Match short-circuit:
- Deterministic ETag 'thumb-{id}-{size}' on every response
- If browser sends matching If-None-Match → 304 with zero I/O
- Cache-Control: immutable prevents even conditional requests

Additional improvements:
- Remove moka TTL (was 600s); thumbnails are immutable, weight-only eviction
- generate_all_sizes_background populates moka after disk write;
  first GET after upload served from RAM with zero disk I/O
- DB path only taken on cache miss for images needing generation
This commit is contained in:
Diocrafts
2026-03-07 19:48:35 +01:00
parent f5dd2b9a95
commit fc5f101e61
2 changed files with 70 additions and 26 deletions
@@ -105,12 +105,16 @@ impl ThumbnailService {
// for variable-size thumbnails than entry-count limits.
let _ = max_cache_entries;
// No time_to_live — thumbnails are immutable (content never changes
// for a given file_id). Eviction is purely weight-based: when the
// cache exceeds max_cache_bytes the lightest entries are dropped.
// On eviction the thumbnail is still on disk; the next request
// promotes it back with a single async read (~0.1 ms).
let cache = moka::future::Cache::builder()
.max_capacity(max_cache_bytes as u64)
.weigher(|_key: &ThumbnailCacheKey, value: &Bytes| -> u32 {
value.len().min(u32::MAX as usize) as u32
})
.time_to_live(std::time::Duration::from_secs(600))
.build();
Self {
@@ -512,7 +516,8 @@ impl ThumbnailService {
}
};
// Save each size to disk (async I/O, very fast for small WebP files)
// Save each size to disk AND populate moka so the very first
// GET after upload is served from RAM (zero disk I/O).
for (size, bytes) in thumbnails {
let thumb_path = self.get_thumbnail_path(&file_id, size);
if let Some(parent) = thumb_path.parent() {
@@ -521,6 +526,12 @@ impl ThumbnailService {
if let Err(e) = fs::write(&thumb_path, &bytes).await {
tracing::warn!("Failed to save thumbnail {} {:?}: {}", file_id, size, e);
} else {
// Populate in-memory cache for instant first-hit serving
let cache_key = ThumbnailCacheKey {
file_id: file_id.clone(),
size,
};
self.cache.insert(cache_key, bytes).await;
tracing::debug!("✅ Generated thumbnail: {} {:?}", file_id, size);
}
}
+57 -24
View File
@@ -291,18 +291,29 @@ impl FileHandler {
// THUMBNAILS
// ═══════════════════════════════════════════════════════════════════════
/// Get a thumbnail for an image file.
/// Get a thumbnail for a file (image or video).
///
/// Thumbnail orchestration (path resolution, generation, caching) stays here
/// because it is tightly coupled to HTTP response headers.
/// **Cache-first**: if the thumbnail already exists in the moka in-memory
/// cache or on disk, serve it immediately — **zero DB queries**. The
/// ownership check was already performed when the thumbnail was first
/// generated (at upload) or uploaded (PUT by the owner). UUIDv4 file IDs
/// have 122 bits of entropy, making enumeration infeasible.
///
/// **ETag / 304**: responses carry an immutable ETag. If the browser
/// sends `If-None-Match` matching the ETag, we return 304 Not Modified
/// without touching cache or DB — pure header round-trip.
///
/// The DB path is only taken on a **cache miss for images** where the
/// thumbnail hasn't been generated yet (first access after upload if
/// background generation hasn't finished).
pub async fn get_thumbnail(
State(state): State<GlobalState>,
auth_user: AuthUser,
headers: HeaderMap,
Path((id, size)): Path<(String, String)>,
) -> impl IntoResponse {
use crate::application::ports::thumbnail_ports::ThumbnailSize;
let file_retrieval_service = &state.applications.file_retrieval_service;
let thumbnail_service = &state.core.thumbnail_service;
let thumb_size = match size.as_str() {
@@ -320,6 +331,46 @@ impl FileHandler {
}
};
// ── ETag short-circuit (Solution C) ──────────────────────────
// Thumbnails are immutable — the ETag never changes for a given
// (file_id, size) pair. If the browser already has it, return 304
// with zero I/O or DB work.
let etag = format!("\"thumb-{}-{:?}\"", id, thumb_size);
if let Some(if_none_match) = headers.get(header::IF_NONE_MATCH) {
if let Ok(val) = if_none_match.to_str() {
if val == etag || val == "*" {
return Response::builder()
.status(StatusCode::NOT_MODIFIED)
.header(header::ETAG, &etag)
.header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
.body(Body::empty())
.unwrap()
.into_response();
}
}
}
// ── Cache-first path (Solution A) ────────────────────────────
// Try moka (RAM) → disk before touching the database.
// If the thumbnail exists it was authorized at creation time.
if let Some(data) = thumbnail_service
.get_cached_thumbnail(&id, thumb_size.into())
.await
{
return Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "image/webp")
.header(header::CONTENT_LENGTH, data.len())
.header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
.header(header::ETAG, &etag)
.body(Body::from(data))
.unwrap()
.into_response();
}
// ── Cache miss — need DB for ownership + blob resolution ─────
let file_retrieval_service = &state.applications.file_retrieval_service;
let file = match file_retrieval_service
.get_file_owned(&id, auth_user.id)
.await
@@ -330,25 +381,8 @@ impl FileHandler {
}
};
// Non-image (video, etc.) with no cached thumbnail → 204
if !thumbnail_service.is_supported_image(&file.mime_type) {
// For non-images (videos, etc.), serve from cache if available;
// otherwise return 204 to signal "not yet generated — please
// generate client-side and upload via PUT".
if let Some(data) = thumbnail_service
.get_cached_thumbnail(&id, thumb_size.into())
.await
{
let etag = format!("\"thumb-{}-{:?}\"", id, thumb_size);
return Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "image/webp")
.header(header::CONTENT_LENGTH, data.len())
.header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
.header(header::ETAG, etag)
.body(Body::from(data))
.unwrap()
.into_response();
}
return Response::builder()
.status(StatusCode::NO_CONTENT)
.header(header::CACHE_CONTROL, "no-store")
@@ -376,13 +410,12 @@ impl FileHandler {
.await
{
Ok(data) => {
let etag = format!("\"thumb-{}-{:?}\"", id, thumb_size);
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "image/webp")
.header(header::CONTENT_LENGTH, data.len())
.header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
.header(header::ETAG, etag)
.header(header::ETAG, &etag)
.body(Body::from(data))
.unwrap()
.into_response()