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.
This commit is contained in:
Deluan 2026-07-17 15:36:17 -04:00
parent 2cd967a456
commit 5cd75503cb
3 changed files with 24 additions and 8 deletions

View File

@ -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

View File

@ -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 {

View File

@ -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"})