Browse Source

Add seconds, N/step handling, named months/dows, checking activation at a time, a bunch of cron spec tests.

Rob Figueiredo 13 years ago
parent
commit
f16e0d5188
4 changed files with 271 additions and 59 deletions
  1. 81 0
      cron.go
  2. 73 0
      cron_test.go
  3. 106 48
      schedule.go
  4. 11 11
      schedule_test.go

+ 81 - 0
cron.go

@@ -1,5 +1,86 @@
+// This library implements a cron spec parser and runner.  See the README for
+// more details.
 package cron
 
+import (
+	"time"
+)
+
+// Cron keeps track of any number of entries, invoking the associated func as
+// specified by the spec.  See http://en.wikipedia.org/wiki/Cron
+// It may be started and stopped.
 type Cron struct {
 	Entries []*Entry
+	stop    chan struct{}
+}
+
+// A cron entry consists of a schedule and the func to execute on that schedule.
+type Entry struct {
+	*Schedule
+	Func func()
+}
+
+func New() *Cron {
+	return new(Cron)
+}
+
+func (c *Cron) Add(spec string, cmd func()) {
+	c.Entries = append(c.Entries, &Entry{Parse(spec), cmd})
+}
+
+func (c *Cron) Run() {
+	ticker := time.Tick(1 * time.Minute)
+	for {
+		select {
+		case now := <-ticker:
+			for _, entry := range c.Entries {
+				if matches(now, entry.Schedule) {
+					go entry.Func()
+				}
+			}
+
+		case <-c.stop:
+			return
+		}
+	}
 }
+
+func (c Cron) Stop() {
+	c.stop <- struct{}{}
+}
+
+// Return true if the given entries overlap.
+func matches(t time.Time, sched *Schedule) bool {
+	var (
+		domMatch bool = 1<<uint(t.Day())&sched.Dom > 0
+		dowMatch bool = 1<<uint(t.Weekday())&sched.Dow > 0
+		dayMatch bool
+	)
+
+	if sched.Dom&STAR_BIT > 0 || sched.Dow&STAR_BIT > 0 {
+		dayMatch = domMatch && dowMatch
+	} else {
+		dayMatch = domMatch || dowMatch
+	}
+
+	return 1<<uint(t.Minute())&sched.Minute > 0 &&
+		1<<uint(t.Hour())&sched.Hour > 0 &&
+		1<<uint(t.Month())&sched.Month > 0 &&
+		dayMatch
+}
+
+// // Return the number of units betwee now and then.
+// func difference(then, now uint64, r bounds) uint {
+// 	// Shift the current time fields left (and around) until & is non-zero.
+// 	i := 0
+// 	for then & now << ((i - r.min) % (r.max - r.min + 1) + r.min) == 0 {
+// 		// A guard against no units selected.
+// 		if i > r.max {
+// 			panic("Entry had no minute/hour selected.")
+// 		}
+
+// 		i++
+// 	}
+
+// 	return i
+// }

+ 73 - 0
cron_test.go

