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);
}
}