This commit changes the logic around selecting jobs to run. Before this, Boundary used a view "job_jobs_to_run" which was a collation of all jobs that were not running and scheduled to run. When jobs were requested to run, Boundary would look at this view, grab a certain amount of jobs and insert them into the "job_run" table. Importantly, while the amount of jobs to be scheduled at once defaulted to 1 in the scheduler package, this was entirely overriden in the controller package to -1 (no limit, run all jobs that the query would return). With this commit, Boundary now looks at the "job" table directly for jobs to run, regardless of job run status. This was done to improve DB performance as the view (which queried all "job_run" rows) was causing significant DB contention. Additionally, the option to limit job runs is now removed since it was already hardcoded to -1 and not user-configurable anyways. Should the need arise to implement this limit again, this will have to be done in a different way since the new query does not account for running jobs and limiting the rows returned after jobs are already running would yield a no-op (the new query inserts nothing into "job_run" if any given job is already running).
353 lines
10 KiB
Go
353 lines
10 KiB
Go
// Copyright (c) HashiCorp, Inc.
|
|
// SPDX-License-Identifier: BUSL-1.1
|
|
|
|
package scheduler
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/hashicorp/boundary/internal/errors"
|
|
"github.com/hashicorp/boundary/internal/event"
|
|
"github.com/hashicorp/boundary/internal/scheduler/job"
|
|
ua "go.uber.org/atomic"
|
|
)
|
|
|
|
type jobRepoFactory func() (*job.Repository, error)
|
|
|
|
type runningJob struct {
|
|
runId string
|
|
cancelCtx context.CancelFunc
|
|
status func() JobStatus
|
|
}
|
|
|
|
// Scheduler is used to register and run recurring jobs on the controller.
|
|
type Scheduler struct {
|
|
serverId string
|
|
jobRepoFn jobRepoFactory
|
|
|
|
registeredJobs *sync.Map
|
|
runningJobs *sync.Map
|
|
started ua.Bool
|
|
|
|
runJobsInterval time.Duration
|
|
monitorInterval time.Duration
|
|
interruptThreshold time.Duration
|
|
runNow chan struct{}
|
|
}
|
|
|
|
// New creates a new Scheduler
|
|
//
|
|
// • serverId must be provided and is the private id of the server that will run the scheduler
|
|
//
|
|
// • jobRepoFn must be provided and is a function that returns the job repository
|
|
//
|
|
// WithRunJobsInterval, WithMonitorInterval and WithInterruptThreshold are
|
|
// the only valid options.
|
|
func New(ctx context.Context, serverId string, jobRepoFn jobRepoFactory, opt ...Option) (*Scheduler, error) {
|
|
const op = "scheduler.New"
|
|
if serverId == "" {
|
|
return nil, errors.New(ctx, errors.InvalidParameter, op, "missing server id")
|
|
}
|
|
if jobRepoFn == nil {
|
|
return nil, errors.New(ctx, errors.InvalidParameter, op, "missing job repo function")
|
|
}
|
|
|
|
opts := getOpts(opt...)
|
|
return &Scheduler{
|
|
serverId: serverId,
|
|
jobRepoFn: jobRepoFn,
|
|
registeredJobs: new(sync.Map),
|
|
runningJobs: new(sync.Map),
|
|
runJobsInterval: opts.withRunJobInterval,
|
|
monitorInterval: opts.withMonitorInterval,
|
|
interruptThreshold: opts.withInterruptThreshold,
|
|
runNow: make(chan struct{}, 1),
|
|
}, nil
|
|
}
|
|
|
|
// RegisterJob registers a job with the scheduler and persists the job into the repository.
|
|
//
|
|
// • job must be provided and is an implementer of the Job interface.
|
|
//
|
|
// WithNextRunIn is the only valid options.
|
|
func (s *Scheduler) RegisterJob(ctx context.Context, j Job, opt ...Option) error {
|
|
const op = "scheduler.(Scheduler).RegisterJob"
|
|
if err := validateJob(ctx, j); err != nil {
|
|
return errors.Wrap(ctx, err, op)
|
|
}
|
|
|
|
if _, ok := s.registeredJobs.Load(j.Name()); ok {
|
|
// Job already registered
|
|
return nil
|
|
}
|
|
|
|
repo, err := s.jobRepoFn()
|
|
if err != nil {
|
|
return errors.Wrap(ctx, err, op)
|
|
}
|
|
|
|
opts := getOpts(opt...)
|
|
_, err = repo.UpsertJob(ctx, j.Name(), j.Description(), job.WithNextRunIn(opts.withNextRunIn))
|
|
if err != nil {
|
|
return errors.Wrap(ctx, err, op)
|
|
}
|
|
s.registeredJobs.Store(j.Name(), j)
|
|
|
|
return nil
|
|
}
|
|
|
|
// UpdateJobNextRunInAtLeast updates the next scheduled run time for the provided name,
|
|
// setting the job's NextScheduledRun time to either the current database time incremented by
|
|
// the nextRunInAtLeast parameter or the current NextScheduledRun time value, which ever is sooner.
|
|
// If nextRunInAtLeast == 0 the job will be available to run immediately.
|
|
//
|
|
// WithRunNow is the only supported option.
|
|
func (s *Scheduler) UpdateJobNextRunInAtLeast(ctx context.Context, name string, nextRunInAtLeast time.Duration, opt ...Option) error {
|
|
const op = "scheduler.(Scheduler).UpdateJobNextRunInAtLeast"
|
|
if name == "" {
|
|
return errors.New(ctx, errors.InvalidParameter, op, "missing name")
|
|
}
|
|
repo, err := s.jobRepoFn()
|
|
if err != nil {
|
|
return errors.Wrap(ctx, err, op)
|
|
}
|
|
|
|
_, err = repo.UpdateJobNextRunInAtLeast(ctx, name, nextRunInAtLeast)
|
|
if err != nil {
|
|
return errors.Wrap(ctx, err, op)
|
|
}
|
|
|
|
opts := getOpts(opt...)
|
|
if opts.withRunNow {
|
|
s.RunNow()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Start begins the scheduling loop that will query the repository for jobs to run and
|
|
// run them in a goroutine, the scheduler will stop all running jobs and stop requesting
|
|
// new jobs once the ctx past in is canceled.
|
|
// The scheduler cannot be started again once the ctx is canceled, a new scheduler will
|
|
// need to be instantiated in order to begin scheduling again.
|
|
func (s *Scheduler) Start(ctx context.Context, wg *sync.WaitGroup) error {
|
|
const op = "scheduler.(Scheduler).Start"
|
|
if !s.started.CompareAndSwap(s.started.Load(), true) {
|
|
event.WriteSysEvent(ctx, op, "scheduler already started, skipping")
|
|
return nil
|
|
}
|
|
if ctx == nil {
|
|
return errors.New(ctx, errors.InvalidParameter, op, "missing context")
|
|
}
|
|
if wg == nil {
|
|
return errors.New(ctx, errors.InvalidParameter, op, "missing wait group")
|
|
}
|
|
|
|
if err := ctx.Err(); err != nil {
|
|
return errors.Wrap(ctx, err, op)
|
|
}
|
|
|
|
repo, err := s.jobRepoFn()
|
|
if err != nil {
|
|
return errors.Wrap(ctx, err, op)
|
|
}
|
|
|
|
// Interrupt all runs previously allocated to this server
|
|
_, err = repo.InterruptRuns(ctx, 0, job.WithControllerId(s.serverId))
|
|
if err != nil {
|
|
return errors.Wrap(ctx, err, op)
|
|
}
|
|
|
|
wg.Add(2)
|
|
go func() {
|
|
defer wg.Done()
|
|
s.start(ctx)
|
|
}()
|
|
go func() {
|
|
defer wg.Done()
|
|
s.monitorJobs(ctx)
|
|
}()
|
|
|
|
return nil
|
|
}
|
|
|
|
// RunNow attempts to trigger the scheduling loop, if the scheduling loop is actively running it will
|
|
// cause the loop to run again immediately after finishing.
|
|
func (s *Scheduler) RunNow() {
|
|
// Do not block on writing to runNow chan.
|
|
select {
|
|
case s.runNow <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
|
|
func (s *Scheduler) start(ctx context.Context) {
|
|
const op = "scheduler.(Scheduler).start"
|
|
event.WriteSysEvent(ctx, op, "scheduling loop running",
|
|
"server id", s.serverId,
|
|
"run interval", s.runJobsInterval.String(),
|
|
)
|
|
timer := time.NewTimer(0)
|
|
var wg sync.WaitGroup
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
event.WriteSysEvent(ctx, op, "scheduling loop received shutdown, waiting for jobs to finish",
|
|
"server id", s.serverId)
|
|
wg.Wait()
|
|
event.WriteSysEvent(ctx, op, "scheduling loop shutting down", "server id", s.serverId)
|
|
return
|
|
case <-timer.C:
|
|
case <-s.runNow:
|
|
}
|
|
|
|
s.schedule(ctx, &wg)
|
|
timer.Reset(s.runJobsInterval)
|
|
}
|
|
}
|
|
|
|
func (s *Scheduler) schedule(ctx context.Context, wg *sync.WaitGroup) {
|
|
const op = "scheduler.(Scheduler).schedule"
|
|
repo, err := s.jobRepoFn()
|
|
if err != nil {
|
|
event.WriteError(ctx, op, err, event.WithInfoMsg("error creating job repo"))
|
|
return
|
|
}
|
|
|
|
runs, err := repo.RunJobs(ctx, s.serverId)
|
|
if err != nil {
|
|
event.WriteError(ctx, op, err, event.WithInfoMsg("error getting jobs to run from repo"))
|
|
return
|
|
}
|
|
|
|
for _, r := range runs {
|
|
err := s.runJob(ctx, wg, r)
|
|
if err != nil {
|
|
event.WriteError(ctx, op, err, event.WithInfoMsg("error starting job"))
|
|
if _, inner := repo.FailRun(ctx, r.PrivateId, 0, 0, 0); inner != nil {
|
|
event.WriteError(ctx, op, inner, event.WithInfoMsg("error updating failed job run"))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Scheduler) runJob(ctx context.Context, wg *sync.WaitGroup, r *job.Run) error {
|
|
const op = "scheduler.(Scheduler).runJob"
|
|
regJob, ok := s.registeredJobs.Load(r.JobName)
|
|
if !ok {
|
|
return fmt.Errorf("job %q not registered on scheduler", r.JobName)
|
|
}
|
|
|
|
repo, err := s.jobRepoFn()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create job repository: %w", err)
|
|
}
|
|
|
|
j := regJob.(Job)
|
|
rj := &runningJob{runId: r.PrivateId, status: j.Status}
|
|
_, loaded := s.runningJobs.LoadOrStore(r.JobName, rj)
|
|
if loaded {
|
|
return fmt.Errorf("job %q is already running", r.JobName)
|
|
}
|
|
var jobContext context.Context
|
|
jobContext, rj.cancelCtx = context.WithCancel(ctx)
|
|
|
|
wg.Add(1)
|
|
go func() {
|
|
defer rj.cancelCtx()
|
|
defer wg.Done()
|
|
runErr := j.Run(jobContext, s.interruptThreshold)
|
|
|
|
var updateErr error
|
|
switch {
|
|
case ctx.Err() != nil:
|
|
// Base context is no longer valid, skip repo updates as they will fail and exit
|
|
case runErr == nil:
|
|
nextRun, inner := j.NextRunIn(ctx)
|
|
if inner != nil {
|
|
event.WriteError(ctx, op, inner, event.WithInfoMsg("error getting next run time", "name", j.Name()))
|
|
}
|
|
updateErr = repo.CompleteRun(ctx, r.PrivateId, nextRun)
|
|
default:
|
|
event.WriteError(ctx, op, runErr, event.WithInfoMsg("job run failed", "run id", r.PrivateId, "name", j.Name()))
|
|
|
|
status := j.Status() // Get final status report to update run progress with
|
|
_, updateErr = repo.FailRun(ctx, r.PrivateId, status.Completed, status.Total, status.Retries)
|
|
}
|
|
|
|
if updateErr != nil {
|
|
event.WriteError(ctx, op, updateErr, event.WithInfoMsg("error updating job run", "name", j.Name()))
|
|
}
|
|
s.runningJobs.Delete(j.Name())
|
|
}()
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *Scheduler) monitorJobs(ctx context.Context) {
|
|
const op = "scheduler.(Scheduler).monitorJobs"
|
|
event.WriteSysEvent(ctx, op, "monitor loop running",
|
|
"server id", s.serverId,
|
|
"monitor interval", s.monitorInterval.String(),
|
|
"interrupt threshold", s.interruptThreshold.String())
|
|
timer := time.NewTimer(0)
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
event.WriteSysEvent(ctx, op, "job monitor loop shutting down")
|
|
return
|
|
|
|
case <-timer.C:
|
|
// Update progress of all running jobs
|
|
s.runningJobs.Range(func(_, v any) bool {
|
|
err := s.updateRunningJobProgress(ctx, v.(*runningJob))
|
|
if err != nil {
|
|
event.WriteError(ctx, op, err, event.WithInfoMsg("error updating job progress"))
|
|
}
|
|
return true
|
|
})
|
|
|
|
// Check for defunct runs to interrupt
|
|
repo, err := s.jobRepoFn()
|
|
if err != nil {
|
|
event.WriteError(ctx, op, err, event.WithInfoMsg("error creating job repo"))
|
|
break
|
|
}
|
|
|
|
_, err = repo.InterruptRuns(ctx, s.interruptThreshold)
|
|
if err != nil {
|
|
event.WriteError(ctx, op, err, event.WithInfoMsg("error interrupting job runs"))
|
|
}
|
|
}
|
|
timer.Reset(s.monitorInterval)
|
|
}
|
|
}
|
|
|
|
func (s *Scheduler) updateRunningJobProgress(ctx context.Context, j *runningJob) error {
|
|
repo, err := s.jobRepoFn()
|
|
if err != nil {
|
|
return fmt.Errorf("error creating job repo %w", err)
|
|
}
|
|
status := j.status()
|
|
_, err = repo.UpdateProgress(ctx, j.runId, status.Completed, status.Total, status.Retries)
|
|
if errors.Match(errors.T(errors.InvalidJobRunState), err) {
|
|
// Job has been persisted with a final run status, cancel job context to trigger early exit.
|
|
j.cancelCtx()
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetRunJobsInterval returns the value runJobsInterval,
|
|
// which represents an interval at which the scheduler
|
|
// will query the repository for jobs to run.
|
|
func (s *Scheduler) GetRunJobsInterval() time.Duration {
|
|
return s.runJobsInterval
|
|
}
|