343 lines
7.6 KiB
Go
343 lines
7.6 KiB
Go
package crontab
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"reflect"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Crontab struct representing cron table
|
|
type Crontab struct {
|
|
ticker *time.Ticker
|
|
jobs []*job
|
|
sync.RWMutex
|
|
}
|
|
|
|
// job in cron table
|
|
type job struct {
|
|
min map[int]struct{}
|
|
hour map[int]struct{}
|
|
day map[int]struct{}
|
|
month map[int]struct{}
|
|
dayOfWeek map[int]struct{}
|
|
|
|
fn interface{}
|
|
args []interface{}
|
|
sync.RWMutex
|
|
}
|
|
|
|
// tick is individual tick that occures each minute
|
|
type tick struct {
|
|
min int
|
|
hour int
|
|
day int
|
|
month int
|
|
dayOfWeek int
|
|
}
|
|
|
|
// New initializes and returns new cron table
|
|
func New() *Crontab {
|
|
return new(time.Minute)
|
|
}
|
|
|
|
// new creates new crontab, arg provided for testing purpose
|
|
func new(t time.Duration) *Crontab {
|
|
c := &Crontab{
|
|
ticker: time.NewTicker(t),
|
|
jobs: []*job{},
|
|
}
|
|
|
|
go func() {
|
|
for t := range c.ticker.C {
|
|
c.runScheduled(t)
|
|
}
|
|
}()
|
|
|
|
return c
|
|
}
|
|
|
|
// AddJob to cron table
|
|
//
|
|
// Returns error if:
|
|
//
|
|
// * Cron syntax can't be parsed or out of bounds
|
|
//
|
|
// * fn is not function
|
|
//
|
|
// * Provided args don't match the number and/or the type of fn args
|
|
func (c *Crontab) AddJob(schedule string, fn interface{}, args ...interface{}) error {
|
|
j, err := parseSchedule(schedule)
|
|
c.Lock()
|
|
defer c.Unlock()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if fn == nil || reflect.ValueOf(fn).Kind() != reflect.Func {
|
|
return fmt.Errorf("Cron job must be func()")
|
|
}
|
|
|
|
fnType := reflect.TypeOf(fn)
|
|
if len(args) != fnType.NumIn() {
|
|
return fmt.Errorf("Number of func() params and number of provided params doesn't match")
|
|
}
|
|
|
|
for i := 0; i < fnType.NumIn(); i++ {
|
|
a := args[i]
|
|
t1 := fnType.In(i)
|
|
t2 := reflect.TypeOf(a)
|
|
|
|
if t1 != t2 {
|
|
if t1.Kind() != reflect.Interface {
|
|
return fmt.Errorf("Param with index %d shold be `%s` not `%s`", i, t1, t2)
|
|
}
|
|
if !t2.Implements(t1) {
|
|
return fmt.Errorf("Param with index %d of type `%s` doesn't implement interface `%s`", i, t2, t1)
|
|
}
|
|
}
|
|
}
|
|
|
|
// all checked, add job to cron tab
|
|
j.fn = fn
|
|
j.args = args
|
|
c.jobs = append(c.jobs, j)
|
|
return nil
|
|
}
|
|
|
|
// MustAddJob is like AddJob but panics if there is an problem with job
|
|
//
|
|
// It simplifies initialization, since we usually add jobs at the beggining so you won't have to check for errors (it will panic when program starts).
|
|
// It is a similar aproach as go's std lib package `regexp` and `regexp.Compile()` `regexp.MustCompile()`
|
|
// MustAddJob will panic if:
|
|
//
|
|
// * Cron syntax can't be parsed or out of bounds
|
|
//
|
|
// * fn is not function
|
|
//
|
|
// * Provided args don't match the number and/or the type of fn args
|
|
func (c *Crontab) MustAddJob(schedule string, fn interface{}, args ...interface{}) {
|
|
if err := c.AddJob(schedule, fn, args...); err != nil {
|
|
panic(err)
|
|
}
|
|
}
|
|
|
|
// Shutdown the cron table schedule
|
|
//
|
|
// Once stopped, it can't be restarted.
|
|
// This function is pre-shuttdown helper for your app, there is no Start/Stop functionallity with crontab package.
|
|
func (c *Crontab) Shutdown() {
|
|
c.ticker.Stop()
|
|
}
|
|
|
|
// Clear all jobs from cron table
|
|
func (c *Crontab) Clear() {
|
|
c.Lock()
|
|
c.jobs = []*job{}
|
|
c.Unlock()
|
|
}
|
|
|
|
// RunAll jobs in cron table, shcheduled or not
|
|
func (c *Crontab) RunAll() {
|
|
c.RLock()
|
|
defer c.RUnlock()
|
|
for _, j := range c.jobs {
|
|
go j.run()
|
|
}
|
|
}
|
|
|
|
// RunScheduled jobs
|
|
func (c *Crontab) runScheduled(t time.Time) {
|
|
tick := getTick(t)
|
|
c.RLock()
|
|
defer c.RUnlock()
|
|
|
|
for _, j := range c.jobs {
|
|
if j.tick(tick) {
|
|
go j.run()
|
|
}
|
|
}
|
|
}
|
|
|
|
// run the job using reflection
|
|
// Recover from panic although all functions and params are checked by AddJob, but you never know.
|
|
func (j *job) run() {
|
|
j.RLock()
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
log.Println("Crontab error", r)
|
|
}
|
|
}()
|
|
v := reflect.ValueOf(j.fn)
|
|
rargs := make([]reflect.Value, len(j.args))
|
|
for i, a := range j.args {
|
|
rargs[i] = reflect.ValueOf(a)
|
|
}
|
|
j.RUnlock()
|
|
v.Call(rargs)
|
|
}
|
|
|
|
// tick decides should the job be lauhcned at the tick
|
|
func (j *job) tick(t tick) bool {
|
|
j.RLock()
|
|
defer j.RUnlock()
|
|
if _, ok := j.min[t.min]; !ok {
|
|
return false
|
|
}
|
|
|
|
if _, ok := j.hour[t.hour]; !ok {
|
|
return false
|
|
}
|
|
|
|
// cummulative day and dayOfWeek, as it should be
|
|
_, day := j.day[t.day]
|
|
_, dayOfWeek := j.dayOfWeek[t.dayOfWeek]
|
|
if !day && !dayOfWeek {
|
|
return false
|
|
}
|
|
|
|
if _, ok := j.month[t.month]; !ok {
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// regexps for parsing schedule string
|
|
var (
|
|
matchSpaces = regexp.MustCompile(`\s+`)
|
|
matchN = regexp.MustCompile(`(.*)/(\d+)`)
|
|
matchRange = regexp.MustCompile(`^(\d+)-(\d+)$`)
|
|
)
|
|
|
|
// parseSchedule string and creates job struct with filled times to launch, or error if synthax is wrong
|
|
func parseSchedule(s string) (*job, error) {
|
|
var err error
|
|
j := &job{}
|
|
j.Lock()
|
|
defer j.Unlock()
|
|
s = matchSpaces.ReplaceAllLiteralString(s, " ")
|
|
parts := strings.Split(s, " ")
|
|
if len(parts) != 5 {
|
|
return j, errors.New("Schedule string must have five components like * * * * *")
|
|
}
|
|
|
|
j.min, err = parsePart(parts[0], 0, 59)
|
|
if err != nil {
|
|
return j, err
|
|
}
|
|
|
|
j.hour, err = parsePart(parts[1], 0, 23)
|
|
if err != nil {
|
|
return j, err
|
|
}
|
|
|
|
j.day, err = parsePart(parts[2], 1, 31)
|
|
if err != nil {
|
|
return j, err
|
|
}
|
|
|
|
j.month, err = parsePart(parts[3], 1, 12)
|
|
if err != nil {
|
|
return j, err
|
|
}
|
|
|
|
j.dayOfWeek, err = parsePart(parts[4], 0, 6)
|
|
if err != nil {
|
|
return j, err
|
|
}
|
|
|
|
// day/dayOfWeek combination
|
|
switch {
|
|
case len(j.day) < 31 && len(j.dayOfWeek) == 7: // day set, but not dayOfWeek, clear dayOfWeek
|
|
j.dayOfWeek = make(map[int]struct{})
|
|
case len(j.dayOfWeek) < 7 && len(j.day) == 31: // dayOfWeek set, but not day, clear day
|
|
j.day = make(map[int]struct{})
|
|
default:
|
|
// both day and dayOfWeek are * or both are set, use combined
|
|
// i.e. don't do anything here
|
|
}
|
|
|
|
return j, nil
|
|
}
|
|
|
|
// parsePart parse individual schedule part from schedule string
|
|
func parsePart(s string, min, max int) (map[int]struct{}, error) {
|
|
|
|
r := make(map[int]struct{})
|
|
|
|
// wildcard pattern
|
|
if s == "*" {
|
|
for i := min; i <= max; i++ {
|
|
r[i] = struct{}{}
|
|
}
|
|
return r, nil
|
|
}
|
|
|
|
// */2 1-59/5 pattern
|
|
if matches := matchN.FindStringSubmatch(s); matches != nil {
|
|
localMin := min
|
|
localMax := max
|
|
if matches[1] != "" && matches[1] != "*" {
|
|
if rng := matchRange.FindStringSubmatch(matches[1]); rng != nil {
|
|
localMin, _ = strconv.Atoi(rng[1])
|
|
localMax, _ = strconv.Atoi(rng[2])
|
|
if localMin < min || localMax > max {
|
|
return nil, fmt.Errorf("Out of range for %s in %s. %s must be in range %d-%d", rng[1], s, rng[1], min, max)
|
|
}
|
|
} else {
|
|
return nil, fmt.Errorf("Unable to parse %s part in %s", matches[1], s)
|
|
}
|
|
}
|
|
n, _ := strconv.Atoi(matches[2])
|
|
for i := localMin; i <= localMax; i += n {
|
|
r[i] = struct{}{}
|
|
}
|
|
return r, nil
|
|
}
|
|
|
|
// 1,2,4 or 1,2,10-15,20,30-45 pattern
|
|
parts := strings.Split(s, ",")
|
|
for _, x := range parts {
|
|
if rng := matchRange.FindStringSubmatch(x); rng != nil {
|
|
localMin, _ := strconv.Atoi(rng[1])
|
|
localMax, _ := strconv.Atoi(rng[2])
|
|
if localMin < min || localMax > max {
|
|
return nil, fmt.Errorf("Out of range for %s in %s. %s must be in range %d-%d", x, s, x, min, max)
|
|
}
|
|
for i := localMin; i <= localMax; i++ {
|
|
r[i] = struct{}{}
|
|
}
|
|
} else if i, err := strconv.Atoi(x); err == nil {
|
|
if i < min || i > max {
|
|
return nil, fmt.Errorf("Out of range for %d in %s. %d must be in range %d-%d", i, s, i, min, max)
|
|
}
|
|
r[i] = struct{}{}
|
|
} else {
|
|
return nil, fmt.Errorf("Unable to parse %s part in %s", x, s)
|
|
}
|
|
}
|
|
|
|
if len(r) == 0 {
|
|
return nil, fmt.Errorf("Unable to parse %s", s)
|
|
}
|
|
|
|
return r, nil
|
|
}
|
|
|
|
// getTick returns the tick struct from time
|
|
func getTick(t time.Time) tick {
|
|
return tick{
|
|
min: t.Minute(),
|
|
hour: t.Hour(),
|
|
day: t.Day(),
|
|
month: int(t.Month()),
|
|
dayOfWeek: int(t.Weekday()),
|
|
}
|
|
}
|