- Score candidate metadata completeness and auto-match the richer duplicate when title/year/type tie - Enrich near-duplicate candidates via the provider chain before initial match selection - Seed both movie and series match queues for mixed-type libraries and wait for TV queue settle - Add taskmanager worker test coverage and a plan doc for the tie-breaker work
291 lines
6.3 KiB
Go
291 lines
6.3 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.Error("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.Error("failed to persist default triggers", "task", key, "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
w.setTriggers(configs, m.triggerFactory, w.lastResult, false)
|
|
|
|
go m.triggerLoop(ctx, w)
|
|
}
|
|
|
|
m.logger.Info("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
|
|
}
|
|
|
|
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.Error("failed to persist execution result",
|
|
"task", w.task.Key(), "error", insertErr)
|
|
}
|
|
}
|
|
|
|
m.rearmTriggers(w)
|
|
}
|
|
}
|
|
|
|
// rearmTriggers stops and restarts all triggers for a worker.
|
|
func (m *TaskManager) rearmTriggers(w *taskWorker) {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
for _, tr := range w.triggers {
|
|
tr.Stop()
|
|
tr.Start(w.lastResult)
|
|
}
|
|
}
|
|
|
|
// 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.Error("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
|
|
}
|