Skip to content

Commit 1b1b5f9

Browse files
authored
feat(core): add run-on-startup option for jobs (#436)
## Summary Add optional `run-on-startup` parameter that executes jobs immediately when the scheduler starts, before regular cron scheduling begins. - Add `RunOnStartup` field to `BareJob` struct (applies to all job types: exec, run, local, service-run, compose) - Add `ShouldRunOnStartup()` method to `Job` interface - Add `runStartupJobs()` method to Scheduler that runs startup jobs in goroutines for non-blocking execution - Include `RunOnStartup` in job hash for change detection ### Configuration Example ```ini [job-exec "backup"] schedule = @daily container = my-container command = /backup.sh run-on-startup = true ``` ### Design Decisions 1. **Non-blocking**: Startup jobs run in goroutines to prevent blocking other job registrations and scheduler start 2. **All job types**: The option is in `BareJob`, so it works for exec, run, local, service-run, and compose jobs 3. **Default false**: Existing configurations are unaffected (backward compatible) ## Test Plan - [x] Verify job with `run-on-startup=true` executes on scheduler start - [x] Verify job with `run-on-startup=false` does NOT run on startup - [x] Verify multiple startup jobs run concurrently (non-blocking) - [x] Verify `Start()` returns immediately (non-blocking verification) - [x] Verify config parsing works for all job types - [x] Verify triggered-only jobs (`@triggered`) with `run-on-startup=true` work correctly - [x] All existing tests pass - [x] Lint passes Closes #347
2 parents 6f86bf2 + 2db8a20 commit 1b1b5f9

File tree

8 files changed

+338
-5
lines changed

8 files changed

+338
-5
lines changed

cli/config_parsing_test.go

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,156 @@ command = echo hello
304304
}
305305
}
306306

