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,178 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||
"gitea.mixdep.ru/mix/gosentry/src/platform/autostart"
|
||||
"gitea.mixdep.ru/mix/gosentry/src/runner"
|
||||
"gitea.mixdep.ru/mix/gosentry/src/scheduler"
|
||||
"gitea.mixdep.ru/mix/gosentry/src/storage"
|
||||
)
|
||||
|
||||
// Service is the application-service layer: the single owner of GoSentry's
|
||||
// in-memory state. It holds the durable jobs slice, the transient runtime map
|
||||
// keyed by Job.ID, and a reference to the store that persists them. All access
|
||||
// to that state goes through a mutex so the GUI and the scheduler can no longer
|
||||
// race on a shared *[]Job.
|
||||
//
|
||||
// State ownership and the locking contract were established in T3.1; the
|
||||
// event/observer machinery in T3.2. T3.3 added the state-mutating intents
|
||||
// (CreateJob, UpdateJob, DeleteJob, SetEnabled, RunNow, SetGlobalPause,
|
||||
// UpdateSettings) in operations.go: the Service is the sole writer of job and
|
||||
// runtime state, persisting through the store and announcing changes via events.
|
||||
//
|
||||
// T3.4 makes the Service drive scheduling too. It owns the timing loop through a
|
||||
// scheduler.Scheduler that calls RunDue on every tick; the scheduler holds no
|
||||
// job state and never touches the slice directly. The old shared *[]domain.Job
|
||||
// between GUI and scheduler is gone — both go through the Service.
|
||||
//
|
||||
// Locking contract: mu is a plain, non-reentrant mutex. Exported methods take
|
||||
// it; unexported helpers ending in "Locked" assume the caller already holds it.
|
||||
// The Service must never call back into the UI (or any code that might re-enter
|
||||
// the Service) while holding mu — in particular emit() is always called after
|
||||
// mu is released.
|
||||
type Service struct {
|
||||
mu sync.Mutex
|
||||
store *storage.Store
|
||||
jobs []domain.Job
|
||||
runtimes map[int]*domain.JobRuntime
|
||||
|
||||
// schedules caches a parsed Schedule per job ID so timing math does not
|
||||
// re-parse the schedule string on every use. paused is the global pause flag.
|
||||
// Both are guarded by mu.
|
||||
schedules map[int]domain.Schedule
|
||||
paused bool
|
||||
|
||||
// runJob is the run seam. It defaults to runner.RunJob and is overridden in
|
||||
// tests with a fake so the run paths can be exercised without spawning real
|
||||
// processes. ctx is the lifecycle context passed to runs; Start replaces it
|
||||
// with a cancelable context so Stop can abort in-flight runs, and until Start
|
||||
// it is context.Background().
|
||||
runJob func(ctx context.Context, job *domain.Job, trigger string, logsDir string) domain.RunRecord
|
||||
ctx context.Context
|
||||
|
||||
// sched is the timing loop installed by Start; cancel tears down ctx on Stop.
|
||||
// Both are guarded by mu.
|
||||
sched *scheduler.Scheduler
|
||||
cancel context.CancelFunc
|
||||
|
||||
// manager is the platform autostart implementation. It is nil in tests that
|
||||
// do not exercise autostart; Open() wires it via autostart.New().
|
||||
manager autostart.Manager
|
||||
|
||||
// observers and their guard live in events.go. dispatchMu is separate from mu
|
||||
// so that emitting an event never requires (or is held under) the state lock:
|
||||
// the Service must release mu before dispatching, per the locking contract.
|
||||
dispatchMu sync.Mutex
|
||||
observers []Observer
|
||||
}
|
||||
|
||||
// NewService wires the Service to a loaded store and its jobs. It builds the
|
||||
// initial runtime map from the durable jobs so every job has transient state
|
||||
// from the moment the Service exists, and parses each job's schedule once. The
|
||||
// store is the Service's sole channel to persistence.
|
||||
func NewService(store *storage.Store, jobs []domain.Job) *Service {
|
||||
s := &Service{
|
||||
store: store,
|
||||
jobs: jobs,
|
||||
runtimes: domain.NewRuntimes(jobs),
|
||||
schedules: make(map[int]domain.Schedule, len(jobs)),
|
||||
runJob: runner.RunJob,
|
||||
ctx: context.Background(),
|
||||
}
|
||||
// Parse every schedule once, then compute each job's first next-run so the
|
||||
// Service is ready to schedule the moment it exists — mirroring the old
|
||||
// scheduler's reset-on-construction. No lock is needed: construction is
|
||||
// single-threaded, before Start launches the timing loop.
|
||||
now := time.Now()
|
||||
for index := range s.jobs {
|
||||
job := &s.jobs[index]
|
||||
s.parseScheduleLocked(job)
|
||||
s.refreshNextRunFromLocked(job, s.runtimes[job.ID], now)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Start begins scheduling with the real wall clock. It is the production entry
|
||||
// point; tests should call StartWith and supply a fake clock instead. Start is
|
||||
// expected once, during setup, before any concurrent use.
|
||||
func (s *Service) Start() {
|
||||
s.StartWith(scheduler.NewRealClock())
|
||||
}
|
||||
|
||||
// StartWith begins scheduling driven by the given clock; every tick calls
|
||||
// RunDue. Used by tests to inject a fake clock.
|
||||
func (s *Service) StartWith(clock scheduler.Clock) {
|
||||
s.mu.Lock()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s.ctx = ctx
|
||||
s.cancel = cancel
|
||||
s.sched = scheduler.NewScheduler(clock, s.RunDue)
|
||||
sched := s.sched
|
||||
s.mu.Unlock()
|
||||
|
||||
sched.Start()
|
||||
}
|
||||
|
||||
// Stop halts scheduling and cancels the run context so in-flight runs see a
|
||||
// canceled context. It is safe to call when Start was never called.
|
||||
func (s *Service) Stop() {
|
||||
s.mu.Lock()
|
||||
sched := s.sched
|
||||
cancel := s.cancel
|
||||
s.mu.Unlock()
|
||||
|
||||
if sched != nil {
|
||||
sched.Stop()
|
||||
}
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// Open loads the store and constructs a Service from it in one step. It is the
|
||||
// convenience entry point for the application; tests inject a pre-built store
|
||||
// via NewService instead.
|
||||
func Open() (*Service, error) {
|
||||
store, jobs, err := storage.OpenStore()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
svc := NewService(store, jobs)
|
||||
svc.manager = autostart.New()
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
// Store returns the underlying store. It is exposed so callers that still need
|
||||
// resolved paths and config (the GUI, during the transition) can reach them;
|
||||
// later phases narrow this surface.
|
||||
func (s *Service) Store() *storage.Store {
|
||||
return s.store
|
||||
}
|
||||
|
||||
// Jobs returns a copy of the durable jobs slice. Returning a copy keeps callers
|
||||
// from mutating Service-owned state behind its back: the Service stays the sole
|
||||
// writer.
|
||||
func (s *Service) Jobs() []domain.Job {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
jobs := make([]domain.Job, len(s.jobs))
|
||||
copy(jobs, s.jobs)
|
||||
return jobs
|
||||
}
|
||||
|
||||
// Runtime returns the transient runtime state for a job ID, or nil if no job
|
||||
// with that ID is loaded. The returned pointer is the live runtime; reads of it
|
||||
// are only safe while no concurrent mutation is in flight. The scheduler now
|
||||
// drives the Service rather than sharing state, so the remaining concurrent
|
||||
// reader is the UI listener, which T4.1 marshals onto the main thread.
|
||||
func (s *Service) Runtime(id int) *domain.JobRuntime {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
return s.runtimes[id]
|
||||
}
|
||||
Reference in New Issue
Block a user