go.charczuk.com@v0.0.0-20240327042549-bc490516bd1a/sdk/cron/job_scheduler.go (about)

     1  /*
     2  
     3  Copyright (c) 2023 - Present. Will Charczuk. All rights reserved.
     4  Use of this source code is governed by a MIT license that can be found in the LICENSE file at the root of the repository.
     5  
     6  */
     7  
     8  package cron
     9  
    10  import (
    11  	"context"
    12  	"fmt"
    13  	"sync"
    14  	"time"
    15  
    16  	"go.charczuk.com/sdk/async"
    17  )
    18  
    19  // NewJobScheduler returns a job scheduler for a given job.
    20  func NewJobScheduler(jm *JobManager, job Job) *JobScheduler {
    21  	js := &JobScheduler{
    22  		Job:   job,
    23  		latch: async.NewLatch(),
    24  		jm:    jm,
    25  	}
    26  	if typed, ok := job.(ScheduleProvider); ok {
    27  		js.schedule = typed.Schedule()
    28  	}
    29  	return js
    30  }
    31  
    32  // JobScheduler is a job instance.
    33  type JobScheduler struct {
    34  	Job Job
    35  
    36  	jm          *JobManager
    37  	config      JobConfig
    38  	schedule    Schedule
    39  	lifecycle   JobLifecycle
    40  	nextRuntime time.Time
    41  
    42  	latch       *async.Latch
    43  	currentLock sync.Mutex
    44  	current     *JobInvocation
    45  	lastLock    sync.Mutex
    46  	last        *JobInvocation
    47  }
    48  
    49  // Name returns the job name.
    50  func (js *JobScheduler) Name() string {
    51  	return js.Job.Name()
    52  }
    53  
    54  // Config returns the job config provided by a job or an empty config.
    55  func (js *JobScheduler) Config() JobConfig {
    56  	if typed, ok := js.Job.(ConfigProvider); ok {
    57  		return typed.Config()
    58  	}
    59  	return js.config
    60  }
    61  
    62  // Lifecycle returns job lifecycle steps or an empty set.
    63  func (js *JobScheduler) Lifecycle() JobLifecycle {
    64  	if typed, ok := js.Job.(LifecycleProvider); ok {
    65  		return typed.Lifecycle()
    66  	}
    67  	return js.lifecycle
    68  }
    69  
    70  // Labels returns the job labels, including
    71  // automatically added ones like `name`.
    72  func (js *JobScheduler) Labels() map[string]string {
    73  	output := map[string]string{
    74  		"name":      js.Name(),
    75  		"scheduler": string(js.State()),
    76  		"active":    fmt.Sprint(!js.IsIdle()),
    77  		"enabled":   fmt.Sprint(!js.config.Disabled),
    78  	}
    79  	if js.Last() != nil {
    80  		output["last"] = string(js.Last().Status)
    81  	}
    82  	for key, value := range js.Config().Labels {
    83  		output[key] = value
    84  	}
    85  	return output
    86  }
    87  
    88  // State returns the job scheduler state.
    89  func (js *JobScheduler) State() JobSchedulerState {
    90  	if js.latch.IsStarted() {
    91  		return JobSchedulerStateRunning
    92  	}
    93  	if js.latch.IsStopped() {
    94  		return JobSchedulerStateStopped
    95  	}
    96  	return JobSchedulerStateUnknown
    97  }
    98  
    99  // Start starts the scheduler.
   100  // This call blocks.
   101  func (js *JobScheduler) Start(ctx context.Context) error {
   102  	if !js.latch.CanStart() {
   103  		return ErrCannotStart
   104  	}
   105  	js.latch.Starting()
   106  	js.runLoop(ctx)
   107  	return nil
   108  }
   109  
   110  // Stop stops the scheduler.
   111  func (js *JobScheduler) Stop(ctx context.Context) error {
   112  	if !js.latch.CanStop() {
   113  		return ErrCannotStop
   114  	}
   115  	js.latch.Stopping() // trigger the `NotifyStopping` channel
   116  
   117  	// if it's currently running
   118  	// cancel or wait to cancel
   119  	if current := js.Current(); current != nil {
   120  		ctx := js.withBaseContext(ctx)
   121  		gracePeriod := js.Config().ShutdownGracePeriodOrDefault()
   122  		if gracePeriod > 0 {
   123  			var cancel func()
   124  			ctx, cancel = js.withTimeoutOrCancel(ctx, gracePeriod)
   125  			defer cancel()
   126  			js.waitCurrentComplete(ctx)
   127  		} else {
   128  			current.Cancel()
   129  		}
   130  	}
   131  
   132  	// wait for the runloop to exit
   133  	<-js.latch.NotifyStopped()
   134  	js.latch.Reset()
   135  	js.nextRuntime = Zero
   136  	return nil
   137  }
   138  
   139  // NotifyStarted notifies the job scheduler has started.
   140  func (js *JobScheduler) NotifyStarted() <-chan struct{} {
   141  	return js.latch.NotifyStarted()
   142  }
   143  
   144  // NotifyStopped notifies the job scheduler has stopped.
   145  func (js *JobScheduler) NotifyStopped() <-chan struct{} {
   146  	return js.latch.NotifyStopped()
   147  }
   148  
   149  // Enable sets the job as enabled.
   150  func (js *JobScheduler) Enable(ctx context.Context) {
   151  	ctx = js.withBaseContext(ctx)
   152  	js.config.Disabled = false
   153  	if lifecycle := js.Lifecycle(); lifecycle.OnEnabled != nil {
   154  		lifecycle.OnEnabled(ctx)
   155  	}
   156  }
   157  
   158  // Disable sets the job as disabled.
   159  func (js *JobScheduler) Disable(ctx context.Context) {
   160  	ctx = js.withBaseContext(ctx)
   161  	js.config.Disabled = true
   162  	if lifecycle := js.Lifecycle(); lifecycle.OnDisabled != nil {
   163  		lifecycle.OnDisabled(ctx)
   164  	}
   165  }
   166  
   167  // Cancel stops all running invocations.
   168  func (js *JobScheduler) Cancel(ctx context.Context) error {
   169  	ctx = js.withBaseContext(ctx)
   170  	if js.Current() == nil {
   171  		return nil
   172  	}
   173  	gracePeriod := js.Config().ShutdownGracePeriodOrDefault()
   174  	if gracePeriod > 0 {
   175  		ctx, cancel := js.withTimeoutOrCancel(ctx, gracePeriod)
   176  		defer cancel()
   177  		js.waitCurrentComplete(ctx)
   178  	}
   179  	if current := js.Current(); current != nil && current.Status == JobInvocationStatusRunning {
   180  		current.Cancel()
   181  	}
   182  	return nil
   183  }
   184  
   185  // RunAsync starts a job invocation with a given context.
   186  func (js *JobScheduler) RunAsync(ctx context.Context) (*JobInvocation, error) {
   187  	if !js.IsIdle() {
   188  		return nil, ErrJobAlreadyRunning
   189  	}
   190  
   191  	ctx = js.withBaseContext(ctx)
   192  	ctx, ji := js.withInvocationContext(ctx)
   193  	js.setCurrent(ji)
   194  
   195  	var err error
   196  	go func() {
   197  		defer func() {
   198  			switch {
   199  			case err != nil && IsJobCanceled(err):
   200  				js.onJobCompleteCanceled(ctx) // the job was canceled, either manually or by a timeout
   201  			case err != nil:
   202  				js.onJobCompleteError(ctx, err) // the job completed with an error
   203  			default:
   204  				js.onJobCompleteSuccess(ctx) // the job completed without error
   205  			}
   206  			ji.Cancel()              // if the job was created with a timeout, end the timeout
   207  			js.assignCurrentToLast() // rotate in the current to the last result
   208  		}()
   209  		js.onJobBegin(ctx) // signal the job is starting
   210  
   211  		select {
   212  		case <-ctx.Done(): // if the timeout or cancel is triggered
   213  			err = ErrJobCanceled // set the error to a known error
   214  			return
   215  		case err = <-js.safeBackgroundExec(ctx): // run the job in a background routine and catch panics
   216  			return
   217  		}
   218  	}()
   219  	return ji, nil
   220  }
   221  
   222  // Run forces the job to run.
   223  // This call will block.
   224  func (js *JobScheduler) Run(ctx context.Context) {
   225  	ji, err := js.RunAsync(ctx)
   226  	if err != nil {
   227  		return
   228  	}
   229  	<-ji.Finished
   230  }
   231  
   232  //
   233  // exported utility methods
   234  //
   235  
   236  // CanBeScheduled returns if a job will be triggered automatically
   237  // and isn't already in flight and set to be serial.
   238  func (js *JobScheduler) CanBeScheduled() bool {
   239  	return !js.config.Disabled && js.IsIdle()
   240  }
   241  
   242  // IsIdle returns if the job is not currently running.
   243  func (js *JobScheduler) IsIdle() (isIdle bool) {
   244  	isIdle = js.Current() == nil
   245  	return
   246  }
   247  
   248  //
   249  // internal functions
   250  //
   251  
   252  func (js *JobScheduler) runLoop(ctx context.Context) {
   253  	js.latch.Started()
   254  	defer func() {
   255  		js.latch.Stopped()
   256  		js.latch.Reset()
   257  	}()
   258  
   259  	if js.schedule != nil {
   260  		js.nextRuntime = js.schedule.Next(js.nextRuntime)
   261  	}
   262  	if js.nextRuntime.IsZero() {
   263  		return
   264  	}
   265  
   266  	runAt := time.NewTimer(js.nextRuntime.UTC().Sub(Now()))
   267  	for {
   268  		select {
   269  		case <-runAt.C:
   270  			runAt.Stop()
   271  			if js.CanBeScheduled() {
   272  				_, _ = js.RunAsync(ctx)
   273  			}
   274  			if !js.latch.IsStarted() {
   275  				return
   276  			}
   277  			if js.schedule != nil {
   278  				js.nextRuntime = js.schedule.Next(js.nextRuntime)
   279  				runAt.Reset(js.nextRuntime.UTC().Sub(Now()))
   280  			} else {
   281  				js.nextRuntime = Zero
   282  			}
   283  			if js.nextRuntime.IsZero() {
   284  				return
   285  			}
   286  		case <-js.latch.NotifyStopping():
   287  			runAt.Stop()
   288  			return
   289  		}
   290  	}
   291  }
   292  
   293  // Current returns the current job invocation.
   294  func (js *JobScheduler) Current() (current *JobInvocation) {
   295  	js.currentLock.Lock()
   296  	if js.current != nil {
   297  		current = js.current.Clone()
   298  	}
   299  	js.currentLock.Unlock()
   300  	return
   301  }
   302  
   303  // Last returns the last job invocation.
   304  func (js *JobScheduler) Last() (last *JobInvocation) {
   305  	js.lastLock.Lock()
   306  	if js.last != nil {
   307  		last = js.last
   308  	}
   309  	js.lastLock.Unlock()
   310  	return
   311  }
   312  
   313  // SetCurrent sets the current invocation, it is useful for tests etc.
   314  func (js *JobScheduler) setCurrent(ji *JobInvocation) {
   315  	js.currentLock.Lock()
   316  	js.current = ji
   317  	js.currentLock.Unlock()
   318  }
   319  
   320  // SetLast sets the last invocation, it is useful for tests etc.
   321  func (js *JobScheduler) setLast(ji *JobInvocation) {
   322  	js.lastLock.Lock()
   323  	js.last = ji
   324  	js.lastLock.Unlock()
   325  }
   326  
   327  func (js *JobScheduler) onRegister(ctx context.Context) error {
   328  	ctx = js.withBaseContext(ctx)
   329  	if js.Lifecycle().OnRegister != nil {
   330  		if err := js.Lifecycle().OnRegister(ctx); err != nil {
   331  			return err
   332  		}
   333  	}
   334  	return nil
   335  }
   336  
   337  func (js *JobScheduler) onRemove(ctx context.Context) error {
   338  	ctx = js.withBaseContext(ctx)
   339  	if js.Lifecycle().OnRemove != nil {
   340  		return js.Lifecycle().OnRemove(ctx)
   341  	}
   342  	return nil
   343  }
   344  
   345  func (js *JobScheduler) assignCurrentToLast() {
   346  	js.lastLock.Lock()
   347  	js.currentLock.Lock()
   348  	js.last = js.current
   349  	js.current = nil
   350  	js.currentLock.Unlock()
   351  	js.lastLock.Unlock()
   352  }
   353  
   354  func (js *JobScheduler) waitCurrentComplete(ctx context.Context) {
   355  	if js.Current().Status != JobInvocationStatusRunning {
   356  		return
   357  	}
   358  
   359  	finished := js.current.Finished
   360  	select {
   361  	case <-ctx.Done():
   362  		js.Current().Cancel()
   363  		return
   364  	case <-finished:
   365  		// tick over the loop to check if the current job is complete
   366  		return
   367  	}
   368  }
   369  
   370  func (js *JobScheduler) safeBackgroundExec(ctx context.Context) <-chan error {
   371  	errors := make(chan error, 1)
   372  	go func() {
   373  		defer func() {
   374  			if r := recover(); r != nil {
   375  				errors <- fmt.Errorf("%v", r)
   376  			}
   377  		}()
   378  		errors <- js.Job.Execute(ctx)
   379  	}()
   380  	return errors
   381  }
   382  
   383  func (js *JobScheduler) withBaseContext(ctx context.Context) context.Context {
   384  	if typed, ok := js.Job.(BackgroundProvider); ok {
   385  		ctx = typed.Background(ctx)
   386  	}
   387  	ctx = WithJobScheduler(ctx, js)
   388  	return ctx
   389  }
   390  
   391  func (js *JobScheduler) withTimeoutOrCancel(ctx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
   392  	if timeout > 0 {
   393  		return context.WithTimeout(ctx, timeout)
   394  	}
   395  	return context.WithCancel(ctx)
   396  }
   397  
   398  func (js *JobScheduler) withInvocationContext(ctx context.Context) (context.Context, *JobInvocation) {
   399  	ji := newJobInvocation(js.Name())
   400  	ji.Parameters = MergeJobParameterValues(js.Config().ParameterValues, GetJobParameterValues(ctx))
   401  	ctx, ji.Cancel = js.withTimeoutOrCancel(ctx, js.Config().TimeoutOrDefault())
   402  	ctx = WithJobInvocation(ctx, ji)
   403  	ctx = WithJobParameterValues(ctx, ji.Parameters)
   404  	return ctx, ji
   405  }
   406  
   407  // job lifecycle hooks
   408  
   409  func (js *JobScheduler) onJobBegin(ctx context.Context) {
   410  	js.currentLock.Lock()
   411  	js.current.Started = time.Now().UTC()
   412  	js.current.Status = JobInvocationStatusRunning
   413  	js.currentLock.Unlock()
   414  
   415  	if lifecycle := js.Lifecycle(); lifecycle.OnBegin != nil {
   416  		lifecycle.OnBegin(ctx)
   417  	}
   418  	if js.jm != nil && len(js.jm.onJobBegin) > 0 {
   419  		jse := JobSchedulerEvent{
   420  			Phase:         "job.begin",
   421  			JobName:       js.Job.Name(),
   422  			JobInvocation: GetJobInvocation(ctx).ID,
   423  			Parameters:    GetJobParameterValues(ctx),
   424  		}
   425  		for _, listener := range js.jm.onJobBegin {
   426  			listener(jse)
   427  		}
   428  	}
   429  }
   430  
   431  func (js *JobScheduler) onJobCompleteCanceled(ctx context.Context) {
   432  	js.currentLock.Lock()
   433  	js.current.Complete = time.Now().UTC()
   434  	js.current.Status = JobInvocationStatusCanceled
   435  	close(js.current.Finished)
   436  	js.currentLock.Unlock()
   437  
   438  	lifecycle := js.Lifecycle()
   439  	if lifecycle.OnCancellation != nil {
   440  		lifecycle.OnCancellation(ctx)
   441  	}
   442  	if lifecycle.OnComplete != nil {
   443  		lifecycle.OnComplete(ctx)
   444  	}
   445  	if js.jm != nil && len(js.jm.onJobComplete) > 0 {
   446  		jse := JobSchedulerEvent{
   447  			Phase:         "job.canceled",
   448  			JobName:       js.Job.Name(),
   449  			JobInvocation: GetJobInvocation(ctx).ID,
   450  			Parameters:    GetJobParameterValues(ctx),
   451  		}
   452  		for _, listener := range js.jm.onJobComplete {
   453  			listener(jse)
   454  		}
   455  	}
   456  }
   457  
   458  func (js *JobScheduler) onJobCompleteSuccess(ctx context.Context) {
   459  	js.currentLock.Lock()
   460  	js.current.Complete = time.Now().UTC()
   461  	js.current.Status = JobInvocationStatusSuccess
   462  	close(js.current.Finished)
   463  	js.currentLock.Unlock()
   464  
   465  	lifecycle := js.Lifecycle()
   466  	if lifecycle.OnSuccess != nil {
   467  		lifecycle.OnSuccess(ctx)
   468  	}
   469  	if last := js.Last(); last != nil && last.Status == JobInvocationStatusErrored {
   470  		if lifecycle.OnFixed != nil {
   471  			lifecycle.OnFixed(ctx)
   472  		}
   473  	}
   474  	if lifecycle.OnComplete != nil {
   475  		lifecycle.OnComplete(ctx)
   476  	}
   477  	if js.jm != nil && len(js.jm.onJobComplete) > 0 {
   478  		jse := JobSchedulerEvent{
   479  			Phase:         "job.complete",
   480  			JobName:       js.Job.Name(),
   481  			JobInvocation: GetJobInvocation(ctx).ID,
   482  			Parameters:    GetJobParameterValues(ctx),
   483  		}
   484  		for _, listener := range js.jm.onJobComplete {
   485  			listener(jse)
   486  		}
   487  	}
   488  }
   489  
   490  func (js *JobScheduler) onJobCompleteError(ctx context.Context, err error) {
   491  	js.currentLock.Lock()
   492  	js.current.Complete = time.Now().UTC()
   493  	js.current.Status = JobInvocationStatusErrored
   494  	js.current.Err = err
   495  	close(js.current.Finished)
   496  	js.currentLock.Unlock()
   497  
   498  	//
   499  	// error
   500  	//
   501  
   502  	// always log the error
   503  	lifecycle := js.Lifecycle()
   504  	if lifecycle.OnError != nil {
   505  		lifecycle.OnError(ctx)
   506  	}
   507  
   508  	//
   509  	// broken; assumes that last is set, and last was a success
   510  	//
   511  
   512  	if last := js.Last(); last != nil && last.Status != JobInvocationStatusErrored {
   513  		if lifecycle.OnBroken != nil {
   514  			lifecycle.OnBroken(ctx)
   515  		}
   516  	}
   517  	if lifecycle.OnComplete != nil {
   518  		lifecycle.OnComplete(ctx)
   519  	}
   520  	if js.jm != nil && len(js.jm.onJobComplete) > 0 {
   521  		jse := JobSchedulerEvent{
   522  			Phase:         "job.error",
   523  			JobName:       js.Job.Name(),
   524  			JobInvocation: GetJobInvocation(ctx).ID,
   525  			Parameters:    GetJobParameterValues(ctx),
   526  			Err:           err,
   527  		}
   528  		for _, listener := range js.jm.onJobComplete {
   529  			listener(jse)
   530  		}
   531  	}
   532  }