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

516 lines
18 KiB
Go

package metadata
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/Silo-Server/silo-server/internal/artworkkey"
)
const (
artworkRevisionGCBatchSize = 100
artworkRevisionGCLease = 15 * time.Minute
// artworkRevisionDormantRecheck bounds how stale a parked (referenced)
// revision may get before the sweep re-verifies it. Displacement triggers
// are the fast path; the sweep guarantees a reference that disappears
// through an untriggered surface still becomes collectible eventually.
artworkRevisionDormantRecheck = 24 * time.Hour
)
// ArtworkRevisionDeleter is the object-storage surface used by revision GC.
type ArtworkRevisionDeleter interface {
DeleteObjects(ctx context.Context, bucket string, keys []string) (int, error)
Bucket() string
}
// ArtworkRevisionGCStats summarizes one bounded cleanup pass.
type ArtworkRevisionGCStats struct {
Claimed int `json:"claimed"`
Deleted int `json:"deleted"`
Referenced int `json:"referenced"`
Retried int `json:"retried"`
DormantChecked int `json:"dormant_checked"`
DormantRequeued int `json:"dormant_requeued"`
Healed int `json:"healed"`
}
// ArtworkRevisionGarbageCollector deletes unpublished or displaced immutable
// revisions only after their grace period and while no catalog surface
// references them. Work is leased with SKIP LOCKED so multiple workers are safe.
type ArtworkRevisionGarbageCollector struct {
pool *pgxpool.Pool
s3 ArtworkRevisionDeleter
}
func NewArtworkRevisionGarbageCollector(pool *pgxpool.Pool, s3 ArtworkRevisionDeleter) *ArtworkRevisionGarbageCollector {
if pool == nil || s3 == nil {
return nil
}
return &ArtworkRevisionGarbageCollector{pool: pool, s3: s3}
}
// Run processes one bounded batch. Failed deletions are retried with
// exponential backoff; an expired lease is recoverable by another worker.
func (g *ArtworkRevisionGarbageCollector) Run(ctx context.Context) (ArtworkRevisionGCStats, error) {
stats := ArtworkRevisionGCStats{}
if g == nil || g.pool == nil || g.s3 == nil {
return stats, fmt.Errorf("artwork revision GC is not configured")
}
workerID := uuid.NewString()
candidates, err := g.claim(ctx, workerID, artworkRevisionGCBatchSize)
if err != nil {
return stats, err
}
// Park referenced candidates with one batched reference check instead of a
// per-candidate transaction; most publish/re-cache churn lands here. The
// per-candidate path below re-verifies under the row lock before deleting,
// so a stale answer from this pre-check can only cost extra work, never a
// wrong deletion.
due := candidates
if len(candidates) > 0 {
if referenced, refErr := g.referencedPaths(ctx, candidatePaths(candidates)); refErr == nil {
var parked []int64
due = due[:0]
for _, candidate := range candidates {
// Rows whose objects were already deleted must finish their
// pending heal; a reference to them is broken, not live.
if _, ok := referenced[candidate.originalPath]; ok && candidate.deletedAt == nil {
parked = append(parked, candidate.id)
continue
}
due = append(due, candidate)
}
if len(parked) > 0 {
if parkErr := g.parkClaimed(ctx, parked, workerID); parkErr != nil {
return stats, parkErr
}
stats.Referenced += len(parked)
}
} else {
slog.WarnContext(ctx, "artwork revision GC: batched reference pre-check failed; falling back to per-candidate checks",
"component", "metadata", "error", refErr)
}
}
batchStats, err := processArtworkRevisionGCBatch(
due,
func(candidate artworkRevisionGCCandidate) (artworkRevisionGCOutcome, error) {
return g.processCandidate(ctx, candidate, workerID)
},
func(candidate artworkRevisionGCCandidate, cause error) error {
return g.retry(ctx, candidate, workerID, cause)
},
)
stats.Claimed = len(candidates)
stats.Deleted = batchStats.Deleted
stats.Referenced += batchStats.Referenced
stats.Retried = batchStats.Retried
stats.Healed = batchStats.Healed
checked, requeued, sweepErr := g.sweepDormant(ctx, artworkRevisionGCBatchSize)
stats.DormantChecked = checked
stats.DormantRequeued = requeued
if err == nil {
err = sweepErr
}
return stats, err
}
func processArtworkRevisionGCBatch(
candidates []artworkRevisionGCCandidate,
process func(artworkRevisionGCCandidate) (artworkRevisionGCOutcome, error),
retry func(artworkRevisionGCCandidate, error) error,
) (ArtworkRevisionGCStats, error) {
stats := ArtworkRevisionGCStats{Claimed: len(candidates)}
var firstErr error
for _, candidate := range candidates {
outcome, err := process(candidate)
if err != nil {
stats.Retried++
if retryErr := retry(candidate, err); retryErr != nil && firstErr == nil {
firstErr = retryErr
}
continue
}
switch outcome {
case artworkRevisionGCReferenced:
stats.Referenced++
case artworkRevisionGCDeleted:
stats.Deleted++
case artworkRevisionGCDeletedAndHealed:
stats.Deleted++
stats.Healed++
}
}
return stats, firstErr
}
type artworkRevisionGCOutcome int
const (
artworkRevisionGCSuperseded artworkRevisionGCOutcome = iota
artworkRevisionGCReferenced
artworkRevisionGCDeleted
artworkRevisionGCDeletedAndHealed
)
type artworkRevisionGCCandidate struct {
id int64
originalPath string
imageType string
objectKeys []string
attemptCount int
deletedAt *time.Time
}
func candidatePaths(candidates []artworkRevisionGCCandidate) []string {
paths := make([]string, 0, len(candidates))
for _, candidate := range candidates {
paths = append(paths, candidate.originalPath)
}
return paths
}
func (g *ArtworkRevisionGarbageCollector) claim(ctx context.Context, workerID string, limit int) ([]artworkRevisionGCCandidate, error) {
rows, err := g.pool.Query(ctx, `
WITH due AS (
SELECT id
FROM artwork_revision_gc_candidates
WHERE not_before <= NOW()
AND next_attempt_at <= NOW()
AND (locked_at IS NULL OR locked_at < NOW() - ($3 * interval '1 second'))
ORDER BY next_attempt_at, id
FOR UPDATE SKIP LOCKED
LIMIT $1
)
UPDATE artwork_revision_gc_candidates AS candidate
SET locked_at = NOW(), locked_by = $2, updated_at = NOW()
FROM due
WHERE candidate.id = due.id
RETURNING candidate.id, candidate.original_path, candidate.image_type, candidate.object_keys, candidate.attempt_count, candidate.deleted_at`,
limit, workerID, int64(artworkRevisionGCLease/time.Second))
if err != nil {
return nil, fmt.Errorf("artwork revision GC: claim: %w", err)
}
defer rows.Close()
var candidates []artworkRevisionGCCandidate
for rows.Next() {
var candidate artworkRevisionGCCandidate
if err := rows.Scan(&candidate.id, &candidate.originalPath, &candidate.imageType, &candidate.objectKeys, &candidate.attemptCount, &candidate.deletedAt); err != nil {
return nil, fmt.Errorf("artwork revision GC: scan claim: %w", err)
}
candidates = append(candidates, candidate)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("artwork revision GC: claims: %w", err)
}
return candidates, nil
}
// parkClaimed releases claimed candidates back to the dormant state in one
// statement. Used when the batched pre-check already proved them referenced.
func (g *ArtworkRevisionGarbageCollector) parkClaimed(ctx context.Context, ids []int64, workerID string) error {
_, err := g.pool.Exec(ctx, `
UPDATE artwork_revision_gc_candidates
SET next_attempt_at = NULL,
attempt_count = 0,
locked_at = NULL,
locked_by = '',
last_error = '',
updated_at = NOW()
WHERE id = ANY($1) AND locked_by = $2`, ids, workerID)
if err != nil {
return fmt.Errorf("artwork revision GC: park referenced revisions: %w", err)
}
return nil
}
// processCandidate holds the registry row lock across the last reference check
// and object deletion. A concurrent cache attempt registers the revision before
// uploading and therefore waits here; once deletion commits, that attempt can
// safely recreate the complete object set before publication.
func (g *ArtworkRevisionGarbageCollector) processCandidate(
ctx context.Context,
candidate artworkRevisionGCCandidate,
workerID string,
) (artworkRevisionGCOutcome, error) {
tx, err := g.pool.Begin(ctx)
if err != nil {
return artworkRevisionGCSuperseded, fmt.Errorf("artwork revision GC: begin deletion: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
var originalPath, imageType string
var objectKeys []string
var deletedAt *time.Time
err = tx.QueryRow(ctx, `
SELECT original_path, image_type, object_keys, deleted_at
FROM artwork_revision_gc_candidates
WHERE id = $1 AND locked_by = $2
FOR UPDATE`, candidate.id, workerID).Scan(&originalPath, &imageType, &objectKeys, &deletedAt)
if errors.Is(err, pgx.ErrNoRows) {
return artworkRevisionGCSuperseded, nil
}
if err != nil {
return artworkRevisionGCSuperseded, fmt.Errorf("artwork revision GC: lock candidate: %w", err)
}
// Once objects are deleted, a lingering reference is broken rather than
// live: skip parking and finish the pending heal instead.
if deletedAt == nil {
referenced, err := g.isReferenced(ctx, tx, originalPath)
if err != nil {
return artworkRevisionGCSuperseded, err
}
if referenced {
if _, err := tx.Exec(ctx, `
UPDATE artwork_revision_gc_candidates
SET next_attempt_at = NULL,
attempt_count = 0,
locked_at = NULL,
locked_by = '',
last_error = '',
updated_at = NOW()
WHERE id = $1 AND locked_by = $2`, candidate.id, workerID); err != nil {
return artworkRevisionGCSuperseded, fmt.Errorf("artwork revision GC: park referenced revision: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return artworkRevisionGCSuperseded, fmt.Errorf("artwork revision GC: commit referenced revision: %w", err)
}
return artworkRevisionGCReferenced, nil
}
}
// Rows queued by the displacement triggers carry no manifest; expand the
// expected object set from the image type so the variant ladder stays
// defined once, in artworkkey.
if len(objectKeys) == 0 {
objectKeys = artworkkey.ObjectKeys(originalPath, imageType)
}
if len(objectKeys) > 0 {
deleted, err := g.s3.DeleteObjects(ctx, g.s3.Bucket(), objectKeys)
if err == nil && deleted != len(objectKeys) {
err = fmt.Errorf("deleted %d of %d artwork objects", deleted, len(objectKeys))
}
if err != nil {
return artworkRevisionGCSuperseded, err
}
}
// Keep the row until the post-delete heal succeeds: marking deleted_at
// (instead of deleting the row) preserves a durable retry if healing or
// the final row removal fails after the objects are already gone.
if _, err := tx.Exec(ctx, `
UPDATE artwork_revision_gc_candidates
SET deleted_at = COALESCE(deleted_at, NOW()), updated_at = NOW()
WHERE id = $1 AND locked_by = $2`, candidate.id, workerID); err != nil {
return artworkRevisionGCSuperseded, fmt.Errorf("artwork revision GC: mark deleted: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return artworkRevisionGCSuperseded, fmt.Errorf("artwork revision GC: commit deletion: %w", err)
}
// Writers that assign an existing path without registering a revision
// (bulk upserts copying previously-read values) do not serialize with the
// row lock above. Detect that narrow race after the fact and reset the
// affected rows the same way the artwork reconciler handles missing
// objects, so pipelines re-cache instead of serving 404 artwork.
healed, healErr := g.healDeletedReferences(ctx, originalPath)
if healErr != nil {
return artworkRevisionGCSuperseded, fmt.Errorf("artwork revision GC: post-delete heal: %w", healErr)
}
// Healing can itself displace the path again (clearing a referencing row
// fires the trigger, which re-arms this row and drops our lease), so gate
// the removal on deleted_at instead of the lease: a concurrent tracker
// re-registration clears deleted_at and must survive; anything else with
// deleted_at set is a finished deletion.
if _, err := g.pool.Exec(ctx, `
DELETE FROM artwork_revision_gc_candidates
WHERE id = $1 AND deleted_at IS NOT NULL`, candidate.id); err != nil {
return artworkRevisionGCSuperseded, fmt.Errorf("artwork revision GC: finish: %w", err)
}
if healed {
slog.WarnContext(ctx, "artwork revision GC: deleted revision was re-referenced concurrently; reset rows for re-cache",
"component", "metadata", "original_path", originalPath)
return artworkRevisionGCDeletedAndHealed, nil
}
return artworkRevisionGCDeleted, nil
}
type artworkReferenceQuerier interface {
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
}
func artworkReferenceUnionSQL() string {
surfaces := artworkSweepSurfaces()
parts := make([]string, 0, len(surfaces))
for _, surface := range surfaces {
parts = append(parts, fmt.Sprintf("SELECT %s AS path FROM %s WHERE %s = ANY($1)", surface.pathCol, surface.table, surface.pathCol))
}
return strings.Join(parts, " UNION ALL ")
}
func (g *ArtworkRevisionGarbageCollector) isReferenced(ctx context.Context, q artworkReferenceQuerier, originalPath string) (bool, error) {
var referenced bool
query := "SELECT EXISTS(" + artworkReferenceUnionSQL() + ")"
if err := q.QueryRow(ctx, query, []string{originalPath}).Scan(&referenced); err != nil {
return false, fmt.Errorf("artwork revision GC: check references: %w", err)
}
return referenced, nil
}
// referencedPaths returns the subset of paths referenced by any catalog
// surface, using one query per run instead of one per candidate.
func (g *ArtworkRevisionGarbageCollector) referencedPaths(ctx context.Context, paths []string) (map[string]struct{}, error) {
referenced := make(map[string]struct{})
if len(paths) == 0 {
return referenced, nil
}
rows, err := g.pool.Query(ctx, "SELECT DISTINCT path FROM ("+artworkReferenceUnionSQL()+") refs", paths)
if err != nil {
return nil, fmt.Errorf("artwork revision GC: batch reference check: %w", err)
}
defer rows.Close()
for rows.Next() {
var path string
if err := rows.Scan(&path); err != nil {
return nil, fmt.Errorf("artwork revision GC: scan reference: %w", err)
}
referenced[path] = struct{}{}
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("artwork revision GC: references: %w", err)
}
return referenced, nil
}
// sweepDormant re-verifies a bounded batch of parked revisions whose last
// check is older than the recheck interval, re-arming any that lost every
// reference through a surface without a displacement trigger.
func (g *ArtworkRevisionGarbageCollector) sweepDormant(ctx context.Context, limit int) (checked, requeued int, err error) {
rows, err := g.pool.Query(ctx, `
SELECT id, original_path
FROM artwork_revision_gc_candidates
WHERE next_attempt_at IS NULL
AND updated_at < NOW() - ($2 * interval '1 second')
ORDER BY updated_at, id
LIMIT $1`, limit, int64(artworkRevisionDormantRecheck/time.Second))
if err != nil {
return 0, 0, fmt.Errorf("artwork revision GC: list dormant revisions: %w", err)
}
defer rows.Close()
ids := make(map[string][]int64)
var paths []string
for rows.Next() {
var id int64
var path string
if err := rows.Scan(&id, &path); err != nil {
return 0, 0, fmt.Errorf("artwork revision GC: scan dormant revision: %w", err)
}
if _, ok := ids[path]; !ok {
paths = append(paths, path)
}
ids[path] = append(ids[path], id)
checked++
}
if err := rows.Err(); err != nil {
return 0, 0, fmt.Errorf("artwork revision GC: dormant revisions: %w", err)
}
if checked == 0 {
return 0, 0, nil
}
referenced, err := g.referencedPaths(ctx, paths)
if err != nil {
return checked, 0, err
}
var touch, requeue []int64
for path, pathIDs := range ids {
if _, ok := referenced[path]; ok {
touch = append(touch, pathIDs...)
continue
}
requeue = append(requeue, pathIDs...)
}
if len(requeue) > 0 {
if _, err := g.pool.Exec(ctx, `
UPDATE artwork_revision_gc_candidates
SET next_attempt_at = GREATEST(not_before, NOW()),
updated_at = NOW()
WHERE id = ANY($1) AND next_attempt_at IS NULL`, requeue); err != nil {
return checked, 0, fmt.Errorf("artwork revision GC: requeue dormant revisions: %w", err)
}
requeued = len(requeue)
}
if len(touch) > 0 {
if _, err := g.pool.Exec(ctx, `
UPDATE artwork_revision_gc_candidates
SET updated_at = NOW()
WHERE id = ANY($1) AND next_attempt_at IS NULL`, touch); err != nil {
return checked, requeued, fmt.Errorf("artwork revision GC: touch dormant revisions: %w", err)
}
}
return checked, requeued, nil
}
// healDeletedReferences resets any row still pointing at a just-deleted
// revision, mirroring the reconciler: rows with a re-downloadable source are
// repointed at it (the image cache pipeline re-caches them); the rest are
// cleared for their owning pipeline to refill.
func (g *ArtworkRevisionGarbageCollector) healDeletedReferences(ctx context.Context, originalPath string) (bool, error) {
healed := false
for _, surface := range artworkSweepSurfaces() {
if surface.sourceCol != "" {
resetSQL := fmt.Sprintf(`UPDATE %s SET %s WHERE %s = $1 AND %s`,
surface.table, surface.resetSet(), surface.pathCol, surface.remoteSourcePredicate())
tag, err := g.pool.Exec(ctx, resetSQL, originalPath)
if err != nil {
return healed, fmt.Errorf("artwork revision GC: heal %s: %w", surface.name, err)
}
healed = healed || tag.RowsAffected() > 0
}
clearSQL := fmt.Sprintf(`UPDATE %s SET %s WHERE %s = $1 AND NOT (%s)`,
surface.table, surface.clearSet, surface.pathCol, surface.remoteSourcePredicate())
tag, err := g.pool.Exec(ctx, clearSQL, originalPath)
if err != nil {
return healed, fmt.Errorf("artwork revision GC: heal %s: %w", surface.name, err)
}
healed = healed || tag.RowsAffected() > 0
}
return healed, nil
}
func (g *ArtworkRevisionGarbageCollector) retry(ctx context.Context, candidate artworkRevisionGCCandidate, workerID string, cause error) error {
delay := time.Minute << min(candidate.attemptCount, 10)
_, err := g.pool.Exec(ctx, `
UPDATE artwork_revision_gc_candidates
SET attempt_count = attempt_count + 1,
next_attempt_at = NOW() + ($3 * interval '1 second'),
locked_at = NULL,
locked_by = '',
last_error = $4,
updated_at = NOW()
WHERE id = $1 AND locked_by = $2`, candidate.id, workerID, int64(delay/time.Second), cause.Error())
if err != nil {
return fmt.Errorf("artwork revision GC: schedule retry: %w", err)
}
return nil
}
func (s ArtworkRevisionGCStats) JSON() []byte {
data, _ := json.Marshal(s)
return data
}