* 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>
187 lines
7.5 KiB
Go
187 lines
7.5 KiB
Go
package tasks
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/metadata"
|
|
"github.com/Silo-Server/silo-server/internal/s3client"
|
|
"github.com/Silo-Server/silo-server/internal/taskmanager"
|
|
)
|
|
|
|
// ArtworkStorageIdentityKey is the server_settings key holding the storage
|
|
// identity fingerprint of the public S3 bucket the artwork cache was last
|
|
// reconciled against. Machine-managed; not an admin-editable setting.
|
|
const ArtworkStorageIdentityKey = "s3.public_storage_identity"
|
|
|
|
// ArtworkStorageIdentity builds the fingerprint of the public S3 storage the
|
|
// cached artwork lives in. Only fields that determine *where objects are
|
|
// stored* participate: the read endpoint and URL-auth settings affect how
|
|
// objects are served, not where they live, so changing them must not trigger
|
|
// a reconcile.
|
|
//
|
|
// Normalization mirrors how each field is actually used: endpoints (hostnames)
|
|
// and bucket names are case-insensitive, but the key prefix feeds into
|
|
// case-sensitive object keys, so it keeps its case and is normalized exactly
|
|
// like s3client applies it (slash- and whitespace-trimmed). A case-only prefix
|
|
// edit is a real storage move and must change the fingerprint; a slash-only
|
|
// edit is not and must not.
|
|
func ArtworkStorageIdentity(endpoint, bucket, keyPrefix string) string {
|
|
insensitive := func(v string) string { return strings.ToLower(strings.TrimSpace(v)) }
|
|
return insensitive(endpoint) + "|" + insensitive(bucket) + "|" + s3client.NormalizeKeyPrefix(keyPrefix)
|
|
}
|
|
|
|
// ArtworkReconcileSettingsStore is the server-settings surface the task needs.
|
|
// Satisfied by *catalog.ServerSettingsRepo and its encrypting decorator.
|
|
type ArtworkReconcileSettingsStore interface {
|
|
Get(ctx context.Context, key string) (string, error)
|
|
Set(ctx context.Context, key, value string) error
|
|
}
|
|
|
|
// ArtworkReconcileRunner runs a reconcile sweep. Satisfied by
|
|
// *metadata.ArtworkCacheReconciler.
|
|
type ArtworkReconcileRunner interface {
|
|
Run(ctx context.Context, progress func(percent float64, message string)) (metadata.ArtworkReconcileStats, error)
|
|
}
|
|
|
|
// BrandingAssetReconciler clears branding asset refs whose stored objects are
|
|
// missing. Satisfied by *branding.Service; may be nil when branding has no
|
|
// storage.
|
|
type BrandingAssetReconciler interface {
|
|
ReconcileMissingAssets(ctx context.Context) (checked, cleared int, err error)
|
|
}
|
|
|
|
// ReconcileArtworkCacheTask verifies cached artwork against the currently
|
|
// configured public object storage and resets whatever is missing so the
|
|
// image cache pipeline rebuilds it. Scheduled runs only fire when the storage
|
|
// identity changed since the last completed reconcile; manual runs always
|
|
// sweep, which doubles as recovery from bucket data loss.
|
|
type ReconcileArtworkCacheTask struct {
|
|
runner ArtworkReconcileRunner
|
|
settings ArtworkReconcileSettingsStore
|
|
branding BrandingAssetReconciler
|
|
identity string
|
|
}
|
|
|
|
func NewReconcileArtworkCacheTask(runner ArtworkReconcileRunner, settings ArtworkReconcileSettingsStore, branding BrandingAssetReconciler, identity string) *ReconcileArtworkCacheTask {
|
|
return &ReconcileArtworkCacheTask{runner: runner, settings: settings, branding: branding, identity: identity}
|
|
}
|
|
|
|
func (t *ReconcileArtworkCacheTask) Key() string { return "reconcile_artwork_cache" }
|
|
func (t *ReconcileArtworkCacheTask) Name() string { return "Reconcile Artwork Cache" }
|
|
func (t *ReconcileArtworkCacheTask) Description() string {
|
|
return "Verifies cached artwork against object storage and re-caches anything missing (runs automatically after the storage provider changes)"
|
|
}
|
|
func (t *ReconcileArtworkCacheTask) Category() taskmanager.TaskCategory {
|
|
return taskmanager.TaskCategoryMetadata
|
|
}
|
|
func (t *ReconcileArtworkCacheTask) IsHidden() bool { return false }
|
|
|
|
func (t *ReconcileArtworkCacheTask) DefaultTriggers() []taskmanager.TriggerConfig {
|
|
return []taskmanager.TriggerConfig{
|
|
{Type: taskmanager.TriggerTypeStartup},
|
|
}
|
|
}
|
|
|
|
// ShouldRun suppresses the startup trigger while the storage identity is
|
|
// unchanged. Manual RunTask calls bypass this and always sweep.
|
|
//
|
|
// The startup trigger fires exactly once per process, so a transient settings
|
|
// read failure here would postpone a needed reconcile until the next restart;
|
|
// retry briefly before giving up. (The task manager skips the run on a
|
|
// preflight error rather than failing open into a full sweep.)
|
|
func (t *ReconcileArtworkCacheTask) ShouldRun(ctx context.Context) (bool, error) {
|
|
if t.runner == nil || t.settings == nil {
|
|
return false, nil
|
|
}
|
|
var stored string
|
|
var err error
|
|
for attempt := 0; attempt < 3; attempt++ {
|
|
stored, err = t.settings.Get(ctx, ArtworkStorageIdentityKey)
|
|
if err == nil {
|
|
return stored != "" && stored != t.identity, nil
|
|
}
|
|
timer := time.NewTimer(time.Duration(attempt+1) * time.Second)
|
|
select {
|
|
case <-timer.C:
|
|
case <-ctx.Done():
|
|
timer.Stop()
|
|
return false, ctx.Err()
|
|
}
|
|
}
|
|
return false, fmt.Errorf("reading artwork storage identity: %w", err)
|
|
}
|
|
|
|
func (t *ReconcileArtworkCacheTask) Execute(ctx context.Context, progress taskmanager.ProgressReporter) error {
|
|
if t.runner == nil || t.settings == nil {
|
|
progress.Report(100, "Artwork reconcile is not configured")
|
|
return nil
|
|
}
|
|
|
|
stats, err := t.runner.Run(ctx, progress.Report)
|
|
if err != nil {
|
|
if data, marshalErr := json.Marshal(stats); marshalErr == nil {
|
|
progress.SetResultData(data)
|
|
}
|
|
return fmt.Errorf("reconciling artwork cache: %w", err)
|
|
}
|
|
|
|
// Only a clean, completed sweep certifies the current storage. Sweep
|
|
// errors mean rows were skipped unverified, so the fingerprint stays
|
|
// stale and the next startup retries; resets already applied this run
|
|
// are durable either way.
|
|
if stats.SweepErrors > 0 {
|
|
if data, marshalErr := json.Marshal(stats); marshalErr == nil {
|
|
progress.SetResultData(data)
|
|
}
|
|
return fmt.Errorf(
|
|
"artwork reconcile: %d rows skipped on storage errors (verified %d, re-queued %d, cleared %d); storage identity left uncertified so the next startup retries",
|
|
stats.SweepErrors, stats.Verified, stats.Requeued, stats.Cleared,
|
|
)
|
|
}
|
|
// Certify before the branding check: a transient failure on that
|
|
// 4-object pass must not discard a completed catalog sweep and force it
|
|
// to repeat every boot.
|
|
if setErr := t.settings.Set(ctx, ArtworkStorageIdentityKey, t.identity); setErr != nil {
|
|
return fmt.Errorf("persisting artwork storage identity: %w", setErr)
|
|
}
|
|
|
|
brandingNote := ""
|
|
if t.branding != nil {
|
|
brandingChecked, brandingCleared, brandingErr := t.branding.ReconcileMissingAssets(ctx)
|
|
stats.Cleared += brandingCleared
|
|
stats.Checked += brandingChecked
|
|
if brandingErr != nil {
|
|
stats.Errors++
|
|
brandingNote = fmt.Sprintf("; branding asset check failed: %v (re-run the task to retry)", brandingErr)
|
|
slog.Warn("artwork reconcile: branding asset check failed", "error", brandingErr)
|
|
}
|
|
}
|
|
|
|
if data, marshalErr := json.Marshal(stats); marshalErr == nil {
|
|
progress.SetResultData(data)
|
|
}
|
|
|
|
message := fmt.Sprintf(
|
|
"Verified %d cached images intact, re-queued %d for re-cache, cleared %d without a re-downloadable source",
|
|
stats.Verified, stats.Requeued, stats.Cleared,
|
|
)
|
|
if stats.Mode == "bulk_reset" {
|
|
message = fmt.Sprintf(
|
|
"Storage probe found %d/%d sampled objects missing; reset all cached artwork (re-queued %d, cleared %d)",
|
|
stats.SampleMissing, stats.Sampled, stats.Requeued, stats.Cleared,
|
|
)
|
|
}
|
|
if stats.Errors > 0 {
|
|
// SweepErrors is zero here (checked above), so these are probe or
|
|
// branding errors — reported, but they don't reduce sweep coverage.
|
|
message += fmt.Sprintf(", %d storage errors during probing", stats.Errors)
|
|
}
|
|
progress.Report(100, message+brandingNote)
|
|
return nil
|
|
}
|