From 5cd75503cbe8badd4eced1bf3471572235e04ec9 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 17 Jul 2026 15:36:17 -0400 Subject: [PATCH] fix(artwork): backfill warm caches and supersede pending gone on refill Two issues in the fill-triggered blurhash recompute: 1. Enqueuing was gated on a cache miss (!r.Cached). On an upgraded instance the image cache is persisted and adopted across restarts, so already-cached artwork serves as a cache hit and never enqueued, leaving its migrated-empty blur_hash as a synthetic value indefinitely. Enqueue on every original-size serve instead: the worker's freshness guard turns already-hashed rows into a cheap read and only recomputes when the hash is stale or missing. 2. Enqueue merged the gone flag with a sticky OR, so a cover restored right after a missing-art serve kept gone=true and took the clear-and-return path, never recomputing. A successful serve proves the artwork exists, so it now clears any pending gone for that artwork; a gone that follows a fill still sticks. --- core/artwork/artwork.go | 9 +++++---- core/artwork/blurhash_updater.go | 12 +++++++++--- core/artwork/blurhash_updater_internal_test.go | 11 ++++++++++- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/core/artwork/artwork.go b/core/artwork/artwork.go index 7b99d89ee..f3dcb7593 100644 --- a/core/artwork/artwork.go +++ b/core/artwork/artwork.go @@ -87,10 +87,11 @@ func (a *artwork) Get(ctx context.Context, artID model.ArtworkID, size int, squa } return nil, time.Time{}, err } - if a.blurHashes != nil && size == 0 && !square && !r.Cached { - // An original-size cache fill is exactly when the served bytes change: the single recompute - // trigger. Resized fills recurse through Get(size=0); a disabled cache reports every serve as - // a fill (the worker's unchanged-hash guard keeps that write-free). + if a.blurHashes != nil && size == 0 && !square { + // Every original-size serve carries the reader's true version; the worker's freshness guard + // turns already-hashed rows into a cheap read and only recomputes when the hash is stale or + // missing. Enqueuing on cache hits too (not just fills) is what backfills warm caches adopted + // from a pre-blurhash version, whose rows migrated in with an empty hash. a.blurHashes.Enqueue(artID, artReader.LastUpdated()) } return r, artReader.LastUpdated(), nil diff --git a/core/artwork/blurhash_updater.go b/core/artwork/blurhash_updater.go index eb2f9f40a..37a39fdd9 100644 --- a/core/artwork/blurhash_updater.go +++ b/core/artwork/blurhash_updater.go @@ -83,10 +83,16 @@ func (u *blurHashUpdater) enqueue(artID model.ArtworkID, req enqueueRequest) { go u.run(ctx) } prev := u.buffer[artID] - if req.snapshot.After(prev.snapshot) { - prev.snapshot = req.snapshot + if !req.snapshot.IsZero() { + // A successful serve proves the artwork exists, so it supersedes any pending gone request for + // the same artwork (a cover restored right after a missing-art serve must still recompute). + prev.gone = false + if req.snapshot.After(prev.snapshot) { + prev.snapshot = req.snapshot + } + } else if req.gone { + prev.gone = true } - prev.gone = prev.gone || req.gone u.buffer[artID] = prev u.mutex.Unlock() select { diff --git a/core/artwork/blurhash_updater_internal_test.go b/core/artwork/blurhash_updater_internal_test.go index d2e5960bb..8a27a2c9e 100644 --- a/core/artwork/blurhash_updater_internal_test.go +++ b/core/artwork/blurhash_updater_internal_test.go @@ -45,7 +45,7 @@ var _ = Describe("blurHashUpdater", func() { Expect(u.buffer[id].gone).To(BeFalse()) }) - It("merges a gone flag onto a pending snapshot for the same artwork", func() { + It("keeps a gone flag when it follows a pending fill (cover then vanished)", func() { id := model.Album{ID: "al-1"}.CoverArtID() t1 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) u.Enqueue(id, t1) @@ -54,6 +54,15 @@ var _ = Describe("blurHashUpdater", func() { Expect(u.buffer[id].gone).To(BeTrue()) }) + It("lets a successful fill supersede a pending gone (cover restored)", func() { + id := model.Album{ID: "al-1"}.CoverArtID() + t1 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + u.EnqueueGone(id) + u.Enqueue(id, t1) + Expect(u.buffer[id].snapshot).To(Equal(t1)) + Expect(u.buffer[id].gone).To(BeFalse()) + }) + It("ignores other artwork kinds", func() { u.Enqueue(model.ArtworkID{Kind: model.KindMediaFileArtwork, ID: "mf-1"}, time.Time{}) u.EnqueueGone(model.ArtworkID{Kind: model.KindRadioArtwork, ID: "ra-1"})