@@ -0,0 +1,73 @@
+package cron
+
+import (
+	"testing"
+	"time"
+)
+
+func TestActivation(t *testing.T) {
+	tests := []struct {
+		time, spec string
+		expected   bool
+	}{
+		// Every fifteen minutes.
+		{"Mon Jul 9 15:00 2012", "0 0/15 * * *", true},
+		{"Mon Jul 9 15:45 2012", "0 0/15 * * *", true},
+		{"Mon Jul 9 15:40 2012", "0 0/15 * * *", false},
+
+		// Every fifteen minutes, starting at 5 minutes.
+		{"Mon Jul 9 15:05 2012", "0 5/15 * * *", true},
+		{"Mon Jul 9 15:20 2012", "0 5/15 * * *", true},
+		{"Mon Jul 9 15:50 2012", "0 5/15 * * *", true},
+
+		// Named months
+		{"Sun Jul 15 15:00 2012", "0 0/15 * * Jul", true},
+		{"Sun Jul 15 15:00 2012", "0 0/15 * * Jun", false},
+
+		// Everything set.
+		{"Sun Jul 15 08:30 2012", "0 30 08 ? Jul Sun", true},
+		{"Sun Jul 15 08:30 2012", "0 30 08 15 Jul ?", true},
+		{"Mon Jul 16 08:30 2012", "0 30 08 ? Jul Sun", false},
+		{"Mon Jul 16 08:30 2012", "0 30 08 15 Jul ?", false},
+
+		// Predefined schedules
+		{"Mon Jul 9 15:00 2012", "@hourly", true},
+		{"Mon Jul 9 15:04 2012", "@hourly", false},
+		{"Mon Jul 9 15:00 2012", "@daily", false},
+		{"Mon Jul 9 00:00 2012", "@daily", true},
+		{"Mon Jul 9 00:00 2012", "@weekly", false},
+		{"Sun Jul 8 00:00 2012", "@weekly", true},
+		{"Sun Jul 8 01:00 2012", "@weekly", false},
+		{"Sun Jul 8 00:00 2012", "@monthly", false},
+		{"Sun Jul 1 00:00 2012", "@monthly", true},
+
+		// Test interaction of DOW and DOM.
+		// If both are specified, then only one needs to match.
+		{"Sun Jul 15 00:00 2012", "0 * * 1,15 * Sun", true},
+		{"Fri Jun 15 00:00 2012", "0 * * 1,15 * Sun", true},
+		{"Wed Aug 1 00:00 2012", "0 * * 1,15 * Sun", true},
+
+		// However, if one has a star, then both need to match.
+		{"Sun Jul 15 00:00 2012", "0 * * * * Mon", false},
+		{"Sun Jul 15 00:00 2012", "0 * * */10 * Sun", false},
+		{"Mon Jul 9 00:00 2012", "0 * * 1,15 * *", false},
+		{"Sun Jul 15 00:00 2012", "0 * * 1,15 * *", true},
+	}
+
+	for _, test := range tests {
+		actual := matches(getTime(test.time), Parse(test.spec))
+		if test.expected != actual {
+			t.Logf("Actual Minutes mask: %b", Parse(test.spec).Minute)
+			t.Errorf("Fail evaluating %s on %s: (expected) %t != %t (actual)",
+				test.spec, test.time, test.expected, actual)
+		}
+	}
+}
+
+func getTime(value string) time.Time {
+	t, err := time.Parse("Mon Jan 2 15:04 2006", value)
+	if err != nil {
+		panic(err)
+	}
+	return t
+}

+ 106 - 48
schedule.go

@@ -7,59 +7,93 @@ import (
 	"strings"
 )
 