307+
// TestBuildFromString_RunOnStartup tests parsing of run-on-startup option
308+
func TestBuildFromString_RunOnStartup(t *testing.T) {
309+
t.Parallel()
310+
tests := []struct {
311+
name string
312+
config string
313+
wantRunOnStart bool
314+
wantSchedule string
315+
}{
316+
{
317+
name: "run-on-startup_true",
318+
config: `
319+
[job-exec "startup-job"]
320+
schedule = @every 1h
321+
container = my-container
322+
command = echo hello
323+
run-on-startup = true
324+
`,
325+
wantRunOnStart: true,
326+
wantSchedule: "@every 1h",
327+
},
328+
{
329+
name: "run-on-startup_false",
330+
config: `
331+
[job-exec "no-startup-job"]
332+
schedule = @every 1h
333+
container = my-container
334+
command = echo hello
335+
run-on-startup = false
336+
`,
337+
wantRunOnStart: false,
338+
wantSchedule: "@every 1h",
339+
},
340+
{
341+
name: "run-on-startup_default",
342+
config: `
343+
[job-exec "default-job"]
344+
schedule = @every 1h
345+
container = my-container
346+
command = echo hello
347+
`,
348+
wantRunOnStart: false, // Default is false
349+
wantSchedule: "@every 1h",
350+
},
351+
{
352+
name: "run-on-startup_with_triggered_schedule",
353+
config: `
354+
[job-exec "triggered-job"]
355+
schedule = @triggered
356+
container = my-container
357+
command = echo hello
358+
run-on-startup = true
359+
`,
360+
wantRunOnStart: true,
361+
wantSchedule: "@triggered",
362+
},
363+
}
364+
365+
for _, tt := range tests {
366+
t.Run(tt.name, func(t *testing.T) {
367+
t.Parallel()
368+
logger := test.NewTestLogger()
369+
cfg, err := BuildFromString(tt.config, logger)
370+
if err != nil {
371+
t.Fatalf("BuildFromString failed: %v", err)
372+
}
373+
374+
if len(cfg.ExecJobs) != 1 {
375+
t.Fatalf("Expected 1 exec job, got %d", len(cfg.ExecJobs))
376+
}
377+
378+
for _, job := range cfg.ExecJobs {
379+
if job.RunOnStartup != tt.wantRunOnStart {
380+
t.Errorf("RunOnStartup = %v, want %v", job.RunOnStartup, tt.wantRunOnStart)
381+
}
382+
if job.Schedule != tt.wantSchedule {
383+
t.Errorf("Schedule = %v, want %v", job.Schedule, tt.wantSchedule)
384+
}
385+
}
386+
})
387+
}
388+
}
389+
390+
// TestBuildFromString_RunOnStartupAllJobTypes tests run-on-startup works for all job types
391+
func TestBuildFromString_RunOnStartupAllJobTypes(t *testing.T) {
392+
t.Parallel()
393+
configStr := `
394+
[job-exec "exec-startup"]
395+
schedule = @every 1h
396+
container = test-container
397+
command = echo exec
398+
run-on-startup = true
399+
400+
[job-run "run-startup"]
401+
schedule = @every 1h
402+
image = alpine
403+
command = echo run
404+
run-on-startup = true
405+
406+
[job-local "local-startup"]
407+
schedule = @every 1h
408+
command = echo local
409+
run-on-startup = true
410+
411+
[job-service-run "service-startup"]
412+
schedule = @every 1h
413+
image = nginx
414+
command = echo service
415+
run-on-startup = true
416+
417+
[job-compose "compose-startup"]
418+
schedule = @every 1h
419+
command = up -d
420+
run-on-startup = true
421+
`
422+
423+
logger := test.NewTestLogger()
424+
cfg, err := BuildFromString(configStr, logger)
425+
if err != nil {
426+
t.Fatalf("BuildFromString failed: %v", err)
427+
}
428+
429+
// Verify all job types have run-on-startup enabled
430+
for _, job := range cfg.ExecJobs {
431+
if !job.RunOnStartup {
432+
t.Errorf("ExecJob %q: RunOnStartup = false, want true", job.Name)
433+
}
434+
}
435+
for _, job := range cfg.RunJobs {
436+
if !job.RunOnStartup {
437+
t.Errorf("RunJob %q: RunOnStartup = false, want true", job.Name)
438+
}
439+
}
440+
for _, job := range cfg.LocalJobs {
441+
if !job.RunOnStartup {
442+
t.Errorf("LocalJob %q: RunOnStartup = false, want true", job.Name)
443+
}
444+
}
445+
for _, job := range cfg.ServiceJobs {
446+
if !job.RunOnStartup {
447+
t.Errorf("ServiceJob %q: RunOnStartup = false, want true", job.Name)
448+
}
449+
}
450+
for _, job := range cfg.ComposeJobs {
451+
if !job.RunOnStartup {
452+
t.Errorf("ComposeJob %q: RunOnStartup = false, want true", job.Name)
453+
}
454+
}
455+
}
456+
307457
// TestResolveConfigFiles tests the resolveConfigFiles function
308458
func TestResolveConfigFiles(t *testing.T) {
309459
t.Parallel()

core/bare_job.go

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,13 @@ import (
77
)
88

99
type BareJob struct {
10-
Schedule string `hash:"true"`
11-
Name string `hash:"true"`
12-
Command string `hash:"true"`
10+
Schedule string `hash:"true"`
11+
Name string `hash:"true"`
12+
Command string `hash:"true"`
13+
// RunOnStartup controls whether the job is executed once immediately when the scheduler starts,
14+
// before regular cron-based scheduling begins. This is a boolean flag with a default value of false.
15+
// Startup executions are dispatched in non-blocking goroutines so they do not delay scheduler startup.
16+
RunOnStartup bool `default:"false" gcfg:"run-on-startup" mapstructure:"run-on-startup" hash:"true"`
1317
HistoryLimit int `default:"10"`
1418
MaxRetries int `default:"0"` // Maximum number of retry attempts (0 = no retries)
1519
RetryDelayMs int `default:"1000"` // Initial retry delay in milliseconds
@@ -40,6 +44,11 @@ func (j *BareJob) GetCommand() string {
4044
return j.Command
4145
}
4246

47+
// ShouldRunOnStartup returns true if the job should run immediately when the scheduler starts.
48+
func (j *BareJob) ShouldRunOnStartup() bool {
49+
return j.RunOnStartup
50+
}
51+
4352
func (j *BareJob) Running() int32 {
4453
return atomic.LoadInt32(&j.running)
4554
}

core/common.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ type Job interface {
2525
GetName() string
2626
GetSchedule() string
2727
GetCommand() string
28+
ShouldRunOnStartup() bool
2829
Middlewares() []Middleware
2930
Use(...Middleware)
3031
Run(*Context) error

core/common_extra3_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@ func TestBareJobHashValue(t *testing.T) {
3737
Name: "name",
3838
Command: "cmd",
3939
}
40-
// Expect "schednamecmd"
41-
want := "schednamecmd"
40+
// Expect "schednamecmdfalse" (includes RunOnStartup=false)
41+
want := "schednamecmdfalse"
4242
got, err := job.Hash()
4343
if err != nil {
4444
t.Fatalf("hash error: %v", err)

core/context_log_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ type stubJob struct {
4545
func (j *stubJob) GetName() string { return j.name }
4646
func (j *stubJob) GetSchedule() string { return "" }
4747
func (j *stubJob) GetCommand() string { return "" }
48+
func (j *stubJob) ShouldRunOnStartup() bool { return false }
4849
func (j *stubJob) Middlewares() []Middleware { return nil }
4950
func (j *stubJob) Use(...Middleware) {}
5051
func (j *stubJob) Run(*Context) error { return nil }

core/scheduler.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,9 +322,32 @@ func (s *Scheduler) Start() error {
322322
s.mu.Unlock()
323323
s.Logger.Debugf("Starting scheduler")
324324
s.cron.Start()
325+
326+
// Run jobs marked with run-on-startup
327+
s.runStartupJobs()
328+
325329
return nil
326330
}
327331

332+
// runStartupJobs executes jobs that have run-on-startup=true.
333+
// Each job runs in its own goroutine to avoid blocking the scheduler.
334+
func (s *Scheduler) runStartupJobs() {
335+
s.mu.RLock()
336+
jobs := make([]Job, 0)
337+
for _, j := range s.Jobs {
338+
if j.ShouldRunOnStartup() {
339+
jobs = append(jobs, j)
340+
}
341+
}
342+
s.mu.RUnlock()
343+
344+
for _, job := range jobs {
345+
s.Logger.Noticef("Running startup job: %s", job.GetName())
346+
wrapper := &jobWrapper{s: s, j: job}
347+
go wrapper.Run()
348+
}
349+
}
350+
328351
// DefaultStopTimeout is the default timeout for graceful shutdown.
329352
const DefaultStopTimeout = 30 * time.Second
330353

core/scheduler_test.go

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,3 +198,142 @@ func TestSchedulerWithCronClock(t *testing.T) {
198198
_ = sc.Stop()
199199
assert.False(t, sc.IsRunning())
200200
}
201+
202+
func TestSchedulerRunOnStartup(t *testing.T) {
203+
t.Parallel()
204+
205+
job := &TestJob{}
206+
job.Schedule = "@every 1h" // Long interval so it won't fire during test
207+
job.RunOnStartup = true
208+
209+
sc := NewSchedulerWithOptions(&TestLogger{}, nil, 10*time.Millisecond)
210+
err := sc.AddJob(job)
211+
require.NoError(t, err)
212+
213+
jobCompleted := make(chan struct{}, 1)
214+
sc.SetOnJobComplete(func(_ string, _ bool) {
215+
select {
216+
case jobCompleted <- struct{}{}:
217+
default:
218+
}
219+
})
220+
221+
_ = sc.Start()
222+
223+
// Job should run immediately on startup
224+
select {
225+
case <-jobCompleted:
226+
// Success - job ran on startup
227+
case <-time.After(500 * time.Millisecond):
228+
t.Fatal("Startup job should have run immediately")
229+
}
230+
231+
assert.Equal(t, 1, job.Called())
232+
233+
_ = sc.Stop()
234+
}
235+
236+
func TestSchedulerRunOnStartupDisabled(t *testing.T) {
237+
t.Parallel()
238+
239+
job := &TestJob{}
240+
job.Schedule = "@every 1h" // Long interval so it won't fire during test
241+
job.RunOnStartup = false // Explicitly disabled
242+
243+
sc := NewSchedulerWithOptions(&TestLogger{}, nil, 10*time.Millisecond)
244+
err := sc.AddJob(job)
245+
require.NoError(t, err)
246+
247+
_ = sc.Start()
248+
249+
// Wait a bit to ensure job doesn't run
250+
time.Sleep(150 * time.Millisecond)
251+
252+
// Job should NOT have run since RunOnStartup is false
253+
assert.Equal(t, 0, job.Called())
254+
255+
_ = sc.Stop()
256+
}
257+
258+
func TestSchedulerRunOnStartupMultipleJobs(t *testing.T) {
259+
t.Parallel()
260+
261+
job1 := &TestJob{}
262+
job1.Name = "startup-job-1"
263+
job1.Schedule = "@every 1h"
264+
job1.RunOnStartup = true
265+
266+
job2 := &TestJob{}
267+
job2.Name = "startup-job-2"
268+
job2.Schedule = "@every 1h"
269+
job2.RunOnStartup = true
270+
271+
job3 := &TestJob{}
272+
job3.Name = "no-startup-job"
273+
job3.Schedule = "@every 1h"
274+
job3.RunOnStartup = false
275+
276+
sc := NewSchedulerWithOptions(&TestLogger{}, nil, 10*time.Millisecond)
277+
require.NoError(t, sc.AddJob(job1))
278+
require.NoError(t, sc.AddJob(job2))
279+
require.NoError(t, sc.AddJob(job3))
280+
281+
jobsCompleted := make(chan string, 3)
282+
sc.SetOnJobComplete(func(jobName string, _ bool) {
283+
select {
284+
case jobsCompleted <- jobName:
285+
default:
286+
}
287+
})
288+
289+
_ = sc.Start()
290+
291+
// Wait for both startup jobs to complete
292+
completedCount := 0
293+
timeout := time.After(500 * time.Millisecond)
294+
for completedCount < 2 {
295+
select {
296+
case <-jobsCompleted:
297+
completedCount++
298+
case <-timeout:
299+
t.Fatalf("Expected 2 startup jobs to complete, got %d", completedCount)
300+
}
301+
}
302+
303+
// Give a bit more time to ensure job3 didn't run
304+
time.Sleep(100 * time.Millisecond)
305+
306+
assert.Equal(t, 1, job1.Called(), "job1 should have run once on startup")
307+
assert.Equal(t, 1, job2.Called(), "job2 should have run once on startup")
308+
assert.Equal(t, 0, job3.Called(), "job3 should not have run (no startup)")
309+
310+
_ = sc.Stop()
311+
}
312+
313+
func TestSchedulerRunOnStartupNonBlocking(t *testing.T) {
314+
t.Parallel()
315+
316+
// Create a job that takes a while to run
317+
job := &TestJob{}
318+
job.Schedule = "@every 1h"
319+
job.RunOnStartup = true
320+
321+
sc := NewSchedulerWithOptions(&TestLogger{}, nil, 10*time.Millisecond)
322+
err := sc.AddJob(job)
323+
require.NoError(t, err)
324+
325+
// Start() should return quickly even though startup job takes 50ms
326+
startTime := time.Now()
327+
_ = sc.Start()
328+
elapsed := time.Since(startTime)
329+
330+
// Start() should complete quickly (non-blocking)
331+
// The startup job runs in a goroutine, so Start() returns immediately
332+
assert.Less(t, elapsed, 30*time.Millisecond, "Start() should be non-blocking")
333+
334+
// Wait for job to complete
335+
time.Sleep(150 * time.Millisecond)
336+
assert.Equal(t, 1, job.Called())
337+
338+
_ = sc.Stop()
339+
}

0 commit comments

Comments
 (0)