Skip to content

Commit

Permalink
add support for hash scheduling (#1260)
Browse files Browse the repository at this point in the history
* add support for hash scheduling
* docs updated
  • Loading branch information
fopina authored Sep 16, 2024
1 parent 45cbc4f commit ebcb366
Show file tree
Hide file tree
Showing 4 changed files with 84 additions and 9 deletions.
63 changes: 61 additions & 2 deletions dkron/job.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"errors"
"fmt"
"regexp"
"strconv"
"strings"
"time"

"github.com/distribworks/dkron/v4/extcron"
Expand Down Expand Up @@ -31,6 +33,9 @@ const (
ConcurrencyAllow = "allow"
// ConcurrencyForbid forbids a job from executing concurrency.
ConcurrencyForbid = "forbid"

// HashSymbol is the "magic" character used in scheduled to be replaced with a value based on job name
HashSymbol = "~"
)

var (
Expand Down Expand Up @@ -303,10 +308,64 @@ func (j *Job) GetTimeLocation() *time.Location {
return loc
}

// nameHash returns hash code of the job name
func (j *Job) nameHash() int {
hash := 0
for _, c := range j.Name {
hash += int(c)
}
return hash
}

// scheduleHash replaces H in the cron spec by a value derived from job Name
// such as "0 0 ~ * * *"
func (j *Job) scheduleHash() string {
spec := j.Schedule

if !strings.Contains(spec, HashSymbol) {
return spec
}

hash := j.nameHash()
parts := strings.Split(spec, " ")
partIndex := 0
for index, part := range parts {
if strings.HasPrefix(part, "@") {
// this is a pre-defined scheduled, ignore everything
return spec
}
if strings.HasPrefix(part, "TZ=") || strings.HasPrefix(part, "CRON_TZ=") {
// do not increase partIndex
continue
}

if strings.Contains(part, HashSymbol) {
// mods taken in accordance with https://dkron.io/docs/usage/cron-spec/#cron-expression-format
partHash := hash
switch partIndex {
case 2:
partHash %= 24
case 3:
partHash = (partHash % 28) + 1
case 4:
partHash = (partHash % 12) + 1
case 5:
partHash %= 7
default:
partHash %= 60
}
parts[index] = strings.ReplaceAll(part, HashSymbol, strconv.Itoa(partHash))
}

partIndex++
}
return strings.Join(parts, " ")
}

// GetNext returns the job's next schedule from now
func (j *Job) GetNext() (time.Time, error) {
if j.Schedule != "" {
s, err := extcron.Parse(j.Schedule)
s, err := extcron.Parse(j.scheduleHash())
if err != nil {
return time.Time{}, err
}
Expand Down Expand Up @@ -367,7 +426,7 @@ func (j *Job) Validate() error {

// Validate schedule, allow empty schedule if parent job set.
if j.Schedule != "" || j.ParentJob == "" {
if _, err := extcron.Parse(j.Schedule); err != nil {
if _, err := extcron.Parse(j.scheduleHash()); err != nil {
return fmt.Errorf("%s: %s", ErrScheduleParse.Error(), err)
}
}
Expand Down
12 changes: 12 additions & 0 deletions dkron/job_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,18 @@ func Test_isRunnable(t *testing.T) {
}
}

func Test_scheduleHash(t *testing.T) {
job := &Job{
Name: "test_job",
}
job.Schedule = "0 0 ~ * * *"
assert.Equal(t, "0 0 18 * * *", job.scheduleHash())
job.Schedule = "TZ=Europe/Madrid 0 0 1 * ~ *"
assert.Equal(t, "TZ=Europe/Madrid 0 0 1 * 7 *", job.scheduleHash())
job.Schedule = "TZ=Europe/Madrid @at something with ~"
assert.Equal(t, "TZ=Europe/Madrid @at something with ~", job.scheduleHash())
}

type gRPCClientMock struct {
}

Expand Down
2 changes: 1 addition & 1 deletion dkron/scheduler.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ func (s *Scheduler) AddJob(job *Job) error {
// If Timezone is set on the job, and not explicitly in its schedule,
// AND its not a descriptor (that don't support timezones), add the
// timezone to the schedule so robfig/cron knows about it.
schedule := job.Schedule
schedule := job.scheduleHash()
if job.Timezone != "" &&
!strings.HasPrefix(schedule, "@") &&
!strings.HasPrefix(schedule, "TZ=") &&
Expand Down
16 changes: 10 additions & 6 deletions website/docs/usage/cron-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,12 @@ A cron expression represents a set of times, using 6 space-separated fields.

Field name | Mandatory? | Allowed values | Allowed special characters
---------- | ---------- | -------------- | --------------------------
Seconds | Yes | 0-59 | * / , -
Minutes | Yes | 0-59 | * / , -
Hours | Yes | 0-23 | * / , -
Day of month | Yes | 1-31 | * / , - ?
Month | Yes | 1-12 or JAN-DEC | * / , -
Day of week | Yes | 0-6 or SUN-SAT | * / , - ?
Seconds | Yes | 0-59 | * / , - ~
Minutes | Yes | 0-59 | * / , - ~
Hours | Yes | 0-23 | * / , - ~
Day of month | Yes | 1-31 | * / , - ? ~
Month | Yes | 1-12 or JAN-DEC | * / , - ~
Day of week | Yes | 0-6 or SUN-SAT | * / , - ? ~

Note: Month and Day-of-week field values are case insensitive. "SUN", "Sun",
and "sun" are equally accepted.
Expand Down Expand Up @@ -51,6 +51,10 @@ Question mark ( ? )
Question mark may be used instead of '*' for leaving either day-of-month or
day-of-week blank.

Tilde ( ~ )

Tilde will be replaced by a numeric value valid for the range where it is used. It allows periodically scheduled tasks to produce even load on the system. For example, scheduling multiple hourly jobs to "0 ~ * * * *" rather than "0 0 * * * *" will run the jobs at different minutes of every hour. It can be thought of as a random value over a range, but it actually is a hash of the job name, not a random function, so that the value remains stable for any given job.

### Predefined schedules

You may use one of several pre-defined schedules in place of a cron expression.
Expand Down

0 comments on commit ebcb366

Please sign in to comment.