navidrome/core/artwork/trace.go
Deluan b04d0c42cc refactor(artwork): own the kind list and the chain predicate in the package
Export RecheckKinds and add WalksPriorityChain so the CLI stops keeping its
own copies of both, and unexport externalCandidate, which nothing outside the
package consumes.
2026-08-14 15:22:37 -04:00

99 lines
2.7 KiB
Go

package artwork
import (
"context"
"errors"
"io"
"slices"
"sync"
)
// Outcome is what the priority chain observed for one candidate; the CLI renders and branches on these.
type Outcome string
const (
OutcomeHit Outcome = "hit"
OutcomeMiss Outcome = "miss"
OutcomeUnreadable Outcome = "unreadable"
OutcomeSkipped Outcome = "skipped"
OutcomeWouldTry Outcome = "would-try"
OutcomeError Outcome = "error"
)
const (
// externalCandidate labels the external tier itself, for the cases that never reach an agent.
externalCandidate = "external"
// ExternalPrefix qualifies a candidate or a stored source with the agent that produced it.
ExternalPrefix = externalCandidate + ":"
)
// TraceStep is one candidate the priority chain considered.
type TraceStep struct {
Candidate string
Outcome Outcome
Detail string
}
// ChainTrace collects the walk of a single resolution. The artwork worker never attaches
// one; only the CLI does, so resolution stays allocation-free in the hot path.
type ChainTrace struct {
mu sync.Mutex
steps []TraceStep
}
func (t *ChainTrace) add(step TraceStep) {
if t == nil {
return
}
t.mu.Lock()
defer t.mu.Unlock()
t.steps = append(t.steps, step)
}
func (t *ChainTrace) Steps() []TraceStep {
if t == nil {
return nil
}
t.mu.Lock()
defer t.mu.Unlock()
return slices.Clone(t.steps)
}
type traceCtxKey struct{}
func withTrace(ctx context.Context, t *ChainTrace) context.Context {
return context.WithValue(ctx, traceCtxKey{}, t)
}
func traceFrom(ctx context.Context) *ChainTrace {
t, _ := ctx.Value(traceCtxKey{}).(*ChainTrace)
return t
}
var errOfflineSkipped = errors.New("artwork: external lookup skipped (offline)")
// tracingGate records each external agent's outcome without changing what the gate returns.
func tracingGate(t *ChainTrace, inner gateFunc) gateFunc {
return func(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) {
r, path, err := inner(name, f)
candidate := ExternalPrefix + name
switch {
case r != nil:
t.add(TraceStep{Candidate: candidate, Outcome: OutcomeHit, Detail: path})
case isTransientExternal(err):
t.add(TraceStep{Candidate: candidate, Outcome: OutcomeError, Detail: err.Error()})
default:
t.add(TraceStep{Candidate: candidate, Outcome: OutcomeMiss})
}
return r, path, err
}
}
// offlineGate reports which agents would be asked without asking them, so a diagnostic
// command cannot add load to a provider that is already rate-limiting us.
func offlineGate(t *ChainTrace) gateFunc {
return func(name string, _ func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) {
t.add(TraceStep{Candidate: ExternalPrefix + name, Outcome: OutcomeWouldTry})
return nil, "", errOfflineSkipped
}
}