Files
threshold/internal/scheduler/scheduler.go
T
Hugo f227020b67 feat(scheduler): Use job table to select jobs to run
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).
2024-11-06 16:15:49 +00:00

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
}