From 4c91fead6ecc480e7977a46700d68cf7211780dc Mon Sep 17 00:00:00 2001 From: Adam Petrovic Date: Fri, 18 Oct 2024 09:13:22 +1100 Subject: [PATCH 1/2] add support for simple jenkins-style 'H' (hash) expressions in cron Also supports steps. e.g. 'H/2'. https://www.jenkins.io/doc/book/pipeline/syntax/#cron-syntax --- parser.go | 82 ++++++++++++++---- parser_test.go | 229 ++++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 264 insertions(+), 47 deletions(-) diff --git a/parser.go b/parser.go index 8da6547a..fd4b9127 100644 --- a/parser.go +++ b/parser.go @@ -2,6 +2,7 @@ package cron import ( "fmt" + "hash/fnv" "math" "strconv" "strings" @@ -56,18 +57,17 @@ type Parser struct { // // Examples // -// // Standard parser without descriptors -// specParser := NewParser(Minute | Hour | Dom | Month | Dow) -// sched, err := specParser.Parse("0 0 15 */3 *") +// // Standard parser without descriptors +// specParser := NewParser(Minute | Hour | Dom | Month | Dow) +// sched, err := specParser.Parse("0 0 15 */3 *") // -// // Same as above, just excludes time fields -// specParser := NewParser(Dom | Month | Dow) -// sched, err := specParser.Parse("15 */3 *") -// -// // Same as above, just makes Dow optional -// specParser := NewParser(Dom | Month | DowOptional) -// sched, err := specParser.Parse("15 */3") +// // Same as above, just excludes time fields +// specParser := NewParser(Dom | Month | Dow) +// sched, err := specParser.Parse("15 */3 *") // +// // Same as above, just makes Dow optional +// specParser := NewParser(Dom | Month | DowOptional) +// sched, err := specParser.Parse("15 */3") func NewParser(options ParseOption) Parser { optionals := 0 if options&DowOptional > 0 { @@ -86,6 +86,10 @@ func NewParser(options ParseOption) Parser { // It returns a descriptive error if the spec is not valid. // It accepts crontab specs and features configured by NewParser. func (p Parser) Parse(spec string) (Schedule, error) { + return p.ParseWithJobName(spec, "") +} + +func (p Parser) ParseWithJobName(spec string, jobName string) (Schedule, error) { if len(spec) == 0 { return nil, fmt.Errorf("empty spec string") } @@ -125,7 +129,7 @@ func (p Parser) Parse(spec string) (Schedule, error) { return 0 } var bits uint64 - bits, err = getField(field, r) + bits, err = getField(field, r, jobName) return bits } @@ -233,11 +237,11 @@ func ParseStandard(standardSpec string) (Schedule, error) { // getField returns an Int with the bits set representing all of the times that // the field represents or error parsing field value. A "field" is a comma-separated // list of "ranges". -func getField(field string, r bounds) (uint64, error) { +func getField(field string, r bounds, jobName string) (uint64, error) { var bits uint64 ranges := strings.FieldsFunc(field, func(r rune) bool { return r == ',' }) for _, expr := range ranges { - bit, err := getRange(expr, r) + bit, err := getRange(expr, r, jobName) if err != nil { return bits, err } @@ -247,9 +251,11 @@ func getField(field string, r bounds) (uint64, error) { } // getRange returns the bits indicated by the given expression: -// number | number "-" number [ "/" number ] +// +// number | number "-" number [ "/" number ] +// // or error parsing range. -func getRange(expr string, r bounds) (uint64, error) { +func getRange(expr string, r bounds, jobName string) (uint64, error) { var ( start, end, step uint rangeAndStep = strings.Split(expr, "/") @@ -259,6 +265,10 @@ func getRange(expr string, r bounds) (uint64, error) { ) var extra uint64 + if lowAndHigh[0] == "H" { + return getHashedValue(expr, r, jobName) + } + if lowAndHigh[0] == "*" || lowAndHigh[0] == "?" { start = r.min end = r.max @@ -317,6 +327,48 @@ func getRange(expr string, r bounds) (uint64, error) { return getBits(start, end, step) | extra, nil } +func getHashedValue(expr string, r bounds, jobName string) (uint64, error) { + var step uint + var err error + + // Parse step if present + parts := strings.Split(expr, "/") + if len(parts) == 1 { + step = 1 + } else if len(parts) == 2 { + step, err = mustParseInt(parts[1]) + if err != nil { + return 0, fmt.Errorf("invalid step value in '%s': %v", expr, err) + } + } else { + return 0, fmt.Errorf("invalid hashed expression: %s", expr) + } + + // Generate a hash value based on the job name (if provided) and field bounds + h := fnv.New64a() + if jobName != "" { + h.Write([]byte(jobName)) + } + h.Write([]byte(fmt.Sprintf("%d%d", r.min, r.max))) + hash := h.Sum64() + + if step == 1 { + // For 'H' case, return a single bit + start := r.min + (uint(hash) % ((r.max - r.min) + 1)) + return 1 << start, nil + } else { + // For 'H/n' case, use the hash to determine the offset + offset := uint(hash % uint64(step)) + + // Generate the bit pattern + var result uint64 + for i := r.min + offset; i <= r.max; i += step { + result |= 1 << i + } + return result, nil + } +} + // parseIntOrName returns the (possibly-named) integer contained in expr. func parseIntOrName(expr string, names map[string]uint) (uint, error) { if names != nil { diff --git a/parser_test.go b/parser_test.go index 41c8c520..04a23e19 100644 --- a/parser_test.go +++ b/parser_test.go @@ -1,6 +1,7 @@ package cron import ( + "fmt" "reflect" "strings" "testing" @@ -16,35 +17,44 @@ func TestRange(t *testing.T) { min, max uint expected uint64 err string + jobName string }{ - {"5", 0, 7, 1 << 5, ""}, - {"0", 0, 7, 1 << 0, ""}, - {"7", 0, 7, 1 << 7, ""}, - - {"5-5", 0, 7, 1 << 5, ""}, - {"5-6", 0, 7, 1<<5 | 1<<6, ""}, - {"5-7", 0, 7, 1<<5 | 1<<6 | 1<<7, ""}, - - {"5-6/2", 0, 7, 1 << 5, ""}, - {"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 | starBit, ""}, - {"*/2", 1, 3, 1<<1 | 1<<3, ""}, - - {"5--5", 0, 0, zero, "too many hyphens"}, - {"jan-x", 0, 0, zero, "failed to parse int from"}, - {"2-x", 1, 5, zero, "failed to parse int from"}, - {"*/-12", 0, 0, zero, "negative number"}, - {"*//2", 0, 0, zero, "too many slashes"}, - {"1", 3, 5, zero, "below minimum"}, - {"6", 3, 5, zero, "above maximum"}, - {"5-3", 3, 5, zero, "beyond end of range"}, - {"*/0", 0, 0, zero, "should be a positive number"}, + {"5", 0, 7, 1 << 5, "", ""}, + {"0", 0, 7, 1 << 0, "", ""}, + {"7", 0, 7, 1 << 7, "", ""}, + + {"5-5", 0, 7, 1 << 5, "", ""}, + {"5-6", 0, 7, 1<<5 | 1<<6, "", ""}, + {"5-7", 0, 7, 1<<5 | 1<<6 | 1<<7, "", ""}, + + {"5-6/2", 0, 7, 1 << 5, "", ""}, + {"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 | starBit, "", ""}, + {"*/2", 1, 3, 1<<1 | 1<<3, "", ""}, + + {"H", 0, 59, 1 << 3, "", "job1"}, + {"H/15", 0, 59, 1<<3 | 1<<18 | 1<<33 | 1<<48, "", "job1"}, + {"H", 0, 23, 1 << 2, "", "job1"}, + {"H", 0, 59, 1 << 28, "", "job2"}, + {"H", 0, 6, 1 << 2, "", "dowJob1"}, + {"H", 1, 7, 1 << 1, "", "dowJob2"}, + {"H/2", 0, 6, 1<<1 | 1<<3 | 1<<5, "", "dowJob3"}, + + {"5--5", 0, 0, zero, "too many hyphens", ""}, + {"jan-x", 0, 0, zero, "failed to parse int from", ""}, + {"2-x", 1, 5, zero, "failed to parse int from", ""}, + {"*/-12", 0, 0, zero, "negative number", ""}, + {"*//2", 0, 0, zero, "too many slashes", ""}, + {"1", 3, 5, zero, "below minimum", ""}, + {"6", 3, 5, zero, "above maximum", ""}, + {"5-3", 3, 5, zero, "beyond end of range", ""}, + {"*/0", 0, 0, zero, "should be a positive number", ""}, } for _, c := range ranges { - actual, err := getRange(c.expr, bounds{c.min, c.max, nil}) + actual, err := getRange(c.expr, bounds{c.min, c.max, nil}, c.jobName) if len(c.err) != 0 && (err == nil || !strings.Contains(err.Error(), c.err)) { t.Errorf("%s => expected %v, got %v", c.expr, c.err, err) } @@ -52,7 +62,8 @@ func TestRange(t *testing.T) { t.Errorf("%s => unexpected error %v", c.expr, err) } if actual != c.expected { - t.Errorf("%s => expected %d, got %d", c.expr, c.expected, actual) + t.Errorf("%s => expected %s, got %s", + c.expr, uint64ToBitShiftRepr(c.expected), uint64ToBitShiftRepr(actual)) } } } @@ -62,21 +73,113 @@ func TestField(t *testing.T) { expr string min, max uint expected uint64 + jobName string }{ - {"5", 1, 7, 1 << 5}, - {"5,6", 1, 7, 1<<5 | 1<<6}, - {"5,6,7", 1, 7, 1<<5 | 1<<6 | 1<<7}, - {"1,5-7/2,3", 1, 7, 1<<1 | 1<<5 | 1<<7 | 1<<3}, + {"5", 1, 7, 1 << 5, ""}, + {"5,6", 1, 7, 1<<5 | 1<<6, ""}, + {"5,6,7", 1, 7, 1<<5 | 1<<6 | 1<<7, ""}, + {"1,5-7/2,3", 1, 7, 1<<1 | 1<<5 | 1<<7 | 1<<3, ""}, + {"H", 0, 59, 1 << 3, "job1"}, + {"H,30", 0, 59, 1<<3 | 1<<30, "job1"}, + {"H/30,40", 0, 59, 1<<3 | 1<<33 | 1<<40, "job1"}, } for _, c := range fields { - actual, _ := getField(c.expr, bounds{c.min, c.max, nil}) + actual, _ := getField(c.expr, bounds{c.min, c.max, nil}, c.jobName) if actual != c.expected { - t.Errorf("%s => expected %d, got %d", c.expr, c.expected, actual) + t.Errorf("%s => expected %s, got %s", c.expr, + uint64ToBitShiftRepr(c.expected), uint64ToBitShiftRepr(actual)) } } } +func TestGetHashedValue(t *testing.T) { + tests := []struct { + name string + expr string + bounds bounds + jobName string + expected uint64 + }{ + { + name: "Simple H", + expr: "H", + bounds: bounds{min: 0, max: 59}, + jobName: "job1", + expected: 1 << 3, + }, + { + name: "Simple H #2", + expr: "H", + bounds: bounds{min: 0, max: 59}, + jobName: "dowJob1", + expected: 1 << 43, + }, + { + name: "Empty Job", + expr: "H/2", + bounds: bounds{min: 0, max: 6}, + jobName: "", + expected: 1<<1 | 1<<3 | 1<<5, + }, + { + name: "Simple H day of week", + expr: "H/2", + bounds: bounds{min: 0, max: 6}, + jobName: "dow2", + expected: 1<<1 | 1<<3 | 1<<5, + }, + { + name: "H with step", + expr: "H/13", + bounds: bounds{min: 0, max: 59}, + jobName: "job2", + expected: 1<<2 | 1<<15 | 1<<28 | 1<<41 | 1<<54, + }, + { + name: "Same job, different bounds", + expr: "H", + bounds: bounds{min: 0, max: 23}, + jobName: "job1", + expected: 1 << 2, + }, + { + name: "Different job, same bounds", + expr: "H", + bounds: bounds{min: 0, max: 59}, + jobName: "job3", + expected: 1 << 9, + }, + } + + for _, c := range tests { + t.Run(c.name, func(t *testing.T) { + actual, err := getHashedValue(c.expr, c.bounds, c.jobName) + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if actual != c.expected { + t.Errorf("%s => expected %s (%b), got %s (%b)", c.expr, uint64ToBitShiftRepr(c.expected), c.expected, uint64ToBitShiftRepr(actual), actual) + } + }) + } +} + +func uint64ToBitShiftRepr(value uint64) string { + var shifts []string + for i := uint(0); i < 64; i++ { + if value&(uint64(1)< unexpected error %v", c.expr, err) + } + if !reflect.DeepEqual(actual, c.expected) { + t.Errorf("%s => expected %b, got %b", c.expr, c.expected, actual) + } + } +} + func TestOptionalSecondSchedule(t *testing.T) { parser := NewParser(SecondOptional | Minute | Hour | Dom | Month | Dow | Descriptor) entries := []struct { From 8a9b049fa22cacd226401bae65b8b9ddaf1609d5 Mon Sep 17 00:00:00 2001 From: Adam Petrovic Date: Wed, 23 Oct 2024 06:53:55 +1100 Subject: [PATCH 2/2] Added support for ranges in Jenkins-style hash-based expressions --- parser.go | 109 +++++++++++++++++++++++++++++++++++++------------ parser_test.go | 109 ++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 190 insertions(+), 28 deletions(-) diff --git a/parser.go b/parser.go index fd4b9127..2bb79e20 100644 --- a/parser.go +++ b/parser.go @@ -50,6 +50,10 @@ type Parser struct { options ParseOption } +type hashSpec struct { + min, max, step uint +} + // NewParser creates a Parser with custom options. // // It panics if more than one Optional is given, since it would be impossible to @@ -327,24 +331,81 @@ func getRange(expr string, r bounds, jobName string) (uint64, error) { return getBits(start, end, step) | extra, nil } -func getHashedValue(expr string, r bounds, jobName string) (uint64, error) { - var step uint - var err error +// parseHashExpression parses a hashed cron expression and returns a hashSpec containing +// the parsed values. The expression can be in the following forms: +// - "H": Uses the provided bounds as min/max with step of 1 +// - "H/n": Uses the provided bounds as min/max with step of n +// - "H(min-max)": Uses the specified range with step of 1 +// - "H(min-max)/n": Uses the specified range with step of n +// +// Parameters: +// - expr: The hash expression to parse (e.g., "H", "H/2", "H(1-10)", "H(1-10)/2") +// - r: The bounds object containing the minimum and maximum allowed values +// +// Returns: +// - hashSpec: Contains the parsed min, max, and step values +// - error: If the expression is invalid or values are out of bounds +func parseHashExpression(expr string, r bounds) (hashSpec, error) { + // set default + spec := hashSpec{min: r.min, max: r.max, step: 1} + + // Parse range if present + if rangeStart := strings.Index(expr, "("); rangeStart != -1 { + rangeEnd := strings.Index(expr, ")") + if rangeEnd == -1 { + return spec, fmt.Errorf("missing closing parenthesis") + } + + rangeParts := strings.Split(expr[rangeStart+1:rangeEnd], "-") + if len(rangeParts) != 2 { + return spec, fmt.Errorf("invalid range format") + } + + var err error + if spec.min, err = mustParseInt(rangeParts[0]); err != nil { + return spec, err + } + if spec.max, err = mustParseInt(rangeParts[1]); err != nil { + return spec, err + } + + if spec.min > spec.max || spec.min < r.min || spec.max > r.max { + return spec, fmt.Errorf("invalid range") + } + + expr = expr[:rangeStart] + expr[rangeEnd+1:] + } // Parse step if present - parts := strings.Split(expr, "/") - if len(parts) == 1 { - step = 1 - } else if len(parts) == 2 { - step, err = mustParseInt(parts[1]) - if err != nil { - return 0, fmt.Errorf("invalid step value in '%s': %v", expr, err) + if strings.Contains(expr, "/") { + parts := strings.Split(expr, "/") + if len(parts) != 2 { + return spec, fmt.Errorf("invalid step format") + } + + var err error + if spec.step, err = mustParseInt(parts[1]); err != nil { + return spec, err } - } else { - return 0, fmt.Errorf("invalid hashed expression: %s", expr) } - // Generate a hash value based on the job name (if provided) and field bounds + return spec, nil +} + +// getHashedValue generates a bit pattern based on a hashed cron expression. +// It supports various formats of hashed expressions and generates consistent +// but pseudo-random values based on the provided job name. +// +// Parameters: +// - expr: The hash expression to evaluate (e.g., "H", "H/2", "H(1-10)", "H(1-10)/2") +// - r: The bounds object containing the minimum and maximum allowed values +// - jobName: A string identifier used to generate consistent hash values +func getHashedValue(expr string, r bounds, jobName string) (uint64, error) { + spec, err := parseHashExpression(expr, r) + if err != nil { + return 0, err + } + h := fnv.New64a() if jobName != "" { h.Write([]byte(jobName)) @@ -352,21 +413,17 @@ func getHashedValue(expr string, r bounds, jobName string) (uint64, error) { h.Write([]byte(fmt.Sprintf("%d%d", r.min, r.max))) hash := h.Sum64() - if step == 1 { - // For 'H' case, return a single bit - start := r.min + (uint(hash) % ((r.max - r.min) + 1)) - return 1 << start, nil - } else { - // For 'H/n' case, use the hash to determine the offset - offset := uint(hash % uint64(step)) + if spec.step == 1 { + value := spec.min + (uint(hash) % (spec.max - spec.min + 1)) + return 1 << value, nil + } - // Generate the bit pattern - var result uint64 - for i := r.min + offset; i <= r.max; i += step { - result |= 1 << i - } - return result, nil + offset := uint(hash % uint64(spec.step)) + var result uint64 + for i := spec.min + offset; i <= spec.max; i += spec.step { + result |= 1 << i } + return result, nil } // parseIntOrName returns the (possibly-named) integer contained in expr. diff --git a/parser_test.go b/parser_test.go index 04a23e19..7671f50d 100644 --- a/parser_test.go +++ b/parser_test.go @@ -93,6 +93,97 @@ func TestField(t *testing.T) { } } +func TestParseHashExpression(t *testing.T) { + tests := []struct { + name string + expr string + bounds bounds + want hashSpec + wantError bool + }{ + { + name: "Simple H", + expr: "H", + bounds: bounds{min: 0, max: 59}, + want: hashSpec{min: 0, max: 59, step: 1}, + }, + { + name: "H with step", + expr: "H/15", + bounds: bounds{min: 0, max: 59}, + want: hashSpec{min: 0, max: 59, step: 15}, + }, + { + name: "H with range", + expr: "H(0-30)", + bounds: bounds{min: 0, max: 59}, + want: hashSpec{min: 0, max: 30, step: 1}, + }, + { + name: "H with range and step", + expr: "H(0-30)/15", + bounds: bounds{min: 0, max: 59}, + want: hashSpec{min: 0, max: 30, step: 15}, + }, + { + name: "H with day of week range", + expr: "H(1-5)", + bounds: bounds{min: 0, max: 6}, + want: hashSpec{min: 1, max: 5, step: 1}, + }, + // Error cases + { + name: "Invalid range format", + expr: "H(1,5)", + bounds: bounds{min: 0, max: 59}, + wantError: true, + }, + { + name: "Range min greater than max", + expr: "H(30-0)", + bounds: bounds{min: 0, max: 59}, + wantError: true, + }, + { + name: "Range exceeds bounds", + expr: "H(0-60)", + bounds: bounds{min: 0, max: 59}, + wantError: true, + }, + { + name: "Invalid step", + expr: "H/", + bounds: bounds{min: 0, max: 59}, + wantError: true, + }, + { + name: "Missing closing parenthesis", + expr: "H(0-30", + bounds: bounds{min: 0, max: 59}, + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseHashExpression(tt.expr, tt.bounds) + if tt.wantError { + if err == nil { + t.Errorf("parseHashExpression() expected error for expr: %s", tt.expr) + } + return + } + if err != nil { + t.Errorf("parseHashExpression() unexpected error: %v", err) + return + } + if got != tt.want { + t.Errorf("parseHashExpression() = %+v, want %+v", got, tt.want) + } + }) + } +} + func TestGetHashedValue(t *testing.T) { tests := []struct { name string @@ -115,6 +206,13 @@ func TestGetHashedValue(t *testing.T) { jobName: "dowJob1", expected: 1 << 43, }, + { + name: "Simple H with range", + expr: "H(0-10)", + bounds: bounds{min: 0, max: 59}, + jobName: "job1", + expected: 1 << 0, + }, { name: "Empty Job", expr: "H/2", @@ -136,6 +234,13 @@ func TestGetHashedValue(t *testing.T) { jobName: "job2", expected: 1<<2 | 1<<15 | 1<<28 | 1<<41 | 1<<54, }, + { + name: "H with ranged step", + expr: "H(0-30)/10", + bounds: bounds{min: 0, max: 59}, + jobName: "job2", + expected: 1<<8 | 1<<18 | 1<<28, + }, { name: "Same job, different bounds", expr: "H", @@ -293,11 +398,11 @@ func TestParseWithJobNameSchedule(t *testing.T) { }{ { parser: secondParser, - expr: "* H * * *", + expr: "* H,47,59 * * *", jobName: "dowJob1", expected: &SpecSchedule{ Second: all(seconds), - Minute: 1 << 43, + Minute: 1<<43 | 1<<47 | 1<<59, Hour: all(hours), Dom: all(dom), Month: all(months),