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

547 lines
20 KiB
Go

package metadata
import (
"context"
"errors"
"fmt"
"os"
"slices"
"sync"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/Silo-Server/silo-server/internal/artworkkey"
"github.com/Silo-Server/silo-server/internal/catalog"
)
type blockingArtworkRevisionDeleter struct {
started chan struct{}
release chan struct{}
once sync.Once
mu sync.Mutex
deleted [][]string
}
func (d *blockingArtworkRevisionDeleter) Bucket() string { return "artwork" }
func (d *blockingArtworkRevisionDeleter) DeleteObjects(ctx context.Context, _ string, keys []string) (int, error) {
d.once.Do(func() { close(d.started) })
if d.release != nil {
select {
case <-d.release:
case <-ctx.Done():
return 0, ctx.Err()
}
}
d.mu.Lock()
d.deleted = append(d.deleted, append([]string(nil), keys...))
d.mu.Unlock()
return len(keys), nil
}
func artworkRevisionGCTestPool(t *testing.T) *pgxpool.Pool {
t.Helper()
dsn := os.Getenv("SILO_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("SILO_TEST_DATABASE_URL is not set")
}
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
t.Fatalf("connect test database: %v", err)
}
t.Cleanup(pool.Close)
return pool
}
func TestProcessArtworkRevisionGCBatchContinuesAfterRetryFailure(t *testing.T) {
candidates := []artworkRevisionGCCandidate{{id: 1}, {id: 2}, {id: 3}}
processed := make([]int64, 0, len(candidates))
retryErr := errors.New("schedule retry")
stats, err := processArtworkRevisionGCBatch(
candidates,
func(candidate artworkRevisionGCCandidate) (artworkRevisionGCOutcome, error) {
processed = append(processed, candidate.id)
switch candidate.id {
case 1:
return artworkRevisionGCSuperseded, errors.New("delete object")
case 2:
return artworkRevisionGCReferenced, nil
default:
return artworkRevisionGCDeleted, nil
}
},
func(candidate artworkRevisionGCCandidate, _ error) error {
if candidate.id == 1 {
return retryErr
}
return nil
},
)
if !errors.Is(err, retryErr) {
t.Fatalf("process batch error = %v, want %v", err, retryErr)
}
if !slices.Equal(processed, []int64{1, 2, 3}) {
t.Fatalf("processed candidates = %v, want all candidates", processed)
}
want := ArtworkRevisionGCStats{Claimed: 3, Deleted: 1, Referenced: 1, Retried: 1}
if stats != want {
t.Fatalf("stats = %+v, want %+v", stats, want)
}
}
func TestArtworkRevisionGCSerializesDeletionWithRetracking(t *testing.T) {
pool := artworkRevisionGCTestPool(t)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
suffix := time.Now().UnixNano()
originalPath := fmt.Sprintf("tmdb/movies/gc-%d/poster/original.old.webp", suffix)
oldKeys := []string{originalPath, fmt.Sprintf("tmdb/movies/gc-%d/poster/w500.old.webp", suffix)}
newKeys := []string{originalPath, fmt.Sprintf("tmdb/movies/gc-%d/poster/w500.old.webp", suffix), fmt.Sprintf("tmdb/movies/gc-%d/poster/w300.old.webp", suffix)}
workerID := fmt.Sprintf("gc-worker-%d", suffix)
var candidateID int64
if err := pool.QueryRow(ctx, `
INSERT INTO artwork_revision_gc_candidates (
original_path, object_keys, not_before, next_attempt_at, locked_at, locked_by
) VALUES ($1, $2, NOW() - interval '1 hour', NOW() - interval '1 hour', NOW(), $3)
RETURNING id`, originalPath, oldKeys, workerID).Scan(&candidateID); err != nil {
t.Fatalf("seed revision: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM artwork_revision_gc_candidates WHERE original_path = $1`, originalPath)
})
deleter := &blockingArtworkRevisionDeleter{started: make(chan struct{}), release: make(chan struct{})}
collector := NewArtworkRevisionGarbageCollector(pool, deleter)
processDone := make(chan struct {
outcome artworkRevisionGCOutcome
err error
}, 1)
go func() {
outcome, err := collector.processCandidate(ctx, artworkRevisionGCCandidate{
id: candidateID, originalPath: originalPath, objectKeys: oldKeys,
}, workerID)
processDone <- struct {
outcome artworkRevisionGCOutcome
err error
}{outcome: outcome, err: err}
}()
select {
case <-deleter.started:
case <-ctx.Done():
t.Fatalf("collector did not begin deletion: %v", ctx.Err())
}
tracker := catalog.NewArtworkRevisionTracker(pool)
trackDone := make(chan error, 1)
go func() { trackDone <- tracker.TrackArtworkRevision(ctx, originalPath, "poster", newKeys) }()
select {
case err := <-trackDone:
t.Fatalf("tracking completed while deletion row was locked: %v", err)
case <-time.After(100 * time.Millisecond):
}
close(deleter.release)
processed := <-processDone
if processed.err != nil {
t.Fatalf("processCandidate: %v", processed.err)
}
if processed.outcome != artworkRevisionGCDeleted {
t.Fatalf("outcome = %v, want deleted", processed.outcome)
}
if err := <-trackDone; err != nil {
t.Fatalf("retrack revision: %v", err)
}
var storedKeys []string
var nextAttempt time.Time
if err := pool.QueryRow(ctx, `
SELECT object_keys, next_attempt_at
FROM artwork_revision_gc_candidates
WHERE original_path = $1`, originalPath).Scan(&storedKeys, &nextAttempt); err != nil {
t.Fatalf("load retracked revision: %v", err)
}
if !slices.Equal(storedKeys, newKeys) {
t.Fatalf("stored manifest = %v, want %v", storedKeys, newKeys)
}
if !nextAttempt.After(time.Now().Add(23 * time.Hour)) {
t.Fatalf("next attempt = %v, want refreshed publication grace", nextAttempt)
}
}
func TestArtworkRevisionGCParksReferencedRevision(t *testing.T) {
pool := artworkRevisionGCTestPool(t)
ctx := context.Background()
suffix := time.Now().UnixNano()
contentID := fmt.Sprintf("gc-referenced-%d", suffix)
originalPath := fmt.Sprintf("tmdb/movies/%d/poster/original.live.webp", suffix)
keys := []string{originalPath}
workerID := fmt.Sprintf("gc-worker-%d", suffix)
if _, err := pool.Exec(ctx, `
INSERT INTO media_items (content_id, type, title, status, genres, poster_path)
VALUES ($1, 'movie', 'GC Referenced', 'matched', '{}'::text[], $2)`, contentID, originalPath); err != nil {
t.Fatalf("seed referenced item: %v", err)
}
var candidateID int64
if err := pool.QueryRow(ctx, `
INSERT INTO artwork_revision_gc_candidates (
original_path, object_keys, not_before, next_attempt_at, locked_at, locked_by
) VALUES ($1, $2, NOW() - interval '1 hour', NOW() - interval '1 hour', NOW(), $3)
RETURNING id`, originalPath, keys, workerID).Scan(&candidateID); err != nil {
t.Fatalf("seed revision: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM media_items WHERE content_id = $1`, contentID)
_, _ = pool.Exec(ctx, `DELETE FROM artwork_revision_gc_candidates WHERE original_path = $1`, originalPath)
})
deleter := &blockingArtworkRevisionDeleter{started: make(chan struct{})}
collector := NewArtworkRevisionGarbageCollector(pool, deleter)
outcome, err := collector.processCandidate(ctx, artworkRevisionGCCandidate{id: candidateID}, workerID)
if err != nil {
t.Fatalf("processCandidate: %v", err)
}
if outcome != artworkRevisionGCReferenced {
t.Fatalf("outcome = %v, want referenced", outcome)
}
select {
case <-deleter.started:
t.Fatal("referenced revision was sent to object deletion")
default:
}
var lockedBy string
var nextAttempt *time.Time
if err := pool.QueryRow(ctx, `
SELECT locked_by, next_attempt_at
FROM artwork_revision_gc_candidates
WHERE id = $1`, candidateID).Scan(&lockedBy, &nextAttempt); err != nil {
t.Fatalf("load parked revision: %v", err)
}
if lockedBy != "" {
t.Fatalf("locked_by = %q, want released", lockedBy)
}
if nextAttempt != nil {
t.Fatalf("next_attempt_at = %v, want dormant NULL", nextAttempt)
}
}
func TestArtworkRevisionTriggerQueuesDisplacedRevision(t *testing.T) {
pool := artworkRevisionGCTestPool(t)
ctx := context.Background()
suffix := time.Now().UnixNano()
contentID := fmt.Sprintf("gc-trigger-%d", suffix)
oldPath := fmt.Sprintf("tmdb/movies/%d/poster/original.old.webp", suffix)
newPath := fmt.Sprintf("tmdb/movies/%d/poster/original.new.webp", suffix)
wantKeys := []string{
oldPath,
fmt.Sprintf("tmdb/movies/%d/poster/w500.old.webp", suffix),
fmt.Sprintf("tmdb/movies/%d/poster/w300.old.webp", suffix),
fmt.Sprintf("tmdb/movies/%d/poster/future-variant.old.webp", suffix),
}
if _, err := pool.Exec(ctx, `
INSERT INTO media_items (content_id, type, title, status, genres, poster_path)
VALUES ($1, 'movie', 'GC Trigger', 'matched', '{}'::text[], $2)`, contentID, oldPath); err != nil {
t.Fatalf("seed artwork: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO artwork_revision_gc_candidates (
original_path, object_keys, not_before, next_attempt_at
) VALUES ($1, $2, NOW(), NULL)`, oldPath, wantKeys); err != nil {
t.Fatalf("seed dormant manifest: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM media_items WHERE content_id = $1`, contentID)
_, _ = pool.Exec(ctx, `DELETE FROM artwork_revision_gc_candidates WHERE original_path IN ($1, $2)`, oldPath, newPath)
})
if _, err := pool.Exec(ctx, `UPDATE media_items SET poster_path = $2 WHERE content_id = $1`, contentID, newPath); err != nil {
t.Fatalf("replace artwork: %v", err)
}
var objectKeys []string
var notBefore time.Time
if err := pool.QueryRow(ctx, `
SELECT object_keys, not_before
FROM artwork_revision_gc_candidates
WHERE original_path = $1`, oldPath).Scan(&objectKeys, &notBefore); err != nil {
t.Fatalf("load displaced candidate: %v", err)
}
if !slices.Equal(objectKeys, wantKeys) {
t.Fatalf("object manifest = %v, want %v", objectKeys, wantKeys)
}
if !notBefore.After(time.Now().Add(23 * time.Hour)) {
t.Fatalf("not before = %v, want displacement grace period", notBefore)
}
}
func TestArtworkRevisionTriggerRecordsImageTypeForCollectorExpansion(t *testing.T) {
pool := artworkRevisionGCTestPool(t)
ctx := context.Background()
suffix := time.Now().UnixNano()
contentID := fmt.Sprintf("gc-trigger-manifest-%d", suffix)
imageTypes := []string{ImageCacheImagePoster, ImageCacheImageBackdrop, ImageCacheImageLogo}
oldPaths := make(map[string]string, len(imageTypes))
newPaths := make(map[string]string, len(imageTypes))
for _, imageType := range imageTypes {
oldPaths[imageType] = fmt.Sprintf(
"tmdb/movies/original.segment/%d/%s/original.old.webp", suffix, imageType,
)
newPaths[imageType] = fmt.Sprintf("tmdb/movies/%d/%s/original.new.webp", suffix, imageType)
}
if _, err := pool.Exec(ctx, `
INSERT INTO media_items (
content_id, type, title, status, genres, poster_path, backdrop_path, logo_path
) VALUES ($1, 'movie', 'GC Trigger Manifest', 'matched', '{}'::text[], $2, $3, $4)`,
contentID,
oldPaths[ImageCacheImagePoster],
oldPaths[ImageCacheImageBackdrop],
oldPaths[ImageCacheImageLogo],
); err != nil {
t.Fatalf("seed artwork: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM media_items WHERE content_id = $1`, contentID)
paths := []string{
oldPaths[ImageCacheImagePoster], oldPaths[ImageCacheImageBackdrop], oldPaths[ImageCacheImageLogo],
newPaths[ImageCacheImagePoster], newPaths[ImageCacheImageBackdrop], newPaths[ImageCacheImageLogo],
}
_, _ = pool.Exec(ctx, `DELETE FROM artwork_revision_gc_candidates WHERE original_path = ANY($1)`, paths)
})
if _, err := pool.Exec(ctx, `
UPDATE media_items
SET poster_path = $2, backdrop_path = $3, logo_path = $4
WHERE content_id = $1`,
contentID,
newPaths[ImageCacheImagePoster],
newPaths[ImageCacheImageBackdrop],
newPaths[ImageCacheImageLogo],
); err != nil {
t.Fatalf("replace artwork: %v", err)
}
// The trigger stores only the displaced path and its image type; the
// collector expands the manifest via artworkkey at deletion time.
for _, imageType := range imageTypes {
var objectKeys []string
var storedType string
if err := pool.QueryRow(ctx, `
SELECT object_keys, image_type
FROM artwork_revision_gc_candidates
WHERE original_path = $1`, oldPaths[imageType]).Scan(&objectKeys, &storedType); err != nil {
t.Fatalf("load %s candidate: %v", imageType, err)
}
if len(objectKeys) != 0 {
t.Fatalf("%s object manifest = %v, want trigger to leave expansion to the collector", imageType, objectKeys)
}
if storedType != imageType {
t.Fatalf("stored image type = %q, want %q", storedType, imageType)
}
}
}
func TestArtworkRevisionTriggerIgnoresUnchangedAssignment(t *testing.T) {
pool := artworkRevisionGCTestPool(t)
ctx := context.Background()
suffix := time.Now().UnixNano()
contentID := fmt.Sprintf("gc-trigger-noop-%d", suffix)
path := fmt.Sprintf("tmdb/movies/%d/poster/original.same.webp", suffix)
if _, err := pool.Exec(ctx, `
INSERT INTO media_items (content_id, type, title, status, genres, poster_path)
VALUES ($1, 'movie', 'GC Trigger Noop', 'matched', '{}'::text[], $2)`, contentID, path); err != nil {
t.Fatalf("seed artwork: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM media_items WHERE content_id = $1`, contentID)
_, _ = pool.Exec(ctx, `DELETE FROM artwork_revision_gc_candidates WHERE original_path = $1`, path)
})
// Upsert-style write assigning the same value must not queue anything.
if _, err := pool.Exec(ctx, `UPDATE media_items SET poster_path = $2 WHERE content_id = $1`, contentID, path); err != nil {
t.Fatalf("no-op update: %v", err)
}
var count int
if err := pool.QueryRow(ctx, `
SELECT COUNT(*) FROM artwork_revision_gc_candidates WHERE original_path = $1`, path).Scan(&count); err != nil {
t.Fatalf("count candidates: %v", err)
}
if count != 0 {
t.Fatalf("unchanged assignment queued %d candidates, want 0", count)
}
}
func TestArtworkRevisionGCExpandsTriggerManifestFromImageType(t *testing.T) {
pool := artworkRevisionGCTestPool(t)
ctx := context.Background()
suffix := time.Now().UnixNano()
originalPath := fmt.Sprintf("tmdb/movies/gc-expand-%d/backdrop/original.old.webp", suffix)
workerID := fmt.Sprintf("gc-worker-%d", suffix)
var candidateID int64
if err := pool.QueryRow(ctx, `
INSERT INTO artwork_revision_gc_candidates (
original_path, image_type, object_keys, not_before, next_attempt_at, locked_at, locked_by
) VALUES ($1, 'backdrop', '{}', NOW() - interval '1 hour', NOW() - interval '1 hour', NOW(), $2)
RETURNING id`, originalPath, workerID).Scan(&candidateID); err != nil {
t.Fatalf("seed trigger-style candidate: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM artwork_revision_gc_candidates WHERE original_path = $1`, originalPath)
})
deleter := &blockingArtworkRevisionDeleter{started: make(chan struct{})}
collector := NewArtworkRevisionGarbageCollector(pool, deleter)
outcome, err := collector.processCandidate(ctx, artworkRevisionGCCandidate{id: candidateID}, workerID)
if err != nil {
t.Fatalf("processCandidate: %v", err)
}
if outcome != artworkRevisionGCDeleted {
t.Fatalf("outcome = %v, want deleted", outcome)
}
deleter.mu.Lock()
deleted := deleter.deleted
deleter.mu.Unlock()
if len(deleted) != 1 {
t.Fatalf("DeleteObjects calls = %d, want 1", len(deleted))
}
want := artworkkey.ObjectKeys(originalPath, "backdrop")
if !slices.Equal(deleted[0], want) {
t.Fatalf("deleted keys = %v, want expanded manifest %v", deleted[0], want)
}
}
func TestArtworkRevisionGCHealsBrokenReferenceAfterObjectDeletion(t *testing.T) {
pool := artworkRevisionGCTestPool(t)
ctx := context.Background()
suffix := time.Now().UnixNano()
contentID := fmt.Sprintf("gc-heal-%d", suffix)
originalPath := fmt.Sprintf("tmdb/movies/%d/poster/original.gone.webp", suffix)
workerID := fmt.Sprintf("gc-worker-%d", suffix)
// A racing writer re-referenced the path after its objects were deleted:
// the candidate row survived with deleted_at set (heal previously failed).
if _, err := pool.Exec(ctx, `
INSERT INTO media_items (content_id, type, title, status, genres, poster_path)
VALUES ($1, 'movie', 'GC Heal', 'matched', '{}'::text[], $2)`, contentID, originalPath); err != nil {
t.Fatalf("seed re-referencing item: %v", err)
}
var candidateID int64
if err := pool.QueryRow(ctx, `
INSERT INTO artwork_revision_gc_candidates (
original_path, image_type, object_keys, not_before, next_attempt_at, deleted_at, locked_at, locked_by
) VALUES ($1, 'poster', '{}', NOW() - interval '1 hour', NOW() - interval '1 hour', NOW() - interval '1 hour', NOW(), $2)
RETURNING id`, originalPath, workerID).Scan(&candidateID); err != nil {
t.Fatalf("seed deleted candidate: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM media_items WHERE content_id = $1`, contentID)
_, _ = pool.Exec(ctx, `DELETE FROM artwork_revision_gc_candidates WHERE original_path = $1`, originalPath)
})
deleter := &blockingArtworkRevisionDeleter{started: make(chan struct{})}
collector := NewArtworkRevisionGarbageCollector(pool, deleter)
outcome, err := collector.processCandidate(ctx, artworkRevisionGCCandidate{id: candidateID}, workerID)
if err != nil {
t.Fatalf("processCandidate: %v", err)
}
if outcome != artworkRevisionGCDeletedAndHealed {
t.Fatalf("outcome = %v, want deleted-and-healed (broken reference must not park)", outcome)
}
var posterPath string
if err := pool.QueryRow(ctx, `
SELECT poster_path FROM media_items WHERE content_id = $1`, contentID).Scan(&posterPath); err != nil {
t.Fatalf("load healed item: %v", err)
}
if posterPath == originalPath {
t.Fatalf("poster_path still references deleted revision %q", posterPath)
}
var remaining int
if err := pool.QueryRow(ctx, `
SELECT COUNT(*) FROM artwork_revision_gc_candidates WHERE original_path = $1`, originalPath).Scan(&remaining); err != nil {
t.Fatalf("count candidates: %v", err)
}
if remaining != 0 {
t.Fatalf("candidate rows remaining = %d, want 0 after successful heal", remaining)
}
}
func TestArtworkRevisionGCDormantSweep(t *testing.T) {
pool := artworkRevisionGCTestPool(t)
ctx := context.Background()
suffix := time.Now().UnixNano()
referencedContentID := fmt.Sprintf("gc-dormant-ref-%d", suffix)
referencedPath := fmt.Sprintf("tmdb/movies/%d/poster/original.ref.webp", suffix)
orphanPath := fmt.Sprintf("tmdb/movies/%d/poster/original.orphan.webp", suffix)
if _, err := pool.Exec(ctx, `
INSERT INTO media_items (content_id, type, title, status, genres, poster_path)
VALUES ($1, 'movie', 'GC Dormant Ref', 'matched', '{}'::text[], $2)`, referencedContentID, referencedPath); err != nil {
t.Fatalf("seed referenced item: %v", err)
}
for _, path := range []string{referencedPath, orphanPath} {
if _, err := pool.Exec(ctx, `
INSERT INTO artwork_revision_gc_candidates (
original_path, image_type, object_keys, not_before, next_attempt_at
) VALUES ($1, 'poster', '{}', NOW() - interval '2 days', NULL)`, path); err != nil {
t.Fatalf("seed dormant candidate: %v", err)
}
}
// Age both rows past the recheck interval; a reference that vanished
// through an untriggered surface looks exactly like the orphan row.
if _, err := pool.Exec(ctx, `
UPDATE artwork_revision_gc_candidates
SET updated_at = NOW() - interval '2 days'
WHERE original_path = ANY($1)`, []string{referencedPath, orphanPath}); err != nil {
t.Fatalf("age dormant candidates: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM media_items WHERE content_id = $1`, referencedContentID)
_, _ = pool.Exec(ctx, `DELETE FROM artwork_revision_gc_candidates WHERE original_path = ANY($1)`, []string{referencedPath, orphanPath})
})
deleter := &blockingArtworkRevisionDeleter{started: make(chan struct{})}
collector := NewArtworkRevisionGarbageCollector(pool, deleter)
checked, requeued, err := collector.sweepDormant(ctx, artworkRevisionGCBatchSize)
if err != nil {
t.Fatalf("sweepDormant: %v", err)
}
if checked < 2 {
t.Fatalf("checked = %d, want at least the two seeded rows", checked)
}
if requeued < 1 {
t.Fatalf("requeued = %d, want at least the orphan row", requeued)
}
var orphanNext, referencedNext *time.Time
if err := pool.QueryRow(ctx, `
SELECT next_attempt_at FROM artwork_revision_gc_candidates WHERE original_path = $1`, orphanPath).Scan(&orphanNext); err != nil {
t.Fatalf("load orphan candidate: %v", err)
}
if err := pool.QueryRow(ctx, `
SELECT next_attempt_at FROM artwork_revision_gc_candidates WHERE original_path = $1`, referencedPath).Scan(&referencedNext); err != nil {
t.Fatalf("load referenced candidate: %v", err)
}
if orphanNext == nil {
t.Fatal("orphan dormant row was not re-armed by the sweep")
}
if referencedNext != nil {
t.Fatalf("referenced dormant row was re-armed: %v", *referencedNext)
}
}