github.com/mattermost/mattermost-plugin-api@v0.1.4/cluster/job.go (about)

     1  package cluster
     2  
     3  import (
     4  	"encoding/json"
     5  	"sync"
     6  	"time"
     7  
     8  	"github.com/mattermost/mattermost-server/v6/model"
     9  	"github.com/pkg/errors"
    10  )
    11  
    12  const (
    13  	// cronPrefix is used to namespace key values created for a job from other key values
    14  	// created by a plugin.
    15  	cronPrefix = "cron_"
    16  )
    17  
    18  // JobPluginAPI is the plugin API interface required to schedule jobs.
    19  type JobPluginAPI interface {
    20  	MutexPluginAPI
    21  	KVGet(key string) ([]byte, *model.AppError)
    22  	KVDelete(key string) *model.AppError
    23  	KVList(page, count int) ([]string, *model.AppError)
    24  }
    25  
    26  // JobConfig defines the configuration of a scheduled job.
    27  type JobConfig struct {
    28  	// Interval is the period of execution for the job.
    29  	Interval time.Duration
    30  }
    31  
    32  // NextWaitInterval is a callback computing the next wait interval for a job.
    33  type NextWaitInterval func(now time.Time, metadata JobMetadata) time.Duration
    34  
    35  // MakeWaitForInterval creates a function to scheduling a job to run on the given interval relative
    36  // to the last finished timestamp.
    37  //
    38  // For example, if the job first starts at 12:01 PM, and is configured with interval 5 minutes,
    39  // it will next run at:
    40  //
    41  //	12:06, 12:11, 12:16, ...
    42  //
    43  // If the job has not previously started, it will run immediately.
    44  func MakeWaitForInterval(interval time.Duration) NextWaitInterval {
    45  	if interval == 0 {
    46  		panic("must specify non-zero ready interval")
    47  	}
    48  
    49  	return func(now time.Time, metadata JobMetadata) time.Duration {
    50  		sinceLastFinished := now.Sub(metadata.LastFinished)
    51  		if sinceLastFinished < interval {
    52  			return interval - sinceLastFinished
    53  		}
    54  
    55  		return 0
    56  	}
    57  }
    58  
    59  // MakeWaitForRoundedInterval creates a function, scheduling a job to run on the nearest rounded
    60  // interval relative to the last finished timestamp.
    61  //
    62  // For example, if the job first starts at 12:04 PM, and is configured with interval 5 minutes,
    63  // and is configured to round to 5 minute intervals, it will next run at:
    64  //
    65  //	12:05 PM, 12:10 PM, 12:15 PM, ...
    66  //
    67  // If the job has not previously started, it will run immediately. Note that this wait interval
    68  // strategy does not guarantee a minimum interval between runs, only that subsequent runs will be
    69  // scheduled on the rounded interval.
    70  func MakeWaitForRoundedInterval(interval time.Duration) NextWaitInterval {
    71  	if interval == 0 {
    72  		panic("must specify non-zero ready interval")
    73  	}
    74  
    75  	return func(now time.Time, metadata JobMetadata) time.Duration {
    76  		if metadata.LastFinished.IsZero() {
    77  			return 0
    78  		}
    79  
    80  		target := metadata.LastFinished.Add(interval).Truncate(interval)
    81  		untilTarget := target.Sub(now)
    82  		if untilTarget > 0 {
    83  			return untilTarget
    84  		}
    85  
    86  		return 0
    87  	}
    88  }
    89  
    90  // Job is a scheduled job whose callback function is executed on a configured interval by at most
    91  // one plugin instance at a time.
    92  //
    93  // Use scheduled jobs to perform background activity on a regular interval without having to
    94  // explicitly coordinate with other instances of the same plugin that might repeat that effort.
    95  type Job struct {
    96  	pluginAPI        JobPluginAPI
    97  	key              string
    98  	mutex            *Mutex
    99  	nextWaitInterval NextWaitInterval
   100  	callback         func()
   101  
   102  	stopOnce sync.Once
   103  	stop     chan bool
   104  	done     chan bool
   105  }
   106  
   107  // JobMetadata persists metadata about job execution.
   108  type JobMetadata struct {
   109  	// LastFinished is the last time the job finished anywhere in the cluster.
   110  	LastFinished time.Time
   111  }
   112  
   113  // Schedule creates a scheduled job.
   114  func Schedule(pluginAPI JobPluginAPI, key string, nextWaitInterval NextWaitInterval, callback func()) (*Job, error) {
   115  	key = cronPrefix + key
   116  
   117  	mutex, err := NewMutex(pluginAPI, key)
   118  	if err != nil {
   119  		return nil, errors.Wrap(err, "failed to create job mutex")
   120  	}
   121  
   122  	job := &Job{
   123  		pluginAPI:        pluginAPI,
   124  		key:              key,
   125  		mutex:            mutex,
   126  		nextWaitInterval: nextWaitInterval,
   127  		callback:         callback,
   128  		stop:             make(chan bool),
   129  		done:             make(chan bool),
   130  	}
   131  
   132  	go job.run()
   133  
   134  	return job, nil
   135  }
   136  
   137  // readMetadata reads the job execution metadata from the kv store.
   138  func (j *Job) readMetadata() (JobMetadata, error) {
   139  	data, appErr := j.pluginAPI.KVGet(j.key)
   140  	if appErr != nil {
   141  		return JobMetadata{}, errors.Wrap(appErr, "failed to read data")
   142  	}
   143  
   144  	if data == nil {
   145  		return JobMetadata{}, nil
   146  	}
   147  
   148  	var metadata JobMetadata
   149  	err := json.Unmarshal(data, &metadata)
   150  	if err != nil {
   151  		return JobMetadata{}, errors.Wrap(err, "failed to decode data")
   152  	}
   153  
   154  	return metadata, nil
   155  }
   156  
   157  // saveMetadata writes updated job execution metadata from the kv store.
   158  //
   159  // It is assumed that the job mutex is held, negating the need to require an atomic write.
   160  func (j *Job) saveMetadata(metadata JobMetadata) error {
   161  	data, err := json.Marshal(metadata)
   162  	if err != nil {
   163  		return errors.Wrap(err, "failed to marshal data")
   164  	}
   165  
   166  	ok, appErr := j.pluginAPI.KVSetWithOptions(j.key, data, model.PluginKVSetOptions{})
   167  	if appErr != nil || !ok {
   168  		return errors.Wrap(appErr, "failed to set data")
   169  	}
   170  
   171  	return nil
   172  }
   173  
   174  // run attempts to run the scheduled job, guaranteeing only one instance is executing concurrently.
   175  func (j *Job) run() {
   176  	defer close(j.done)
   177  
   178  	var waitInterval time.Duration
   179  
   180  	for {
   181  		select {
   182  		case <-j.stop:
   183  			return
   184  		case <-time.After(waitInterval):
   185  		}
   186  
   187  		func() {
   188  			// Acquire the corresponding job lock and hold it throughout execution.
   189  			j.mutex.Lock()
   190  			defer j.mutex.Unlock()
   191  
   192  			metadata, err := j.readMetadata()
   193  			if err != nil {
   194  				j.pluginAPI.LogError("failed to read job metadata", "err", err, "key", j.key)
   195  				waitInterval = nextWaitInterval(waitInterval, err)
   196  				return
   197  			}
   198  
   199  			// Is it time to run the job?
   200  			waitInterval = j.nextWaitInterval(time.Now(), metadata)
   201  			if waitInterval > 0 {
   202  				return
   203  			}
   204  
   205  			// Run the job
   206  			j.callback()
   207  
   208  			metadata.LastFinished = time.Now()
   209  
   210  			err = j.saveMetadata(metadata)
   211  			if err != nil {
   212  				j.pluginAPI.LogError("failed to write job data", "err", err, "key", j.key)
   213  			}
   214  
   215  			waitInterval = j.nextWaitInterval(time.Now(), metadata)
   216  		}()
   217  	}
   218  }
   219  
   220  // Close terminates a scheduled job, preventing it from being scheduled on this plugin instance.
   221  func (j *Job) Close() error {
   222  	j.stopOnce.Do(func() {
   223  		close(j.stop)
   224  	})
   225  	<-j.done
   226  
   227  	return nil
   228  }