Files
silo-server/internal/metadata/image_cache_processor.go
T
14ffc91dfb [codex] Expand provider image cache queue (#176)
* feat(metadata): expand provider image cache queue

* fix(metadata): harden provider image cache queue

Addresses bug-review feedback from Codex/CodeRabbit on the metadata image
cache pipeline. All findings validated against the code before fixing;
false positives (rows/connection deadlock, PhotoSourcePath merge coupling)
were confirmed non-issues and left unchanged.

- Honor metadata.cache_images for the background processor. The
  cache_metadata_images task was registered whenever S3 was configured,
  so merely enabling object storage downloaded the entire provider-artwork
  catalog even with caching disabled. Add ImageCacheProcessor.SetEnabled,
  gate RunOnce/RunUntilIdle on it, and wire it (with hot reload) from
  cfg.Metadata.CacheImages in main.go.
- Guard terminal job updates with lease ownership. EnqueueBatch can
  repurpose a running row with a new source; MarkSucceeded/MarkFailed
  keyed on id alone let a stale worker finalize the replacement job and
  drop the new artwork. Thread locked_by through and add
  status='running' AND locked_by=$n guards.
- Avoid uploading stale jobs onto the live artwork key. Verify the
  target still references the job's source (CurrentTargetSourcePath)
  before CacheImage, so a job whose source an admin/refresh already
  replaced cannot overwrite the deterministic storage object.
- COALESCE nullable external IDs in EnqueueExistingProviderArtwork. A
  NULL tmdb_id/tvdb_id/imdb_id on any candidate failed the scan and
  aborted the whole cache run; matches the existing item_repo pattern.
- Stop re-downloading the catalog every 30 days. Discovery now skips
  targets whose *_path is already a cached relative path, making the
  cached row the durable dedup marker instead of the prunable job row.
- Decouple catalog sweeps from queue draining. RunOnce no longer runs
  discovery per batch; RunUntilIdle sweeps only when the queue drains and
  throttles full sweeps to every 15m, so idle installs stop full-scanning
  every entity table each minute.
- Requeue claimed-but-unstarted jobs on cancellation. Acquire the
  semaphore before spawning workers and RequeueClaimed any jobs not yet
  started, instead of leaving them locked until the 15m lease expires.
- Skip the backoff sleep after the final upload attempt in
  putObjectWithRetry (saves ~1.5s on permanent failures).
- Add the s3/file/local/upload/generated exclusion to the seasons and
  episodes backfill in migration 20260617184537 for consistency with the
  later migration (the bad backfill was inert downstream, but the
  asymmetry is removed).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 10:07:58 -04:00

474 lines
15 KiB
Go

package metadata
import (
"context"
"fmt"
"log/slog"
"strconv"
"sync"
"sync/atomic"
"time"
"github.com/Silo-Server/silo-server/internal/models"
)
// imageCacheDiscoveryInterval throttles the full-catalog backfill sweep so an
// idle installation does not re-scan every entity table on every task tick.
// Draining of already-queued jobs is unaffected and stays responsive.
const imageCacheDiscoveryInterval = 15 * time.Minute
type ImageCacheJobClaimer interface {
ClaimDue(ctx context.Context, workerID string, limit int) ([]*models.MetadataImageCacheJob, error)
MarkSucceeded(ctx context.Context, id int64, lockedBy string) error
MarkFailed(ctx context.Context, id int64, attemptCount int, lockedBy string, errText string) error
RequeueClaimed(ctx context.Context, ids []int64, workerID string) error
CurrentTargetSourcePath(ctx context.Context, job *models.MetadataImageCacheJob) (string, error)
EnqueueExistingProviderArtwork(ctx context.Context, limit int) (int, error)
DeleteSucceededBefore(ctx context.Context, before time.Time, limit int) (int, error)
}
type SeasonArtworkUpdater interface {
UpdateArtworkIfSourceMatches(ctx context.Context, contentID, sourcePath, cachedPath, thumbhash string) (bool, error)
}
type EpisodeStillUpdater interface {
UpdateStillIfSourceMatches(ctx context.Context, contentID, sourcePath, cachedPath, thumbhash string) (bool, error)
}
type ItemArtworkUpdater interface {
UpdateArtworkIfSourceMatches(ctx context.Context, contentID, imageType, sourcePath, cachedPath, thumbhash string) (bool, error)
}
type ItemLocalizationArtworkUpdater interface {
UpdateArtworkIfSourceMatches(ctx context.Context, contentID, language, imageType, sourcePath, cachedPath, thumbhash string) (bool, error)
}
type SeasonLocalizationArtworkUpdater interface {
UpdateArtworkIfSourceMatches(ctx context.Context, contentID, language, sourcePath, cachedPath, thumbhash string) (bool, error)
}
type PersonPhotoUpdater interface {
UpdatePhotoIfSourceMatches(ctx context.Context, personID int64, sourcePath, cachedPath, thumbhash string) (bool, error)
}
type ImageCacheProcessorTargets struct {
Items ItemArtworkUpdater
Seasons SeasonArtworkUpdater
Episodes EpisodeStillUpdater
ItemLocalizations ItemLocalizationArtworkUpdater
SeasonLocalizations SeasonLocalizationArtworkUpdater
People PersonPhotoUpdater
}
type ImageCacheProcessor struct {
jobs ImageCacheJobClaimer
cacher ImageCacher
resolver interface {
ResolveImageURL(ctx context.Context, path string, variant string) string
}
targets ImageCacheProcessorTargets
logger *slog.Logger
enabled atomic.Bool
discoveryInterval time.Duration
discoveryMu sync.Mutex
lastDiscovery time.Time
}
// SetEnabled toggles background caching. When disabled the processor performs
// no discovery, claiming, or uploading, honoring metadata.cache_images so that
// merely configuring object storage does not download the whole catalog.
func (p *ImageCacheProcessor) SetEnabled(enabled bool) {
if p == nil {
return
}
p.enabled.Store(enabled)
}
func NewImageCacheProcessor(
jobs ImageCacheJobClaimer,
cacher ImageCacher,
resolver interface {
ResolveImageURL(ctx context.Context, path string, variant string) string
},
seasons SeasonArtworkUpdater,
episodes EpisodeStillUpdater,
) *ImageCacheProcessor {
return NewImageCacheProcessorWithTargets(jobs, cacher, resolver, ImageCacheProcessorTargets{
Seasons: seasons,
Episodes: episodes,
})
}
func NewImageCacheProcessorWithTargets(
jobs ImageCacheJobClaimer,
cacher ImageCacher,
resolver interface {
ResolveImageURL(ctx context.Context, path string, variant string) string
},
targets ImageCacheProcessorTargets,
) *ImageCacheProcessor {
p := &ImageCacheProcessor{
jobs: jobs,
cacher: cacher,
resolver: resolver,
targets: targets,
logger: slog.Default(),
discoveryInterval: imageCacheDiscoveryInterval,
}
// Default to enabled; callers gate on metadata.cache_images via SetEnabled.
p.enabled.Store(true)
return p
}
type ImageCacheRunStats struct {
Batches int
EnqueuedExisting int
Claimed int
Succeeded int
Failed int
Skipped int
DeletedSucceeded int
RuntimeLimited bool
}
func (s *ImageCacheRunStats) add(other ImageCacheRunStats) {
s.EnqueuedExisting += other.EnqueuedExisting
s.Claimed += other.Claimed
s.Succeeded += other.Succeeded
s.Failed += other.Failed
s.Skipped += other.Skipped
s.DeletedSucceeded += other.DeletedSucceeded
}
// RunOnce claims and processes one batch of already-queued jobs. It does not
// run catalog discovery; callers (RunUntilIdle) drive discovery on a throttled
// cadence so backlog draining stays decoupled from full-table sweeps.
func (p *ImageCacheProcessor) RunOnce(ctx context.Context, workerID string, claimLimit int, concurrency int) (ImageCacheRunStats, error) {
var stats ImageCacheRunStats
if p == nil || p.jobs == nil || p.cacher == nil || !p.enabled.Load() {
return stats, nil
}
if claimLimit <= 0 {
claimLimit = 100
}
if concurrency <= 0 {
concurrency = 4
}
jobs, err := p.jobs.ClaimDue(ctx, workerID, claimLimit)
if err != nil {
return stats, err
}
stats.Claimed = len(jobs)
if len(jobs) == 0 {
p.cleanupSucceeded(ctx, &stats)
return stats, nil
}
sem := make(chan struct{}, concurrency)
var wg sync.WaitGroup
var mu sync.Mutex
var unstarted []int64
loop:
for i, job := range jobs {
// Acquire the semaphore before spawning so cancellation is observed here
// rather than inside a goroutine that already holds a claimed job. Jobs we
// never start are requeued below instead of being left locked until the
// lease expires.
select {
case sem <- struct{}{}:
case <-ctx.Done():
for _, rem := range jobs[i:] {
unstarted = append(unstarted, rem.ID)
}
break loop
}
wg.Add(1)
go func(job *models.MetadataImageCacheJob) {
defer wg.Done()
defer func() { <-sem }()
outcome := p.processOne(ctx, job)
mu.Lock()
switch outcome {
case "succeeded":
stats.Succeeded++
case "skipped":
stats.Skipped++
default:
stats.Failed++
}
mu.Unlock()
}(job)
}
wg.Wait()
if len(unstarted) > 0 {
requeueCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
if err := p.jobs.RequeueClaimed(requeueCtx, unstarted, workerID); err != nil {
p.logger.Warn("metadata image cache: failed to requeue unstarted jobs", "count", len(unstarted), "error", err)
}
cancel()
}
p.cleanupSucceeded(ctx, &stats)
if ctxErr := ctx.Err(); ctxErr != nil && stats.Claimed == 0 {
return stats, ctxErr
}
return stats, nil
}
func (p *ImageCacheProcessor) RunUntilIdle(ctx context.Context, workerID string, claimLimit int, concurrency int, maxRuntime time.Duration) (ImageCacheRunStats, error) {
var total ImageCacheRunStats
if p == nil || p.jobs == nil || p.cacher == nil || !p.enabled.Load() {
return total, nil
}
if maxRuntime <= 0 {
enqueued, derr := p.discoverExisting(ctx, claimLimit)
total.EnqueuedExisting += enqueued
if derr != nil {
return total, derr
}
stats, err := p.RunOnce(ctx, workerID, claimLimit, concurrency)
total.add(stats)
total.Batches = 1
return total, err
}
// Decide once per run whether a full-catalog backfill sweep is due. Within a
// due run we keep sweeping until the catalog is exhausted; otherwise we only
// drain the existing queue.
sweep := p.discoveryDue()
deadline := time.Now().Add(maxRuntime)
for {
if err := ctx.Err(); err != nil {
return total, err
}
if !time.Now().Before(deadline) {
total.RuntimeLimited = true
return total, nil
}
stats, err := p.RunOnce(ctx, workerID, claimLimit, concurrency)
total.Batches++
total.add(stats)
if err != nil {
return total, err
}
if stats.Claimed > 0 {
// Keep draining the queue before spending a full-table sweep.
continue
}
if !sweep {
return total, nil
}
enqueued, err := p.jobs.EnqueueExistingProviderArtwork(ctx, claimLimit)
if err != nil {
return total, err
}
total.EnqueuedExisting += enqueued
if enqueued == 0 {
// Catalog fully swept; throttle the next sweep.
p.markDiscovered()
return total, nil
}
}
}
// discoveryDue reports whether enough time has elapsed since the last completed
// sweep to run another one.
func (p *ImageCacheProcessor) discoveryDue() bool {
if p.discoveryInterval <= 0 {
return true
}
p.discoveryMu.Lock()
defer p.discoveryMu.Unlock()
return p.lastDiscovery.IsZero() || time.Since(p.lastDiscovery) >= p.discoveryInterval
}
func (p *ImageCacheProcessor) markDiscovered() {
p.discoveryMu.Lock()
p.lastDiscovery = time.Now()
p.discoveryMu.Unlock()
}
// discoverExisting runs an unthrottled sweep (single-pass path) and records the
// time so the throttle applies to subsequent interval-driven runs.
func (p *ImageCacheProcessor) discoverExisting(ctx context.Context, limit int) (int, error) {
enqueued, err := p.jobs.EnqueueExistingProviderArtwork(ctx, limit)
if err == nil {
p.markDiscovered()
}
return enqueued, err
}
func (p *ImageCacheProcessor) cleanupSucceeded(ctx context.Context, stats *ImageCacheRunStats) {
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
defer cancel()
deleted, err := p.jobs.DeleteSucceededBefore(cleanupCtx, time.Now().Add(-30*24*time.Hour), 1000)
if err != nil {
p.logger.Warn("metadata image cache: failed to delete old succeeded jobs", "error", err)
} else {
stats.DeletedSucceeded = deleted
}
}
func terminalJobContext(parent context.Context) (context.Context, context.CancelFunc) {
return context.WithTimeout(context.WithoutCancel(parent), 10*time.Second)
}
func (p *ImageCacheProcessor) markFailed(parent context.Context, job *models.MetadataImageCacheJob, errText string) {
writeCtx, cancel := terminalJobContext(parent)
defer cancel()
if err := p.jobs.MarkFailed(writeCtx, job.ID, job.AttemptCount, job.LockedBy, errText); err != nil {
p.logger.Warn("metadata image cache: failed to mark job failed", "job_id", job.ID, "error", err)
}
}
func (p *ImageCacheProcessor) markSucceeded(parent context.Context, job *models.MetadataImageCacheJob) {
writeCtx, cancel := terminalJobContext(parent)
defer cancel()
if err := p.jobs.MarkSucceeded(writeCtx, job.ID, job.LockedBy); err != nil {
p.logger.Warn("metadata image cache: failed to mark job succeeded", "job_id", job.ID, "error", err)
}
}
func (p *ImageCacheProcessor) processOne(ctx context.Context, job *models.MetadataImageCacheJob) string {
if job == nil {
return "skipped"
}
imageType, err := imageCacheJobImageType(job.ImageType)
if err != nil {
p.markFailed(ctx, job, err.Error())
return "failed"
}
// Confirm the target still references this job's source before uploading.
// CacheImage writes to a deterministic, source-independent storage key, so a
// stale job whose source an admin or newer refresh has already replaced would
// otherwise overwrite the live artwork object even though the conditional DB
// update later no-ops. Dropping the obsolete job here avoids that.
current, err := p.jobs.CurrentTargetSourcePath(ctx, job)
if err != nil {
p.markFailed(ctx, job, err.Error())
return "failed"
}
if current != job.SourcePath {
p.markSucceeded(ctx, job)
return "skipped"
}
downloadURL := job.SourcePath
if isProviderImagePath(downloadURL) {
if p.resolver == nil {
p.markFailed(ctx, job, "missing image resolver")
return "failed"
}
downloadURL = p.resolver.ResolveImageURL(ctx, job.SourcePath, "original")
if downloadURL == "" {
p.markFailed(ctx, job, "image resolver returned empty URL")
return "failed"
}
}
result, err := p.cacher.CacheImage(ctx, CacheImageRequest{
SourceURL: downloadURL,
ProviderID: job.ProviderID,
ContentType: job.ContentType,
ContentID: job.ProviderContentID,
ImageType: imageType,
SeasonNumber: job.SeasonNumber,
EpisodeNumber: job.EpisodeNumber,
Language: job.TargetLanguage,
})
if err != nil {
p.markFailed(ctx, job, err.Error())
return "failed"
}
if result == nil {
p.markFailed(ctx, job, "image cache returned no result")
return "failed"
}
cachedPath := cachedOriginalImagePath(result.BasePath, result.Ext)
if cachedPath == "" {
p.markFailed(ctx, job, "image cache returned empty stored path")
return "failed"
}
var updated bool
switch job.TargetType {
case ImageCacheTargetItem:
if p.targets.Items == nil {
p.markFailed(ctx, job, "missing item updater")
return "failed"
}
updated, err = p.targets.Items.UpdateArtworkIfSourceMatches(ctx, job.TargetContentID, job.ImageType, job.SourcePath, cachedPath, result.Thumbhash)
case ImageCacheTargetItemLocalization:
if p.targets.ItemLocalizations == nil {
p.markFailed(ctx, job, "missing item localization updater")
return "failed"
}
updated, err = p.targets.ItemLocalizations.UpdateArtworkIfSourceMatches(ctx, job.TargetContentID, job.TargetLanguage, job.ImageType, job.SourcePath, cachedPath, result.Thumbhash)
case ImageCacheTargetSeason:
if p.targets.Seasons == nil {
p.markFailed(ctx, job, "missing season updater")
return "failed"
}
updated, err = p.targets.Seasons.UpdateArtworkIfSourceMatches(ctx, job.TargetContentID, job.SourcePath, cachedPath, result.Thumbhash)
case ImageCacheTargetSeasonLocalization:
if p.targets.SeasonLocalizations == nil {
p.markFailed(ctx, job, "missing season localization updater")
return "failed"
}
updated, err = p.targets.SeasonLocalizations.UpdateArtworkIfSourceMatches(ctx, job.TargetContentID, job.TargetLanguage, job.SourcePath, cachedPath, result.Thumbhash)
case ImageCacheTargetEpisode:
if p.targets.Episodes == nil {
p.markFailed(ctx, job, "missing episode updater")
return "failed"
}
updated, err = p.targets.Episodes.UpdateStillIfSourceMatches(ctx, job.TargetContentID, job.SourcePath, cachedPath, result.Thumbhash)
case ImageCacheTargetPerson:
if p.targets.People == nil {
p.markFailed(ctx, job, "missing person updater")
return "failed"
}
personID, parseErr := strconv.ParseInt(job.TargetContentID, 10, 64)
if parseErr != nil {
err = fmt.Errorf("invalid person image cache target %q: %w", job.TargetContentID, parseErr)
break
}
updated, err = p.targets.People.UpdatePhotoIfSourceMatches(ctx, personID, job.SourcePath, cachedPath, result.Thumbhash)
default:
err = fmt.Errorf("unknown image cache target type %q", job.TargetType)
}
if err != nil {
p.markFailed(ctx, job, err.Error())
return "failed"
}
if !updated {
p.markSucceeded(ctx, job)
return "skipped"
}
p.markSucceeded(ctx, job)
return "succeeded"
}
func imageCacheJobImageType(value string) (ImageType, error) {
switch value {
case ImageCacheImagePoster:
return ImagePoster, nil
case ImageCacheImageBackdrop:
return ImageBackdrop, nil
case ImageCacheImageLogo:
return ImageLogo, nil
case ImageCacheImageStill:
return ImageStill, nil
case ImageCacheImageProfile:
return ImageProfile, nil
default:
return ImagePoster, fmt.Errorf("unknown metadata image cache image type %q", value)
}
}