Files
silo-server/migrations/sql/20260714120826_artwork_revision_gc.sql
1664c60425 fix(metadata): publish artwork revisions atomically (#399)
* 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>
2026-07-16 17:33:21 -04:00

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;