T3.1: Add app.Service owning state behind a mutex
Create src/app/service.go: the application-service layer that becomes the single owner of the durable jobs slice and the transient runtime map, guarded by a non-reentrant sync.Mutex. NewService wires a loaded store; Open() is the convenience entry point. Read-only accessors (Jobs/Runtime/Store) take the lock, and Jobs() returns a copy to keep callers from mutating Service-owned state. State-mutating intents and the event/observer machinery are deferred to T3.2-T3.4. Adds no-Fyne unit tests for runtime construction, copy isolation, and store wiring. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -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)_
|
- [x] T2.4 — Update `storage`: load/save Job only; move runtime init _(landed with T2.3)_
|
||||||
|
|
||||||
### Phase 3 — Application service layer
|
### 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.2 — Add `src/app/events.go`; Event types + Observer
|
||||||
- [ ] T3.3 — Add state-mutating operations to service
|
- [ ] T3.3 — Add state-mutating operations to service
|
||||||
- [ ] T3.4 — Convert `scheduler` to use service; inject Clock
|
- [ ] T3.4 — Convert `scheduler` to use service; inject Clock
|
||||||
|
|||||||
@@ -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]
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user