Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions docs/admin-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,28 @@ operation:
- Capability documents carry the common `state`, `allowed`, and `revision` members
and support `If-None-Match`.

## Task schedules

`PUT /api/v2/admin/tasks/{key}/triggers` replaces the complete schedule using
`{"triggers":[...]}` and the captured `If-Match` validator. Saving an empty
array disables automatic runs; the task can still be run manually. Added,
edited, and removed triggers survive server restarts and upgrades, including
an explicitly empty schedule. Tasks marked `manual_only` reject nonempty arrays.

Startup persists defaults only when no schedule exists, using the same revision
guard as administrator edits. An edit saved while defaults are being resolved
takes precedence. Default providers are consulted only for unsaved schedules.
If startup cannot load or initialize a schedule, automatic runs for that task
remain idle and the server logs the error. Saving its triggers again or
restarting after storage recovers reloads scheduling. Other running server
processes reload schedule changes on restart.

The existing schedule-revision migration retains trigger rows. Servers from
before that migration did not distinguish a new task from a cleared schedule,
so administrators must clear previously restored triggers again after upgrading
all instances. Saving Autoscan settings separately replaces the Autoscan poll
task's triggers with its configured poll interval.

## Branding assets

Uploadable images white-label the server: the sidebar wordmark, the square
Expand Down
17 changes: 9 additions & 8 deletions internal/taskmanager/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,16 +66,17 @@ func (m *TaskManager) Start(ctx context.Context) {
}

configs, err := m.triggerRepo.GetTriggers(ctx, key)
if err == nil && configs == nil {
// Default providers may read settings. Resolve them only for new
// schedules, outside the repository transaction. Initialization
// rechecks saved state so a concurrent edit still takes precedence.
configs, err = m.triggerRepo.GetOrCreateTriggers(ctx, key, w.task.DefaultTriggers())
}
if err != nil {
m.logger.ErrorContext(ctx, "failed to load triggers", "task", key, "error", err)
}
if configs == nil {
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)
}
}
// Keep automatic runs idle on storage failure. The trigger loop
// still starts so a later administrator edit can recover the task.
configs = nil
}

w.setTriggers(configs, m.triggerFactory, w.lastResult, false)
Expand Down
206 changes: 206 additions & 0 deletions internal/taskmanager/manager_persistence_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
package taskmanager_test

import (
"context"
"errors"
"log/slog"
"slices"
"testing"
"time"

"github.com/Silo-Server/silo-server/internal/taskmanager"
)

// This store retains an explicit empty schedule, just as durable storage must.
type scheduleStore struct {
saved map[string][]taskmanager.TriggerConfig
loadErr error
saveErr error
saveCalls int
}

func (s *scheduleStore) GetTriggers(_ context.Context, key string) ([]taskmanager.TriggerConfig, error) {
return slices.Clone(s.saved[key]), s.loadErr
}

func (s *scheduleStore) GetOrCreateTriggers(ctx context.Context, key string, defaults []taskmanager.TriggerConfig) ([]taskmanager.TriggerConfig, error) {
if s.loadErr != nil {
return nil, s.loadErr
}
if _, exists := s.saved[key]; !exists {
if err := s.SetTriggers(ctx, key, defaults); err != nil {
return nil, err
}
}
return slices.Clone(s.saved[key]), nil
}

func (s *scheduleStore) SetTriggers(_ context.Context, key string, configs []taskmanager.TriggerConfig) error {
s.saveCalls++
if s.saveErr != nil {
return s.saveErr
}
if s.saved == nil {
s.saved = make(map[string][]taskmanager.TriggerConfig)
}
s.saved[key] = append([]taskmanager.TriggerConfig{}, configs...)
return nil
}

type scheduleTask struct{ defaults []taskmanager.TriggerConfig }

func (scheduleTask) Key() string { return "schedule_test" }
func (scheduleTask) Name() string { return "Schedule test" }
func (scheduleTask) Description() string { return "" }
func (scheduleTask) Category() taskmanager.TaskCategory { return taskmanager.TaskCategorySystem }
func (scheduleTask) IsHidden() bool { return false }
func (s scheduleTask) DefaultTriggers() []taskmanager.TriggerConfig { return s.defaults }
func (scheduleTask) Execute(context.Context, taskmanager.ProgressReporter) error { return nil }

type observedDefaultsTask struct {
scheduleTask
onDefaults func()
}

