Deluan d319af9807 fix(artwork): make the prune sweep cancellable
Sweep walked the whole store with no context, and RunPrune holds the
prune write lock for its full duration. In-flight acquisitions park on
the read lock, drain's WaitGroup never returns, and Run never reaches
its ctx.Err() check — so a SIGTERM during a daily prune over a large
store on slow storage waits out the container's grace period and dies
mid-remove.
2026-07-25 10:16:00 -04:00

87 lines
2.4 KiB
Go

package artwork
import (
"context"
"time"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
// pruneMinAge guards the window between artwork insert and item_artwork upsert.
const pruneMinAge = time.Hour
func Prune(ctx context.Context, ds model.DataStore, store *ImageStore) error {
repo := ds.Artwork(ctx)
purged, err := repo.PurgeDanglingItemArtwork()
if err != nil {
return err
}
if purged > 0 {
log.Info(ctx, "Prune: purged dangling item artwork state", "count", purged)
}
// Queue rows for deleted entities would otherwise retry forever (Get -> not found -> failed).
queuePurged, err := ds.ArtworkQueue(ctx).PurgeDangling()
if err != nil {
return err
}
if queuePurged > 0 {
log.Info(ctx, "Prune: purged dangling artwork queue rows", "count", queuePurged)
}
// One grace cutoff for both the DB orphan check and the file sweep: files younger
// than the window may belong to acquisitions whose rows aren't committed yet.
cutoff := time.Now().Add(-pruneMinAge)
candidates, err := repo.GetOrphanHashes(cutoff)
if err != nil {
return err
}
if len(candidates) > 0 {
arts, err := repo.GetImages(candidates)
if err != nil {
return err
}
if err := repo.DeleteOrphans(cutoff, candidates); err != nil {
return err
}
// DeleteOrphans may spare candidates reacquired since the snapshot; only remove files
// for rows actually gone (absent from the post-delete re-read).
survivors, err := repo.GetImages(candidates)
if err != nil {
return err
}
removed := 0
for _, h := range candidates {
if _, ok := survivors[h]; ok {
continue
}
// A spared fresh file is at worst a stray a later sweep reclaims;
// Worker.RunPrune serializes prune against in-flight acquisitions.
if err := store.Remove(h, arts[h].Mime, cutoff); err != nil {
log.Warn(ctx, "Prune: could not remove artwork file", "hash", h, err)
}
removed++
}
log.Info(ctx, "Prune: removed orphan artwork", "count", removed)
}
mimes, err := repo.GetAllMimes()
if err != nil {
return err
}
removed, err := store.Sweep(ctx, cutoff, func(hash, ext string) bool {
// A known hash under a stale extension is a superseded mime variant — reclaim it.
m, ok := mimes[hash]
return ok && ext == extForMime(m)
})
if err != nil {
return err
}
if removed > 0 {
log.Info(ctx, "Prune: swept stray artwork files", "count", removed)
}
return nil
}