Files
silo-server/internal/metadata/image_cache_processor_test.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

561 lines
18 KiB
Go

package metadata
import (
"context"
"errors"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/models"
)
type fakeImageCacheJobs struct {
claimed []*models.MetadataImageCacheJob
succeededID int64
failedID int64
failedText string
deletedCount int
requeuedIDs []int64
currentSource *string // when set, overrides CurrentTargetSourcePath
}
func (f *fakeImageCacheJobs) ClaimDue(context.Context, string, int) ([]*models.MetadataImageCacheJob, error) {
return f.claimed, nil
}
func (f *fakeImageCacheJobs) MarkSucceeded(_ context.Context, id int64, _ string) error {
f.succeededID = id
return nil
}
func (f *fakeImageCacheJobs) MarkFailed(_ context.Context, id int64, _ int, _ string, errText string) error {
f.failedID = id
f.failedText = errText
return nil
}
func (f *fakeImageCacheJobs) RequeueClaimed(_ context.Context, ids []int64, _ string) error {
f.requeuedIDs = append(f.requeuedIDs, ids...)
return nil
}
func (f *fakeImageCacheJobs) CurrentTargetSourcePath(_ context.Context, job *models.MetadataImageCacheJob) (string, error) {
if f.currentSource != nil {
return *f.currentSource, nil
}
return job.SourcePath, nil
}
func (f *fakeImageCacheJobs) EnqueueExistingProviderArtwork(context.Context, int) (int, error) {
return 0, nil
}
func (f *fakeImageCacheJobs) DeleteSucceededBefore(context.Context, time.Time, int) (int, error) {
return f.deletedCount, nil
}
type loopingImageCacheJobs struct {
enqueueResults []int
claimedResults [][]*models.MetadataImageCacheJob
succeededIDs []int64
enqueueCalls int
claimCalls int
}
func (f *loopingImageCacheJobs) EnqueueExistingProviderArtwork(context.Context, int) (int, error) {
result := 0
if f.enqueueCalls < len(f.enqueueResults) {
result = f.enqueueResults[f.enqueueCalls]
}
f.enqueueCalls++
return result, nil
}
func (f *loopingImageCacheJobs) ClaimDue(context.Context, string, int) ([]*models.MetadataImageCacheJob, error) {
var result []*models.MetadataImageCacheJob
if f.claimCalls < len(f.claimedResults) {
result = f.claimedResults[f.claimCalls]
}
f.claimCalls++
return result, nil
}
func (f *loopingImageCacheJobs) MarkSucceeded(_ context.Context, id int64, _ string) error {
f.succeededIDs = append(f.succeededIDs, id)
return nil
}
func (f *loopingImageCacheJobs) MarkFailed(context.Context, int64, int, string, string) error {
return nil
}
func (f *loopingImageCacheJobs) RequeueClaimed(context.Context, []int64, string) error {
return nil
}
func (f *loopingImageCacheJobs) CurrentTargetSourcePath(_ context.Context, job *models.MetadataImageCacheJob) (string, error) {
return job.SourcePath, nil
}
func (f *loopingImageCacheJobs) DeleteSucceededBefore(context.Context, time.Time, int) (int, error) {
return 0, nil
}
type fakeImageCacher struct {
result *CacheImageResult
err error
reqs []CacheImageRequest
}
func (f *fakeImageCacher) CacheImage(_ context.Context, req CacheImageRequest) (*CacheImageResult, error) {
f.reqs = append(f.reqs, req)
if f.err != nil {
return nil, f.err
}
return f.result, nil
}
type fakeImageResolver struct {
url string
}
func (f *fakeImageResolver) ResolveImageURL(context.Context, string, string) string {
return f.url
}
type fakeEpisodeStillUpdater struct {
updated bool
contentID string
sourcePath string
cachedPath string
thumbhash string
}
func (f *fakeEpisodeStillUpdater) UpdateStillIfSourceMatches(_ context.Context, contentID, sourcePath, cachedPath, thumbhash string) (bool, error) {
f.contentID = contentID
f.sourcePath = sourcePath
f.cachedPath = cachedPath
f.thumbhash = thumbhash
return f.updated, nil
}
type fakeItemArtworkUpdater struct {
updated bool
contentID string
imageType string
sourcePath string
cachedPath string
thumbhash string
}
func (f *fakeItemArtworkUpdater) UpdateArtworkIfSourceMatches(_ context.Context, contentID, imageType, sourcePath, cachedPath, thumbhash string) (bool, error) {
f.contentID = contentID
f.imageType = imageType
f.sourcePath = sourcePath
f.cachedPath = cachedPath
f.thumbhash = thumbhash
return f.updated, nil
}
type fakeItemLocalizationArtworkUpdater struct {
updated bool
contentID string
language string
imageType string
sourcePath string
cachedPath string
thumbhash string
}
func (f *fakeItemLocalizationArtworkUpdater) UpdateArtworkIfSourceMatches(_ context.Context, contentID, language, imageType, sourcePath, cachedPath, thumbhash string) (bool, error) {
f.contentID = contentID
f.language = language
f.imageType = imageType
f.sourcePath = sourcePath
f.cachedPath = cachedPath
f.thumbhash = thumbhash
return f.updated, nil
}
type fakePersonPhotoUpdater struct {
updated bool
personID int64
sourcePath string
cachedPath string
thumbhash string
}
func (f *fakePersonPhotoUpdater) UpdatePhotoIfSourceMatches(_ context.Context, personID int64, sourcePath, cachedPath, thumbhash string) (bool, error) {
f.personID = personID
f.sourcePath = sourcePath
f.cachedPath = cachedPath
f.thumbhash = thumbhash
return f.updated, nil
}
func TestImageCacheProcessorUpdatesEpisodeOnSuccess(t *testing.T) {
jobs := &fakeImageCacheJobs{claimed: []*models.MetadataImageCacheJob{{
ID: 1,
TargetType: ImageCacheTargetEpisode,
TargetContentID: "episode-tvdb-1-1-1",
SourcePath: "tvdb://banners/episode.jpg",
ProviderID: "tvdb",
ProviderContentID: "1",
ContentType: "series",
ImageType: ImageCacheImageStill,
SeasonNumber: intPointer(1),
EpisodeNumber: intPointer(1),
}}}
cacher := &fakeImageCacher{result: &CacheImageResult{
BasePath: "tvdb/series/1/seasons/1/episodes/1/still",
Ext: ".webp",
Thumbhash: "thumb",
}}
resolver := &fakeImageResolver{url: "https://artworks.thetvdb.com/banners/episode.jpg"}
episodes := &fakeEpisodeStillUpdater{updated: true}
processor := NewImageCacheProcessor(jobs, cacher, resolver, nil, episodes)
stats, err := processor.RunOnce(context.Background(), "test-worker", 10, 1)
if err != nil {
t.Fatalf("RunOnce() error = %v", err)
}
if stats.Succeeded != 1 {
t.Fatalf("Succeeded = %d, want 1", stats.Succeeded)
}
if episodes.cachedPath != "tvdb/series/1/seasons/1/episodes/1/still/original.webp" {
t.Fatalf("cachedPath = %q", episodes.cachedPath)
}
if episodes.sourcePath != "tvdb://banners/episode.jpg" {
t.Fatalf("sourcePath = %q", episodes.sourcePath)
}
if jobs.succeededID != 1 {
t.Fatalf("succeededID = %d", jobs.succeededID)
}
}
func TestImageCacheProcessorUpdatesItemArtworkOnSuccess(t *testing.T) {
jobs := &fakeImageCacheJobs{claimed: []*models.MetadataImageCacheJob{{
ID: 20,
TargetType: ImageCacheTargetItem,
TargetContentID: "series-1",
SourcePath: "tmdb://poster/series.jpg",
ProviderID: "tmdb",
ProviderContentID: "1396",
ContentType: "series",
ImageType: ImageCacheImageBackdrop,
}}}
cacher := &fakeImageCacher{result: &CacheImageResult{
BasePath: "tmdb/series/1396/backdrop",
Ext: ".webp",
Thumbhash: "thumb",
}}
resolver := &fakeImageResolver{url: "https://image.tmdb.org/t/p/original/backdrop.jpg"}
items := &fakeItemArtworkUpdater{updated: true}
processor := NewImageCacheProcessorWithTargets(jobs, cacher, resolver, ImageCacheProcessorTargets{
Items: items,
})
stats, err := processor.RunOnce(context.Background(), "test-worker", 10, 1)
if err != nil {
t.Fatalf("RunOnce() error = %v", err)
}
if stats.Succeeded != 1 {
t.Fatalf("Succeeded = %d, want 1", stats.Succeeded)
}
if items.imageType != ImageCacheImageBackdrop {
t.Fatalf("imageType = %q, want backdrop", items.imageType)
}
if items.cachedPath != "tmdb/series/1396/backdrop/original.webp" {
t.Fatalf("cachedPath = %q", items.cachedPath)
}
}
func TestImageCacheProcessorPassesLanguageToLocalizedItemArtwork(t *testing.T) {
jobs := &fakeImageCacheJobs{claimed: []*models.MetadataImageCacheJob{{
ID: 21,
TargetType: ImageCacheTargetItemLocalization,
TargetContentID: "series-1",
TargetLanguage: "fr",
SourcePath: "tmdb://logo/fr.png",
ProviderID: "tmdb",
ProviderContentID: "1396",
ContentType: "series",
ImageType: ImageCacheImageLogo,
}}}
cacher := &fakeImageCacher{result: &CacheImageResult{
BasePath: "tmdb/series/1396/localizations/fr/logo",
Ext: ".webp",
}}
resolver := &fakeImageResolver{url: "https://image.tmdb.org/t/p/original/logo.png"}
localizations := &fakeItemLocalizationArtworkUpdater{updated: true}
processor := NewImageCacheProcessorWithTargets(jobs, cacher, resolver, ImageCacheProcessorTargets{
ItemLocalizations: localizations,
})
stats, err := processor.RunOnce(context.Background(), "test-worker", 10, 1)
if err != nil {
t.Fatalf("RunOnce() error = %v", err)
}
if stats.Succeeded != 1 {
t.Fatalf("Succeeded = %d, want 1", stats.Succeeded)
}
if len(cacher.reqs) != 1 || cacher.reqs[0].Language != "fr" {
t.Fatalf("cached request language = %#v, want fr", cacher.reqs)
}
if localizations.language != "fr" || localizations.imageType != ImageCacheImageLogo {
t.Fatalf("localization update = language %q image %q", localizations.language, localizations.imageType)
}
}
func TestImageCacheProcessorUpdatesPersonProfileOnSuccess(t *testing.T) {
jobs := &fakeImageCacheJobs{claimed: []*models.MetadataImageCacheJob{{
ID: 22,
TargetType: ImageCacheTargetPerson,
TargetContentID: "287",
SourcePath: "tmdb://profile/287.jpg",
ProviderID: "tmdb",
ProviderContentID: "287",
ContentType: "people",
ImageType: ImageCacheImageProfile,
}}}
cacher := &fakeImageCacher{result: &CacheImageResult{
BasePath: "tmdb/people/287/profile",
Ext: ".webp",
Thumbhash: "person-thumb",
}}
resolver := &fakeImageResolver{url: "https://image.tmdb.org/t/p/original/person.jpg"}
people := &fakePersonPhotoUpdater{updated: true}
processor := NewImageCacheProcessorWithTargets(jobs, cacher, resolver, ImageCacheProcessorTargets{
People: people,
})
stats, err := processor.RunOnce(context.Background(), "test-worker", 10, 1)
if err != nil {
t.Fatalf("RunOnce() error = %v", err)
}
if stats.Succeeded != 1 {
t.Fatalf("Succeeded = %d, want 1", stats.Succeeded)
}
if people.personID != 287 {
t.Fatalf("personID = %d, want 287", people.personID)
}
if people.cachedPath != "tmdb/people/287/profile/original.webp" {
t.Fatalf("cachedPath = %q", people.cachedPath)
}
if people.thumbhash != "person-thumb" {
t.Fatalf("thumbhash = %q", people.thumbhash)
}
}
func TestImageCacheProcessorMarksSkippedWhenSourceNoLongerMatches(t *testing.T) {
jobs := &fakeImageCacheJobs{claimed: []*models.MetadataImageCacheJob{{
ID: 2,
TargetType: ImageCacheTargetEpisode,
TargetContentID: "episode-tvdb-1-1-1",
SourcePath: "tvdb://banners/episode.jpg",
ProviderID: "tvdb",
ProviderContentID: "1",
ContentType: "series",
ImageType: ImageCacheImageStill,
SeasonNumber: intPointer(1),
EpisodeNumber: intPointer(1),
}}}
cacher := &fakeImageCacher{result: &CacheImageResult{BasePath: "tvdb/series/1/seasons/1/episodes/1/still", Ext: ".webp"}}
resolver := &fakeImageResolver{url: "https://artworks.thetvdb.com/banners/episode.jpg"}
episodes := &fakeEpisodeStillUpdater{updated: false}
processor := NewImageCacheProcessor(jobs, cacher, resolver, nil, episodes)
stats, err := processor.RunOnce(context.Background(), "test-worker", 10, 1)
if err != nil {
t.Fatalf("RunOnce() error = %v", err)
}
if stats.Skipped != 1 {
t.Fatalf("Skipped = %d, want 1", stats.Skipped)
}
if jobs.succeededID != 2 {
t.Fatalf("succeededID = %d, want 2", jobs.succeededID)
}
}
func TestImageCacheProcessorMarksFailureOnCacheError(t *testing.T) {
jobs := &fakeImageCacheJobs{claimed: []*models.MetadataImageCacheJob{{
ID: 3,
TargetType: ImageCacheTargetEpisode,
TargetContentID: "episode-tvdb-1-1-1",
SourcePath: "tvdb://banners/episode.jpg",
ProviderID: "tvdb",
ProviderContentID: "1",
ContentType: "series",
ImageType: ImageCacheImageStill,
SeasonNumber: intPointer(1),
EpisodeNumber: intPointer(1),
}}}
cacher := &fakeImageCacher{err: errors.New("cache failed")}
resolver := &fakeImageResolver{url: "https://artworks.thetvdb.com/banners/episode.jpg"}
episodes := &fakeEpisodeStillUpdater{updated: false}
processor := NewImageCacheProcessor(jobs, cacher, resolver, nil, episodes)
stats, err := processor.RunOnce(context.Background(), "test-worker", 10, 1)
if err != nil {
t.Fatalf("RunOnce() error = %v", err)
}
if stats.Failed != 1 {
t.Fatalf("Failed = %d, want 1", stats.Failed)
}
if jobs.failedID != 3 {
t.Fatalf("failedID = %d, want 3", jobs.failedID)
}
}
func TestImageCacheProcessorMarksFailureOnEmptyCacheResult(t *testing.T) {
jobs := &fakeImageCacheJobs{claimed: []*models.MetadataImageCacheJob{{
ID: 4,
TargetType: ImageCacheTargetEpisode,
TargetContentID: "episode-tvdb-1-1-1",
SourcePath: "tvdb://banners/episode.jpg",
ProviderID: "tvdb",
ProviderContentID: "1",
ContentType: "series",
ImageType: ImageCacheImageStill,
SeasonNumber: intPointer(1),
EpisodeNumber: intPointer(1),
}}}
cacher := &fakeImageCacher{result: &CacheImageResult{}}
resolver := &fakeImageResolver{url: "https://artworks.thetvdb.com/banners/episode.jpg"}
episodes := &fakeEpisodeStillUpdater{updated: true}
processor := NewImageCacheProcessor(jobs, cacher, resolver, nil, episodes)
stats, err := processor.RunOnce(context.Background(), "test-worker", 10, 1)
if err != nil {
t.Fatalf("RunOnce() error = %v", err)
}
if stats.Failed != 1 {
t.Fatalf("Failed = %d, want 1", stats.Failed)
}
if jobs.failedID != 4 {
t.Fatalf("failedID = %d, want 4", jobs.failedID)
}
if episodes.cachedPath != "" {
t.Fatalf("episode updater was called with cachedPath = %q", episodes.cachedPath)
}
}
func TestImageCacheProcessorDeletesOldSucceededJobsWithoutClaimedJobs(t *testing.T) {
jobs := &fakeImageCacheJobs{deletedCount: 7}
processor := NewImageCacheProcessor(jobs, &fakeImageCacher{}, nil, nil, nil)
stats, err := processor.RunOnce(context.Background(), "test-worker", 10, 1)
if err != nil {
t.Fatalf("RunOnce() error = %v", err)
}
if stats.DeletedSucceeded != 7 {
t.Fatalf("DeletedSucceeded = %d, want 7", stats.DeletedSucceeded)
}
}
func TestImageCacheProcessorRunUntilIdleDrainsNewWorkAddedDuringRun(t *testing.T) {
job1 := &models.MetadataImageCacheJob{
ID: 10,
TargetType: ImageCacheTargetEpisode,
TargetContentID: "episode-tvdb-1-1-1",
SourcePath: "tvdb://banners/episode-1.jpg",
ProviderID: "tvdb",
ProviderContentID: "1",
ContentType: "series",
ImageType: ImageCacheImageStill,
SeasonNumber: intPointer(1),
EpisodeNumber: intPointer(1),
}
job2 := &models.MetadataImageCacheJob{
ID: 11,
TargetType: ImageCacheTargetEpisode,
TargetContentID: "episode-tvdb-1-1-2",
SourcePath: "tvdb://banners/episode-2.jpg",
ProviderID: "tvdb",
ProviderContentID: "1",
ContentType: "series",
ImageType: ImageCacheImageStill,
SeasonNumber: intPointer(1),
EpisodeNumber: intPointer(2),
}
// Discovery now runs only when the queue drains, not on every batch: drain
// job1, find the queue empty and sweep (enqueues 1 more), drain job2, find
// the queue empty and sweep again (enqueues 0 -> idle).
jobs := &loopingImageCacheJobs{
enqueueResults: []int{1, 0},
claimedResults: [][]*models.MetadataImageCacheJob{
{job1},
{},
{job2},
{},
},
}
cacher := &fakeImageCacher{result: &CacheImageResult{
BasePath: "tvdb/series/1/seasons/1/episodes/1/still",
Ext: ".webp",
}}
resolver := &fakeImageResolver{url: "https://artworks.thetvdb.com/banners/episode.jpg"}
episodes := &fakeEpisodeStillUpdater{updated: true}
processor := NewImageCacheProcessor(jobs, cacher, resolver, nil, episodes)
stats, err := processor.RunUntilIdle(context.Background(), "test-worker", 1000, 2, time.Minute)
if err != nil {
t.Fatalf("RunUntilIdle() error = %v", err)
}
if stats.Batches != 4 {
t.Fatalf("Batches = %d, want 4", stats.Batches)
}
if stats.EnqueuedExisting != 1 || stats.Claimed != 2 || stats.Succeeded != 2 {
t.Fatalf("stats = %+v, want enqueued=1 claimed=2 succeeded=2", stats)
}
if jobs.enqueueCalls != 2 || jobs.claimCalls != 4 {
t.Fatalf("calls enqueue=%d claim=%d, want enqueue=2 claim=4", jobs.enqueueCalls, jobs.claimCalls)
}
if len(jobs.succeededIDs) != 2 || jobs.succeededIDs[0] != 10 || jobs.succeededIDs[1] != 11 {
t.Fatalf("succeededIDs = %#v, want [10 11]", jobs.succeededIDs)
}
}
func TestImageCacheProcessorSkipsWhenTargetSourceChanged(t *testing.T) {
// A stale job whose target no longer references its source must not upload.
changed := "tmdb://poster/new.jpg"
jobs := &fakeImageCacheJobs{
claimed: []*models.MetadataImageCacheJob{{
ID: 40,
TargetType: ImageCacheTargetItem,
TargetContentID: "series-1",
SourcePath: "tmdb://poster/old.jpg",
ProviderID: "tmdb",
ProviderContentID: "1396",
ContentType: "series",
ImageType: ImageCacheImagePoster,
}},
currentSource: &changed,
}
cacher := &fakeImageCacher{result: &CacheImageResult{BasePath: "tmdb/series/1396/poster", Ext: ".webp"}}
resolver := &fakeImageResolver{url: "https://image.tmdb.org/t/p/original/poster.jpg"}
items := &fakeItemArtworkUpdater{updated: true}
processor := NewImageCacheProcessorWithTargets(jobs, cacher, resolver, ImageCacheProcessorTargets{Items: items})
stats, err := processor.RunOnce(context.Background(), "test-worker", 10, 1)
if err != nil {
t.Fatalf("RunOnce() error = %v", err)
}
if stats.Skipped != 1 {
t.Fatalf("Skipped = %d, want 1", stats.Skipped)
}
if len(cacher.reqs) != 0 {
t.Fatalf("CacheImage called %d times, want 0 (stale job must not upload)", len(cacher.reqs))
}
if items.cachedPath != "" {
t.Fatalf("item updater called with cachedPath = %q, want none", items.cachedPath)
}
if jobs.succeededID != 40 {
t.Fatalf("succeededID = %d, want 40", jobs.succeededID)
}
}
func intPointer(v int) *int {
return &v
}