feat(artwork): prefix every log message and time the slow steps

Prefix: 22 messages still logged unprefixed, so a line from this package
was indistinguishable from any other subsystem's. All 40 now carry
"Artwork: ", matching Scanner:/API:/Watcher: -- which earns its place
because DevLogSourceLine is off by default.

Timing on what can actually be slow: total per acquisition (on every
exit, failures included), the read that also covers the provider
download, hashing, decode+blurhash, resize, drain batch, precache,
prune, backfill, and the external agent call -- with the rate-limiter
wait counted separately, since a throttled agent and a slow one look
identical from the drain.

Debug coverage for states that were previously silent: dedup hit vs
decode, settling absent, serving a lower-priority source after an
external failure, retry scheduling with attempts and budget left, giving
up when the budget runs out, breaker open/close per agent, provisional
read-through, dangling state rows, and the mtime mismatch that makes art
appear to vanish. outcome gained a String() so it reads as a name.
This commit is contained in:
Deluan 2026-07-27 14:18:27 -04:00
parent 764bf55723
commit e0f1acd1a2
14 changed files with 111 additions and 31 deletions

View File

@ -184,6 +184,8 @@ func openOriginal(ia *model.ItemArtwork, mime string, store *ImageStore) (io.Rea
}
if ia.RefMtime != 0 && info.ModTime().UnixNano() != ia.RefMtime {
f.Close()
log.Debug("Artwork: Backing file changed since resolution", "path", ia.SourcePath,
"hash", ia.Hash, "resolvedMtime", ia.RefMtime, "currentMtime", info.ModTime().UnixNano())
return nil, errStaleSource
}
return f, nil
@ -196,6 +198,8 @@ func openOriginal(ia *model.ItemArtwork, mime string, store *ImageStore) (io.Rea
return nil, err
}
if info.ModTime().UnixNano() != ia.RefMtime {
log.Debug("Artwork: Source file changed since resolution", "path", ia.SourcePath,
"hash", ia.Hash, "resolvedMtime", ia.RefMtime, "currentMtime", info.ModTime().UnixNano())
return nil, errStaleSource
}
}
@ -211,6 +215,8 @@ func (s *service) provisional(ctx context.Context, artID model.ArtworkID, size i
return nil, err
}
s.enqueue(ctx, artID, model.ArtworkPriorityBump)
log.Debug(ctx, "Artwork: Provisional read-through, no state row yet", "artID", artID,
"source", res.source, "hit", res.reader != nil)
return s.serveResolution(ctx, res, size, square)
}
@ -322,6 +328,7 @@ func (s *service) serveDisc(ctx context.Context, artID model.ArtworkID, size int
// dangling enqueues a re-resolution at Scan priority and reports the artwork as
// unavailable, leaving the state row untouched.
func (s *service) dangling(ctx context.Context, artID model.ArtworkID) (*Image, error) {
log.Debug(ctx, "Artwork: State row points at bytes we cannot serve, re-resolving", "artID", artID)
s.enqueue(ctx, artID, model.ArtworkPriorityScan)
return nil, ErrUnavailable
}

View File

@ -152,7 +152,7 @@ func (d *discArtworkReader) fromDiscSubtitle(ctx context.Context, subtitle strin
}
f, err := d.lib.FS.Open(file)
if err != nil {
log.Warn(ctx, "Could not open disc art file", "file", file, err)
log.Warn(ctx, "Artwork: Could not open disc art file", "file", file, err)
continue
}
return f, file, nil
@ -210,7 +210,7 @@ func (d *discArtworkReader) fromExternalFile(ctx context.Context, pattern string
name := strings.ToLower(path.Base(file))
match, err := filepath.Match(pattern, name)
if err != nil {
log.Warn(ctx, "Error matching disc art file to pattern", "pattern", pattern, "file", file)
log.Warn(ctx, "Artwork: Error matching disc art file to pattern", "pattern", pattern, "file", file)
continue
}
if !match {
@ -224,7 +224,7 @@ func (d *discArtworkReader) fromExternalFile(ctx context.Context, pattern string
}
f, err := d.lib.FS.Open(file)
if err != nil {
log.Warn(ctx, "Could not open disc art file", "file", file, err)
log.Warn(ctx, "Artwork: Could not open disc art file", "file", file, err)
continue
}
return f, file, nil
@ -240,7 +240,7 @@ func (d *discArtworkReader) fromExternalFile(ctx context.Context, pattern string
for _, file := range fallbacks {
f, err := d.lib.FS.Open(file)
if err != nil {
log.Warn(ctx, "Could not open disc art file", "file", file, err)
log.Warn(ctx, "Artwork: Could not open disc art file", "file", file, err)
continue
}
return f, file, nil

View File

@ -79,7 +79,7 @@ func albumRootParent(ctx context.Context, ds model.DataStore, folders []model.Fo
}
parent, err := ds.Folder(ctx).Get(commonParentID)
if errors.Is(err, model.ErrNotFound) {
log.Warn(ctx, "Parent folder not found for album cover art lookup", "parentID", commonParentID)
log.Warn(ctx, "Artwork: Parent folder not found for album cover art lookup", "parentID", commonParentID)
return nil, nil
}
if err != nil {

View File

@ -66,14 +66,14 @@ func fromArtistFolder(ctx context.Context, libFS fs.FS, libPath, artistFolder, p
// messages so callers see absolute-looking paths consistent with the rest of
// the artwork pipeline.
func findImageInFolder(ctx context.Context, libFS fs.FS, relFolder, absFolder, pattern string) (io.ReadCloser, string, error) {
log.Trace(ctx, "Looking for artist image", "pattern", pattern, "folder", absFolder)
log.Trace(ctx, "Artwork: Looking for artist image", "pattern", pattern, "folder", absFolder)
globPattern := pattern
if relFolder != "." {
globPattern = path.Join(escapeGlobLiteral(relFolder), pattern)
}
matches, err := fs.Glob(libFS, globPattern)
if err != nil {
log.Warn(ctx, "Error matching artist image pattern", "pattern", pattern, "folder", absFolder, err)
log.Warn(ctx, "Artwork: Error matching artist image pattern", "pattern", pattern, "folder", absFolder, err)
return nil, "", err
}
@ -94,7 +94,7 @@ func findImageInFolder(ctx context.Context, libFS fs.FS, relFolder, absFolder, p
for _, p := range imagePaths {
f, err := libFS.Open(p)
if err != nil {
log.Warn(ctx, "Could not open cover art file", "file", p, err)
log.Warn(ctx, "Artwork: Could not open cover art file", "file", p, err)
openErr = fmt.Errorf("%w: %s: %w", errSourceUnreadable, p, err)
continue
}
@ -138,13 +138,13 @@ func loadArtistFolder(ctx context.Context, ds model.DataStore, albums model.Albu
libPath := core.AbsolutePath(ctx, ds, libID, "")
folderID := model.FolderID(model.Library{ID: libID, Path: libPath}, folderPath)
log.Trace(ctx, "Calculating artist folder details", "folderPath", folderPath, "folderID", folderID,
log.Trace(ctx, "Artwork: Calculating artist folder details", "folderPath", folderPath, "folderID", folderID,
"libPath", libPath, "libID", libID, "albumPaths", paths)
// Get the last update time for the folder
folders, err := ds.Folder(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"folder.id": folderID, "missing": false}})
if err != nil || len(folders) == 0 {
log.Warn(ctx, "Could not find folder for artist", "folderPath", folderPath, "id", folderID,
log.Warn(ctx, "Artwork: Could not find folder for artist", "folderPath", folderPath, "id", folderID,
"libPath", libPath, "libID", libID, err)
return "", time.Time{}, err
}

View File

@ -8,6 +8,7 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"golang.org/x/time/rate"
)
@ -45,13 +46,20 @@ type extGate struct {
func (w *Worker) gate(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) {
g := w.gateFor(name)
if !g.breaker.allow() {
log.Debug(w.runCtx, "Artwork: Skipping agent, circuit breaker open", "agent", name)
return nil, "", errBreakerOpen
}
// Waiting for the rate-limit permit is counted separately: a slow agent and a throttled one
// look identical from the drain, but only one of them is the provider's fault.
waitStart := time.Now()
if err := g.limiter.Wait(w.runCtx); err != nil {
return nil, "", err
}
callStart := time.Now()
r, path, err := f()
g.breaker.record(err)
g.breaker.record(name, err)
log.Trace(w.runCtx, "Artwork: External agent call", "agent", name, "hit", r != nil,
"limiterWait", callStart.Sub(waitStart), "elapsed", time.Since(callStart), err)
return r, path, err
}
@ -96,17 +104,22 @@ func (b *breaker) allow() bool {
return false
}
func (b *breaker) record(err error) {
func (b *breaker) record(name string, err error) {
b.mu.Lock()
defer b.mu.Unlock()
// A not-found (from either package) is a definitive answer, not a fault; only real
// errors trip the breaker. Must stay consistent with isTransientExternal.
if err == nil || errors.Is(err, model.ErrNotFound) || errors.Is(err, agents.ErrNotFound) {
if b.failures >= breakerThreshold {
log.Info("Artwork: Circuit breaker closed for agent", "agent", name)
}
b.failures = 0
return
}
b.failures++
if b.failures == breakerThreshold {
b.openedAt = time.Now()
log.Warn("Artwork: Circuit breaker opened for agent", "agent", name,
"consecutiveFailures", b.failures, "probeAfter", breakerProbeAfter, err)
}
}

View File

@ -48,6 +48,7 @@ func fingerprint() string {
// backfill enqueues artwork resolution for every entity when the config fingerprint changed
// (or was never stored), artists first so those pages resolve before the larger backlog.
func backfill(ctx context.Context, ds model.DataStore) (bool, error) {
start := time.Now()
ctx = auth.WithAdminUser(ctx, ds)
current := fingerprint()
props := ds.Property(ctx)
@ -82,7 +83,7 @@ func backfill(ctx context.Context, ds model.DataStore) (bool, error) {
if err := props.Put(consts.ArtConfFingerprintPropertyKey, current); err != nil {
return false, err
}
log.Info(ctx, "Artwork: Config fingerprint changed, backfill enqueued")
log.Info(ctx, "Artwork: Config fingerprint changed, backfill enqueued", "elapsed", time.Since(start))
return true, nil
}

View File

@ -30,7 +30,7 @@ func findPlaylistSidecarPath(ctx context.Context, plsPath string) string {
entries, err := os.ReadDir(dir)
if err != nil {
log.Warn(ctx, "Could not read directory for playlist sidecar", "dir", dir, err)
log.Warn(ctx, "Artwork: Could not read directory for playlist sidecar", "dir", dir, err)
return ""
}
for _, entry := range entries {

View File

@ -31,6 +31,19 @@ const (
outcomeFailed
)
func (o outcome) String() string {
switch o {
case outcomeFound:
return "found"
case outcomeFoundStale:
return "foundStale"
case outcomeAbsent:
return "absent"
default:
return "failed"
}
}
// thumbnailSize is the max dimension fed to blurhash.
const thumbnailSize = 128
@ -61,8 +74,15 @@ type processor struct {
// acquire resolves one queue item end to end: find an image, hash/decode/
// blurhash it, place its bytes, and persist the resulting state.
func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (outcome, *acquired) {
func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (out outcome, got *acquired) {
repo := p.ds.Artwork(ctx)
// Timed on every exit, failures included: an item that is slow is usually one that failed
// slowly, and the outcome alone doesn't say whether the cost was the network or the decode.
start := time.Now()
defer func() {
log.Debug(ctx, "Artwork: Acquisition finished", "kind", item.ItemKind, "id", item.ItemID,
"outcome", out, "elapsed", time.Since(start))
}()
res, err := p.resolver.resolve(ctx, item)
if err != nil {
@ -73,35 +93,48 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o
if res.extError || res.localError {
// A source errored/timed out rather than answering "no image": never settle on
// absent, keep serving old state.
log.Debug(ctx, "Artwork: No image, but a source faulted; keeping previous state",
"kind", item.ItemKind, "id", item.ItemID, "extError", res.extError, "localError", res.localError)
return outcomeFailed, nil
}
return writeAbsent(ctx, repo, item), nil
}
defer res.reader.Close()
// Times the download too: res.reader is the provider's response body for external sources.
readStart := time.Now()
data, err := readCapped(res.reader)
if err != nil {
log.Warn(ctx, "Artwork: Failed to read resolved image", "kind", item.ItemKind, "id", item.ItemID, "source", res.source, err)
return outcomeFailed, nil
}
log.Debug(ctx, "Artwork: Read resolved image", "kind", item.ItemKind, "id", item.ItemID, "source", res.source, "bytes", len(data))
log.Debug(ctx, "Artwork: Read resolved image", "kind", item.ItemKind, "id", item.ItemID,
"source", res.source, "bytes", len(data), "elapsed", time.Since(readStart))
hashStart := time.Now()
hash, err := hashImage(bytes.NewReader(data))
if err != nil {
log.Warn(ctx, "Artwork: Failed to hash image", "kind", item.ItemKind, "id", item.ItemID, err)
return outcomeFailed, nil
}
log.Trace(ctx, "Artwork: Hashed image", "kind", item.ItemKind, "id", item.ItemID,
"hash", hash, "bytes", len(data), "elapsed", time.Since(hashStart))
art, err := repo.GetImage(hash)
switch {
case err == nil:
// Dedup hit: identical bytes already known, reuse dims/mime/blurhash.
log.Debug(ctx, "Artwork: Reusing a known image, skipping decode", "kind", item.ItemKind,
"id", item.ItemID, "hash", hash)
case errors.Is(err, model.ErrNotFound):
decodeStart := time.Now()
art, err = decodeArtwork(ctx, hash, data)
if err != nil {
log.Warn(ctx, "Artwork: Failed to decode resolved image", "kind", item.ItemKind, "id", item.ItemID, err)
return outcomeFailed, nil
}
log.Debug(ctx, "Artwork: Decoded new image", "kind", item.ItemKind, "id", item.ItemID, "hash", hash,
"dims", fmt.Sprintf("%dx%d", art.Width, art.Height), "mime", art.Mime, "elapsed", time.Since(decodeStart))
default:
log.Warn(ctx, "Artwork: Failed to look up image hash", "kind", item.ItemKind, "id", item.ItemID, err)
return outcomeFailed, nil
@ -113,8 +146,11 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o
log.Warn(ctx, "Artwork: Failed to persist resolved image", "kind", item.ItemKind, "id", item.ItemID, err)
return outcomeFailed, nil
}
got := &acquired{ia: ia, mime: art.Mime, data: data}
got = &acquired{ia: ia, mime: art.Mime, data: data}
if res.extError {
// Served, but a higher-priority external source errored: the pick may improve on retry.
log.Debug(ctx, "Artwork: Serving a lower-priority source after an external failure",
"kind", item.ItemKind, "id", item.ItemID, "source", res.source)
return outcomeFoundStale, got
}
return outcomeFound, got
@ -166,6 +202,8 @@ func writeAbsent(ctx context.Context, repo model.ArtworkRepository, item model.A
log.Warn(ctx, "Artwork: Failed to persist absent state", "kind", item.ItemKind, "id", item.ItemID, err)
return outcomeFailed
}
log.Debug(ctx, "Artwork: Settled absent, every source answered definitively",
"kind", item.ItemKind, "id", item.ItemID)
return outcomeAbsent
}

View File

@ -12,6 +12,8 @@ import (
const pruneMinAge = time.Hour
func prune(ctx context.Context, ds model.DataStore, store *ImageStore) error {
start := time.Now()
defer func() { log.Debug(ctx, "Artwork: Prune finished", "elapsed", time.Since(start)) }()
repo := ds.Artwork(ctx)
purged, err := repo.PurgeDanglingItemArtwork()

View File

@ -10,6 +10,7 @@ import (
"image/png"
"io"
"sync"
"time"
"github.com/gen2brain/webp"
"github.com/navidrome/navidrome/conf"
@ -27,9 +28,9 @@ func init() {
// "nodynamic" tag (see Dockerfile), which makes webp.Dynamic() report an
// error here and forces the safe WASM path.
if err := webp.Dynamic(); err != nil {
log.Debug("Using WASM WebP encoder/decoder", "reason", err)
log.Debug("Artwork: Using WASM WebP encoder/decoder", "reason", err)
} else {
log.Debug("Using native libwebp for WebP encoding/decoding")
log.Debug("Artwork: Using native libwebp for WebP encoding/decoding")
}
})
}
@ -43,6 +44,11 @@ var bufPool = sync.Pool{
// resizeImageData resizes raw image bytes to fit size, preserving animation where
// possible. A nil reader means the image was already within bounds (no resize needed).
func resizeImageData(ctx context.Context, ffm ffmpeg.FFmpeg, data []byte, size int, square bool) (io.Reader, int, error) {
start := time.Now()
defer func() {
log.Trace(ctx, "Artwork: Resized image", "bytes", len(data), "size", size, "square", square,
"elapsed", time.Since(start))
}()
// Preserve animation for animated images
if isAnimatedGIF(data) {
if ffm.IsAvailable() {
@ -51,7 +57,7 @@ func resizeImageData(ctx context.Context, ffm ffmpeg.FFmpeg, data []byte, size i
if err == nil {
return r, 0, nil
}
log.Warn(ctx, "Could not convert animated GIF, falling back to static", err)
log.Warn(ctx, "Artwork: Could not convert animated GIF, falling back to static", err)
}
} else if isAnimatedWebP(data) || isAnimatedPNG(data) {
// Animated WebP/APNG: return original as-is (ffmpeg can't re-encode these)

View File

@ -35,11 +35,11 @@ func selectImageReader(ctx context.Context, artID model.ArtworkID, extractFuncs
start := time.Now()
r, path, err := f()
if r != nil {
msg := fmt.Sprintf("Found %s artwork", artID.Kind)
msg := fmt.Sprintf("Artwork: Found %s artwork", artID.Kind)
log.Debug(ctx, msg, "artID", artID, "path", path, "source", f, "elapsed", time.Since(start))
return r, path, nil
}
log.Trace(ctx, "Failed trying to extract artwork", "artID", artID, "source", f, "elapsed", time.Since(start), err)
log.Trace(ctx, "Artwork: Failed trying to extract artwork", "artID", artID, "source", f, "elapsed", time.Since(start), err)
}
return nil, "", fmt.Errorf("could not get `%s` cover art for %s: %w", artID.Kind, artID, ErrUnavailable)
}
@ -63,7 +63,7 @@ func fromExternalFile(ctx context.Context, libFS fs.FS, files []string, pattern
_, name := filepath.Split(file)
match, err := filepath.Match(pattern, strings.ToLower(name))
if err != nil {
log.Warn(ctx, "Error matching cover art file to pattern", "pattern", pattern, "file", file)
log.Warn(ctx, "Artwork: Error matching cover art file to pattern", "pattern", pattern, "file", file)
continue
}
if !match {
@ -71,7 +71,7 @@ func fromExternalFile(ctx context.Context, libFS fs.FS, files []string, pattern
}
f, err := libFS.Open(file)
if err != nil {
log.Warn(ctx, "Could not open cover art file", "file", file, err)
log.Warn(ctx, "Artwork: Could not open cover art file", "file", file, err)
openErr = fmt.Errorf("%w: %s: %w", errSourceUnreadable, file, err)
continue
}
@ -135,12 +135,12 @@ func findBestImageIndex(ctx context.Context, images []taglib.ImageDesc, path str
for _, regex := range picTypeRegexes {
for i, img := range images {
if regex.MatchString(img.Type) {
log.Trace(ctx, "Found embedded image", "type", img.Type, "path", path)
log.Trace(ctx, "Artwork: Found embedded image", "type", img.Type, "path", path)
return i
}
}
}
log.Trace(ctx, "Could not find a front image. Getting the first one", "type", images[0].Type, "path", path)
log.Trace(ctx, "Artwork: Could not find a front image. Getting the first one", "type", images[0].Type, "path", path)
return 0
}

View File

@ -62,7 +62,7 @@ func (s *uploader) SetImage(ctx context.Context, entityType string, entityID str
// Remove old image if it exists
if oldPath != "" {
if err := os.Remove(oldPath); err != nil && !os.IsNotExist(err) {
log.Warn(ctx, "Failed to remove old image", "path", oldPath, err)
log.Warn(ctx, "Artwork: Failed to remove old image", "path", oldPath, err)
}
}
@ -87,7 +87,7 @@ func (s *uploader) EnqueueArtwork(ctx context.Context, entityType, id string) {
return
}
if err := Refresh(ctx, s.ds, kind, id); err != nil {
log.Warn(ctx, "Could not refresh artwork after upload", "kind", kind, "id", id, err)
log.Warn(ctx, "Artwork: Could not refresh artwork after upload", "kind", kind, "id", id, err)
}
}

View File

@ -188,6 +188,7 @@ func (w *Worker) drain(ctx context.Context, concurrency int, kinds ...string) (i
if len(items) == 0 {
return 0, nil
}
drainStart := time.Now()
// Resolved only once there is work, and per drain rather than per item: the worker needs an
// admin identity for private playlists, and can start before any admin exists.
ctx = auth.WithAdminUser(ctx, w.proc.ds)
@ -222,6 +223,8 @@ func (w *Worker) drain(ctx context.Context, concurrency int, kinds ...string) (i
}
wg.Wait()
w.broadcastRefresh(ctx, refresh)
log.Debug(ctx, "Artwork: Drained a batch", "kinds", kinds, "items", len(items),
"refreshed", len(refresh), "concurrency", concurrency, "elapsed", time.Since(drainStart))
return len(items), nil
}
@ -280,15 +283,22 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc
if err := queue.MarkFailedIfUnchanged(item.ItemKind, item.ItemID, item.ImageType, item.RetryAt, retryAt); err != nil {
log.Warn(ctx, "Artwork: Could not reschedule failed queue item", "kind", item.ItemKind, "id", item.ItemID, err)
}
log.Debug(ctx, "Artwork: Rescheduled item", "kind", item.ItemKind, "id", item.ItemID,
"outcome", out, "attempts", item.Attempts+1, "retryIn", time.Until(retryAt),
"budgetLeft", time.Until(item.EnqueuedAt.Add(giveUpAfter)))
break
}
// Retry budget exhausted: stop retrying. Absent is only recoverable where a periodic
// recheck will revisit it, so kinds without one keep no row at all; and art already
// being served is kept, since exhaustion means the source stayed unreachable rather
// than that the entity lost its cover.
settled := "kept previous state"
if out == outcomeFailed && hasRecheckPath(item.ItemKind) && !w.hasResolvedArtwork(ctx, item) {
writeAbsent(ctx, w.proc.ds.Artwork(ctx), item)
settled = "recorded absent"
}
log.Info(ctx, "Artwork: Retry budget exhausted, giving up", "kind", item.ItemKind, "id", item.ItemID,
"outcome", out, "attempts", item.Attempts+1, "budget", giveUpAfter, "settled", settled)
if err := queue.DeleteIfUnchanged(item.ItemKind, item.ItemID, item.ImageType, item.RetryAt); err != nil {
log.Warn(ctx, "Artwork: Could not remove exhausted queue item", "kind", item.ItemKind, "id", item.ItemID, err)
}
@ -313,6 +323,7 @@ func (w *Worker) precache(ctx context.Context, got *acquired) {
if !conf.Server.EnableArtworkPrecache || w.cache == nil || w.cache.Disabled(ctx) {
return
}
precacheStart := time.Now()
// Same key as the serving path (hash/size/square); only the source of the bytes differs.
// square matches what the list surfaces request, otherwise this warms a key nothing reads.
item := &resizedItem{
@ -329,6 +340,8 @@ func (w *Worker) precache(ctx context.Context, got *acquired) {
}
_, _ = io.Copy(io.Discard, stream)
_ = stream.Close()
log.Trace(ctx, "Artwork: Precached UI size", "kind", got.ia.ItemKind, "id", got.ia.ItemID,
"size", conf.Server.UICoverArtSize, "elapsed", time.Since(precacheStart))
}
// backoffFor returns min(5s×4^n, giveUpAfter) scaled by (1+jitter), with jitter in [-0.4, 0.4].

View File

@ -20,7 +20,7 @@ func TestArtworkBreakerHalfOpen(t *testing.T) {
b := newBreaker()
for range breakerThreshold {
b.record(errors.New("boom"))
b.record("agentA", errors.New("boom"))
}
g.Expect(b.allow()).To(BeFalse(), "breaker opens after consecutive errors")
@ -31,11 +31,11 @@ func TestArtworkBreakerHalfOpen(t *testing.T) {
g.Expect(b.allow()).To(BeTrue(), "half-open: one probe is granted")
g.Expect(b.allow()).To(BeFalse(), "only a single probe per interval")
b.record(errors.New("boom")) // probe fails -> stay open
b.record("agentA", errors.New("boom")) // probe fails -> stay open
time.Sleep(breakerProbeAfter)
g.Expect(b.allow()).To(BeTrue(), "another probe after the next interval")
b.record(nil) // probe succeeds -> close
b.record("agentA", nil) // probe succeeds -> close
g.Expect(b.allow()).To(BeTrue(), "closed breaker admits freely")
g.Expect(b.allow()).To(BeTrue())
})