mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
Deleting the absent-retry jobs and the startup backfill orphaned more than the code that went with them. `Worker.agents` lost its only reader when Backfill went. `RadioRepository.GetAllIDs` existed solely for that backfill's bulk enumeration, as its own comment said. `CountAbsent` became a second query for a number `artwork status` already had: only two paths write item_artwork, and only the absent one leaves `source` empty, so `hash = ''` and `source = ''` select the same rows. The absent count now comes from the source breakdown collected in the same loop, four queries cheaper. Two defects surfaced while pulling on that thread. `reprocess --all` returned on an empty match set before recording the fingerprint, so the command the startup warning names could not silence it. And the "did this run apply the config" flag was derived in the CLI from its own globals, which made `--kind ar --kind al --kind pl --kind rd` — work identical to `--all` — leave the fingerprint stale. It is now derived inside reprocessArtwork from the kinds and sources that drive the queries, so a filter added later has to pass through it. settlesAbsentOnGiveUp decided a worker persistence policy by testing membership in a CLI-facing kind list. It now states the policy directly. Its rationale was wrong too: media files already get an absent row on the definitive path, and the reason to skip them on give-up is that a track with no row still falls back to disc or album art. CheckConfigFingerprint wrote to the database on one of three branches, so it is ReconcileConfigFingerprint now.
227 lines
8.4 KiB
Go
227 lines
8.4 KiB
Go
package persistence
|
|
|
|
import (
|
|
"cmp"
|
|
"context"
|
|
"fmt"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
. "github.com/Masterminds/squirrel"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/utils/slice"
|
|
"github.com/pocketbase/dbx"
|
|
)
|
|
|
|
// Keeps each multi-row insert under SQLite's bind-variable limit (at most 7 vars per row).
|
|
const enqueueChunkSize = 100
|
|
|
|
// Every insert writes these, in this order; the INSERT..SELECT forms must project them to match.
|
|
// DequeueBatch also selects exactly these, to leave the drain's rows free of the trace it never reads.
|
|
var enqueueColumns = []string{"item_kind", "item_id", "image_type", "priority", "attempts", "retry_at", "enqueued_at"}
|
|
|
|
type artworkQueueRepository struct {
|
|
sqlRepository
|
|
}
|
|
|
|
func NewArtworkQueueRepository(ctx context.Context, db dbx.Builder) model.ArtworkQueueRepository {
|
|
r := &artworkQueueRepository{}
|
|
r.ctx = ctx
|
|
r.db = db
|
|
r.tableName = "artwork_queue"
|
|
return r
|
|
}
|
|
|
|
func (r *artworkQueueRepository) Get(kind model.Kind, id, imageType string) (*model.ArtworkQueueItem, error) {
|
|
var res model.ArtworkQueueItem
|
|
err := r.queryOne(Select("*").From(r.tableName).
|
|
Where(Eq{"item_kind": kind.Prefix(), "item_id": id, "image_type": imageType}), &res)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &res, nil
|
|
}
|
|
|
|
// Enqueue starts a fresh lifecycle: it resets enqueued_at (so a fresh request does not inherit an old
|
|
// row's spent retry budget) and clears trace (so explain does not show a prior failure at attempts 0).
|
|
func (r *artworkQueueRepository) Enqueue(items ...model.ArtworkQueueItem) error {
|
|
return r.enqueue(`ON CONFLICT (item_kind, item_id, image_type) DO UPDATE SET
|
|
priority = MAX(priority, excluded.priority), retry_at = excluded.retry_at,
|
|
attempts = 0, enqueued_at = excluded.enqueued_at, trace = '[]'`, items)
|
|
}
|
|
|
|
func (r *artworkQueueRepository) EnqueuePreservingBackoff(items ...model.ArtworkQueueItem) error {
|
|
return r.enqueue(`ON CONFLICT (item_kind, item_id, image_type) DO UPDATE SET
|
|
priority = MAX(priority, excluded.priority)`, items)
|
|
}
|
|
|
|
func (r *artworkQueueRepository) EnqueueAllMissing(kind model.Kind, priority int) (int64, error) {
|
|
entityTable, ok := artworkOwnerTables[kind]
|
|
if !ok {
|
|
return 0, fmt.Errorf("artwork queue: no entity table for kind %q", kind.Prefix())
|
|
}
|
|
now := time.Now()
|
|
return r.insertIfNotQueued("", `SELECT ?, id, ?, ?, 0, ?, ?
|
|
FROM `+entityTable+`
|
|
WHERE id NOT IN (SELECT item_id FROM `+itemArtworkTable+` WHERE item_kind = ?)`,
|
|
kind.Prefix(), model.ImageTypePrimary, priority, now, now, kind.Prefix())
|
|
}
|
|
|
|
func (r *artworkQueueRepository) EnqueueIfMissing(items ...model.ArtworkQueueItem) error {
|
|
now := time.Now()
|
|
for chunk := range slices.Chunk(items, enqueueChunkSize) {
|
|
rows := make([]string, 0, len(chunk))
|
|
args := make([]any, 0, len(chunk)*4+2)
|
|
for _, it := range chunk {
|
|
rows = append(rows, "(?,?,?,?)")
|
|
args = append(args, it.ItemKind, it.ItemID, cmp.Or(it.ImageType, model.ImageTypePrimary), it.Priority)
|
|
}
|
|
args = append(args, now, now)
|
|
_, err := r.insertIfNotQueued(
|
|
`WITH new_items(item_kind, item_id, image_type, priority) AS (VALUES `+strings.Join(rows, ",")+`) `,
|
|
`SELECT n.item_kind, n.item_id, n.image_type, n.priority, 0, ?, ?
|
|
FROM new_items n
|
|
WHERE NOT EXISTS (
|
|
SELECT 1 FROM `+itemArtworkTable+` ia
|
|
WHERE ia.item_kind = n.item_kind AND ia.item_id = n.item_id AND ia.image_type = n.image_type)`,
|
|
args...)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DO NOTHING is deliberate: a recheck must not bump the priority or retry_at of an already-queued item.
|
|
const skipIfQueued = ` ON CONFLICT (item_kind, item_id, image_type) DO NOTHING`
|
|
|
|
// insertIfNotQueued inserts the rows selected by the given SQL, optionally prefixed by a CTE.
|
|
func (r *artworkQueueRepository) insertIfNotQueued(with, sql string, args ...any) (int64, error) {
|
|
return r.executeSQL(Expr(with+`INSERT INTO `+r.tableName+
|
|
` (`+strings.Join(enqueueColumns, ", ")+`) `+sql+skipIfQueued, args...))
|
|
}
|
|
|
|
// artworkSourceFilter selects item_artwork rows of a kind; no sources means every source, "" the absent state.
|
|
func artworkSourceFilter(kind model.Kind, sources []string) Sqlizer {
|
|
f := And{Eq{"item_kind": kind.Prefix()}}
|
|
if len(sources) > 0 {
|
|
f = append(f, Eq{"source": sources})
|
|
}
|
|
return f
|
|
}
|
|
|
|
func (r *artworkQueueRepository) CountBySource(kind model.Kind, sources []string) (int64, error) {
|
|
var res struct{ Count int64 }
|
|
err := r.queryOne(Select("count(*) as count").From(itemArtworkTable).
|
|
Where(artworkSourceFilter(kind, sources)), &res)
|
|
return res.Count, err
|
|
}
|
|
|
|
func (r *artworkQueueRepository) SourcesInUse(kind model.Kind) ([]string, error) {
|
|
var res []struct{ Source string }
|
|
err := r.queryAll(Select("distinct source").From(itemArtworkTable).
|
|
Where(Eq{"item_kind": kind.Prefix()}), &res)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return slice.Map(res, func(s struct{ Source string }) string { return s.Source }), nil
|
|
}
|
|
|
|
// EnqueueBySource deliberately leaves item_artwork alone: clearing state in bulk would blank the
|
|
// library's artwork until every item is resolved again.
|
|
func (r *artworkQueueRepository) EnqueueBySource(kind model.Kind, sources []string, priority int) (int64, error) {
|
|
now := time.Now()
|
|
sel := Select("item_kind", "item_id", "image_type").
|
|
Column(Expr("?", priority)).Column("0").Column(Expr("?", now)).Column(Expr("?", now)).
|
|
From(itemArtworkTable).Where(artworkSourceFilter(kind, sources))
|
|
return r.executeSQL(Insert(r.tableName).Columns(enqueueColumns...).Select(sel).Suffix(skipIfQueued))
|
|
}
|
|
|
|
func (r *artworkQueueRepository) enqueue(conflict string, items []model.ArtworkQueueItem) error {
|
|
now := time.Now()
|
|
for chunk := range slices.Chunk(items, enqueueChunkSize) {
|
|
ins := Insert(r.tableName).Columns(enqueueColumns...)
|
|
for _, it := range chunk {
|
|
ins = ins.Values(it.ItemKind, it.ItemID, cmp.Or(it.ImageType, model.ImageTypePrimary), it.Priority, 0, now, now)
|
|
}
|
|
ins = ins.Suffix(conflict)
|
|
if _, err := r.executeSQL(ins); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *artworkQueueRepository) DequeueBatch(n int, kinds ...string) ([]model.ArtworkQueueItem, error) {
|
|
sel := Select(enqueueColumns...).From(r.tableName).
|
|
Where(LtOrEq{"retry_at": time.Now()}).
|
|
OrderBy("priority DESC", "enqueued_at ASC").
|
|
Limit(uint64(n))
|
|
if len(kinds) > 0 {
|
|
sel = sel.Where(Eq{"item_kind": kinds})
|
|
}
|
|
var res []model.ArtworkQueueItem
|
|
err := r.queryAll(sel, &res)
|
|
return res, err
|
|
}
|
|
|
|
func (r *artworkQueueRepository) MarkFailedIfUnchanged(kind, id, imageType string, seenRetryAt, retryAt time.Time, trace string) error {
|
|
upd := Update(r.tableName).
|
|
Set("attempts", Expr("attempts + 1")).
|
|
Set("retry_at", retryAt).
|
|
Set("trace", trace).
|
|
Where(Eq{"item_kind": kind, "item_id": id, "image_type": imageType, "retry_at": seenRetryAt})
|
|
_, err := r.executeSQL(upd)
|
|
return err
|
|
}
|
|
|
|
func (r *artworkQueueRepository) DeleteIfUnchanged(kind, id, imageType string, retryAt time.Time) error {
|
|
return r.delete(Eq{"item_kind": kind, "item_id": id, "image_type": imageType, "retry_at": retryAt})
|
|
}
|
|
|
|
func (r *artworkQueueRepository) PurgeDangling() (int64, error) {
|
|
return purgeDangling(r.sqlRepository)
|
|
}
|
|
|
|
// artworkQueueFilter returns no conditions for an empty filter, so an unfiltered DELETE keeps
|
|
// SQLite's truncate path. It ignores retry_at: a backing-off row is pending work too.
|
|
func artworkQueueFilter(kinds []model.Kind, priorities []int) And {
|
|
var f And
|
|
if len(kinds) > 0 {
|
|
f = append(f, Eq{"item_kind": model.KindPrefixes(kinds)})
|
|
}
|
|
if len(priorities) > 0 {
|
|
f = append(f, Eq{"priority": priorities})
|
|
}
|
|
return f
|
|
}
|
|
|
|
// CountQueued shares its filter with PurgeQueued, so a preview cannot count rows the delete misses.
|
|
func (r *artworkQueueRepository) CountQueued(kinds []model.Kind, priorities []int) ([]model.ArtworkQueueStat, error) {
|
|
sel := Select("item_kind", "priority", "count(*) as count").From(r.tableName).
|
|
GroupBy("item_kind", "priority").OrderBy("item_kind", "priority desc")
|
|
if f := artworkQueueFilter(kinds, priorities); len(f) > 0 {
|
|
sel = sel.Where(f)
|
|
}
|
|
var res []model.ArtworkQueueStat
|
|
err := r.queryAll(sel, &res)
|
|
return res, err
|
|
}
|
|
|
|
func (r *artworkQueueRepository) PurgeQueued(kinds []model.Kind, priorities []int) (int64, error) {
|
|
del := Delete(r.tableName)
|
|
if f := artworkQueueFilter(kinds, priorities); len(f) > 0 {
|
|
del = del.Where(f)
|
|
}
|
|
return r.executeSQL(del)
|
|
}
|
|
|
|
func (r *artworkQueueRepository) Count() (int64, error) {
|
|
var res struct{ Count int64 }
|
|
err := r.queryOne(Select("count(*) as count").From(r.tableName), &res)
|
|
return res.Count, err
|
|
}
|
|
|
|
var _ model.ArtworkQueueRepository = (*artworkQueueRepository)(nil)
|