* Adding explicit MPL license for sub-package. This directory and its subdirectories (packages) contain files licensed with the MPLv2 `LICENSE` file in this directory and are intentionally licensed separately from the BSL `LICENSE` file at the root of this repository. * Adding explicit MPL license for sub-package This directory and its subdirectories (packages) contain files licensed with the MPLv2 `LICENSE` file in this directory and are intentionally licensed separately from the BSL `LICENSE` file at the root of this repository. * Updating the license from MPL to Business Source License Going forward, this project will be licensed under the Business Source License v1.1. Please see our blog post for more details at https://hashi.co/bsl-blog, FAQ at www.hashicorp.com/licensing-faq, and details of the license at www.hashicorp.com/bsl. * Update copyright file headers to BUS-1.1 * Rerun make gen This will pick up the last of the license changes * Revert "Rerun make gen" This reverts commit dbe0968bffd92745d8663c6940debae098395a6b. * Remove copywrite from generation script The current version of copywrite is not sophisticated enough to only apply the copywrite changes to certain subdirectories, so to avoid accidentally relicensing our api and sdk modules, we will stop running the copywrite script until such time that it can do the right thing. * License protobuf API as MPL The protobuf API files are used to generate the SDK, since we want the SDK to be MPL, the protobuf files must also be MPL. This extends to all protobuf files whose sources end up in the SDK directory, including: - internal/proto/controller/api - internal/proto/controller/custom_options - internal/proto/plugin It also includes the generated service directory in internal/gen/controller/api/services, since they are generated from the protobuf sources used for the SDK. * Change website license file name This makes the naming scheme consistent with the rest of the repository. * Add note on licensing to README * Set copywrite license to BUSL * Revert "Add note on licensing to README" This reverts commit 4d865a91a7a4f11605f0dc4609355d7e3c3cfffe. --------- Co-authored-by: hashicorp-copywrite[bot] <110428419+hashicorp-copywrite[bot]@users.noreply.github.com> Co-authored-by: Johan Brandhorst-Satzkorn <johan.brandhorst@gmail.com>
355 lines
10 KiB
Go
355 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/observability/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
|
|
|
|
runJobsLimit int
|
|
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
|
|
//
|
|
// WithRunJobsLimit, 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),
|
|
runJobsLimit: opts.withRunJobsLimit,
|
|
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.CAS(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(),
|
|
"run limit", s.runJobsLimit)
|
|
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, job.WithRunJobsLimit(s.runJobsLimit))
|
|
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); 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)
|
|
|
|
// Get final status report to update run progress with
|
|
status := j.Status()
|
|
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, status.Completed, status.Total)
|
|
default:
|
|
event.WriteError(ctx, op, runErr, event.WithInfoMsg("job run failed", "run id", r.PrivateId, "name", j.Name()))
|
|
_, updateErr = repo.FailRun(ctx, r.PrivateId, status.Completed, status.Total)
|
|
}
|
|
|
|
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)
|
|
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
|
|
}
|