Files
silo-server/internal/metadata/artwork_reconcile.go
04c4344f52 feat(metadata): reconcile artwork cache after public S3 provider changes (#349)
* 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>
2026-07-09 12:00:24 -04:00

770 lines
28 KiB
Go

package metadata
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"strings"
"sync"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// ArtworkObjectChecker is the S3 surface the reconciler needs: existence
// checks against the public asset bucket. Satisfied by *s3client.Client.
type ArtworkObjectChecker interface {
ObjectExists(ctx context.Context, bucket, key string) (bool, error)
Bucket() string
}
// nonProviderImageSchemesSQL mirrors isNonProviderImageScheme for use inside
// SQL predicates: source paths with these schemes cannot be re-downloaded.
const nonProviderImageSchemesSQL = `ARRAY['s3://%', 'file://%', 'local://%', 'upload://%', 'generated://%']`
const (
artworkReconcileSampleTarget = 200
artworkReconcileBatchSize = 500
artworkReconcileHeadWorkers = 16
artworkReconcileHeadTimeout = 15 * time.Second
artworkReconcileErrorBudget = 200
// artworkReconcileBulkThreshold: when at least this fraction of sampled
// objects is missing, skip per-row verification for the large regenerable
// surfaces and reset every cached row.
artworkReconcileBulkThreshold = 0.95
// artworkReconcileBulkMinSample: bulk reset additionally requires this
// many *successful* probe samples. A probe degraded by transport errors
// (errored requests are excluded from the sample) must not bulk-reset the
// catalog off a handful of surviving 404s; small catalogs below this bar
// simply take the per-row verify path, which is cheap at that size.
artworkReconcileBulkMinSample = 25
)
// ArtworkReconcileStats summarizes one reconcile run.
type ArtworkReconcileStats struct {
Mode string `json:"mode"` // "verify" or "bulk_reset"
Sampled int `json:"sampled"`
SampleMissing int `json:"sample_missing"`
Checked int `json:"checked"`
Verified int `json:"verified"`
Requeued int `json:"requeued"` // reset to provider source; re-cached by the image cache pipeline
Cleared int `json:"cleared"` // no re-downloadable source; refilled by scans/enrichment or re-uploaded by an admin
Errors int `json:"errors"`
// SweepErrors is the subset of Errors from the sweep itself (skipped
// rows). Probe errors don't reduce sweep completeness — probed keys are
// re-checked by the sweep — so callers deciding whether the reconcile
// fully covered the catalog must look here, not at Errors.
SweepErrors int `json:"sweep_errors"`
}
// artworkSweepSurface describes one cached-path column the reconciler sweeps.
type artworkSweepSurface struct {
name string
table string
keyCols []string // pagination key expressions; must form a unique order
pathCol string
// sourceCol holds the original source the row can be reset to. Empty for
// surfaces without a re-downloadable source; their rows are always cleared.
sourceCol string
// clearSet is the SQL SET fragment applied when a row has no usable
// source: it must clear pathCol and whatever companion state the owning
// pipeline needs to refill the image.
clearSet string
// alwaysVerify forces per-row HEAD verification even in bulk-reset mode.
// Used for small tables holding admin/user uploads, where a blind reset
// would discard the last pointer to an object that survived migration.
alwaysVerify bool
}
func (s artworkSweepSurface) cachedPredicate() string {
return fmt.Sprintf(
`coalesce(%s, '') NOT IN ('', '-') AND %s NOT LIKE '%%://%%'`,
s.pathCol, s.pathCol,
)
}
func (s artworkSweepSurface) remoteSourcePredicate() string {
if s.sourceCol == "" {
return "FALSE"
}
return fmt.Sprintf(
`coalesce(%s, '') LIKE '%%://%%' AND lower(%s) NOT LIKE ALL (%s)`,
s.sourceCol, s.sourceCol, nonProviderImageSchemesSQL,
)
}
func (s artworkSweepSurface) resetSet() string {
return fmt.Sprintf(`%s = %s, updated_at = NOW()`, s.pathCol, s.sourceCol)
}
// artworkSweepSurfaces lists every cached-artwork destination in the public
// bucket that lives in a plain table column.
//
// The metadata surfaces are kept in sync with EnqueueExistingProviderArtwork:
// resetting a path column here is what makes that query pick the row up
// again. Clearing media_items artwork also nulls last_refreshed so the book
// enrichment sweeps (which require last_refreshed IS NULL) re-extract
// embedded covers.
//
// Chapter thumbnails (JSONB on media_files) and branding assets
// (server_settings refs) have bespoke sweeps and are not listed here.
func artworkSweepSurfaces() []artworkSweepSurface {
itemClear := func(pathCol string) string {
return fmt.Sprintf(`%s = '', last_refreshed = NULL, updated_at = NOW()`, pathCol)
}
plainClear := func(pathCol string) string {
return fmt.Sprintf(`%s = '', updated_at = NOW()`, pathCol)
}
return []artworkSweepSurface{
{name: "item posters", table: "media_items", keyCols: []string{"content_id"}, pathCol: "poster_path", sourceCol: "poster_source_path", clearSet: itemClear("poster_path")},
{name: "item backdrops", table: "media_items", keyCols: []string{"content_id"}, pathCol: "backdrop_path", sourceCol: "backdrop_source_path", clearSet: itemClear("backdrop_path")},
{name: "item logos", table: "media_items", keyCols: []string{"content_id"}, pathCol: "logo_path", sourceCol: "logo_source_path", clearSet: itemClear("logo_path")},
{name: "localized item posters", table: "media_item_localizations", keyCols: []string{"content_id", "language"}, pathCol: "poster_path", sourceCol: "poster_source_path", clearSet: plainClear("poster_path")},
{name: "localized item backdrops", table: "media_item_localizations", keyCols: []string{"content_id", "language"}, pathCol: "backdrop_path", sourceCol: "backdrop_source_path", clearSet: plainClear("backdrop_path")},
{name: "localized item logos", table: "media_item_localizations", keyCols: []string{"content_id", "language"}, pathCol: "logo_path", sourceCol: "logo_source_path", clearSet: plainClear("logo_path")},
{name: "season posters", table: "seasons", keyCols: []string{"content_id"}, pathCol: "poster_path", sourceCol: "poster_source_path", clearSet: plainClear("poster_path")},
{name: "localized season posters", table: "season_localizations", keyCols: []string{"season_content_id", "language"}, pathCol: "poster_path", sourceCol: "poster_source_path", clearSet: plainClear("poster_path")},
{name: "episode stills", table: "episodes", keyCols: []string{"content_id"}, pathCol: "still_path", sourceCol: "still_source_path", clearSet: plainClear("still_path")},
{name: "person photos", table: "people", keyCols: []string{"id::text"}, pathCol: "photo_path", sourceCol: "photo_source_path", clearSet: plainClear("photo_path")},
// Admin/user uploads: no re-downloadable source. Clearing falls back
// to the generated collage (admin collections), the generated poster
// (user collections), or the default tile (library posters); admins
// re-upload anything they want back. alwaysVerify protects surviving
// uploads from blind bulk resets.
{name: "collection posters", table: "library_collections", keyCols: []string{"id"}, pathCol: "poster_url", clearSet: `poster_url = '', poster_thumbhash = '', poster_auto_generated = FALSE, poster_from_template = FALSE, updated_at = NOW()`, alwaysVerify: true},
{name: "collection backdrops", table: "library_collections", keyCols: []string{"id"}, pathCol: "backdrop_url", clearSet: `backdrop_url = '', backdrop_thumbhash = '', updated_at = NOW()`, alwaysVerify: true},
{name: "user collection posters", table: "user_personal_collections", keyCols: []string{"id"}, pathCol: "poster_url", clearSet: `poster_url = '', poster_thumbhash = '', updated_at = NOW()`, alwaysVerify: true},
{name: "library posters", table: "media_folders", keyCols: []string{"id::text"}, pathCol: "poster_path", clearSet: `poster_path = ''`, alwaysVerify: true},
}
}
// ArtworkCacheReconciler verifies cached artwork keys against the public S3
// bucket and resets rows whose objects are missing, so the existing pipelines
// (image cache queue, book enrichment, chapter thumbnail backfill, collection
// collage generation) rebuild them in the currently configured storage.
type ArtworkCacheReconciler struct {
pool *pgxpool.Pool
s3 ArtworkObjectChecker
}
func NewArtworkCacheReconciler(pool *pgxpool.Pool, s3 ArtworkObjectChecker) *ArtworkCacheReconciler {
if pool == nil || s3 == nil {
return nil
}
return &ArtworkCacheReconciler{pool: pool, s3: s3}
}
// Run executes a full reconcile: probe, then either a bulk reset or a
// per-row verification sweep. It returns an error (leaving the storage
// fingerprint untouched at the caller) when storage cannot be reached or the
// error budget is exhausted, and never resets rows on the basis of transport
// errors.
func (r *ArtworkCacheReconciler) Run(ctx context.Context, progress func(percent float64, message string)) (ArtworkReconcileStats, error) {
stats := ArtworkReconcileStats{Mode: "verify"}
if r == nil || r.pool == nil || r.s3 == nil {
return stats, fmt.Errorf("artwork reconcile: not configured")
}
if progress == nil {
progress = func(float64, string) {}
}
surfaces := artworkSweepSurfaces()
// Probe before anything else: it decides the mode, and in bulk mode the
// per-surface count(*) queries (full scans on unindexable predicates)
// are never needed — bulk resets report their own RowsAffected.
progress(0, "Probing object storage")
if err := r.probe(ctx, surfaces, &stats); err != nil {
return stats, err
}
if stats.Sampled == 0 {
progress(100, "No cached artwork to verify")
return stats, nil
}
if shouldBulkReset(stats.Sampled, stats.SampleMissing) {
stats.Mode = "bulk_reset"
progress(5, fmt.Sprintf("Probe found %d/%d objects missing; resetting cached artwork", stats.SampleMissing, stats.Sampled))
steps := len(surfaces) + 1
for i, s := range surfaces {
pct := 5 + 90*float64(i+1)/float64(steps)
if s.alwaysVerify {
// Small upload-holding tables: never blind-reset; a surviving
// upload's row is the last pointer to its object.
if err := r.sweepSurface(ctx, s, &stats, func(done int) {
progress(pct, fmt.Sprintf("Verifying %s (%d rows)", s.name, done))
}); err != nil {
return stats, err
}
continue
}
if err := r.bulkResetSurface(ctx, s, &stats); err != nil {
return stats, err
}
progress(pct, fmt.Sprintf("Reset %s", s.name))
}
if err := r.bulkResetChapterThumbnails(ctx, &stats); err != nil {
return stats, err
}
progress(95, "Reset chapter thumbnails")
return stats, nil
}
// Verify mode: count cached rows once so progress has a denominator.
progress(2, "Counting cached artwork")
totals := make([]int, len(surfaces))
total := 0
for i, s := range surfaces {
n, err := r.countCached(ctx, s)
if err != nil {
return stats, err
}
totals[i] = n
total += n
}
chapterTotal, err := r.countChapterThumbnailFiles(ctx)
if err != nil {
return stats, err
}
total += chapterTotal
if total == 0 {
progress(100, "No cached artwork to verify")
return stats, nil
}
done := 0
report := func(surfaceName string) func(int) {
return func(surfaceDone int) {
pct := 5 + 90*float64(done+surfaceDone)/float64(total)
progress(pct, fmt.Sprintf("Verifying %s (%d/%d overall)", surfaceName, done+surfaceDone, total))
}
}
for i, s := range surfaces {
if totals[i] == 0 {
continue
}
if err := r.sweepSurface(ctx, s, &stats, report(s.name)); err != nil {
return stats, err
}
done += totals[i]
}
if chapterTotal > 0 {
if err := r.sweepChapterThumbnails(ctx, &stats, report("chapter thumbnails")); err != nil {
return stats, err
}
}
return stats, nil
}
// shouldBulkReset decides between a blind bulk reset and per-row
// verification. Probe HEADs are ground truth, so a near-total miss rate
// means the bucket plainly does not hold the cache; the threshold is below
// 1.0 only so a handful of coincidentally-present keys cannot force millions
// of pointless per-row checks. The minimum-sample bar keeps a probe thinned
// out by transport errors (or a tiny catalog) on the safe per-row path.
func shouldBulkReset(sampled, missing int) bool {
return sampled >= artworkReconcileBulkMinSample &&
float64(missing) >= artworkReconcileBulkThreshold*float64(sampled)
}
func (r *ArtworkCacheReconciler) countCached(ctx context.Context, s artworkSweepSurface) (int, error) {
var n int
q := fmt.Sprintf(`SELECT count(*) FROM %s WHERE %s`, s.table, s.cachedPredicate())
if err := r.pool.QueryRow(ctx, q).Scan(&n); err != nil {
return 0, fmt.Errorf("artwork reconcile: counting %s: %w", s.name, err)
}
return n, nil
}
// probe samples cached keys across all surfaces and HEADs them. A probe where
// every request errors aborts the run (storage unreachable ≠ objects missing).
func (r *ArtworkCacheReconciler) probe(ctx context.Context, surfaces []artworkSweepSurface, stats *ArtworkReconcileStats) error {
perSurface := artworkReconcileSampleTarget / (len(surfaces) + 1)
if perSurface < 1 {
perSurface = 1
}
// Plain LIMIT sampling (no ORDER BY random(), which would full-scan and
// sort every surface): the probe only has to answer "does the bucket
// hold this cache at all", and any N stored keys answer that. Partial
// migrations that skew the sample simply land in per-row verify mode,
// which handles them correctly anyway.
var keys []string
for _, s := range surfaces {
q := fmt.Sprintf(
`SELECT %s FROM %s WHERE %s LIMIT $1`,
s.pathCol, s.table, s.cachedPredicate(),
)
sampled, err := r.queryStrings(ctx, q, perSurface)
if err != nil {
return fmt.Errorf("artwork reconcile: sampling %s: %w", s.name, err)
}
keys = append(keys, sampled...)
}
chapterKeys, err := r.queryStrings(ctx, `
SELECT e->>'thumbnail_path'
FROM media_files, jsonb_array_elements(chapters) e
WHERE chapters IS NOT NULL AND coalesce(e->>'thumbnail_path', '') <> ''
LIMIT $1
`, perSurface)
if err != nil {
return fmt.Errorf("artwork reconcile: sampling chapter thumbnails: %w", err)
}
keys = append(keys, chapterKeys...)
if len(keys) == 0 {
return nil
}
present, missing, errored := r.headBatch(ctx, keys)
stats.Sampled = present + missing
stats.SampleMissing = missing
stats.Errors += errored
// A probe that mostly errors is not a probe of the cache, it is a probe
// of an outage: errored requests are excluded from the sample, so acting
// on the survivors could bulk-reset the catalog off a handful of 404s.
// Abort and leave the fingerprint stale; the next startup retries.
if errored*2 > len(keys) {
return fmt.Errorf("artwork reconcile: object storage unreliable: %d/%d probe requests failed", errored, len(keys))
}
return nil
}
func (r *ArtworkCacheReconciler) queryStrings(ctx context.Context, q string, args ...any) ([]string, error) {
rows, err := r.pool.Query(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var v string
if err := rows.Scan(&v); err != nil {
return nil, err
}
out = append(out, v)
}
return out, rows.Err()
}
// headBatch checks the given keys with bounded concurrency and returns
// (present, missing, errored) counts.
func (r *ArtworkCacheReconciler) headBatch(ctx context.Context, keys []string) (present, missing, errored int) {
verdicts := r.headKeys(ctx, keys)
for _, v := range verdicts {
switch {
case v.err != nil:
errored++
case v.missing:
missing++
default:
present++
}
}
return present, missing, errored
}
type headVerdict struct {
missing bool
err error
}
// headKeys HEADs every key with bounded concurrency, preserving order.
func (r *ArtworkCacheReconciler) headKeys(ctx context.Context, keys []string) []headVerdict {
bucket := r.s3.Bucket()
verdicts := make([]headVerdict, len(keys))
var wg sync.WaitGroup
sem := make(chan struct{}, artworkReconcileHeadWorkers)
for i, key := range keys {
wg.Add(1)
go func(i int, key string) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
exists, err := r.objectExistsWithRetry(ctx, bucket, key)
verdicts[i] = headVerdict{missing: err == nil && !exists, err: err}
}(i, key)
}
wg.Wait()
return verdicts
}
func (r *ArtworkCacheReconciler) objectExistsWithRetry(ctx context.Context, bucket, key string) (bool, error) {
const maxAttempts = 3
var lastErr error
for attempt := 0; attempt < maxAttempts; attempt++ {
// Per-attempt deadline: a stalled HEAD must fail this attempt and
// move on, not hold the retry loop open until the run's context dies.
attemptCtx, cancel := context.WithTimeout(ctx, artworkReconcileHeadTimeout)
exists, err := r.s3.ObjectExists(attemptCtx, bucket, key)
cancel()
if err == nil {
return exists, nil
}
lastErr = err
if attempt == maxAttempts-1 {
break
}
timer := time.NewTimer(time.Duration(attempt+1) * 250 * time.Millisecond)
select {
case <-timer.C:
case <-ctx.Done():
timer.Stop()
return false, ctx.Err()
}
}
return false, lastErr
}
// bulkResetSurface resets every cached row without per-row verification. Rows
// with a re-downloadable provider source go back to that source (the enqueue
// loop re-caches them); rows without one are cleared so their owning pipeline
// can refill them.
func (r *ArtworkCacheReconciler) bulkResetSurface(ctx context.Context, s artworkSweepSurface, stats *ArtworkReconcileStats) error {
if s.sourceCol != "" {
requeue := fmt.Sprintf(
`UPDATE %s SET %s WHERE %s AND %s`,
s.table, s.resetSet(), s.cachedPredicate(), s.remoteSourcePredicate(),
)
tag, err := r.pool.Exec(ctx, requeue)
if err != nil {
return fmt.Errorf("artwork reconcile: bulk reset %s: %w", s.name, err)
}
stats.Requeued += int(tag.RowsAffected())
stats.Checked += int(tag.RowsAffected())
}
clearSQL := fmt.Sprintf(
`UPDATE %s SET %s WHERE %s AND NOT (%s)`,
s.table, s.clearSet, s.cachedPredicate(), s.remoteSourcePredicate(),
)
tag, err := r.pool.Exec(ctx, clearSQL)
if err != nil {
return fmt.Errorf("artwork reconcile: bulk clear %s: %w", s.name, err)
}
stats.Cleared += int(tag.RowsAffected())
stats.Checked += int(tag.RowsAffected())
return nil
}
// sweptRow is one candidate row in the per-row verification sweep.
type sweptRow struct {
keys []string
path string
remoteSource bool
}
func (r *ArtworkCacheReconciler) sweepSurface(ctx context.Context, s artworkSweepSurface, stats *ArtworkReconcileStats, onProgress func(done int)) error {
var cursor []string
done := 0
for {
rows, err := r.fetchSweepBatch(ctx, s, cursor)
if err != nil {
return err
}
if len(rows) == 0 {
return nil
}
cursor = rows[len(rows)-1].keys
if err := r.verifyAndReset(ctx, s, rows, stats); err != nil {
return err
}
if stats.SweepErrors > artworkReconcileErrorBudget {
return fmt.Errorf("artwork reconcile: aborting after %d sweep storage errors (errored rows were left untouched)", stats.SweepErrors)
}
done += len(rows)
onProgress(done)
}
}
func (r *ArtworkCacheReconciler) fetchSweepBatch(ctx context.Context, s artworkSweepSurface, cursor []string) ([]sweptRow, error) {
var b strings.Builder
args := make([]any, 0, len(cursor)+1)
fmt.Fprintf(&b, `SELECT %s, %s, (%s) FROM %s WHERE %s`,
strings.Join(s.keyCols, ", "), s.pathCol, s.remoteSourcePredicate(), s.table, s.cachedPredicate())
if len(cursor) > 0 {
placeholders := make([]string, len(cursor))
for i, v := range cursor {
args = append(args, v)
placeholders[i] = fmt.Sprintf("$%d", len(args))
}
fmt.Fprintf(&b, ` AND (%s) > (%s)`, strings.Join(s.keyCols, ", "), strings.Join(placeholders, ", "))
}
args = append(args, artworkReconcileBatchSize)
fmt.Fprintf(&b, ` ORDER BY %s LIMIT $%d`, strings.Join(s.keyCols, ", "), len(args))
rows, err := r.pool.Query(ctx, b.String(), args...)
if err != nil {
return nil, fmt.Errorf("artwork reconcile: fetching %s batch: %w", s.name, err)
}
defer rows.Close()
out := make([]sweptRow, 0, artworkReconcileBatchSize)
for rows.Next() {
row := sweptRow{keys: make([]string, len(s.keyCols))}
dest := make([]any, 0, len(s.keyCols)+2)
for i := range row.keys {
dest = append(dest, &row.keys[i])
}
dest = append(dest, &row.path, &row.remoteSource)
if err := rows.Scan(dest...); err != nil {
return nil, fmt.Errorf("artwork reconcile: scanning %s batch: %w", s.name, err)
}
out = append(out, row)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("artwork reconcile: iterating %s batch: %w", s.name, err)
}
return out, nil
}
func (r *ArtworkCacheReconciler) verifyAndReset(ctx context.Context, s artworkSweepSurface, batch []sweptRow, stats *ArtworkReconcileStats) error {
keys := make([]string, len(batch))
for i, row := range batch {
keys[i] = row.path
}
verdicts := r.headKeys(ctx, keys)
pkPredicate := keyEqualityPredicate(s.keyCols)
var pgBatch pgx.Batch
remoteByQueued := make([]bool, 0)
for i, v := range verdicts {
stats.Checked++
switch {
case v.err != nil:
stats.Errors++
stats.SweepErrors++
slog.Warn("artwork reconcile: object check failed; leaving row untouched",
"surface", s.name, "key", batch[i].path, "error", v.err)
case v.missing:
row := batch[i]
args := make([]any, 0, len(row.keys)+1)
for _, k := range row.keys {
args = append(args, k)
}
args = append(args, row.path)
var set string
if row.remoteSource {
set = s.resetSet()
} else {
set = s.clearSet
slog.Warn("artwork reconcile: cached image missing with no re-downloadable source; cleared",
"surface", s.name, "key", row.path, "row", strings.Join(row.keys, "/"))
}
pgBatch.Queue(fmt.Sprintf(`UPDATE %s SET %s WHERE %s AND %s = $%d`,
s.table, set, pkPredicate, s.pathCol, len(args)), args...)
remoteByQueued = append(remoteByQueued, row.remoteSource)
default:
stats.Verified++
}
}
if pgBatch.Len() == 0 {
return nil
}
results := r.pool.SendBatch(ctx, &pgBatch)
defer func() { _ = results.Close() }()
for _, remote := range remoteByQueued {
tag, err := results.Exec()
if err != nil {
return fmt.Errorf("artwork reconcile: resetting %s row: %w", s.name, err)
}
if tag.RowsAffected() == 0 {
// Row changed concurrently (metadata refresh, admin edit); leave it alone.
continue
}
if remote {
stats.Requeued++
} else {
stats.Cleared++
}
}
return nil
}
func keyEqualityPredicate(keyCols []string) string {
parts := make([]string, len(keyCols))
for i, col := range keyCols {
parts[i] = fmt.Sprintf("%s = $%d", col, i+1)
}
return strings.Join(parts, " AND ")
}
// --- Chapter thumbnails ---------------------------------------------------
//
// Chapter thumbnails live inside the media_files.chapters JSONB array
// (thumbnail_path / thumbnail_thumbhash per element). Clearing the path and
// the retry state makes the scheduled chapter_thumbnail_backfill task
// regenerate them from the media file.
const chapterThumbnailFilesPredicate = `chapters IS NOT NULL AND EXISTS (
SELECT 1 FROM jsonb_array_elements(chapters) e
WHERE coalesce(e->>'thumbnail_path', '') <> ''
)`
func (r *ArtworkCacheReconciler) countChapterThumbnailFiles(ctx context.Context) (int, error) {
var n int
q := `SELECT count(*) FROM media_files WHERE ` + chapterThumbnailFilesPredicate
if err := r.pool.QueryRow(ctx, q).Scan(&n); err != nil {
return 0, fmt.Errorf("artwork reconcile: counting chapter thumbnail files: %w", err)
}
return n, nil
}
func (r *ArtworkCacheReconciler) bulkResetChapterThumbnails(ctx context.Context, stats *ArtworkReconcileStats) error {
tag, err := r.pool.Exec(ctx, `
UPDATE media_files
SET chapters = (
SELECT jsonb_agg(
CASE WHEN coalesce(e->>'thumbnail_path', '') <> ''
THEN (e - 'thumbnail_retry_after' - 'thumbnail_failed_at' - 'thumbnail_last_error')
|| '{"thumbnail_path": "", "thumbnail_thumbhash": ""}'::jsonb
ELSE e
END
ORDER BY ord
)
FROM jsonb_array_elements(chapters) WITH ORDINALITY AS t(e, ord)
),
chapter_thumbnail_retry_after = NULL
WHERE `+chapterThumbnailFilesPredicate)
if err != nil {
return fmt.Errorf("artwork reconcile: bulk clearing chapter thumbnails: %w", err)
}
stats.Cleared += int(tag.RowsAffected())
stats.Checked += int(tag.RowsAffected())
return nil
}
// chapterFileRow is one media_files row in the chapter thumbnail sweep.
// Chapters are decoded as generic maps so fields this code does not know
// about survive a rewrite.
type chapterFileRow struct {
id int64
raw []byte
chapters []map[string]any
}
func (r *ArtworkCacheReconciler) sweepChapterThumbnails(ctx context.Context, stats *ArtworkReconcileStats, onProgress func(done int)) error {
cursor := int64(0)
done := 0
for {
rows, err := r.pool.Query(ctx, `
SELECT id, chapters FROM media_files
WHERE `+chapterThumbnailFilesPredicate+` AND id > $1
ORDER BY id LIMIT $2
`, cursor, artworkReconcileBatchSize)
if err != nil {
return fmt.Errorf("artwork reconcile: fetching chapter thumbnail batch: %w", err)
}
batch := make([]chapterFileRow, 0, artworkReconcileBatchSize)
for rows.Next() {
var f chapterFileRow
if err := rows.Scan(&f.id, &f.raw); err != nil {
rows.Close()
return fmt.Errorf("artwork reconcile: scanning chapter thumbnail batch: %w", err)
}
batch = append(batch, f)
}
rows.Close()
if err := rows.Err(); err != nil {
return fmt.Errorf("artwork reconcile: iterating chapter thumbnail batch: %w", err)
}
if len(batch) == 0 {
return nil
}
cursor = batch[len(batch)-1].id
if err := r.reconcileChapterBatch(ctx, batch, stats); err != nil {
return err
}
if stats.SweepErrors > artworkReconcileErrorBudget {
return fmt.Errorf("artwork reconcile: aborting after %d sweep storage errors (errored rows were left untouched)", stats.SweepErrors)
}
done += len(batch)
onProgress(done)
}
}
// reconcileChapterBatch verifies every chapter thumbnail across the whole
// batch in one HEAD fan-out — per-file checking would cap effective
// concurrency at one file's handful of chapters — then rewrites only the
// files whose arrays changed.
func (r *ArtworkCacheReconciler) reconcileChapterBatch(ctx context.Context, batch []chapterFileRow, stats *ArtworkReconcileStats) error {
type chapterRef struct{ file, chapter int }
var keys []string
var refs []chapterRef
for fi := range batch {
f := &batch[fi]
if err := json.Unmarshal(f.raw, &f.chapters); err != nil {
stats.Errors++
stats.SweepErrors++
slog.Warn("artwork reconcile: unparseable chapters JSON; skipping file", "file_id", f.id, "error", err)
f.chapters = nil
continue
}
for ci, ch := range f.chapters {
path, _ := ch["thumbnail_path"].(string)
if strings.TrimSpace(path) == "" {
continue
}
keys = append(keys, path)
refs = append(refs, chapterRef{file: fi, chapter: ci})
}
}
if len(keys) == 0 {
return nil
}
verdicts := r.headKeys(ctx, keys)
changed := make(map[int]bool, len(batch))
for vi, v := range verdicts {
stats.Checked++
ref := refs[vi]
switch {
case v.err != nil:
stats.Errors++
stats.SweepErrors++
slog.Warn("artwork reconcile: chapter thumbnail check failed; leaving chapter untouched",
"file_id", batch[ref.file].id, "key", keys[vi], "error", v.err)
case v.missing:
ch := batch[ref.file].chapters[ref.chapter]
ch["thumbnail_path"] = ""
ch["thumbnail_thumbhash"] = ""
delete(ch, "thumbnail_retry_after")
delete(ch, "thumbnail_failed_at")
delete(ch, "thumbnail_last_error")
changed[ref.file] = true
stats.Cleared++
default:
stats.Verified++
}
}
for fi := range batch {
if !changed[fi] {
continue
}
f := batch[fi]
updated, err := json.Marshal(f.chapters)
if err != nil {
return fmt.Errorf("artwork reconcile: encoding chapters for file %d: %w", f.id, err)
}
// Guard on the original JSON so a concurrent thumbnail-service write wins.
tag, err := r.pool.Exec(ctx, `
UPDATE media_files
SET chapters = $1::jsonb, chapter_thumbnail_retry_after = NULL
WHERE id = $2 AND chapters = $3::jsonb
`, updated, f.id, f.raw)
if err != nil {
return fmt.Errorf("artwork reconcile: updating chapters for file %d: %w", f.id, err)
}
if tag.RowsAffected() == 0 {
slog.Debug("artwork reconcile: chapters changed concurrently; skipped", "file_id", f.id)
}
}
return nil
}