Files
silo-server/migrations/sql/20260617184537_metadata_image_cache_queue.sql
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

88 lines
3.1 KiB
SQL

-- +goose Up
ALTER TABLE public.seasons
ADD COLUMN IF NOT EXISTS poster_source_path text NOT NULL DEFAULT '';
ALTER TABLE public.episodes
ADD COLUMN IF NOT EXISTS still_source_path text NOT NULL DEFAULT '';
UPDATE public.seasons
SET poster_source_path = poster_path
WHERE poster_source_path = ''
AND poster_path LIKE '%://%'
AND lower(poster_path) NOT LIKE ALL (ARRAY['s3://%', 'file://%', 'local://%', 'upload://%', 'generated://%']);
UPDATE public.episodes
SET still_source_path = still_path
WHERE still_source_path = ''
AND still_path LIKE '%://%'
AND lower(still_path) NOT LIKE ALL (ARRAY['s3://%', 'file://%', 'local://%', 'upload://%', 'generated://%']);
CREATE TABLE public.metadata_image_cache_jobs (
id bigserial PRIMARY KEY,
target_type text NOT NULL,
target_content_id text NOT NULL,
series_id text NOT NULL,
source_path text NOT NULL,
provider_id text NOT NULL,
provider_content_id text NOT NULL,
content_type text NOT NULL DEFAULT 'series',
image_type text NOT NULL,
season_number integer,
episode_number integer,
status text NOT NULL DEFAULT 'queued',
attempt_count integer NOT NULL DEFAULT 0,
next_attempt_at timestamp with time zone NOT NULL DEFAULT now(),
locked_at timestamp with time zone,
locked_by text NOT NULL DEFAULT '',
last_error text NOT NULL DEFAULT '',
created_at timestamp with time zone NOT NULL DEFAULT now(),
updated_at timestamp with time zone NOT NULL DEFAULT now(),
completed_at timestamp with time zone,
CONSTRAINT metadata_image_cache_jobs_target_check
CHECK (target_type IN ('season', 'episode')),
CONSTRAINT metadata_image_cache_jobs_image_type_check
CHECK (image_type IN ('poster', 'still')),
CONSTRAINT metadata_image_cache_jobs_status_check
CHECK (status IN ('queued', 'running', 'succeeded', 'failed')),
CONSTRAINT metadata_image_cache_jobs_shape_check CHECK (
(
target_type = 'season'
AND image_type = 'poster'
AND season_number IS NOT NULL
AND episode_number IS NULL
)
OR (
target_type = 'episode'
AND image_type = 'still'
AND season_number IS NOT NULL
AND episode_number IS NOT NULL
)
),
CONSTRAINT metadata_image_cache_jobs_target_unique
UNIQUE (target_type, target_content_id, image_type)
);
CREATE INDEX metadata_image_cache_jobs_due_idx
ON public.metadata_image_cache_jobs (next_attempt_at, id)
WHERE status = 'queued';
CREATE INDEX metadata_image_cache_jobs_running_lease_idx
ON public.metadata_image_cache_jobs (locked_at, id)
WHERE status = 'running';
CREATE INDEX metadata_image_cache_jobs_series_idx
ON public.metadata_image_cache_jobs (series_id, status);
CREATE INDEX metadata_image_cache_jobs_succeeded_retention_idx
ON public.metadata_image_cache_jobs (completed_at, id)
WHERE status = 'succeeded';
-- +goose Down
DROP TABLE IF EXISTS public.metadata_image_cache_jobs;
ALTER TABLE public.episodes
DROP COLUMN IF EXISTS still_source_path;
ALTER TABLE public.seasons
DROP COLUMN IF EXISTS poster_source_path;