Compare commits
2 Commits
554ce2b93a
...
b9505e83ab
| Author | SHA1 | Date | |
|---|---|---|---|
| b9505e83ab | |||
| b0d9883627 |
@@ -97,10 +97,10 @@ Done first because both share a compact, single-line record formatter.
|
|||||||
- [x] T2.7 — stats tests
|
- [x] T2.7 — stats tests
|
||||||
|
|
||||||
### Phase 3 — Per-job run policy
|
### Phase 3 — Per-job run policy
|
||||||
- [ ] T3.1 — `Job.OverlapPolicy` field
|
- [x] T3.1 — `Job.OverlapPolicy` field
|
||||||
- [ ] T3.2 — effective-policy dispatch + inherit
|
- [x] T3.2 — effective-policy dispatch + inherit
|
||||||
- [ ] T3.3 — dialog select + settings/format wording
|
- [x] T3.3 — dialog select + settings/format wording
|
||||||
- [ ] T3.4 — per-job override tests
|
- [x] T3.4 — per-job override tests
|
||||||
|
|
||||||
### Phase 4 — Persist global pause state
|
### Phase 4 — Persist global pause state
|
||||||
- [ ] T4.1 — `Config.Paused` field
|
- [ ] T4.1 — `Config.Paused` field
|
||||||
|
|||||||
@@ -152,13 +152,17 @@ func (s *Service) SetEnabled(id int, enabled bool) error {
|
|||||||
func (s *Service) SetGlobalPause(paused bool) error {
|
func (s *Service) SetGlobalPause(paused bool) error {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.paused = paused
|
s.paused = paused
|
||||||
|
s.store.Config.Paused = paused
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
for index := range s.jobs {
|
for index := range s.jobs {
|
||||||
job := &s.jobs[index]
|
job := &s.jobs[index]
|
||||||
runtime := s.runtimeForLocked(job)
|
runtime := s.runtimeForLocked(job)
|
||||||
s.refreshNextRunFromLocked(job, runtime, now)
|
s.refreshNextRunFromLocked(job, runtime, now)
|
||||||
}
|
}
|
||||||
err := s.store.SaveJobs(s.jobs)
|
err := s.store.SaveConfig()
|
||||||
|
if err == nil {
|
||||||
|
err = s.store.SaveJobs(s.jobs)
|
||||||
|
}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
state, detail := "Resumed", "All job execution resumed"
|
state, detail := "Resumed", "All job execution resumed"
|
||||||
|
|||||||
@@ -286,6 +286,147 @@ func TestRunDueQueueRerunsAfterFinish(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRunDuePerJobQueueOverridesGlobalSkip verifies that a job carrying its own
|
||||||
|
// "queue" policy queues a re-run even though the global default is "skip": the
|
||||||
|
// effective policy is resolved per job, so the job-level value wins.
|
||||||
|
func TestRunDuePerJobQueueOverridesGlobalSkip(t *testing.T) {
|
||||||
|
svc := newQueueService(t, domain.ExecutionModeParallel, domain.OverlapPolicySkip, []domain.Job{
|
||||||
|
{ID: 1, Name: "A", Schedule: "@every 1h", Command: "echo", Enabled: true, OverlapPolicy: string(domain.OverlapPolicyQueue)},
|
||||||
|
})
|
||||||
|
|
||||||
|
entered := make(chan int, 2)
|
||||||
|
release := make(chan struct{})
|
||||||
|
var calls int32
|
||||||
|
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) domain.RunRecord {
|
||||||
|
atomic.AddInt32(&calls, 1)
|
||||||
|
entered <- job.ID
|
||||||
|
<-release
|
||||||
|
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}
|
||||||
|
}
|
||||||
|
done := completions(svc)
|
||||||
|
|
||||||
|
primeDue(t, svc, 1)
|
||||||
|
svc.RunDue(time.Now())
|
||||||
|
if id := <-entered; id != 1 {
|
||||||
|
t.Fatalf("started job = %d, want 1", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-due the running job and tick. Despite the global "skip", the job's own
|
||||||
|
// "queue" policy must mark it Pending.
|
||||||
|
primeDue(t, svc, 1)
|
||||||
|
svc.RunDue(time.Now())
|
||||||
|
expectNoEntry(t, entered)
|
||||||
|
|
||||||
|
svc.mu.Lock()
|
||||||
|
pending := svc.runtimes[1].Pending
|
||||||
|
svc.mu.Unlock()
|
||||||
|
if !pending {
|
||||||
|
t.Fatal("per-job queue policy must mark the job Pending despite global skip")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Releasing the first run lets executeRun start the deferred re-run.
|
||||||
|
close(release)
|
||||||
|
waitRecord(t, done)
|
||||||
|
if id := <-entered; id != 1 {
|
||||||
|
t.Fatalf("re-run job = %d, want 1", id)
|
||||||
|
}
|
||||||
|
waitRecord(t, done)
|
||||||
|
if got := atomic.LoadInt32(&calls); got != 2 {
|
||||||
|
t.Errorf("runner called %d time(s), want 2 (original + queued re-run)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunDuePerJobSkipOverridesGlobalQueue verifies the reverse override: a job
|
||||||
|
// carrying its own "skip" policy drops an overlapping run even though the global
|
||||||
|
// default is "queue".
|
||||||
|
func TestRunDuePerJobSkipOverridesGlobalQueue(t *testing.T) {
|
||||||
|
svc := newQueueService(t, domain.ExecutionModeParallel, domain.OverlapPolicyQueue, []domain.Job{
|
||||||
|
{ID: 1, Name: "A", Schedule: "@every 1h", Command: "echo", Enabled: true, OverlapPolicy: string(domain.OverlapPolicySkip)},
|
||||||
|
})
|
||||||
|
|
||||||
|
entered := make(chan int, 2)
|
||||||
|
release := make(chan struct{})
|
||||||
|
var calls int32
|
||||||
|
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) domain.RunRecord {
|
||||||
|
atomic.AddInt32(&calls, 1)
|
||||||
|
entered <- job.ID
|
||||||
|
<-release
|
||||||
|
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}
|
||||||
|
}
|
||||||
|
done := completions(svc)
|
||||||
|
|
||||||
|
primeDue(t, svc, 1)
|
||||||
|
svc.RunDue(time.Now())
|
||||||
|
if id := <-entered; id != 1 {
|
||||||
|
t.Fatalf("started job = %d, want 1", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-due the running job and tick. Despite the global "queue", the job's own
|
||||||
|
// "skip" policy must drop it without marking Pending.
|
||||||
|
primeDue(t, svc, 1)
|
||||||
|
svc.RunDue(time.Now())
|
||||||
|
expectNoEntry(t, entered)
|
||||||
|
|
||||||
|
svc.mu.Lock()
|
||||||
|
pending := svc.runtimes[1].Pending
|
||||||
|
svc.mu.Unlock()
|
||||||
|
if pending {
|
||||||
|
t.Error("per-job skip policy must not mark the job Pending despite global queue")
|
||||||
|
}
|
||||||
|
|
||||||
|
close(release)
|
||||||
|
waitRecord(t, done)
|
||||||
|
expectNoEntry(t, entered)
|
||||||
|
if got := atomic.LoadInt32(&calls); got != 1 {
|
||||||
|
t.Errorf("runner called %d time(s), want 1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunDueEmptyOverlapInheritsGlobal verifies that a job with no own policy
|
||||||
|
// inherits the global default: with global "queue" and an empty Job.OverlapPolicy
|
||||||
|
// the job queues a re-run.
|
||||||
|
func TestRunDueEmptyOverlapInheritsGlobal(t *testing.T) {
|
||||||
|
svc := newQueueService(t, domain.ExecutionModeParallel, domain.OverlapPolicyQueue, []domain.Job{
|
||||||
|
{ID: 1, Name: "A", Schedule: "@every 1h", Command: "echo", Enabled: true},
|
||||||
|
})
|
||||||
|
if svc.jobs[0].OverlapPolicy != "" {
|
||||||
|
t.Fatalf("test setup: job OverlapPolicy = %q, want empty (inherit)", svc.jobs[0].OverlapPolicy)
|
||||||
|
}
|
||||||
|
|
||||||
|
entered := make(chan int, 2)
|
||||||
|
release := make(chan struct{})
|
||||||
|
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) domain.RunRecord {
|
||||||
|
entered <- job.ID
|
||||||
|
<-release
|
||||||
|
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}
|
||||||
|
}
|
||||||
|
done := completions(svc)
|
||||||
|
|
||||||
|
primeDue(t, svc, 1)
|
||||||
|
svc.RunDue(time.Now())
|
||||||
|
if id := <-entered; id != 1 {
|
||||||
|
t.Fatalf("started job = %d, want 1", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
primeDue(t, svc, 1)
|
||||||
|
svc.RunDue(time.Now())
|
||||||
|
expectNoEntry(t, entered)
|
||||||
|
|
||||||
|
svc.mu.Lock()
|
||||||
|
pending := svc.runtimes[1].Pending
|
||||||
|
svc.mu.Unlock()
|
||||||
|
if !pending {
|
||||||
|
t.Fatal("empty per-job policy must inherit the global queue and mark Pending")
|
||||||
|
}
|
||||||
|
|
||||||
|
close(release)
|
||||||
|
waitRecord(t, done)
|
||||||
|
if id := <-entered; id != 1 {
|
||||||
|
t.Fatalf("inherited-queue re-run job = %d, want 1", id)
|
||||||
|
}
|
||||||
|
waitRecord(t, done)
|
||||||
|
}
|
||||||
|
|
||||||
// TestRunNowSequentialGuard verifies the sequential-mode guard in RunNow: a manual
|
// TestRunNowSequentialGuard verifies the sequential-mode guard in RunNow: a manual
|
||||||
// run is refused while another job is running, and allowed once nothing is.
|
// run is refused while another job is running, and allowed once nothing is.
|
||||||
func TestRunNowSequentialGuard(t *testing.T) {
|
func TestRunNowSequentialGuard(t *testing.T) {
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ func NewService(store *storage.Store, jobs []domain.Job) *Service {
|
|||||||
schedules: make(map[int]domain.Schedule, len(jobs)),
|
schedules: make(map[int]domain.Schedule, len(jobs)),
|
||||||
runJob: runner.RunJob,
|
runJob: runner.RunJob,
|
||||||
ctx: context.Background(),
|
ctx: context.Background(),
|
||||||
|
paused: store.Config.Paused,
|
||||||
}
|
}
|
||||||
// Parse every schedule once, then compute each job's first next-run so the
|
// 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
|
// Service is ready to schedule the moment it exists — mirroring the old
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ type Config struct {
|
|||||||
NotifyOnFailure bool `json:"notify_on_failure,omitempty"`
|
NotifyOnFailure bool `json:"notify_on_failure,omitempty"`
|
||||||
ExecutionMode ExecutionMode `json:"execution_mode,omitempty"`
|
ExecutionMode ExecutionMode `json:"execution_mode,omitempty"`
|
||||||
OverlapPolicy OverlapPolicy `json:"overlap_policy,omitempty"`
|
OverlapPolicy OverlapPolicy `json:"overlap_policy,omitempty"`
|
||||||
|
Paused bool `json:"paused,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// JobsFile is the on-disk shape of jobs.json. Wrapping the slice in a top-level
|
// JobsFile is the on-disk shape of jobs.json. Wrapping the slice in a top-level
|
||||||
|
|||||||
@@ -24,15 +24,16 @@ type Store struct {
|
|||||||
// domain struct so the value conversions in importYAMLConfig / importYAMLJobs
|
// domain struct so the value conversions in importYAMLConfig / importYAMLJobs
|
||||||
// remain valid.
|
// remain valid.
|
||||||
type yamlConfig struct {
|
type yamlConfig struct {
|
||||||
JobsDir string `yaml:"jobs_dir"`
|
JobsDir string `yaml:"jobs_dir"`
|
||||||
LogsDir string `yaml:"logs_dir"`
|
LogsDir string `yaml:"logs_dir"`
|
||||||
MaxLogFiles int `yaml:"max_log_files"`
|
MaxLogFiles int `yaml:"max_log_files"`
|
||||||
MaxLogAgeDays int `yaml:"max_log_age_days"`
|
MaxLogAgeDays int `yaml:"max_log_age_days"`
|
||||||
StartOnLogin bool `yaml:"start_on_login,omitempty"`
|
StartOnLogin bool `yaml:"start_on_login,omitempty"`
|
||||||
KeepRunningInTray bool `yaml:"keep_running_in_tray,omitempty"`
|
KeepRunningInTray bool `yaml:"keep_running_in_tray,omitempty"`
|
||||||
NotifyOnFailure bool `yaml:"notify_on_failure,omitempty"`
|
NotifyOnFailure bool `yaml:"notify_on_failure,omitempty"`
|
||||||
ExecutionMode domain.ExecutionMode `yaml:"execution_mode,omitempty"`
|
ExecutionMode domain.ExecutionMode `yaml:"execution_mode,omitempty"`
|
||||||
OverlapPolicy domain.OverlapPolicy `yaml:"overlap_policy,omitempty"`
|
OverlapPolicy domain.OverlapPolicy `yaml:"overlap_policy,omitempty"`
|
||||||
|
Paused bool `yaml:"paused,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type yamlJob struct {
|
type yamlJob struct {
|
||||||
|
|||||||
Reference in New Issue
Block a user