-type Entry struct {
-	Minute, Hour, Dom, Month, Dow uint64
-	Func                          func()
+// A cron schedule that specifies a duty cycle (to the second granularity).
+// Schedules are computed initially and stored as bit sets.
+type Schedule struct {
+	Second, Minute, Hour, Dom, Month, Dow uint64
 }
 
-type Range struct{ min, max uint }
+// A range of acceptable values.
+type bounds struct {
+	min, max uint
+	names    map[string]uint
+}
 
+// The bounds for each field.
 var (
-	minutes = Range{0, 59}
-	hours   = Range{0, 23}
-	dom     = Range{1, 31}
-	months  = Range{1, 12}
-	dow     = Range{0, 7}
+	seconds = bounds{0, 59, nil}
+	minutes = bounds{0, 59, nil}
+	hours   = bounds{0, 23, nil}
+	dom     = bounds{1, 31, nil}
+	months  = bounds{1, 12, map[string]uint{
+		"jan": 1,
+		"feb": 2,
+		"mar": 3,
+		"apr": 4,
+		"may": 5,
+		"jun": 6,
+		"jul": 7,
+		"aug": 8,
+		"sep": 9,
+		"oct": 10,
+		"nov": 11,
+		"dec": 12,
+	}}
+	dow = bounds{0, 7, map[string]uint{
+		"sun": 0,
+		"mon": 1,
+		"tue": 2,
+		"wed": 3,
+		"thu": 4,
+		"fri": 5,
+		"sat": 6,
+	}}
+)
+
+const (
+	// Set the top bit if a star was included in the expression.
+	STAR_BIT = 1 << 63
 )
 
-// Returns a new crontab entry representing the given spec.
+// Returns a new crontab schedule representing the given spec.
 // Panics with a descriptive error if the spec is not valid.
-func NewEntry(spec string, cmd func()) *Entry {
+func Parse(spec string) *Schedule {
 	if spec[0] == '@' {
-		entry := parseDescriptor(spec)
-		entry.Func = cmd
-		return entry
+		return parseDescriptor(spec)
 	}
 
 	// Split on whitespace.  We require 4 or 5 fields.
 	// (minute) (hour) (day of month) (month) (day of week, optional)
 	fields := strings.Fields(spec)
-	if len(fields) != 4 && len(fields) != 5 {
-		log.Panicf("Expected 4 or 5 fields, found %d: %s", len(fields), spec)
+	if len(fields) != 5 && len(fields) != 6 {
+		log.Panicf("Expected 5 or 6 fields, found %d: %s", len(fields), spec)
 	}
 
-	entry := &Entry{
-		Minute: getField(fields[0], minutes),
-		Hour:   getField(fields[1], hours),
-		Dom:    getField(fields[2], dom),
-		Month:  getField(fields[3], months),
-		Func:   cmd,
-	}
+	// If a fifth field is not provided (DayOfWeek), then it is equivalent to star.
 	if len(fields) == 5 {
-		entry.Dow = getField(fields[4], dow)
+		fields = append(fields, "*")
+	}
 
-		// If either bit 0 or 7 are set, set both.  (both accepted as Sunday)
-		if entry.Dow&1|entry.Dow&1<<7 > 0 {
-			entry.Dow = entry.Dow | 1 | 1<<7
-		}
+	schedule := &Schedule{
+		Second: getField(fields[0], seconds),
+		Minute: getField(fields[1], minutes),
+		Hour:   getField(fields[2], hours),
+		Dom:    getField(fields[3], dom),
+		Month:  getField(fields[4], months),
+		Dow:    getField(fields[5], dow),
+	}
+
+	// If either bit 0 or 7 are set, set both.  (both accepted as Sunday)
+	if 1&schedule.Dow|1<<7&schedule.Dow > 0 {
+		schedule.Dow = schedule.Dow | 1 | 1<<7
 	}
 
-	return entry
+	return schedule
 }
 
 // Return an Int with the bits set representing all of the times that the field represents.
 // A "field" is a comma-separated list of "ranges".
-func getField(field string, r Range) uint64 {
+func getField(field string, r bounds) uint64 {
 	// list = range {"," range}
 	var bits uint64
 	ranges := strings.FieldsFunc(field, func(r rune) bool { return r == ',' })
@@ -69,24 +103,29 @@ func getField(field string, r Range) uint64 {
 	return bits
 }
 
-func getRange(expr string, r Range) uint64 {
+func getRange(expr string, r bounds) uint64 {
 	// number | number "-" number [ "/" number ]
-	var start, end, step uint
-	rangeAndStep := strings.Split(expr, "/")
-	lowAndHigh := strings.Split(rangeAndStep[0], "-")
+	var (
+		start, end, step uint
+		rangeAndStep     = strings.Split(expr, "/")
+		lowAndHigh       = strings.Split(rangeAndStep[0], "-")
+		singleDigit      = len(lowAndHigh) == 1
+	)
 
-	if lowAndHigh[0] == "*" {
+	var extra_star uint64
+	if lowAndHigh[0] == "*" || lowAndHigh[0] == "?" {
 		start = r.min
 		end = r.max
+		extra_star = STAR_BIT
 	} else {
-		start = mustParseInt(lowAndHigh[0])
+		start = parseIntOrName(lowAndHigh[0], r.names)
 		switch len(lowAndHigh) {
 		case 1:
 			end = start
 		case 2:
-			end = mustParseInt(lowAndHigh[1])
+			end = parseIntOrName(lowAndHigh[1], r.names)
 		default:
-			log.Panicf("Too many commas: %s", expr)
+			log.Panicf("Too many hyphens: %s", expr)
 		}
 	}
 
@@ -95,6 +134,11 @@ func getRange(expr string, r Range) uint64 {
 		step = 1
 	case 2:
 		step = mustParseInt(rangeAndStep[1])
+
+		// Special handling: "N/step" means "N-max/step".
+		if singleDigit {
+			end = r.max
+		}
 	default:
 		log.Panicf("Too many slashes: %s", expr)
 	}
@@ -109,7 +153,16 @@ func getRange(expr string, r Range) uint64 {
 		log.Panicf("Beginning of range (%d) beyond end of range (%d): %s", start, end, expr)
 	}
 
-	return getBits(start, end, step)
+	return getBits(start, end, step) | extra_star
+}
+
+func parseIntOrName(expr string, names map[string]uint) uint {
+	if names != nil {
+		if namedInt, ok := names[strings.ToLower(expr)]; ok {
+			return namedInt
+		}
+	}
+	return mustParseInt(expr)
 }
 
 func mustParseInt(expr string) uint {
@@ -139,18 +192,19 @@ func getBits(min, max, step uint) uint64 {
 	return bits
 }
 
-func all(r Range) uint64 {
-	return getBits(r.min, r.max, 1)
+func all(r bounds) uint64 {
+	return getBits(r.min, r.max, 1) | STAR_BIT
 }
 
-func first(r Range) uint64 {
+func first(r bounds) uint64 {
 	return getBits(r.min, r.min, 1)
 }
 
-func parseDescriptor(spec string) *Entry {
+func parseDescriptor(spec string) *Schedule {
 	switch spec {
 	case "@yearly", "@annually":
-		return &Entry{
+		return &Schedule{
+			Second: 1 << seconds.min,
 			Minute: 1 << minutes.min,
 			Hour:   1 << hours.min,
 			Dom:    1 << dom.min,
@@ -159,7 +213,8 @@ func parseDescriptor(spec string) *Entry {
 		}
 
 	case "@monthly":
-		return &Entry{
+		return &Schedule{
+			Second: 1 << seconds.min,
 			Minute: 1 << minutes.min,
 			Hour:   1 << hours.min,
 			Dom:    1 << dom.min,
@@ -168,7 +223,8 @@ func parseDescriptor(spec string) *Entry {
 		}
 
 	case "@weekly":
-		return &Entry{
+		return &Schedule{
+			Second: 1 << seconds.min,
 			Minute: 1 << minutes.min,
 			Hour:   1 << hours.min,
 			Dom:    all(dom),
@@ -177,7 +233,8 @@ func parseDescriptor(spec string) *Entry {
 		}
 
 	case "@daily", "@midnight":
-		return &Entry{
+		return &Schedule{
+			Second: 1 << seconds.min,
 			Minute: 1 << minutes.min,
 			Hour:   1 << hours.min,
 			Dom:    all(dom),
@@ -186,7 +243,8 @@ func parseDescriptor(spec string) *Entry {
 		}
 
 	case "@hourly":
-		return &Entry{
+		return &Schedule{
+			Second: 1 << seconds.min,
 			Minute: 1 << minutes.min,
 			Hour:   all(hours),
 			Dom:    all(dom),

+ 11 - 11
schedule_test.go

@@ -23,12 +23,12 @@ func TestRange(t *testing.T) {
 		{"5-7/2", 0, 7, 1<<5 | 1<<7},
 		{"5-7/1", 0, 7, 1<<5 | 1<<6 | 1<<7},
 
-		{"*", 1, 3, 1<<1 | 1<<2 | 1<<3},
-		{"*/2", 1, 3, 1<<1 | 1<<3},
+		{"*", 1, 3, 1<<1 | 1<<2 | 1<<3 | STAR_BIT},
+		{"*/2", 1, 3, 1<<1 | 1<<3 | STAR_BIT},
 	}
 
 	for _, c := range ranges {
-		actual := getRange(c.expr, Range{c.min, c.max})
+		actual := getRange(c.expr, bounds{c.min, c.max, nil})
 		if actual != c.expected {
 			t.Errorf("%s => (expected) %d != %d (actual)", c.expr, c.expected, actual)
 		}
@@ -48,7 +48,7 @@ func TestField(t *testing.T) {
 	}
 
 	for _, c := range fields {
-		actual := getField(c.expr, Range{c.min, c.max})
+		actual := getField(c.expr, bounds{c.min, c.max, nil})
 		if actual != c.expected {
 			t.Errorf("%s => (expected) %d != %d (actual)", c.expr, c.expected, actual)
 		}
@@ -57,7 +57,7 @@ func TestField(t *testing.T) {
 
 func TestBits(t *testing.T) {
 	allBits := []struct {
-		r        Range
+		r        bounds
 		expected uint64
 	}{
 		{minutes, 0xfffffffffffffff}, // 0-59: 60 ones
@@ -68,8 +68,8 @@ func TestBits(t *testing.T) {
 	}
 
 	for _, c := range allBits {
-		actual := all(c.r)
-		if c.expected != actual {
+		actual := all(c.r) // all() adds the STAR_BIT, so compensate for that..
+		if c.expected|STAR_BIT != actual {
 			t.Errorf("%d-%d/%d => (expected) %b != %b (actual)",
 				c.r.min, c.r.max, 1, c.expected, actual)
 		}
@@ -95,16 +95,16 @@ func TestBits(t *testing.T) {
 	}
 }
 
-func TestEntry(t *testing.T) {
+func TestSchedule(t *testing.T) {
 	entries := []struct {
 		expr     string
-		expected Entry
+		expected Schedule
 	}{
-		{"5 * * * *", Entry{1 << 5, all(hours), all(dom), all(months), all(dow), nil}},
+		{"* 5 * * * *", Schedule{all(seconds), 1 << 5, all(hours), all(dom), all(months), all(dow)}},
 	}
 
 	for _, c := range entries {
-		actual := *NewEntry(c.expr, nil)
+		actual := *Parse(c.expr)
 		if !reflect.DeepEqual(actual, c.expected) {
 			t.Errorf("%s => (expected) %b != %b (actual)", c.expr, c.expected, actual)
 		}