* fix(metadata): publish artwork revisions atomically * fix(metadata): harden artwork revision cleanup * fix(metadata): address artwork revision review findings - restore image applies for all media_items types and reject unsupported target/image combinations with 400 before uploading; episodes coerce to stills and the web dialog no longer offers image tabs episodes can't use - add WHEN clauses to displacement triggers and hoist to_jsonb so bulk catalog upserts that assign unchanged artwork columns skip the trigger - make artworkkey the single variant-ladder owner: imagecache derives its widths from it and triggers store image_type instead of hardcoded variant arrays, expanded by the collector at deletion time - sweep dormant registry rows periodically so references lost through untriggered surfaces degrade to slow cleanup instead of leaking - park just-published revisions dormant, keep dormant rows dormant on re-cache, and batch the GC reference pre-check per run - heal rows re-referencing a just-deleted revision via reconciler-style resets after the deletion commits - share a per-URL image-loaded hook across DetailHero, ItemCard, SectionItemCard, GlobalSearch, and CollectionPosterCard - deduplicate Cache/CacheBytes finalization and drop unused VariantPaths plumbing Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(catalog): cast reused timestamp parameter in revision upsert Postgres cannot deduce one type for $3 used both as a plain value and inside a CASE arm; the dev deploy surfaced it as SQLSTATE 42P08 on every publication. Cast both uses and cover the arm/park/track upserts with database-backed tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(metadata): address artwork revision review comments - keep a durable heal path: deletion marks deleted_at instead of removing the registry row, so a failed post-delete heal retries with backoff and broken references never park; trackers clear the marker on re-upload - never treat bare existence as an immutable-content match; backends without content verification rewrite the object - exercise revisioned cover keys in scanner/enrichment fakes, compare the tracked manifest exactly, and honor cancellation in the blocking test deleter Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
196 lines
8.9 KiB
PL/PgSQL
196 lines
8.9 KiB
PL/PgSQL
-- +goose Up
|
|
CREATE TABLE public.artwork_revision_gc_candidates (
|
|
id bigserial PRIMARY KEY,
|
|
original_path text NOT NULL UNIQUE,
|
|
-- Image type ("poster", "backdrop", ...) lets the collector expand the
|
|
-- expected object keys in Go (artworkkey.ObjectKeys) when no exact
|
|
-- manifest was tracked, so the variant ladder lives in one place.
|
|
image_type text NOT NULL DEFAULT '',
|
|
object_keys text[] NOT NULL DEFAULT '{}',
|
|
not_before timestamptz NOT NULL,
|
|
-- NULL means the revision is currently referenced and dormant. Artwork
|
|
-- displacement triggers or the collector's dormant sweep reactivate it.
|
|
next_attempt_at timestamptz,
|
|
-- Set once the objects have been deleted from storage. The row then only
|
|
-- survives until the post-delete reference heal succeeds, so a transient
|
|
-- heal failure keeps a durable retry instead of orphaning broken pointers.
|
|
deleted_at timestamptz,
|
|
attempt_count integer NOT NULL DEFAULT 0,
|
|
locked_at timestamptz,
|
|
locked_by text NOT NULL DEFAULT '',
|
|
last_error text NOT NULL DEFAULT '',
|
|
created_at timestamptz NOT NULL DEFAULT NOW(),
|
|
updated_at timestamptz NOT NULL DEFAULT NOW(),
|
|
CONSTRAINT artwork_revision_gc_original_path_check CHECK (BTRIM(original_path) <> ''),
|
|
CONSTRAINT artwork_revision_gc_manifest_check CHECK (cardinality(object_keys) > 0 OR BTRIM(image_type) <> ''),
|
|
CONSTRAINT artwork_revision_gc_attempt_count_check CHECK (attempt_count >= 0)
|
|
);
|
|
|
|
CREATE INDEX artwork_revision_gc_due_idx
|
|
ON public.artwork_revision_gc_candidates (next_attempt_at, id)
|
|
WHERE locked_at IS NULL AND next_attempt_at IS NOT NULL;
|
|
|
|
CREATE INDEX artwork_revision_gc_lease_idx
|
|
ON public.artwork_revision_gc_candidates (locked_at, id)
|
|
WHERE locked_at IS NOT NULL;
|
|
|
|
-- The dormant sweep periodically re-verifies parked rows so a reference that
|
|
-- disappears through a surface without a displacement trigger degrades to
|
|
-- slower cleanup instead of a permanent leak.
|
|
CREATE INDEX artwork_revision_gc_dormant_idx
|
|
ON public.artwork_revision_gc_candidates (updated_at, id)
|
|
WHERE next_attempt_at IS NULL;
|
|
|
|
-- Queue displaced immutable artwork at the database boundary so every writer
|
|
-- participates in the same lifecycle, including background refreshes, scanners,
|
|
-- localizations, and future code paths that do not use the admin publication API.
|
|
-- The trigger stores only the displaced path and its image type; the collector
|
|
-- expands object keys in Go so the variant ladder has a single source of truth.
|
|
-- +goose StatementBegin
|
|
CREATE OR REPLACE FUNCTION public.queue_displaced_artwork_revision()
|
|
RETURNS trigger
|
|
LANGUAGE plpgsql
|
|
AS $$
|
|
DECLARE
|
|
old_row jsonb := to_jsonb(OLD);
|
|
new_row jsonb;
|
|
arg_index integer := 0;
|
|
path_column text;
|
|
image_type text;
|
|
previous_path text;
|
|
replacement_path text;
|
|
cleanup_at timestamptz := NOW() + interval '24 hours';
|
|
BEGIN
|
|
IF TG_OP = 'UPDATE' THEN
|
|
new_row := to_jsonb(NEW);
|
|
END IF;
|
|
|
|
WHILE arg_index < TG_NARGS LOOP
|
|
path_column := TG_ARGV[arg_index];
|
|
image_type := TG_ARGV[arg_index + 1];
|
|
previous_path := old_row ->> path_column;
|
|
replacement_path := CASE WHEN new_row IS NULL THEN NULL ELSE new_row ->> path_column END;
|
|
|
|
IF COALESCE(BTRIM(previous_path), '') <> ''
|
|
AND previous_path NOT LIKE '%://%'
|
|
AND previous_path ~ '/original\.[^/]+$'
|
|
AND (TG_OP = 'DELETE' OR previous_path IS DISTINCT FROM replacement_path) THEN
|
|
INSERT INTO public.artwork_revision_gc_candidates (
|
|
original_path, image_type, object_keys, not_before, next_attempt_at
|
|
) VALUES (previous_path, image_type, '{}', cleanup_at, cleanup_at)
|
|
ON CONFLICT (original_path) DO UPDATE SET
|
|
image_type = CASE
|
|
WHEN artwork_revision_gc_candidates.image_type = '' THEN EXCLUDED.image_type
|
|
ELSE artwork_revision_gc_candidates.image_type
|
|
END,
|
|
not_before = EXCLUDED.not_before,
|
|
next_attempt_at = EXCLUDED.next_attempt_at,
|
|
attempt_count = 0,
|
|
locked_at = NULL,
|
|
locked_by = '',
|
|
last_error = '',
|
|
updated_at = NOW();
|
|
END IF;
|
|
|
|
arg_index := arg_index + 2;
|
|
END LOOP;
|
|
|
|
IF TG_OP = 'DELETE' THEN
|
|
RETURN OLD;
|
|
END IF;
|
|
RETURN NEW;
|
|
END;
|
|
$$;
|
|
-- +goose StatementEnd
|
|
|
|
-- UPDATE triggers carry WHEN clauses so the function only runs when an artwork
|
|
-- column actually changes; catalog upserts assign these columns on every row
|
|
-- write. DELETE triggers cannot reference NEW and are declared separately.
|
|
CREATE TRIGGER media_items_artwork_revision_gc_update
|
|
AFTER UPDATE OF poster_path, backdrop_path, logo_path ON public.media_items
|
|
FOR EACH ROW
|
|
WHEN (OLD.poster_path IS DISTINCT FROM NEW.poster_path
|
|
OR OLD.backdrop_path IS DISTINCT FROM NEW.backdrop_path
|
|
OR OLD.logo_path IS DISTINCT FROM NEW.logo_path)
|
|
EXECUTE FUNCTION public.queue_displaced_artwork_revision(
|
|
'poster_path', 'poster', 'backdrop_path', 'backdrop', 'logo_path', 'logo'
|
|
);
|
|
|
|
CREATE TRIGGER media_items_artwork_revision_gc_delete
|
|
AFTER DELETE ON public.media_items
|
|
FOR EACH ROW EXECUTE FUNCTION public.queue_displaced_artwork_revision(
|
|
'poster_path', 'poster', 'backdrop_path', 'backdrop', 'logo_path', 'logo'
|
|
);
|
|
|
|
CREATE TRIGGER media_item_localizations_artwork_revision_gc_update
|
|
AFTER UPDATE OF poster_path, backdrop_path, logo_path ON public.media_item_localizations
|
|
FOR EACH ROW
|
|
WHEN (OLD.poster_path IS DISTINCT FROM NEW.poster_path
|
|
OR OLD.backdrop_path IS DISTINCT FROM NEW.backdrop_path
|
|
OR OLD.logo_path IS DISTINCT FROM NEW.logo_path)
|
|
EXECUTE FUNCTION public.queue_displaced_artwork_revision(
|
|
'poster_path', 'poster', 'backdrop_path', 'backdrop', 'logo_path', 'logo'
|
|
);
|
|
|
|
CREATE TRIGGER media_item_localizations_artwork_revision_gc_delete
|
|
AFTER DELETE ON public.media_item_localizations
|
|
FOR EACH ROW EXECUTE FUNCTION public.queue_displaced_artwork_revision(
|
|
'poster_path', 'poster', 'backdrop_path', 'backdrop', 'logo_path', 'logo'
|
|
);
|
|
|
|
CREATE TRIGGER seasons_artwork_revision_gc_update
|
|
AFTER UPDATE OF poster_path ON public.seasons
|
|
FOR EACH ROW
|
|
WHEN (OLD.poster_path IS DISTINCT FROM NEW.poster_path)
|
|
EXECUTE FUNCTION public.queue_displaced_artwork_revision('poster_path', 'poster');
|
|
|
|
CREATE TRIGGER seasons_artwork_revision_gc_delete
|
|
AFTER DELETE ON public.seasons
|
|
FOR EACH ROW EXECUTE FUNCTION public.queue_displaced_artwork_revision('poster_path', 'poster');
|
|
|
|
CREATE TRIGGER season_localizations_artwork_revision_gc_update
|
|
AFTER UPDATE OF poster_path ON public.season_localizations
|
|
FOR EACH ROW
|
|
WHEN (OLD.poster_path IS DISTINCT FROM NEW.poster_path)
|
|
EXECUTE FUNCTION public.queue_displaced_artwork_revision('poster_path', 'poster');
|
|
|
|
CREATE TRIGGER season_localizations_artwork_revision_gc_delete
|
|
AFTER DELETE ON public.season_localizations
|
|
FOR EACH ROW EXECUTE FUNCTION public.queue_displaced_artwork_revision('poster_path', 'poster');
|
|
|
|
CREATE TRIGGER episodes_artwork_revision_gc_update
|
|
AFTER UPDATE OF still_path ON public.episodes
|
|
FOR EACH ROW
|
|
WHEN (OLD.still_path IS DISTINCT FROM NEW.still_path)
|
|
EXECUTE FUNCTION public.queue_displaced_artwork_revision('still_path', 'still');
|
|
|
|
CREATE TRIGGER episodes_artwork_revision_gc_delete
|
|
AFTER DELETE ON public.episodes
|
|
FOR EACH ROW EXECUTE FUNCTION public.queue_displaced_artwork_revision('still_path', 'still');
|
|
|
|
CREATE TRIGGER people_artwork_revision_gc_update
|
|
AFTER UPDATE OF photo_path ON public.people
|
|
FOR EACH ROW
|
|
WHEN (OLD.photo_path IS DISTINCT FROM NEW.photo_path)
|
|
EXECUTE FUNCTION public.queue_displaced_artwork_revision('photo_path', 'profile');
|
|
|
|
CREATE TRIGGER people_artwork_revision_gc_delete
|
|
AFTER DELETE ON public.people
|
|
FOR EACH ROW EXECUTE FUNCTION public.queue_displaced_artwork_revision('photo_path', 'profile');
|
|
|
|
-- +goose Down
|
|
DROP TRIGGER IF EXISTS people_artwork_revision_gc_delete ON public.people;
|
|
DROP TRIGGER IF EXISTS people_artwork_revision_gc_update ON public.people;
|
|
DROP TRIGGER IF EXISTS episodes_artwork_revision_gc_delete ON public.episodes;
|
|
DROP TRIGGER IF EXISTS episodes_artwork_revision_gc_update ON public.episodes;
|
|
DROP TRIGGER IF EXISTS season_localizations_artwork_revision_gc_delete ON public.season_localizations;
|
|
DROP TRIGGER IF EXISTS season_localizations_artwork_revision_gc_update ON public.season_localizations;
|
|
DROP TRIGGER IF EXISTS seasons_artwork_revision_gc_delete ON public.seasons;
|
|
DROP TRIGGER IF EXISTS seasons_artwork_revision_gc_update ON public.seasons;
|
|
DROP TRIGGER IF EXISTS media_item_localizations_artwork_revision_gc_delete ON public.media_item_localizations;
|
|
DROP TRIGGER IF EXISTS media_item_localizations_artwork_revision_gc_update ON public.media_item_localizations;
|
|
DROP TRIGGER IF EXISTS media_items_artwork_revision_gc_delete ON public.media_items;
|
|
DROP TRIGGER IF EXISTS media_items_artwork_revision_gc_update ON public.media_items;
|
|
DROP FUNCTION IF EXISTS public.queue_displaced_artwork_revision();
|
|
DROP TABLE IF EXISTS public.artwork_revision_gc_candidates;
|