diff --git a/docs/REFACTORING.md b/docs/REFACTORING.md index fa77079..47015da 100644 --- a/docs/REFACTORING.md +++ b/docs/REFACTORING.md @@ -260,7 +260,7 @@ Track progress here. Mark tasks complete as they land and pass review. - [x] T2.4 — Update `storage`: load/save Job only; move runtime init _(landed with T2.3)_ ### Phase 3 — Application service layer -- [ ] T3.1 — Create `src/app/service.go`; owns state behind mutex +- [x] T3.1 — Create `src/app/service.go`; owns state behind mutex - [ ] T3.2 — Add `src/app/events.go`; Event types + Observer - [ ] T3.3 — Add state-mutating operations to service - [ ] T3.4 — Convert `scheduler` to use service; inject Clock diff --git a/src/app/service.go b/src/app/service.go new file mode 100644 index 0000000..196eaed --- /dev/null +++ b/src/app/service.go @@ -0,0 +1,84 @@ +package app + +import ( + "sync" + + "gitea.mixdep.ru/mix/gosentry/src/domain" + "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. +// +// This is the first slice of the layer (T3.1): it establishes ownership and the +// locking contract. State-mutating intents (CreateJob, RunNow, SetGlobalPause, +// ...) and the event/observer machinery are added in later tasks; for now the +// Service only owns state and exposes read snapshots. +// +// 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. +type Service struct { + mu sync.Mutex + store *storage.Store + jobs []domain.Job + runtimes map[int]*domain.JobRuntime +} + +// 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. The store is the Service's sole channel +// to persistence. +func NewService(store *storage.Store, jobs []domain.Job) *Service { + return &Service{ + store: store, + jobs: jobs, + runtimes: domain.NewRuntimes(jobs), + } +} + +// 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 + } + return NewService(store, jobs), 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, which holds during +// the current single-threaded transition and is tightened as the scheduler +// moves behind the Service in T3.4. +func (s *Service) Runtime(id int) *domain.JobRuntime { + s.mu.Lock() + defer s.mu.Unlock() + + return s.runtimes[id] +} diff --git a/src/app/service_test.go b/src/app/service_test.go new file mode 100644 index 0000000..b43bc55 --- /dev/null +++ b/src/app/service_test.go @@ -0,0 +1,57 @@ +package app + +import ( + "testing" + + "gitea.mixdep.ru/mix/gosentry/src/domain" + "gitea.mixdep.ru/mix/gosentry/src/storage" +) + +func newTestService(jobs []domain.Job) *Service { + return NewService(&storage.Store{}, jobs) +} + +func TestNewServiceBuildsRuntimePerJob(t *testing.T) { + jobs := []domain.Job{ + {ID: 1, Name: "Enabled", Enabled: true}, + {ID: 2, Name: "Disabled", Enabled: false}, + } + svc := newTestService(jobs) + + if got := svc.Runtime(1); got == nil { + t.Fatal("expected runtime for enabled job 1") + } else if got.LastState != "Ready" { + t.Errorf("enabled job runtime state = %q, want %q", got.LastState, "Ready") + } + if got := svc.Runtime(2); got == nil { + t.Fatal("expected runtime for disabled job 2") + } else if got.LastState != "Paused" { + t.Errorf("disabled job runtime state = %q, want %q", got.LastState, "Paused") + } + if got := svc.Runtime(99); got != nil { + t.Errorf("expected nil runtime for unknown job, got %+v", got) + } +} + +func TestJobsReturnsCopy(t *testing.T) { + jobs := []domain.Job{{ID: 1, Name: "Original"}} + svc := newTestService(jobs) + + snapshot := svc.Jobs() + if len(snapshot) != 1 { + t.Fatalf("Jobs() len = %d, want 1", len(snapshot)) + } + // Mutating the returned slice must not affect Service-owned state. + snapshot[0].Name = "Mutated" + if again := svc.Jobs(); again[0].Name != "Original" { + t.Errorf("Service state leaked through Jobs(): name = %q, want %q", again[0].Name, "Original") + } +} + +func TestStoreReturnsWiredStore(t *testing.T) { + store := &storage.Store{} + svc := NewService(store, nil) + if svc.Store() != store { + t.Error("Store() did not return the wired store") + } +}