fix(thumbnails): ETag names the blob actually served

fe9c4f49 keyed the ETag on the SOURCE file's content hash. That is wrong
whenever the response comes from a satellite table, and for attachments
it is wrong in two ways.

Uploading a preview does not change the file's content, so a
source-keyed ETag does not change either — and with `immutable` set,
clients never revalidate and keep the previous render for up to a year.
The exact staleness fe9c4f49 set out to fix, re-entering through the
attachment path.

Worse: a copy inherits the source hash, so an original and a copy have
identical ETags. Give either one a different uploaded preview and they
serve different bytes under one validator, which a shared cache may hand
to either request. That is a collision, not just staleness.

thumbnail_content_id resolves the identity through the same tier
precedence the read path uses: an attached blob's own hash, else a
derived blob's own hash, else the source-keyed form. An ETag naming a
different tier than the one answering is worse than a coarse one, so the
two orders must not drift.

Derived-hash keying is strictly better than source-keying and never
worse. The sidecar and the derived row are written from the same bytes;
where they can diverge — a sidecar re-rendered while the derived row
stays pinned by ON CONFLICT DO NOTHING — source-keying is wrong too,
because the renderer is not part of that key. This is the step 10 change
arriving early, forced by the attachment case; the plan note stands for
the read-order flip itself.

Known gap: a legacy ext-{file_id}.jpg with no file_attached_blobs row
yet falls through to the source-keyed form. No worse than today, and it
resolves when the import backfills.

attached_thumbnail_copy.hurl now asserts ETags, which is why this went
unnoticed: it compared bytes only, and thumbnail_etag_content_keyed
covers content replacement rather than preview upload. A fresh GET
returned the right bytes throughout — the same "healthy locally, broken
for anyone caching" shape as the two bugs before it.
This commit is contained in:
Edouard Vanbelle
2026-08-25 21:28:34 +02:00
parent d71dd973e7
commit 7d9418f63c
4 changed files with 122 additions and 22 deletions
@@ -505,6 +505,62 @@ impl ThumbnailService {
/// `blob_hash` is used to locate the file on disk (dedup-aware).
/// If `None`, only the in-memory cache is checked (used for video
/// thumbnails where blob_hash is not yet resolved).
/// Identity of the bytes a thumbnail request will serve — the body of its
/// HTTP ETag.
///
/// Mirrors the tier precedence in [`Self::get_cached_thumbnail`], because
/// an ETag that names a different tier than the one answering is worse
/// than a coarse one: it lets two resources serving different bytes share
/// a validator, and a shared cache may then hand either to either.
///
/// * An **attached** blob wins, and its own hash is the identity. Nothing
/// else works: uploading a preview does not change the file's content,
/// so a source-keyed ETag would not change either — and with
/// `immutable` set, clients would never revalidate. Worse, a copy
/// inherits the source hash, so an original and a copy carrying
/// *different* uploaded previews would collide on one ETag.
/// * Otherwise a **derived** blob's own hash. Strictly better than
/// source-keying, never worse: the pair is written from the same bytes,
/// and where they can diverge — a sidecar re-rendered while the derived
/// row stays pinned by `ON CONFLICT DO NOTHING` — a source-keyed ETag
/// is wrong too, because the renderer is not part of the key.
/// * Otherwise the source-keyed form, which still identifies a render of
/// known content at a known size and format.
///
/// Known gap: a legacy `ext-{file_id}.jpg` with no `file_attached_blobs`
/// row yet falls through to the source-keyed form, so those bytes keep
/// today's coarse validator until the import backfills the row. No worse
/// than current behaviour, and it disappears with the migration.
pub async fn thumbnail_content_id(
&self,
file_id: &str,
blob_hash: &str,
size: ThumbnailSize,
format: ThumbnailFormat,
dedup: Option<&DedupService>,
) -> String {
if let Some(dedup) = dedup {
if let Some(attached) = dedup
.find_attached_blob(file_id, "preview", size.dir_name())
.await
{
return attached.blob_hash;
}
if let Some(derived) = dedup
.find_derived_blob(blob_hash, "thumbnail", size.dir_name())
.await
{
return derived.blob_hash;
}
}
format!(
"thumb-{}-{}-{}",
blob_hash,
size.dir_name(),
format.as_str()
)
}
/// Drain a blob through the dedup stack into memory.
///
/// Shared by the attached and derived tiers — the only difference between