diff --git a/docs/plan/derived-blobs.md b/docs/plan/derived-blobs.md index e3f31686..db386840 100644 --- a/docs/plan/derived-blobs.md +++ b/docs/plan/derived-blobs.md @@ -991,6 +991,22 @@ yet generated and for formats the derived tier does not hold. The `LEFT JOIN` in step 2 above already returns the derived hash in the same query, so the ETag costs no extra round-trip. +**Attempted early (2026-08-26) and reverted — the constraint above is +load-bearing.** The attached half shipped and is correct, because an +upload writes its row synchronously before any read can observe it. The +*derived* half was brought forward at the same time and had to be backed +out: the ETag is computed **before** the body, so on a cache miss no row +exists and the handler emits the source-keyed form — then rendering +creates the row, and the very next request resolves to the derived hash. +The validator changed as a side effect of producing the body, so every +first render was immediately stale. Caught by +`thumbnail_etag_content_keyed.hurl`, where two consecutive GETs of an +unchanged file stopped revalidating to 304. + +The flip is what removes the hazard: once the derived tier is +authoritative it is populated before it is consulted, so there is no +window in which the row appears between two reads. + **The disk cache is `CachedBlobBackend`, reused unchanged.** No thumbnail-specific cache, no second root path. Routing derived blobs through the same stack gets, for free: diff --git a/src/infrastructure/services/thumbnail_service.rs b/src/infrastructure/services/thumbnail_service.rs index 3cfbff57..6c2a9270 100644 --- a/src/infrastructure/services/thumbnail_service.rs +++ b/src/infrastructure/services/thumbnail_service.rs @@ -607,14 +607,26 @@ impl ThumbnailService { /// `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 + /// * Otherwise the **source-keyed** form, which identifies a render of /// known content at a known size and format. /// + /// # Why the derived blob's own hash is NOT used yet + /// + /// It would be a better key — the hash *is* the bytes, so any change in + /// output invalidates by construction. But it cannot be resolved here + /// without flipping on the first render: the ETag is computed *before* + /// the body, so on a cache miss no `content_derived_blobs` row exists yet + /// and this returns the source-keyed form — then rendering *creates* that + /// row, and the next request resolves to the derived hash instead. The + /// validator would change as a side effect of producing the body, making + /// every first render immediately stale. + /// + /// It belongs with the read-order flip, when the derived tier becomes + /// authoritative and is populated before it is consulted. See + /// `docs/plan/derived-blobs.md`. The attached lookup above has no such + /// problem: an upload writes its row synchronously, before any read that + /// could observe it. + /// /// 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 @@ -627,19 +639,12 @@ impl ThumbnailService { format: ThumbnailFormat, dedup: Option<&DedupService>, ) -> String { - if let Some(dedup) = dedup { - if let Some(attached) = dedup + if let Some(dedup) = dedup + && 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; - } + { + return attached.blob_hash; } format!( "thumb-{}-{}-{}",