* 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>
331 lines
7.6 KiB
Go
331 lines
7.6 KiB
Go
package taskmanager
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"sort"
|
|
"sync"
|
|
)
|
|
|
|
// TriggerFactory is a function that creates a live Trigger from a TriggerConfig.
|
|
type TriggerFactory func(TriggerConfig) Trigger
|
|
|
|
// TaskManager is the central orchestrator for background tasks.
|
|
type TaskManager struct {
|
|
tasks map[string]*taskWorker
|
|
mu sync.RWMutex
|
|
triggerRepo TriggerRepository
|
|
historyRepo ExecutionRepository
|
|
triggerFactory TriggerFactory
|
|
logger *slog.Logger
|
|
observers []Observer
|
|
}
|
|
|
|
// New creates a new TaskManager.
|
|
func New(triggerRepo TriggerRepository, historyRepo ExecutionRepository, triggerFactory TriggerFactory, logger *slog.Logger) *TaskManager {
|
|
if logger == nil {
|
|
logger = slog.Default()
|
|
}
|
|
return &TaskManager{
|
|
tasks: make(map[string]*taskWorker),
|
|
triggerRepo: triggerRepo,
|
|
historyRepo: historyRepo,
|
|
triggerFactory: triggerFactory,
|
|
logger: logger,
|
|
}
|
|
}
|
|
|
|
func (m *TaskManager) AddObserver(observer Observer) {
|
|
if m == nil || observer == nil {
|
|
return
|
|
}
|
|
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.observers = append(m.observers, observer)
|
|
}
|
|
|
|
// Register adds a task to the manager. Must be called before Start.
|
|
func (m *TaskManager) Register(task Task) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.tasks[task.Key()] = newTaskWorker(task, m)
|
|
}
|
|
|
|
// Start loads triggers from the repository and begins all scheduling loops.
|
|
func (m *TaskManager) Start(ctx context.Context) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
|
|
for key, w := range m.tasks {
|
|
if latest, err := m.historyRepo.GetLatest(ctx, key); err == nil && latest != nil {
|
|
w.mu.Lock()
|
|
w.lastResult = latest
|
|
w.mu.Unlock()
|
|
}
|
|
|
|
configs, err := m.triggerRepo.GetTriggers(ctx, key)
|
|
if err != nil {
|
|
m.logger.ErrorContext(ctx, "failed to load triggers", "task", key, "error", err)
|
|
}
|
|
if len(configs) == 0 {
|
|
configs = w.task.DefaultTriggers()
|
|
if len(configs) > 0 {
|
|
if err := m.triggerRepo.SetTriggers(ctx, key, configs); err != nil {
|
|
m.logger.ErrorContext(ctx, "failed to persist default triggers", "task", key, "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
w.setTriggers(configs, m.triggerFactory, w.lastResult, false)
|
|
|
|
go m.triggerLoop(ctx, w)
|
|
}
|
|
|
|
m.logger.InfoContext(ctx, "task manager started", "tasks", len(m.tasks))
|
|
}
|
|
|
|
// triggerLoop listens on all trigger channels for a worker and runs the task
|
|
// when any trigger fires.
|
|
func (m *TaskManager) triggerLoop(ctx context.Context, w *taskWorker) {
|
|
for {
|
|
w.mu.RLock()
|
|
trigs := w.triggers
|
|
w.mu.RUnlock()
|
|
|
|
if len(trigs) == 0 {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-w.triggerUpdate:
|
|
continue
|
|
}
|
|
}
|
|
|
|
merged := make(chan struct{}, 1)
|
|
done := make(chan struct{})
|
|
|
|
for _, tr := range trigs {
|
|
tr := tr
|
|
go func() {
|
|
select {
|
|
case <-done:
|
|
return
|
|
case _, ok := <-tr.C():
|
|
if ok {
|
|
select {
|
|
case merged <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
go func() {
|
|
select {
|
|
case <-done:
|
|
return
|
|
case <-w.triggerUpdate:
|
|
select {
|
|
case merged <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
}()
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
close(done)
|
|
return
|
|
case <-merged:
|
|
close(done)
|
|
}
|
|
|
|
if w.triggerChanged.CompareAndSwap(true, false) {
|
|
continue
|
|
}
|
|
|
|
shouldRun, shouldRunErr := m.shouldRunScheduledTask(ctx, w)
|
|
if shouldRunErr != nil {
|
|
// Fail closed: a preflight that cannot answer must not launch the
|
|
// task — for expensive conditional tasks a transient error would
|
|
// otherwise trigger the exact work the gate exists to suppress.
|
|
// Interval/daily triggers retry at their next firing; manual
|
|
// RunTask always bypasses the gate.
|
|
m.logger.WarnContext(ctx, "scheduled task preflight failed; skipping run",
|
|
"task", w.task.Key(), "error", shouldRunErr)
|
|
m.rearmTriggersFromNow(w)
|
|
continue
|
|
}
|
|
if !shouldRun {
|
|
m.rearmTriggersFromNow(w)
|
|
continue
|
|
}
|
|
|
|
result, err := w.run(ctx)
|
|
if err != nil {
|
|
// If the task is already running (e.g. via manual RunTask), don't
|
|
// rearm — the concurrent runner will rearm when it finishes.
|
|
// For other errors, rearm so the trigger fires again later.
|
|
if err != ErrTaskAlreadyRunning {
|
|
m.rearmTriggers(w)
|
|
}
|
|
continue
|
|
}
|
|
|
|
if result != nil {
|
|
if insertErr := m.historyRepo.Insert(ctx, *result); insertErr != nil {
|
|
m.logger.ErrorContext(ctx, "failed to persist execution result",
|
|
"task", w.task.Key(), "error", insertErr)
|
|
}
|
|
}
|
|
|
|
m.rearmTriggers(w)
|
|
}
|
|
}
|
|
|
|
func (m *TaskManager) shouldRunScheduledTask(ctx context.Context, w *taskWorker) (bool, error) {
|
|
task, ok := w.task.(ScheduledConditionalTask)
|
|
if !ok {
|
|
return true, nil
|
|
}
|
|
return task.ShouldRun(ctx)
|
|
}
|
|
|
|
// rearmTriggers stops and restarts all triggers for a worker.
|
|
func (m *TaskManager) rearmTriggers(w *taskWorker) {
|
|
w.mu.Lock()
|
|
lastResult := w.lastResult
|
|
for _, tr := range w.triggers {
|
|
tr.Stop()
|
|
tr.Start(lastResult)
|
|
}
|
|
w.mu.Unlock()
|
|
w.notify()
|
|
}
|
|
|
|
// rearmTriggersFromNow restarts triggers after a skipped conditional task. This
|
|
// intentionally ignores lastResult; otherwise an old completed_at can make an
|
|
// interval trigger fire immediately in a loop while there is no work.
|
|
func (m *TaskManager) rearmTriggersFromNow(w *taskWorker) {
|
|
w.mu.Lock()
|
|
for _, tr := range w.triggers {
|
|
tr.Stop()
|
|
tr.Start(nil)
|
|
}
|
|
w.mu.Unlock()
|
|
w.notify()
|
|
}
|
|
|
|
// Stop stops all triggers and cancels running tasks.
|
|
func (m *TaskManager) Stop() {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
|
|
for _, w := range m.tasks {
|
|
w.stopTriggers()
|
|
w.mu.RLock()
|
|
if w.cancel != nil {
|
|
w.cancel()
|
|
}
|
|
w.mu.RUnlock()
|
|
}
|
|
m.logger.Info("task manager stopped")
|
|
}
|
|
|
|
// RunTask triggers immediate execution of a task.
|
|
func (m *TaskManager) RunTask(ctx context.Context, key string) error {
|
|
w, err := m.getWorker(key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
result, err := w.run(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if result != nil {
|
|
if insertErr := m.historyRepo.Insert(ctx, *result); insertErr != nil {
|
|
m.logger.ErrorContext(ctx, "failed to persist execution result", "task", key, "error", insertErr)
|
|
}
|
|
}
|
|
|
|
m.rearmTriggers(w)
|
|
return nil
|
|
}
|
|
|
|
// CancelTask requests cancellation of a running task.
|
|
func (m *TaskManager) CancelTask(key string) error {
|
|
w, err := m.getWorker(key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return w.requestCancel()
|
|
}
|
|
|
|
// GetTaskInfo returns the current state of a task.
|
|
func (m *TaskManager) GetTaskInfo(key string) TaskInfo {
|
|
w, err := m.getWorker(key)
|
|
if err != nil {
|
|
return TaskInfo{}
|
|
}
|
|
return w.info()
|
|
}
|
|
|
|
// ListTasks returns info for all registered tasks, optionally including hidden ones.
|
|
func (m *TaskManager) ListTasks(includeHidden bool) []TaskInfo {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
|
|
var infos []TaskInfo
|
|
for _, w := range m.tasks {
|
|
if !includeHidden && w.task.IsHidden() {
|
|
continue
|
|
}
|
|
infos = append(infos, w.info())
|
|
}
|
|
sort.Slice(infos, func(i, j int) bool { return infos[i].Key < infos[j].Key })
|
|
return infos
|
|
}
|
|
|
|
// UpdateTriggers replaces the triggers for a task.
|
|
func (m *TaskManager) UpdateTriggers(key string, triggerConfigs []TriggerConfig) error {
|
|
w, err := m.getWorker(key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := m.triggerRepo.SetTriggers(context.Background(), key, triggerConfigs); err != nil {
|
|
return err
|
|
}
|
|
|
|
w.setTriggers(triggerConfigs, m.triggerFactory, nil, true)
|
|
m.notifyTaskUpdated(w.info())
|
|
return nil
|
|
}
|
|
|
|
func (m *TaskManager) notifyTaskUpdated(info TaskInfo) {
|
|
if m == nil {
|
|
return
|
|
}
|
|
|
|
m.mu.RLock()
|
|
observers := append([]Observer(nil), m.observers...)
|
|
m.mu.RUnlock()
|
|
for _, observer := range observers {
|
|
observer.TaskUpdated(info)
|
|
}
|
|
}
|
|
|
|
func (m *TaskManager) getWorker(key string) (*taskWorker, error) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
w, ok := m.tasks[key]
|
|
if !ok {
|
|
return nil, ErrTaskNotFound
|
|
}
|
|
return w, nil
|
|
}
|