Files
silo-server/internal/taskmanager/tasks/contribute_markers.go
QuickandGitHub 39efe308af fix(markers): stop retrying contribution conflicts (#559)
* fix(markers): stop retrying contribution conflicts

* fix(markers): claim contributions atomically

* fix(markers): recover stale contribution claims
2026-08-07 11:21:26 -04:00

162 lines
5.2 KiB
Go

package tasks
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/Silo-Server/silo-server/internal/markers"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/taskmanager"
)
// ContributionRunner submits a file's eligible markers (satisfied by
// *markers.ContributionService).
type ContributionRunner interface {
ContributeFile(ctx context.Context, file *models.MediaFile, opts markers.ContributeOptions) ([]markers.ContributionOutcome, error)
}
// AutoContributeConfigReader exposes per-provider contribution config (satisfied
// by *markers.ProviderConfigStore).
type AutoContributeConfigReader interface {
List() []markers.ProviderConfig
}
// ContributionCandidateSource lists local-intro files eligible for auto
// contribution (satisfied by *markers.ContributionStore).
type ContributionCandidateSource interface {
CandidateLocalIntroFiles(ctx context.Context, minConfidence float64, afterID, limit int) ([]int, error)
}
// ContributionFileLoader loads files by id (satisfied by *scanner.FileRepository).
type ContributionFileLoader interface {
GetByIDs(ctx context.Context, ids []int) ([]*models.MediaFile, error)
}
// ContributeMarkersTask submits high-confidence local intro markers to providers
// that have auto-contribution enabled. It is a no-op when no provider opts in.
type ContributeMarkersTask struct {
service ContributionRunner
config AutoContributeConfigReader
candidates ContributionCandidateSource
files ContributionFileLoader
}
// NewContributeMarkersTask constructs the task.
func NewContributeMarkersTask(service ContributionRunner, config AutoContributeConfigReader, candidates ContributionCandidateSource, files ContributionFileLoader) *ContributeMarkersTask {
return &ContributeMarkersTask{service: service, config: config, candidates: candidates, files: files}
}
func (t *ContributeMarkersTask) Key() string { return "contribute_markers" }
func (t *ContributeMarkersTask) Name() string { return "Contribute Markers" }
func (t *ContributeMarkersTask) Description() string {
return "Submits high-confidence local intro markers to enabled contribution providers"
}
func (t *ContributeMarkersTask) Category() taskmanager.TaskCategory {
return taskmanager.TaskCategoryLibrary
}
func (t *ContributeMarkersTask) IsHidden() bool { return false }
func (t *ContributeMarkersTask) DefaultTriggers() []taskmanager.TriggerConfig {
// Runs after the 03:30 local-detection task so freshly detected markers are
// eligible the same night.
return []taskmanager.TriggerConfig{{Type: taskmanager.TriggerTypeDaily, TimeOfDay: "04:00"}}
}
func (t *ContributeMarkersTask) Execute(ctx context.Context, progress taskmanager.ProgressReporter) error {
if t.service == nil || t.config == nil || t.candidates == nil || t.files == nil {
progress.Report(100, "Contribution is not configured")
return nil
}
minConfidence := 0.0
autoEnabled := false
for _, c := range t.config.List() {
if !c.ContributeEnabled || !c.ContributeAutoLocal {
continue
}
if !autoEnabled || c.ContributeMinConfidence < minConfidence {
minConfidence = c.ContributeMinConfidence
}
autoEnabled = true
}
if !autoEnabled {
progress.Report(100, "No provider has auto-contribution enabled")
return nil
}
const batch = 100
afterID := 0
submitted, skipped, failed := 0, 0, 0
for {
ids, err := t.candidates.CandidateLocalIntroFiles(ctx, minConfidence, afterID, batch)
if err != nil {
return fmt.Errorf("load contribution candidates: %w", err)
}
if len(ids) == 0 {
break
}
files, err := t.files.GetByIDs(ctx, ids)
if err != nil {
return fmt.Errorf("load candidate files: %w", err)
}
byID := make(map[int]*models.MediaFile, len(files))
for _, f := range files {
byID[f.ID] = f
}
for _, id := range ids {
afterID = id
file := byID[id]
if file == nil {
continue
}
outcomes, err := t.service.ContributeFile(ctx, file, markers.ContributeOptions{Auto: true})
if err != nil {
failed++
continue
}
for _, o := range outcomes {
switch o.Status {
case markers.OutcomeStatusSkipped, markers.OutcomeStatusConflict:
skipped++
case markers.OutcomeStatusRateLimited:
failed++
writeContributionTaskResult(progress, submitted, skipped, failed, o.RetryAfter)
progress.Report(100, fmt.Sprintf("Contribution usage-limited; retry after %s", formatRetryAfter(o.RetryAfter)))
return nil
case markers.OutcomeStatusError:
failed++
default:
submitted++
}
}
}
if ctx.Err() != nil {
return ctx.Err()
}
}
writeContributionTaskResult(progress, submitted, skipped, failed, 0)
progress.Report(100, fmt.Sprintf("Contributed %d, skipped %d, failed %d", submitted, skipped, failed))
return nil
}
func writeContributionTaskResult(progress taskmanager.ProgressReporter, submitted, skipped, failed int, retryAfter time.Duration) {
result := map[string]int{"submitted": submitted, "skipped": skipped, "failed": failed}
if retryAfter > 0 {
result["retry_after_seconds"] = int(retryAfter.Seconds())
}
if data, err := json.Marshal(result); err == nil {
progress.SetResultData(data)
}
}
func formatRetryAfter(d time.Duration) string {
if d <= 0 {
return "later"
}
return d.String()
}