Refactoring complete: v0.4.0 architectural milestone (#1)
## Summary Completed Phase 5 refactoring and reached the target architecture. **Architectural milestone achieved:** - Service layer owns all state and is the sole writer - UI is a thin Fyne view, all widget updates marshaled via `fyne.Do` - Core engines are stateless and injectable - Domain types are pure (no `yaml:"-"` fields) - Full module builds and `go vet ./...` clean ## Changes - Bump version: 0.3.6 → 0.4.0 - Update CHANGELOG with Phase 5 summary - Add ROADMAP "Refactoring Follow-Ups" section ## Known follow-up work 1. **Linux test build broken** — `runner_test.go` needs `//go:build windows` tag 2. **File-size limits exceeded** — `operations.go` (486 lines), `jobs_view.go` (415 lines) See ROADMAP.md for details. --------- Co-authored-by: mixeme <mix.public@ya.ru> Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
package scheduler
|
||||
|
||||
import "time"
|
||||
|
||||
// Clock supplies the scheduler with the current time and a stream of ticks.
|
||||
// Hiding both behind an interface lets tests drive the loop deterministically —
|
||||
// firing ticks and controlling "now" — instead of waiting on the wall clock.
|
||||
// Production uses RealClock.
|
||||
type Clock interface {
|
||||
// Now returns the current time. It is the value passed to the tick callback
|
||||
// on each tick, so a fake can make due-evaluation deterministic.
|
||||
Now() time.Time
|
||||
// Ticks returns a channel that delivers a value on every scheduler tick. The
|
||||
// scheduler reads it for the lifetime of the loop.
|
||||
Ticks() <-chan time.Time
|
||||
// Stop releases the resources backing Ticks. The scheduler calls it once when
|
||||
// the loop exits.
|
||||
Stop()
|
||||
}
|
||||
|
||||
// RealClock is the production Clock: wall-clock time and a one-second ticker.
|
||||
//
|
||||
// A one-second cadence is accurate enough for cron-style desktop automation —
|
||||
// five-field cron expressions have minute precision, while @every values may be
|
||||
// shorter for testing and lightweight local tasks — and it keeps a single timer
|
||||
// instead of one per job.
|
||||
type RealClock struct {
|
||||
ticker *time.Ticker
|
||||
}
|
||||
|
||||
// NewRealClock returns a real clock. The underlying ticker is created lazily on
|
||||
// the first Ticks call so a clock that is never started leaks nothing.
|
||||
func NewRealClock() *RealClock {
|
||||
return &RealClock{}
|
||||
}
|
||||
|
||||
// Now returns the wall-clock time.
|
||||
func (c *RealClock) Now() time.Time {
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
// Ticks starts (once) and returns the one-second ticker channel.
|
||||
func (c *RealClock) Ticks() <-chan time.Time {
|
||||
if c.ticker == nil {
|
||||
c.ticker = time.NewTicker(time.Second)
|
||||
}
|
||||
return c.ticker.C
|
||||
}
|
||||
|
||||
// Stop halts the ticker if it was ever started.
|
||||
func (c *RealClock) Stop() {
|
||||
if c.ticker != nil {
|
||||
c.ticker.Stop()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeClock is a Clock whose ticks and "now" are driven by the test instead of
|
||||
// the wall clock, so the scheduler loop can be exercised deterministically.
|
||||
type fakeClock struct {
|
||||
ticks chan time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
now time.Time
|
||||
stopped bool
|
||||
}
|
||||
|
||||
func newFakeClock(now time.Time) *fakeClock {
|
||||
return &fakeClock{ticks: make(chan time.Time, 1), now: now}
|
||||
}
|
||||
|
||||
func (c *fakeClock) Now() time.Time {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.now
|
||||
}
|
||||
|
||||
func (c *fakeClock) Ticks() <-chan time.Time { return c.ticks }
|
||||
|
||||
func (c *fakeClock) Stop() {
|
||||
c.mu.Lock()
|
||||
c.stopped = true
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *fakeClock) isStopped() bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.stopped
|
||||
}
|
||||
|
||||
// fire advances the clock to t and delivers one tick.
|
||||
func (c *fakeClock) fire(t time.Time) {
|
||||
c.mu.Lock()
|
||||
c.now = t
|
||||
c.mu.Unlock()
|
||||
c.ticks <- t
|
||||
}
|
||||
|
||||
func TestSchedulerCallsTickWithClockNow(t *testing.T) {
|
||||
clock := newFakeClock(time.Unix(0, 0))
|
||||
got := make(chan time.Time, 1)
|
||||
s := NewScheduler(clock, func(now time.Time) { got <- now })
|
||||
s.Start()
|
||||
defer s.Stop()
|
||||
|
||||
want := time.Date(2026, 6, 19, 12, 0, 0, 0, time.UTC)
|
||||
clock.fire(want)
|
||||
|
||||
select {
|
||||
case now := <-got:
|
||||
if !now.Equal(want) {
|
||||
t.Errorf("tick now = %v, want %v", now, want)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("scheduler did not call tick after a clock tick")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchedulerStopReleasesClock(t *testing.T) {
|
||||
clock := newFakeClock(time.Now())
|
||||
s := NewScheduler(clock, func(time.Time) {})
|
||||
s.Start()
|
||||
s.Stop()
|
||||
|
||||
// After Stop the loop exits and releases the clock via the deferred Stop.
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for !clock.isStopped() {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("clock was not stopped after scheduler Stop")
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user