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 }