a4c93a5122
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>
57 lines
1.5 KiB
Go
57 lines
1.5 KiB
Go
package scheduler
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
)
|
|
|
|
// 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 {
|
|
clock Clock
|
|
tick func(now time.Time)
|
|
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
}
|
|
|
|
// 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())
|
|
return &Scheduler{
|
|
clock: clock,
|
|
tick: tick,
|
|
ctx: ctx,
|
|
cancel: cancel,
|
|
}
|
|
}
|
|
|
|
// Start launches the loop on its own goroutine and returns immediately.
|
|
func (s *Scheduler) Start() {
|
|
go func() {
|
|
ticks := s.clock.Ticks()
|
|
defer s.clock.Stop()
|
|
for {
|
|
select {
|
|
case <-s.ctx.Done():
|
|
return
|
|
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()
|
|
}
|