fix: complete code review follow-ups for queue, stats, and docs
Replace overlap Pending flag with PendingRuns counter, match seed stats by job_id, align average duration with TimedRunCount, and tidy docs. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+13
-9
@@ -57,8 +57,8 @@ func (s *Service) RunNow(id int) error {
|
||||
// sequential mode a due job is left for a later tick while any other job is
|
||||
// running. The overlap policy decides what happens when a job comes due again
|
||||
// while its own previous run is still in flight: "skip" drops the new run,
|
||||
// "queue" marks it Pending so executeRun re-runs it the moment the current run
|
||||
// finishes. Either way NextDue is advanced past the fired occurrence so the same
|
||||
// "queue" increments PendingRuns so executeRun drains missed occurrences after
|
||||
// the current run finishes. Either way NextDue is advanced past the fired occurrence so the same
|
||||
// moment is not re-evaluated on every tick.
|
||||
func (s *Service) RunDue(now time.Time) {
|
||||
s.mu.Lock()
|
||||
@@ -78,7 +78,7 @@ func (s *Service) RunDue(now time.Time) {
|
||||
// Apply the effective overlap policy and step past this
|
||||
// occurrence.
|
||||
if s.effectiveOverlapPolicy(job) == domain.OverlapPolicyQueue {
|
||||
runtime.Pending = true
|
||||
runtime.PendingRuns++
|
||||
}
|
||||
s.advanceNextDueLocked(job, runtime, now)
|
||||
continue
|
||||
@@ -152,8 +152,8 @@ func (s *Service) startRunLocked(job *domain.Job, runtime *domain.JobRuntime, tr
|
||||
// executeRun runs the job off the lock, then records the result back through the
|
||||
// Service under the lock and announces it. If the job was marked Pending while
|
||||
// running (the "queue" overlap policy), and it is still enabled and the scheduler
|
||||
// is not paused, the deferred run is started immediately. It runs on its own
|
||||
// goroutine.
|
||||
// is not paused, deferred runs are started one at a time until PendingRuns reaches
|
||||
// zero. Each deferred run runs on its own goroutine.
|
||||
func (s *Service) executeRun(ctx context.Context, jobCopy domain.Job, trigger string, env runEnv) {
|
||||
record, logErr := s.runJob(ctx, &jobCopy, trigger, env.logsDir)
|
||||
|
||||
@@ -167,11 +167,11 @@ func (s *Service) executeRun(ctx context.Context, jobCopy domain.Job, trigger st
|
||||
runtime.Output = record.Output
|
||||
prependLog(runtime, record)
|
||||
updateStats(runtime, record)
|
||||
rerun := runtime.Pending && current.Enabled && !s.paused
|
||||
runtime.Pending = false
|
||||
rerun := runtime.PendingRuns > 0 && current.Enabled && !s.paused
|
||||
if rerun {
|
||||
runtime.PendingRuns--
|
||||
// A scheduled occurrence fired while this run was active under the
|
||||
// "queue" policy; start that deferred run now.
|
||||
// "queue" policy; start one deferred run now.
|
||||
saveErr = s.startRunLocked(current, runtime, "Schedule", time.Now())
|
||||
rerunStarted = saveErr == nil
|
||||
} else {
|
||||
@@ -241,11 +241,15 @@ func updateStats(rt *domain.JobRuntime, r domain.RunRecord) {
|
||||
if r.State == "Failed" {
|
||||
rt.FailCount++
|
||||
}
|
||||
if r.DurationMS <= 0 {
|
||||
return
|
||||
}
|
||||
rt.LastDurationMS = r.DurationMS
|
||||
if r.DurationMS > rt.MaxDurationMS {
|
||||
rt.MaxDurationMS = r.DurationMS
|
||||
}
|
||||
rt.AvgDurationMS = (rt.AvgDurationMS*int64(rt.RunCount-1) + r.DurationMS) / int64(rt.RunCount)
|
||||
rt.TimedRunCount++
|
||||
rt.AvgDurationMS = (rt.AvgDurationMS*int64(rt.TimedRunCount-1) + r.DurationMS) / int64(rt.TimedRunCount)
|
||||
}
|
||||
|
||||
// runningOutput is the placeholder output shown while a job is running, before
|
||||
|
||||
+89
-21
@@ -105,6 +105,24 @@ func TestUpdateStats(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateStatsSkipsZeroDuration(t *testing.T) {
|
||||
rt := &domain.JobRuntime{}
|
||||
updateStats(rt, domain.RunRecord{State: "OK", DurationMS: 200})
|
||||
updateStats(rt, domain.RunRecord{State: "OK", DurationMS: 0})
|
||||
if rt.RunCount != 2 {
|
||||
t.Fatalf("RunCount = %d, want 2", rt.RunCount)
|
||||
}
|
||||
if rt.TimedRunCount != 1 {
|
||||
t.Fatalf("TimedRunCount = %d, want 1", rt.TimedRunCount)
|
||||
}
|
||||
if rt.AvgDurationMS != 200 {
|
||||
t.Errorf("AvgDurationMS = %d, want 200 (zero-duration run excluded)", rt.AvgDurationMS)
|
||||
}
|
||||
if rt.LastDurationMS != 200 {
|
||||
t.Errorf("LastDurationMS = %d, want 200", rt.LastDurationMS)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunDueParallelStartsAllDueJobs verifies that in parallel mode every due job
|
||||
// starts at once: both runs are in flight (blocked in the runner) before either
|
||||
// is released.
|
||||
@@ -214,10 +232,10 @@ func TestRunDueSkipDropsOverlap(t *testing.T) {
|
||||
expectNoEntry(t, entered)
|
||||
|
||||
svc.mu.Lock()
|
||||
pending := svc.runtimes[1].Pending
|
||||
pending := svc.runtimes[1].PendingRuns
|
||||
svc.mu.Unlock()
|
||||
if pending {
|
||||
t.Error("skip policy must not mark the job Pending")
|
||||
if pending != 0 {
|
||||
t.Error("skip policy must not queue deferred runs")
|
||||
}
|
||||
|
||||
close(release)
|
||||
@@ -230,7 +248,7 @@ func TestRunDueSkipDropsOverlap(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestRunDueQueueRerunsAfterFinish verifies that under the "queue" overlap policy a
|
||||
// job coming due again while running is marked Pending and re-run as soon as the
|
||||
// job coming due again while running increments PendingRuns and re-runs after the
|
||||
// in-flight run finishes.
|
||||
func TestRunDueQueueRerunsAfterFinish(t *testing.T) {
|
||||
svc := newQueueService(t, domain.ExecutionModeParallel, domain.OverlapPolicyQueue, []domain.Job{
|
||||
@@ -254,17 +272,17 @@ func TestRunDueQueueRerunsAfterFinish(t *testing.T) {
|
||||
t.Fatalf("started job = %d, want 1", id)
|
||||
}
|
||||
|
||||
// Re-due the running job and tick: queue must mark it Pending without starting
|
||||
// a second concurrent run.
|
||||
// Re-due the running job and tick: queue must increment PendingRuns without
|
||||
// starting a second concurrent run.
|
||||
primeDue(t, svc, 1)
|
||||
svc.RunDue(time.Now())
|
||||
expectNoEntry(t, entered)
|
||||
|
||||
svc.mu.Lock()
|
||||
pending := svc.runtimes[1].Pending
|
||||
pending := svc.runtimes[1].PendingRuns
|
||||
svc.mu.Unlock()
|
||||
if !pending {
|
||||
t.Fatal("queue policy must mark the job Pending")
|
||||
if pending != 1 {
|
||||
t.Fatalf("queue policy must queue one deferred run, PendingRuns = %d", pending)
|
||||
}
|
||||
|
||||
// Releasing the first run lets executeRun start the deferred run automatically.
|
||||
@@ -279,10 +297,60 @@ func TestRunDueQueueRerunsAfterFinish(t *testing.T) {
|
||||
t.Errorf("runner called %d time(s), want 2 (original + queued re-run)", got)
|
||||
}
|
||||
svc.mu.Lock()
|
||||
pending = svc.runtimes[1].Pending
|
||||
pending = svc.runtimes[1].PendingRuns
|
||||
svc.mu.Unlock()
|
||||
if pending {
|
||||
t.Error("Pending must be cleared after the re-run starts")
|
||||
if pending != 0 {
|
||||
t.Errorf("PendingRuns must be cleared after the re-run starts, got %d", pending)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunDueQueueDrainsMultipleOverlaps verifies that each missed occurrence
|
||||
// under the queue policy eventually runs after the in-flight run finishes.
|
||||
func TestRunDueQueueDrainsMultipleOverlaps(t *testing.T) {
|
||||
svc := newQueueService(t, domain.ExecutionModeParallel, domain.OverlapPolicyQueue, []domain.Job{
|
||||
{ID: 1, Name: "A", Schedule: "@every 1h", Command: "echo", Enabled: true},
|
||||
})
|
||||
|
||||
release := make(chan struct{})
|
||||
var calls int32
|
||||
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) (domain.RunRecord, error) {
|
||||
if atomic.LoadInt32(&calls) == 0 {
|
||||
<-release
|
||||
}
|
||||
atomic.AddInt32(&calls, 1)
|
||||
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}, nil
|
||||
}
|
||||
done := completions(svc)
|
||||
|
||||
primeDue(t, svc, 1)
|
||||
svc.RunDue(time.Now())
|
||||
|
||||
// Three extra due ticks while the first run is still in flight.
|
||||
for range 3 {
|
||||
primeDue(t, svc, 1)
|
||||
svc.RunDue(time.Now())
|
||||
}
|
||||
|
||||
svc.mu.Lock()
|
||||
pending := svc.runtimes[1].PendingRuns
|
||||
svc.mu.Unlock()
|
||||
if pending != 3 {
|
||||
t.Fatalf("PendingRuns = %d, want 3 queued occurrences", pending)
|
||||
}
|
||||
|
||||
close(release)
|
||||
for range 4 {
|
||||
waitRecord(t, done)
|
||||
}
|
||||
|
||||
if got := atomic.LoadInt32(&calls); got != 4 {
|
||||
t.Errorf("runner called %d time(s), want 4 (original + 3 queued)", got)
|
||||
}
|
||||
svc.mu.Lock()
|
||||
pending = svc.runtimes[1].PendingRuns
|
||||
svc.mu.Unlock()
|
||||
if pending != 0 {
|
||||
t.Errorf("PendingRuns = %d after drain, want 0", pending)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,10 +386,10 @@ func TestRunDuePerJobQueueOverridesGlobalSkip(t *testing.T) {
|
||||
expectNoEntry(t, entered)
|
||||
|
||||
svc.mu.Lock()
|
||||
pending := svc.runtimes[1].Pending
|
||||
pending := svc.runtimes[1].PendingRuns
|
||||
svc.mu.Unlock()
|
||||
if !pending {
|
||||
t.Fatal("per-job queue policy must mark the job Pending despite global skip")
|
||||
if pending != 1 {
|
||||
t.Fatalf("per-job queue policy must queue a deferred run, PendingRuns = %d", pending)
|
||||
}
|
||||
|
||||
// Releasing the first run lets executeRun start the deferred re-run.
|
||||
@@ -368,10 +436,10 @@ func TestRunDuePerJobSkipOverridesGlobalQueue(t *testing.T) {
|
||||
expectNoEntry(t, entered)
|
||||
|
||||
svc.mu.Lock()
|
||||
pending := svc.runtimes[1].Pending
|
||||
pending := svc.runtimes[1].PendingRuns
|
||||
svc.mu.Unlock()
|
||||
if pending {
|
||||
t.Error("per-job skip policy must not mark the job Pending despite global queue")
|
||||
if pending != 0 {
|
||||
t.Errorf("per-job skip policy must not queue deferred runs, PendingRuns = %d", pending)
|
||||
}
|
||||
|
||||
close(release)
|
||||
@@ -413,10 +481,10 @@ func TestRunDueEmptyOverlapInheritsGlobal(t *testing.T) {
|
||||
expectNoEntry(t, entered)
|
||||
|
||||
svc.mu.Lock()
|
||||
pending := svc.runtimes[1].Pending
|
||||
pending := svc.runtimes[1].PendingRuns
|
||||
svc.mu.Unlock()
|
||||
if !pending {
|
||||
t.Fatal("empty per-job policy must inherit the global queue and mark Pending")
|
||||
if pending != 1 {
|
||||
t.Fatalf("empty per-job policy must inherit global queue, PendingRuns = %d", pending)
|
||||
}
|
||||
|
||||
close(release)
|
||||
|
||||
+6
-13
@@ -18,16 +18,9 @@ import (
|
||||
// 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.
|
||||
// Mutations live in operations.go; scheduling and run dispatch live in run.go;
|
||||
// typed events live in events.go. The scheduler is a thin timing loop that calls
|
||||
// RunDue on every tick and holds no job state of its own.
|
||||
//
|
||||
// Locking contract: mu is a plain, non-reentrant mutex. Exported methods take
|
||||
// it; unexported helpers ending in "Locked" assume the caller already holds it.
|
||||
@@ -107,6 +100,7 @@ func NewService(store *storage.Store, jobs []domain.Job) *Service {
|
||||
runtime.LastDurationMS = seed.LastDurationMS
|
||||
runtime.AvgDurationMS = seed.AvgDurationMS
|
||||
runtime.MaxDurationMS = seed.MaxDurationMS
|
||||
runtime.TimedRunCount = seed.TimedRunCount
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -182,9 +176,8 @@ func (s *Service) Jobs() []domain.Job {
|
||||
|
||||
// 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.
|
||||
// are only safe while no concurrent mutation is in flight. The UI listener
|
||||
// marshals reads onto the main thread via fyne.Do.
|
||||
func (s *Service) Runtime(id int) *domain.JobRuntime {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
Reference in New Issue
Block a user