func (t observedDefaultsTask) DefaultTriggers() []taskmanager.TriggerConfig {
t.onDefaults()
return t.scheduleTask.DefaultTriggers()
}

func TestSavedTaskScheduleDoesNotLoadDefaults(t *testing.T) {
for _, saved := range [][]taskmanager.TriggerConfig{
{}, {{Type: taskmanager.TriggerTypeDaily, TimeOfDay: "09:45"}},
} {
store := &scheduleStore{saved: map[string][]taskmanager.TriggerConfig{"schedule_test": saved}}
consulted := false
m := taskmanager.New(store, scheduleHistory{}, func(c taskmanager.TriggerConfig) taskmanager.Trigger {
return scheduleTrigger{config: c}
}, slog.New(slog.DiscardHandler))
m.Register(observedDefaultsTask{onDefaults: func() { consulted = true }})
ctx, cancel := context.WithCancel(context.Background())
m.Start(ctx)
cancel()
m.Stop()
if consulted {
t.Errorf("loaded defaults for saved schedule %+v", saved)
}
}
}

func TestDefaultResolutionPreservesConcurrentScheduleEdit(t *testing.T) {
for _, saved := range [][]taskmanager.TriggerConfig{
{}, {{Type: taskmanager.TriggerTypeDaily, TimeOfDay: "09:45"}},
} {
store := &scheduleStore{}
m := taskmanager.New(store, scheduleHistory{}, func(c taskmanager.TriggerConfig) taskmanager.Trigger {
return scheduleTrigger{config: c}
}, slog.New(slog.DiscardHandler))
m.Register(observedDefaultsTask{
scheduleTask: scheduleTask{defaults: []taskmanager.TriggerConfig{{Type: taskmanager.TriggerTypeStartup}}},
onDefaults: func() {
// Model an edit after the initial lookup but before atomic seeding.
if err := store.SetTriggers(context.Background(), "schedule_test", saved); err != nil {
t.Fatal(err)
}
},
})
ctx, cancel := context.WithCancel(context.Background())
m.Start(ctx)
cancel()
m.Stop()
if got := m.GetTaskInfo("schedule_test").Triggers; !slices.Equal(got, saved) {
t.Errorf("concurrent schedule edit replaced by defaults: %+v, want %+v", got, saved)
}
}
}

type scheduleHistory struct{}

func (scheduleHistory) Insert(context.Context, taskmanager.ExecutionResult) error { return nil }
func (scheduleHistory) GetLatest(context.Context, string) (*taskmanager.ExecutionResult, error) {
return nil, nil
}
func (scheduleHistory) List(context.Context, string, int) ([]taskmanager.ExecutionResult, error) {
return nil, nil
}

// Scheduling is inert: these tests exercise persistence, not timer delivery.
type scheduleTrigger struct{ config taskmanager.TriggerConfig }

func (scheduleTrigger) Start(*taskmanager.ExecutionResult) {}
func (scheduleTrigger) Stop() {}
func (scheduleTrigger) C() <-chan struct{} { return nil }
func (scheduleTrigger) NextRunTime() time.Time { return time.Time{} }
func (s scheduleTrigger) Config() taskmanager.TriggerConfig { return s.config }

func startScheduleManager(t *testing.T, store *scheduleStore, defaults []taskmanager.TriggerConfig) *taskmanager.TaskManager {
t.Helper()
m := taskmanager.New(store, scheduleHistory{}, func(c taskmanager.TriggerConfig) taskmanager.Trigger {
return scheduleTrigger{config: c}
}, slog.New(slog.DiscardHandler))
m.Register(scheduleTask{defaults: defaults})
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(func() { cancel(); m.Stop() })
m.Start(ctx)
return m
}

