* feat(metadata): reconcile artwork cache after public S3 provider changes Changing the public S3 provider previously broke every cached image permanently: the DB keeps bucket-relative keys, the image cache pipeline treats a cached path as its durable dedup marker and never re-enqueues, and clients eat the 404s straight from S3 so the server never notices. Add a storage identity fingerprint (s3.public_storage_identity, seeded via SetIfAbsent at boot) and a reconcile_artwork_cache task whose startup trigger only fires when the identity changed; manual runs always sweep, doubling as bucket-data-loss recovery. The task probes a random sample of cached objects, then either bulk-resets (near-total miss) or per-row verifies. Missing provider-sourced artwork is reset to its *_source_path so the existing enqueue loop re-caches it; surfaces without a re-downloadable source (chapter thumbnails, collection artwork, library posters, branding refs, embedded book covers) are cleared so their owning pipelines refill them. Small upload-holding tables are always per-row verified so bulk mode cannot blind-clear an upload that survived migration, and transport errors never reset rows. Users never see broken images during the transition: reset rows serve the provider's original URL via the existing absolute-URL pass-through and thumbhashes are preserved. The storage settings page now warns that uploads cannot be re-downloaded when the identity fields are edited. Part of #348 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(metadata): harden artwork reconcile per code review Address the confirmed findings from the PR review: - Fingerprint the key prefix case-sensitively and slash-trimmed exactly as s3client applies it (new exported NormalizeKeyPrefix): a case-only prefix edit is a real storage move and must reconcile; a slash-only edit is not and must not. - Certify the storage fingerprint immediately after the artwork sweep succeeds and make the 4-object branding check non-fatal (reported in the task message), so a transient branding error cannot discard a completed catalog sweep and force it to repeat every boot. - Fail closed on conditional-task preflight errors in the task manager (previously fail-open ran the task), and retry transient settings reads in ShouldRun since the startup trigger fires once per process. - Track probe HEAD errors against a separate baseline so a flaky probe cannot consume the sweep's error budget. - Probe before counting: bulk mode skips the per-surface count(*) full scans entirely, and probe sampling drops ORDER BY random() (plain LIMIT answers "is the cache in this bucket" just as well). - Verify chapter thumbnails across a whole 500-file batch in one HEAD fan-out instead of per file, keeping the worker pool saturated. - Replace the 10 inline non-provider-scheme ARRAY literals in the enqueue query with the shared nonProviderImageSchemesSQL constant. Part of #348 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(metadata): guard bulk reset against degraded probes, certify only clean sweeps Address bot review feedback on the reconcile hardening: - A probe where more than half the HEAD requests error aborts the run: errored requests are excluded from the sample, so a partial outage could otherwise present a handful of surviving 404s as a ~100% miss rate and bulk-reset the catalog. Bulk mode additionally requires a minimum number of successful samples; thinned probes and tiny catalogs take the safe per-row verify path. - Track sweep errors separately from probe/branding errors (stats.sweep_errors) and certify the storage fingerprint only when the sweep completed with zero of them — skipped rows were never verified, so the next startup retries. Applied resets stay durable. - Give each ObjectExists attempt its own timeout so a stalled HEAD fails that attempt instead of pinning the retry loop to the run context. - Report branding assets checked (not just cleared) in stats.Checked. - Drop the dead settingsRepo/brandingSvc nil guards in cmd/silo and sync spec numbers with the implementation constants. Part of #348 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
302 lines
11 KiB
Go
302 lines
11 KiB
Go
package metadata
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
// fakeObjectChecker treats every key as present unless listed in missing or
|
|
// erroring. Defaulting to present keeps sweeps over rows seeded by other
|
|
// tests in the shared test database side-effect free.
|
|
type fakeObjectChecker struct {
|
|
mu sync.Mutex
|
|
missing map[string]bool
|
|
erroring map[string]bool
|
|
errorAll bool
|
|
checked map[string]int
|
|
}
|
|
|
|
func (f *fakeObjectChecker) Bucket() string { return "test-bucket" }
|
|
|
|
func (f *fakeObjectChecker) ObjectExists(_ context.Context, _ string, key string) (bool, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if f.checked == nil {
|
|
f.checked = map[string]int{}
|
|
}
|
|
f.checked[key]++
|
|
if f.errorAll || f.erroring[key] {
|
|
return false, errors.New("simulated storage error")
|
|
}
|
|
return !f.missing[key], nil
|
|
}
|
|
|
|
func TestShouldBulkReset(t *testing.T) {
|
|
if shouldBulkReset(0, 0) {
|
|
t.Fatal("empty probe must not trigger bulk reset")
|
|
}
|
|
if shouldBulkReset(100, 94) {
|
|
t.Fatal("94% missing is below the bulk threshold")
|
|
}
|
|
if !shouldBulkReset(100, 95) {
|
|
t.Fatal("95% missing must trigger bulk reset")
|
|
}
|
|
// A probe thinned below the minimum successful-sample bar (transport
|
|
// errors, tiny catalog) must take the safe per-row path even at a 100%
|
|
// miss rate — a handful of surviving 404s is not a mandate to bulk-reset.
|
|
if shouldBulkReset(artworkReconcileBulkMinSample-1, artworkReconcileBulkMinSample-1) {
|
|
t.Fatal("below-minimum sample must not trigger bulk reset")
|
|
}
|
|
if !shouldBulkReset(artworkReconcileBulkMinSample, artworkReconcileBulkMinSample) {
|
|
t.Fatal("all-missing probe at the minimum sample size must trigger bulk reset")
|
|
}
|
|
}
|
|
|
|
func TestArtworkReconcileVerifySweep(t *testing.T) {
|
|
dsn := os.Getenv("SILO_TEST_DATABASE_URL")
|
|
if dsn == "" {
|
|
t.Skip("SILO_TEST_DATABASE_URL is not set")
|
|
}
|
|
ctx := context.Background()
|
|
pool, err := pgxpool.New(ctx, dsn)
|
|
if err != nil {
|
|
t.Fatalf("connect test database: %v", err)
|
|
}
|
|
t.Cleanup(pool.Close)
|
|
|
|
suffix := time.Now().UnixNano()
|
|
id := func(name string) string { return fmt.Sprintf("arc-%s-%d", name, suffix) }
|
|
key := func(name string) string { return fmt.Sprintf("tmdb/movies/arc-%d/%s/original.webp", suffix, name) }
|
|
|
|
// Four items covering the sweep verdicts: intact, missing with a provider
|
|
// source, missing with an upload source, and an uncached provider URL.
|
|
seedItem := func(contentID, posterPath, posterSource string) {
|
|
if _, err := pool.Exec(ctx, `
|
|
INSERT INTO media_items (content_id, type, title, status, genres, poster_path, poster_source_path, last_refreshed)
|
|
VALUES ($1, 'movie', 'ARC Test', 'matched', '{}'::text[], $2, $3, NOW())
|
|
`, contentID, posterPath, posterSource); err != nil {
|
|
t.Fatalf("seed item %s: %v", contentID, err)
|
|
}
|
|
}
|
|
seedItem(id("intact"), key("intact"), "https://img.example/intact.jpg")
|
|
seedItem(id("missing"), key("missing"), "https://img.example/missing.jpg")
|
|
seedItem(id("upload"), key("upload"), "upload://admin/poster.jpg")
|
|
seedItem(id("uncached"), "https://img.example/direct.jpg", "https://img.example/direct.jpg")
|
|
|
|
var fileID int64
|
|
chapters := fmt.Sprintf(
|
|
`[{"index":0,"title":"One","thumbnail_path":%q,"thumbnail_thumbhash":"aGFzaA==","custom":"kept"},`+
|
|
`{"index":1,"title":"Two","thumbnail_path":%q,"thumbnail_thumbhash":"aGFzaA==","thumbnail_failed_at":"2026-01-01T00:00:00Z"}]`,
|
|
key("chapter-intact"), key("chapter-missing"),
|
|
)
|
|
var folderID int
|
|
if err := pool.QueryRow(ctx,
|
|
`INSERT INTO media_folders (type, name, enabled, poster_path) VALUES ('movies', 'ARC Folder', true, $1) RETURNING id`,
|
|
fmt.Sprintf("library-posters/arc-%d.png", suffix),
|
|
).Scan(&folderID); err != nil {
|
|
t.Fatalf("seed folder: %v", err)
|
|
}
|
|
if err := pool.QueryRow(ctx, `
|
|
INSERT INTO media_files (content_id, media_folder_id, file_path, chapters)
|
|
VALUES ($1, $2, $3, $4::jsonb) RETURNING id
|
|
`, id("intact"), folderID, fmt.Sprintf("/arc-%d/movie.mkv", suffix), chapters).Scan(&fileID); err != nil {
|
|
t.Fatalf("seed file: %v", err)
|
|
}
|
|
if _, err := pool.Exec(ctx, `
|
|
INSERT INTO library_collections (id, library_id, slug, title, collection_type, poster_url, poster_thumbhash, poster_from_template)
|
|
VALUES ($1, $2, $1, 'ARC Collection', 'manual', $3, 'aGFzaA==', TRUE)
|
|
`, id("coll"), folderID, key("coll")); err != nil {
|
|
t.Fatalf("seed collection: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_, _ = pool.Exec(ctx, `DELETE FROM library_collections WHERE id = $1`, id("coll"))
|
|
_, _ = pool.Exec(ctx, `DELETE FROM media_files WHERE id = $1`, fileID)
|
|
_, _ = pool.Exec(ctx, `DELETE FROM media_folders WHERE id = $1`, folderID)
|
|
for _, name := range []string{"intact", "missing", "upload", "uncached"} {
|
|
_, _ = pool.Exec(ctx, `DELETE FROM media_items WHERE content_id = $1`, id(name))
|
|
}
|
|
})
|
|
|
|
checker := &fakeObjectChecker{missing: map[string]bool{
|
|
key("missing"): true,
|
|
key("upload"): true,
|
|
key("chapter-missing"): true,
|
|
key("coll"): true,
|
|
fmt.Sprintf("library-posters/arc-%d.png", suffix): true,
|
|
}}
|
|
stats, err := NewArtworkCacheReconciler(pool, checker).Run(ctx, nil)
|
|
if err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
if stats.Mode != "verify" {
|
|
t.Fatalf("Mode = %q, want verify (fake checker defaults to present)", stats.Mode)
|
|
}
|
|
|
|
var posterPath string
|
|
var lastRefreshed *time.Time
|
|
mustScanItem := func(contentID string) (string, *time.Time) {
|
|
if err := pool.QueryRow(ctx,
|
|
`SELECT poster_path, last_refreshed FROM media_items WHERE content_id = $1`, contentID,
|
|
).Scan(&posterPath, &lastRefreshed); err != nil {
|
|
t.Fatalf("read item %s: %v", contentID, err)
|
|
}
|
|
return posterPath, lastRefreshed
|
|
}
|
|
|
|
if got, _ := mustScanItem(id("intact")); got != key("intact") {
|
|
t.Fatalf("intact poster_path = %q, want untouched %q", got, key("intact"))
|
|
}
|
|
if got, _ := mustScanItem(id("missing")); got != "https://img.example/missing.jpg" {
|
|
t.Fatalf("missing poster_path = %q, want reset to provider source", got)
|
|
}
|
|
if got, refreshed := mustScanItem(id("upload")); got != "" || refreshed != nil {
|
|
t.Fatalf("upload poster_path = %q (last_refreshed %v), want cleared with last_refreshed NULL", got, refreshed)
|
|
}
|
|
if got, _ := mustScanItem(id("uncached")); got != "https://img.example/direct.jpg" {
|
|
t.Fatalf("uncached poster_path = %q, want untouched provider URL", got)
|
|
}
|
|
if checker.checked["https://img.example/direct.jpg"] != 0 {
|
|
t.Fatal("provider URLs must not be HEAD-checked")
|
|
}
|
|
|
|
var rawChapters string
|
|
var retryAfter *time.Time
|
|
if err := pool.QueryRow(ctx,
|
|
`SELECT chapters::text, chapter_thumbnail_retry_after FROM media_files WHERE id = $1`, fileID,
|
|
).Scan(&rawChapters, &retryAfter); err != nil {
|
|
t.Fatalf("read chapters: %v", err)
|
|
}
|
|
assertContains := func(s, substr, what string) {
|
|
t.Helper()
|
|
if !strings.Contains(s, substr) {
|
|
t.Fatalf("%s: %q not found in %s", what, substr, s)
|
|
}
|
|
}
|
|
assertContains(rawChapters, key("chapter-intact"), "intact chapter thumbnail kept")
|
|
assertContains(rawChapters, `"kept"`, "unknown chapter fields preserved")
|
|
if strings.Contains(rawChapters, key("chapter-missing")) || strings.Contains(rawChapters, "thumbnail_failed_at") {
|
|
t.Fatalf("missing chapter thumbnail not cleared: %s", rawChapters)
|
|
}
|
|
if retryAfter != nil {
|
|
t.Fatal("chapter_thumbnail_retry_after not cleared")
|
|
}
|
|
|
|
var collPoster, collHash string
|
|
var fromTemplate bool
|
|
if err := pool.QueryRow(ctx,
|
|
`SELECT poster_url, poster_thumbhash, poster_from_template FROM library_collections WHERE id = $1`, id("coll"),
|
|
).Scan(&collPoster, &collHash, &fromTemplate); err != nil {
|
|
t.Fatalf("read collection: %v", err)
|
|
}
|
|
if collPoster != "" || collHash != "" || fromTemplate {
|
|
t.Fatalf("collection artwork not fully cleared: url=%q hash=%q from_template=%v", collPoster, collHash, fromTemplate)
|
|
}
|
|
|
|
var folderPoster string
|
|
if err := pool.QueryRow(ctx, `SELECT poster_path FROM media_folders WHERE id = $1`, folderID).Scan(&folderPoster); err != nil {
|
|
t.Fatalf("read folder: %v", err)
|
|
}
|
|
if folderPoster != "" {
|
|
t.Fatalf("library poster not cleared: %q", folderPoster)
|
|
}
|
|
}
|
|
|
|
func TestArtworkReconcileLeavesRowsAloneOnStorageErrors(t *testing.T) {
|
|
dsn := os.Getenv("SILO_TEST_DATABASE_URL")
|
|
if dsn == "" {
|
|
t.Skip("SILO_TEST_DATABASE_URL is not set")
|
|
}
|
|
ctx := context.Background()
|
|
pool, err := pgxpool.New(ctx, dsn)
|
|
if err != nil {
|
|
t.Fatalf("connect test database: %v", err)
|
|
}
|
|
t.Cleanup(pool.Close)
|
|
|
|
suffix := time.Now().UnixNano()
|
|
contentID := fmt.Sprintf("arc-err-%d", suffix)
|
|
okContentID := fmt.Sprintf("arc-err-ok-%d", suffix)
|
|
cachedKey := fmt.Sprintf("tmdb/movies/%s/poster/original.webp", contentID)
|
|
okKey := fmt.Sprintf("tmdb/movies/%s/poster/original.webp", okContentID)
|
|
seed := func(id, key string) {
|
|
if _, err := pool.Exec(ctx, `
|
|
INSERT INTO media_items (content_id, type, title, status, genres, poster_path, poster_source_path)
|
|
VALUES ($1, 'movie', 'ARC Err', 'matched', '{}'::text[], $2, 'https://img.example/err.jpg')
|
|
`, id, key); err != nil {
|
|
t.Fatalf("seed item %s: %v", id, err)
|
|
}
|
|
}
|
|
// The healthy sibling keeps the probe from concluding storage is
|
|
// unreachable (an all-errored probe aborts before any sweep runs), so
|
|
// the sweep-level skip-on-error behavior is what gets exercised.
|
|
seed(contentID, cachedKey)
|
|
seed(okContentID, okKey)
|
|
t.Cleanup(func() {
|
|
_, _ = pool.Exec(ctx, `DELETE FROM media_items WHERE content_id = ANY($1)`, []string{contentID, okContentID})
|
|
})
|
|
|
|
checker := &fakeObjectChecker{erroring: map[string]bool{cachedKey: true}}
|
|
stats, err := NewArtworkCacheReconciler(pool, checker).Run(ctx, nil)
|
|
if err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
if stats.Errors == 0 {
|
|
t.Fatal("expected the erroring key to be counted")
|
|
}
|
|
|
|
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("read item: %v", err)
|
|
}
|
|
if posterPath != cachedKey {
|
|
t.Fatalf("poster_path = %q, want untouched %q after storage error", posterPath, cachedKey)
|
|
}
|
|
}
|
|
|
|
func TestArtworkReconcileAbortsWhenStorageUnreachable(t *testing.T) {
|
|
dsn := os.Getenv("SILO_TEST_DATABASE_URL")
|
|
if dsn == "" {
|
|
t.Skip("SILO_TEST_DATABASE_URL is not set")
|
|
}
|
|
ctx := context.Background()
|
|
pool, err := pgxpool.New(ctx, dsn)
|
|
if err != nil {
|
|
t.Fatalf("connect test database: %v", err)
|
|
}
|
|
t.Cleanup(pool.Close)
|
|
|
|
suffix := time.Now().UnixNano()
|
|
contentID := fmt.Sprintf("arc-down-%d", suffix)
|
|
cachedKey := fmt.Sprintf("tmdb/movies/%s/poster/original.webp", contentID)
|
|
if _, err := pool.Exec(ctx, `
|
|
INSERT INTO media_items (content_id, type, title, status, genres, poster_path, poster_source_path)
|
|
VALUES ($1, 'movie', 'ARC Down', 'matched', '{}'::text[], $2, 'https://img.example/down.jpg')
|
|
`, contentID, cachedKey); err != nil {
|
|
t.Fatalf("seed item: %v", err)
|
|
}
|
|
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM media_items WHERE content_id = $1`, contentID) })
|
|
|
|
// Every probe HEAD errors: storage is unreachable, which must abort the
|
|
// run (missing ≠ unreachable) and leave the row untouched.
|
|
checker := &fakeObjectChecker{errorAll: true}
|
|
if _, err := NewArtworkCacheReconciler(pool, checker).Run(ctx, nil); err == nil {
|
|
t.Fatal("Run with unreachable storage returned nil error")
|
|
}
|
|
|
|
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("read item: %v", err)
|
|
}
|
|
if posterPath != cachedKey {
|
|
t.Fatalf("poster_path = %q, want untouched %q after unreachable storage", posterPath, cachedKey)
|
|
}
|
|
}
|