perf(thumbnail): cache attached-blob lookups on the request path

Every thumbnail request paid an uncached storage.file_attached_blobs
point query before it could answer — including 304 revalidations and
RAM thumbnail hits, where the ETag path (thumbnail_content_id) probes
the row every time and tier 2b probes it again with the same key. A
photos grid revalidating 60 thumbnails per visit meant 60+ point
queries per browse, repeated on every visit.

find_attached_blob now reads through a process-local moka cache in
DedupService, keyed by the row's (file_id, kind, variant) PK, holding
positive and negative entries (most files have no attached preview, so
the negative side carries the win). Two rules keep it honest:

- DB faults are surfaced as Err and never cached — a transient outage
  cannot freeze "no attached blob" into a negative entry (a read
  failure is never proof that data is absent). The public signature is
  unchanged; the SQL body moved to find_attached_blob_uncached.
- Writes invalidate eagerly: store_attached_blob and the Inserted arm
  of store_attached_blob_if_absent on success, and deletions via
  ThumbnailRefreshHook::on_file_deleted, which all three production
  delete paths (single file, folder cascade, trash clear) fire after
  the DELETE commits. The 60s TTL bounds only what the process cannot
  see (bare SQL, copy_file_satellites races).

The Nextcloud preview endpoint rides the same lookup and benefits
identically. Five in-memory contract tests pin the cache behaviour,
including the fault-not-cached rule.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-09-20 00:28:00 +08:00
parent 68e21f4bef
commit d33d1932b6
4 changed files with 340 additions and 7 deletions
@@ -1874,10 +1874,16 @@ impl crate::application::ports::file_lifecycle::FileLifecycleHook for ThumbnailR
fn on_file_deleted(&self, file_id: &str) {
let thumbnail = self.thumbnail.clone();
let file_id = file_id.to_string();
// The row is gone (CASCADE cleared file_attached_blobs) — drop any
// cached attached-blob lookup for this file too. TTL would bound the
// staleness anyway, but deletes are rare and the cache lookup after a
// delete is pure waste.
let dedup = self.dedup.clone();
tokio::spawn(async move {
if let Err(e) = thumbnail.delete_thumbnails(&file_id).await {
tracing::warn!("Failed to delete thumbnails for file {}: {}", file_id, e);
}
dedup.invalidate_attached_blobs_for_file(&file_id).await;
});
}
}