func TestTaskScheduleSurvivesRestartAndChangedDefaults(t *testing.T) {
defaults := []taskmanager.TriggerConfig{
{Type: taskmanager.TriggerTypeStartup},
{Type: taskmanager.TriggerTypeDaily, TimeOfDay: "03:30"},
}
for _, tc := range []struct {
name string
saved []taskmanager.TriggerConfig
}{
{"delete_all", []taskmanager.TriggerConfig{}},
{"delete_one", defaults[1:]},
{"add", append(slices.Clone(defaults), taskmanager.TriggerConfig{Type: taskmanager.TriggerTypeWeekly, DayOfWeek: 2, TimeOfDay: "11:15"})},
{"modify", []taskmanager.TriggerConfig{{Type: taskmanager.TriggerTypeInterval, IntervalMs: 123000, MaxRuntimeMs: 45000}}},
} {
t.Run(tc.name, func(t *testing.T) {
store := &scheduleStore{}
before := startScheduleManager(t, store, defaults)
if err := before.UpdateTriggers("schedule_test", tc.saved); err != nil {
t.Fatal(err)
}
before.Stop()
// A new binary may ship different defaults. Neither old nor new
// defaults should replace the administrator's saved schedule.
upgradedDefaults := []taskmanager.TriggerConfig{{Type: taskmanager.TriggerTypeDaily, TimeOfDay: "06:00"}}
after := startScheduleManager(t, store, upgradedDefaults)
if got := after.GetTaskInfo("schedule_test").Triggers; !slices.Equal(got, tc.saved) {
t.Fatalf("schedule after restart = %+v, want %+v", got, tc.saved)
}
})
}
}

func TestTaskScheduleLoadFailureDoesNotOverwriteOrRunDefaults(t *testing.T) {
store := &scheduleStore{loadErr: errors.New("database unavailable")}
defaults := []taskmanager.TriggerConfig{{Type: taskmanager.TriggerTypeStartup}}
m := startScheduleManager(t, store, defaults)
if store.saveCalls != 0 {
t.Fatalf("load failure attempted %d writes", store.saveCalls)
}
if got := m.GetTaskInfo("schedule_test").Triggers; len(got) != 0 {
t.Fatalf("load failure activated defaults: %+v", got)
}
// The task remains editable after startup could not load its schedule.
store.loadErr = nil
if err := m.UpdateTriggers("schedule_test", defaults); err != nil {
t.Fatal(err)
}
if got := m.GetTaskInfo("schedule_test").Triggers; !slices.Equal(got, defaults) {
t.Fatalf("schedule after recovery = %+v", got)
}
}

func TestTaskScheduleSeedFailureDoesNotRunUnpersistedDefaults(t *testing.T) {
store := &scheduleStore{saveErr: errors.New("write unavailable")}
m := startScheduleManager(t, store, []taskmanager.TriggerConfig{{Type: taskmanager.TriggerTypeStartup}})
if got := m.GetTaskInfo("schedule_test").Triggers; len(got) != 0 {
t.Fatalf("seed failure activated defaults: %+v", got)
}
}
20 changes: 17 additions & 3 deletions internal/taskmanager/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"io"
"log/slog"
"reflect"
"slices"
"sync"
"testing"
"time"
Expand All @@ -23,22 +24,35 @@ type fakeTriggerRepository struct {
func (r *fakeTriggerRepository) GetTriggers(_ context.Context, taskKey string) ([]taskmanager.TriggerConfig, error) {
r.mu.Lock()
defer r.mu.Unlock()
return append([]taskmanager.TriggerConfig(nil), r.triggers[taskKey]...), nil
return slices.Clone(r.triggers[taskKey]), nil
}

func (r *fakeTriggerRepository) GetOrCreateTriggers(_ context.Context, taskKey string, defaults []taskmanager.TriggerConfig) ([]taskmanager.TriggerConfig, error) {
r.mu.Lock()
defer r.mu.Unlock()
if _, exists := r.triggers[taskKey]; !exists {
r.setTriggers(taskKey, defaults)
}
return slices.Clone(r.triggers[taskKey]), nil
}

func (r *fakeTriggerRepository) SetTriggers(_ context.Context, taskKey string, triggers []taskmanager.TriggerConfig) error {
r.mu.Lock()
defer r.mu.Unlock()
r.setTriggers(taskKey, triggers)
return nil
}

func (r *fakeTriggerRepository) setTriggers(taskKey string, triggers []taskmanager.TriggerConfig) {
if r.triggers == nil {
r.triggers = map[string][]taskmanager.TriggerConfig{}
}
if r.setCalls == nil {
r.setCalls = map[string][]taskmanager.TriggerConfig{}
}
copied := append([]taskmanager.TriggerConfig(nil), triggers...)
copied := append([]taskmanager.TriggerConfig{}, triggers...)
r.triggers[taskKey] = copied
r.setCalls[taskKey] = copied
return nil
}

type fakeExecutionRepository struct{}
Expand Down
Loading
Loading