T3.4: Convert scheduler to drive app.Service; inject Clock
The scheduler no longer shares a *[]domain.Job with the GUI. It is now a thin timing loop with an injected Clock that calls a tick callback; the application service is the sole writer of job and runtime state. - scheduler: add Clock interface + RealClock (clock.go); strip all job logic from scheduler.go (NewScheduler(clock, tick)); rewrite tests to cover the loop with a fake clock. - app.Service: add RunDue(now) (pause + one-run-per-tick policy, records back through the service) and Start(Clock)/Stop() owning a cancelable run context; prime each job's first next-run at construction. Capture the run context under the lock for executeRun. - gui: talk only to app.Service (no shared state) — Open() the service, keep a refreshed snapshot, route every mutation through the service, and react to changes via a single Subscribe listener. - Tests: add RunDue (due/not-due/paused) and Start-drives-RunDue cases. Verified with CGO + MSYS2 UCRT64: go vet ./... clean, go test -race ./... green (GUI included), full module builds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+27
-238
@@ -2,266 +2,55 @@ package scheduler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||
"gitea.mixdep.ru/mix/gosentry/src/runner"
|
||||
"gitea.mixdep.ru/mix/gosentry/src/storage"
|
||||
)
|
||||
|
||||
// Scheduler owns the timing loop for jobs that are currently loaded in the GUI.
|
||||
// It receives a pointer to the jobs slice because the GUI edits the same slice;
|
||||
// this keeps the early architecture simple while storage and scheduling are
|
||||
// still in one desktop process.
|
||||
// Scheduler is a thin timing loop. It owns no job or runtime state: on every
|
||||
// clock tick it calls the injected tick function with the current time, and that
|
||||
// function — the application service's RunDue — decides what, if anything, to
|
||||
// run. Keeping all state and mutation in the service makes the service the sole
|
||||
// writer (resolving the old shared-*[]Job data race) and reduces the scheduler
|
||||
// to a loop that is trivially testable with a fake Clock.
|
||||
type Scheduler struct {
|
||||
store *storage.Store
|
||||
jobs *[]domain.Job
|
||||
runtimes map[int]*domain.JobRuntime
|
||||
onChange func(domain.RunRecord)
|
||||
clock Clock
|
||||
tick func(now time.Time)
|
||||
|
||||
mu sync.Mutex
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
paused bool
|
||||
schedules map[int]domain.Schedule // parsed once per job on load/edit
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// NewScheduler shares the durable jobs slice and the transient runtime map with
|
||||
// the GUI. Both still point at the same in-memory state for now; Phase 3 moves
|
||||
// ownership behind an application service.
|
||||
func NewScheduler(store *storage.Store, jobs *[]domain.Job, runtimes map[int]*domain.JobRuntime, onChange func(domain.RunRecord)) *Scheduler {
|
||||
// NewScheduler builds a scheduler that calls tick on every Clock tick. The clock
|
||||
// is injected so tests can drive the loop without the wall clock.
|
||||
func NewScheduler(clock Clock, tick func(now time.Time)) *Scheduler {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s := &Scheduler{
|
||||
store: store,
|
||||
jobs: jobs,
|
||||
runtimes: runtimes,
|
||||
onChange: onChange,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
schedules: make(map[int]domain.Schedule),
|
||||
return &Scheduler{
|
||||
clock: clock,
|
||||
tick: tick,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
s.resetNextRuns(time.Now())
|
||||
return s
|
||||
}
|
||||
|
||||
// runtimeFor returns the runtime state for a job, lazily creating it if the map
|
||||
// has no entry yet. This keeps the scheduler robust if a job is added to the
|
||||
// shared slice without a matching runtime.
|
||||
func (s *Scheduler) runtimeFor(job *domain.Job) *domain.JobRuntime {
|
||||
runtime, ok := s.runtimes[job.ID]
|
||||
if !ok || runtime == nil {
|
||||
runtime = domain.NewRuntime(*job)
|
||||
s.runtimes[job.ID] = runtime
|
||||
}
|
||||
return runtime
|
||||
}
|
||||
|
||||
// Start launches the loop on its own goroutine and returns immediately.
|
||||
func (s *Scheduler) Start() {
|
||||
// A one-second ticker is accurate enough for cron-style desktop automation
|
||||
// and avoids the complexity of maintaining one timer per job. Five-field cron
|
||||
// expressions have minute precision, while @every values may be shorter for
|
||||
// testing and lightweight local tasks.
|
||||
ticker := time.NewTicker(time.Second)
|
||||
go func() {
|
||||
defer ticker.Stop()
|
||||
ticks := s.clock.Ticks()
|
||||
defer s.clock.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-s.ctx.Done():
|
||||
return
|
||||
case now := <-ticker.C:
|
||||
s.tick(now)
|
||||
case <-ticks:
|
||||
// Pass the clock's notion of "now" rather than the tick value so a
|
||||
// fake clock can control due-evaluation precisely.
|
||||
s.tick(s.clock.Now())
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Stop ends the loop. A tick already in progress finishes; no further ticks are
|
||||
// delivered.
|
||||
func (s *Scheduler) Stop() {
|
||||
s.cancel()
|
||||
}
|
||||
|
||||
func (s *Scheduler) SetPaused(paused bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.paused = paused
|
||||
now := time.Now()
|
||||
// Pause state is reflected into each job's display string so the list view is
|
||||
// understandable even before the next scheduler tick.
|
||||
for index := range *s.jobs {
|
||||
job := &(*s.jobs)[index]
|
||||
runtime := s.runtimeFor(job)
|
||||
if !job.Enabled {
|
||||
runtime.NextRun = "Paused"
|
||||
continue
|
||||
}
|
||||
if paused {
|
||||
runtime.NextRun = "Scheduler paused"
|
||||
continue
|
||||
}
|
||||
s.prepareNextRun(job, runtime, now)
|
||||
}
|
||||
_ = s.store.SaveJobs(*s.jobs)
|
||||
}
|
||||
|
||||
func (s *Scheduler) RunNow(index int) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if index < 0 || index >= len(*s.jobs) {
|
||||
return false
|
||||
}
|
||||
// Manual runs share the same runner and log writer as scheduled runs. The
|
||||
// Trigger field is the only difference, which keeps History comparable and
|
||||
// prevents "Run now" from becoming a separate behavior path.
|
||||
return s.startRunLocked(index, "Manual")
|
||||
}
|
||||
|
||||
func (s *Scheduler) RefreshSchedule(index int) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if index < 0 || index >= len(*s.jobs) {
|
||||
return
|
||||
}
|
||||
job := &(*s.jobs)[index]
|
||||
runtime := s.runtimeFor(job)
|
||||
s.parseJobSchedule(job) // re-parse in case the schedule string changed
|
||||
if !job.Enabled {
|
||||
runtime.NextRun = "Paused"
|
||||
return
|
||||
}
|
||||
if s.paused {
|
||||
runtime.NextRun = "Scheduler paused"
|
||||
return
|
||||
}
|
||||
s.prepareNextRun(job, runtime, time.Now())
|
||||
}
|
||||
|
||||
func (s *Scheduler) tick(now time.Time) {
|
||||
var changed bool
|
||||
|
||||
s.mu.Lock()
|
||||
if !s.paused {
|
||||
for index := range *s.jobs {
|
||||
job := &(*s.jobs)[index]
|
||||
runtime := s.runtimeFor(job)
|
||||
if !job.Enabled || runtime.NextDue.IsZero() || now.Before(runtime.NextDue) {
|
||||
continue
|
||||
}
|
||||
// Run only one due job per tick for now. That avoids overlapping shell
|
||||
// commands in the GUI process and keeps the first version predictable;
|
||||
// a future worker pool can add concurrency once cancellation and status
|
||||
// reporting are more explicit.
|
||||
changed = s.startRunLocked(index, "Schedule")
|
||||
break
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
_ = changed
|
||||
}
|
||||
|
||||
func (s *Scheduler) startRunLocked(index int, trigger string) bool {
|
||||
job := &(*s.jobs)[index]
|
||||
runtime := s.runtimeFor(job)
|
||||
if runtime.LastState == "Running" {
|
||||
return false
|
||||
}
|
||||
|
||||
jobCopy := *job
|
||||
runtime.LastState = "Running"
|
||||
runtime.NextRun = "Running"
|
||||
runtime.Output = runningOutput(jobCopy, trigger, time.Now())
|
||||
runtime.NextDue = time.Time{}
|
||||
_ = s.store.SaveJobs(*s.jobs)
|
||||
|
||||
go func() {
|
||||
record := runner.RunJob(s.ctx, &jobCopy, trigger, s.store.Paths.LogsDir)
|
||||
|
||||
s.mu.Lock()
|
||||
if current := s.findJobByIDLocked(jobCopy.ID); current != nil {
|
||||
currentRuntime := s.runtimeFor(current)
|
||||
currentRuntime.LastRun = record.Time
|
||||
currentRuntime.LastState = record.State
|
||||
currentRuntime.Output = record.Output
|
||||
currentRuntime.Logs = append([]domain.RunRecord{record}, currentRuntime.Logs...)
|
||||
if len(currentRuntime.Logs) > 50 {
|
||||
currentRuntime.Logs = currentRuntime.Logs[:50]
|
||||
}
|
||||
s.prepareNextRun(current, currentRuntime, time.Now())
|
||||
_ = runner.CleanupLogs(s.store.Paths.LogsDir, s.store.Config.MaxLogFiles, s.store.Config.MaxLogAgeDays)
|
||||
_ = s.store.SaveJobs(*s.jobs)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
if s.onChange != nil {
|
||||
s.onChange(record)
|
||||
}
|
||||
}()
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Scheduler) findJobByIDLocked(id int) *domain.Job {
|
||||
for index := range *s.jobs {
|
||||
if (*s.jobs)[index].ID == id {
|
||||
return &(*s.jobs)[index]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runningOutput(job domain.Job, trigger string, started time.Time) string {
|
||||
var builder strings.Builder
|
||||
builder.WriteString("status:\n")
|
||||
builder.WriteString("Running since " + started.Format("2006-01-02 15:04:05") + "\n\n")
|
||||
builder.WriteString("trigger:\n")
|
||||
builder.WriteString(trigger + "\n\n")
|
||||
builder.WriteString("command:\n")
|
||||
builder.WriteString(job.Command + "\n\n")
|
||||
builder.WriteString("arguments:\n")
|
||||
builder.WriteString(runner.LogArguments(job.Arguments))
|
||||
builder.WriteString("\n\nsuccess_exit_codes:\n")
|
||||
builder.WriteString(runner.SuccessExitCodesText(job))
|
||||
builder.WriteString("\n\nstart_only:\n")
|
||||
builder.WriteString(fmt.Sprintf("%t", job.StartOnly))
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func (s *Scheduler) resetNextRuns(now time.Time) {
|
||||
for index := range *s.jobs {
|
||||
job := &(*s.jobs)[index]
|
||||
runtime := s.runtimeFor(job)
|
||||
s.parseJobSchedule(job) // parse once on load
|
||||
if !job.Enabled {
|
||||
runtime.NextRun = "Paused"
|
||||
continue
|
||||
}
|
||||
s.prepareNextRun(job, runtime, now)
|
||||
}
|
||||
_ = s.store.SaveJobs(*s.jobs)
|
||||
}
|
||||
|
||||
// parseJobSchedule caches a parsed domain.Schedule for the job. Invalid
|
||||
// schedule strings are silently dropped from the cache so prepareNextRun can
|
||||
// distinguish them from valid ones.
|
||||
func (s *Scheduler) parseJobSchedule(job *domain.Job) {
|
||||
sched, err := domain.Parse(job.Schedule)
|
||||
if err != nil {
|
||||
delete(s.schedules, job.ID)
|
||||
return
|
||||
}
|
||||
s.schedules[job.ID] = sched
|
||||
}
|
||||
|
||||
func (s *Scheduler) prepareNextRun(job *domain.Job, runtime *domain.JobRuntime, from time.Time) {
|
||||
sched, ok := s.schedules[job.ID]
|
||||
if !ok {
|
||||
runtime.NextRun = "Invalid schedule"
|
||||
runtime.NextDue = time.Time{}
|
||||
return
|
||||
}
|
||||
runtime.NextDue = sched.Next(from)
|
||||
runtime.NextRun = runtime.NextDue.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user