Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow specification of timezones in Periodic Jobs #2321

Merged
merged 3 commits into from
Feb 17, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions api/jobs.go
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,15 @@ type PeriodicConfig struct {
Spec string
SpecType string
ProhibitOverlap bool
TimeZone *string
}

func (p *PeriodicConfig) GetLocation() (*time.Location, error) {
if p.TimeZone == nil || *p.TimeZone == "" {
return time.UTC, nil
}

return time.LoadLocation(*p.TimeZone)
}

// ParameterizedJobConfig is used to configure the parameterized job.
Expand Down
3 changes: 2 additions & 1 deletion command/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -218,8 +218,9 @@ func formatDryRun(resp *api.JobPlanResponse, job *structs.Job) string {
}

if next := resp.NextPeriodicLaunch; !next.IsZero() {
now := time.Now().In(job.Periodic.GetLocation())
out += fmt.Sprintf("[green]- If submitted now, next periodic launch would be at %s (%s from now).\n",
formatTime(next), formatTimeDifference(time.Now().UTC(), next, time.Second))
formatTime(next), formatTimeDifference(now, next, time.Second))
}

out = strings.TrimSuffix(out, "\n")
Expand Down
2 changes: 1 addition & 1 deletion command/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ func (c *RunCommand) Run(args []string) int {
if detach || periodic || paramjob {
c.Ui.Output("Job registration successful")
if periodic {
now := time.Now().UTC()
now := time.Now().In(job.Periodic.GetLocation())
next := job.Periodic.Next(now)
c.Ui.Output(fmt.Sprintf("Approximate next launch time: %s (%s from now)",
formatTime(next), formatTimeDifference(now, next, time.Second)))
Expand Down
3 changes: 2 additions & 1 deletion command/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ func (c *StatusCommand) Run(args []string) int {
c.Ui.Error(fmt.Sprintf("Error converting job: %s", err))
return 1
}
sJob.Canonicalize()
periodic := sJob.IsPeriodic()
parameterized := sJob.IsParameterized()

Expand All @@ -155,7 +156,7 @@ func (c *StatusCommand) Run(args []string) int {
}

if periodic {
now := time.Now().UTC()
now := time.Now().In(sJob.Periodic.GetLocation())
next := sJob.Periodic.Next(now)
basic = append(basic, fmt.Sprintf("Next Periodic Launch|%s",
fmt.Sprintf("%s (%s from now)",
Expand Down
1 change: 1 addition & 0 deletions jobspec/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -1172,6 +1172,7 @@ func parsePeriodic(result **structs.PeriodicConfig, list *ast.ObjectList) error
"enabled",
"cron",
"prohibit_overlap",
"time_zone",
}
if err := checkHCLKeys(o.Val, valid); err != nil {
return err
Expand Down
1 change: 1 addition & 0 deletions jobspec/parse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,7 @@ func TestParse(t *testing.T) {
SpecType: structs.PeriodicSpecCron,
Spec: "*/5 * * *",
ProhibitOverlap: true,
TimeZone: "Europe/Minsk",
},
},
false,
Expand Down
1 change: 1 addition & 0 deletions jobspec/test-fixtures/periodic-cron.hcl
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@ job "foo" {
periodic {
cron = "*/5 * * *"
prohibit_overlap = true
time_zone = "Europe/Minsk"
}
}
2 changes: 1 addition & 1 deletion nomad/job_endpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -687,7 +687,7 @@ func (j *Job) Plan(args *structs.JobPlanRequest, reply *structs.JobPlanResponse)

// If it is a periodic job calculate the next launch
if args.Job.IsPeriodic() && args.Job.Periodic.Enabled {
reply.NextPeriodicLaunch = args.Job.Periodic.Next(time.Now().UTC())
reply.NextPeriodicLaunch = args.Job.Periodic.Next(time.Now().In(args.Job.Periodic.GetLocation()))
}

reply.FailedTGAllocs = updatedEval.FailedTGAllocs
Expand Down
6 changes: 3 additions & 3 deletions nomad/periodic.go
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ func (p *PeriodicDispatch) Add(job *structs.Job) error {

// Add or update the job.
p.tracked[job.ID] = job
next := job.Periodic.Next(time.Now().UTC())
next := job.Periodic.Next(time.Now().In(job.Periodic.GetLocation()))
if tracked {
if err := p.heap.Update(job, next); err != nil {
return fmt.Errorf("failed to update job %v launch time: %v", job.ID, err)
Expand Down Expand Up @@ -289,7 +289,7 @@ func (p *PeriodicDispatch) ForceRun(jobID string) (*structs.Evaluation, error) {
}

p.l.Unlock()
return p.createEval(job, time.Now().UTC())
return p.createEval(job, time.Now().In(job.Periodic.GetLocation()))
}

// shouldRun returns whether the long lived run function should run.
Expand All @@ -309,7 +309,7 @@ func (p *PeriodicDispatch) run() {
if launch.IsZero() {
launchCh = nil
} else {
launchDur := launch.Sub(time.Now().UTC())
launchDur := launch.Sub(time.Now().In(job.Periodic.GetLocation()))
launchCh = time.After(launchDur)
p.logger.Printf("[DEBUG] nomad.periodic: launching job %q in %s", job.ID, launchDur)
}
Expand Down
30 changes: 30 additions & 0 deletions nomad/structs/diff_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,7 @@ func TestJobDiff(t *testing.T) {
Spec: "*/15 * * * * *",
SpecType: "foo",
ProhibitOverlap: false,
TimeZone: "Europe/Minsk",
},
},
Expected: &JobDiff{
Expand Down Expand Up @@ -548,6 +549,12 @@ func TestJobDiff(t *testing.T) {
Old: "",
New: "foo",
},
{
Type: DiffTypeAdded,
Name: "TimeZone",
Old: "",
New: "Europe/Minsk",
},
},
},
},
Expand All @@ -561,6 +568,7 @@ func TestJobDiff(t *testing.T) {
Spec: "*/15 * * * * *",
SpecType: "foo",
ProhibitOverlap: false,
TimeZone: "Europe/Minsk",
},
},
New: &Job{},
Expand Down Expand Up @@ -595,6 +603,12 @@ func TestJobDiff(t *testing.T) {
Old: "foo",
New: "",
},
{
Type: DiffTypeDeleted,
Name: "TimeZone",
Old: "Europe/Minsk",
New: "",
},
},
},
},
Expand All @@ -608,6 +622,7 @@ func TestJobDiff(t *testing.T) {
Spec: "*/15 * * * * *",
SpecType: "foo",
ProhibitOverlap: false,
TimeZone: "Europe/Minsk",
},
},
New: &Job{
Expand All @@ -616,6 +631,7 @@ func TestJobDiff(t *testing.T) {
Spec: "* * * * * *",
SpecType: "cron",
ProhibitOverlap: true,
TimeZone: "America/Los_Angeles",
},
},
Expected: &JobDiff{
Expand Down Expand Up @@ -649,6 +665,12 @@ func TestJobDiff(t *testing.T) {
Old: "foo",
New: "cron",
},
{
Type: DiffTypeEdited,
Name: "TimeZone",
Old: "Europe/Minsk",
New: "America/Los_Angeles",
},
},
},
},
Expand All @@ -663,6 +685,7 @@ func TestJobDiff(t *testing.T) {
Spec: "*/15 * * * * *",
SpecType: "foo",
ProhibitOverlap: false,
TimeZone: "Europe/Minsk",
},
},
New: &Job{
Expand All @@ -671,6 +694,7 @@ func TestJobDiff(t *testing.T) {
Spec: "* * * * * *",
SpecType: "foo",
ProhibitOverlap: false,
TimeZone: "Europe/Minsk",
},
},
Expected: &JobDiff{
Expand Down Expand Up @@ -704,6 +728,12 @@ func TestJobDiff(t *testing.T) {
Old: "foo",
New: "foo",
},
{
Type: DiffTypeNone,
Name: "TimeZone",
Old: "Europe/Minsk",
New: "Europe/Minsk",
},
},
},
},
Expand Down
51 changes: 47 additions & 4 deletions nomad/structs/structs.go
Original file line number Diff line number Diff line change
Expand Up @@ -1175,6 +1175,10 @@ func (j *Job) Canonicalize() {
if j.ParameterizedJob != nil {
j.ParameterizedJob.Canonicalize()
}

if j.Periodic != nil {
j.Periodic.Canonicalize()
}
}

// Copy returns a deep copy of the Job. It is expected that callers use recover.
Expand Down Expand Up @@ -1542,6 +1546,16 @@ type PeriodicConfig struct {

// ProhibitOverlap enforces that spawned jobs do not run in parallel.
ProhibitOverlap bool `mapstructure:"prohibit_overlap"`

// TimeZone is the user specified string that determines the time zone to
// launch against. The time zones must be specified from IANA Time Zone
// database, such as "America/New_York".
// Reference: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
// Reference: https://www.iana.org/time-zones
TimeZone string `mapstructure:"time_zone"`

// location is the time zone to evaluate the launch time against
location *time.Location
}

func (p *PeriodicConfig) Copy() *PeriodicConfig {
Expand All @@ -1558,23 +1572,41 @@ func (p *PeriodicConfig) Validate() error {
return nil
}

var mErr multierror.Error
if p.Spec == "" {
return fmt.Errorf("Must specify a spec")
multierror.Append(&mErr, fmt.Errorf("Must specify a spec"))
}

// Check if we got a valid time zone
if p.TimeZone != "" {
if _, err := time.LoadLocation(p.TimeZone); err != nil {
multierror.Append(&mErr, fmt.Errorf("Invalid time zone %q: %v", p.TimeZone, err))
}
}

switch p.SpecType {
case PeriodicSpecCron:
// Validate the cron spec
if _, err := cronexpr.Parse(p.Spec); err != nil {
return fmt.Errorf("Invalid cron spec %q: %v", p.Spec, err)
multierror.Append(&mErr, fmt.Errorf("Invalid cron spec %q: %v", p.Spec, err))
}
case PeriodicSpecTest:
// No-op
default:
return fmt.Errorf("Unknown periodic specification type %q", p.SpecType)
multierror.Append(&mErr, fmt.Errorf("Unknown periodic specification type %q", p.SpecType))
}

return nil
return mErr.ErrorOrNil()
}

func (p *PeriodicConfig) Canonicalize() {
// Load the location
l, err := time.LoadLocation(p.TimeZone)
if err != nil {
p.location = time.UTC
}

p.location = l
}

// Next returns the closest time instant matching the spec that is after the
Expand Down Expand Up @@ -1615,6 +1647,17 @@ func (p *PeriodicConfig) Next(fromTime time.Time) time.Time {
return time.Time{}
}

// GetLocation returns the location to use for determining the time zone to run
// the periodic job against.
func (p *PeriodicConfig) GetLocation() *time.Location {
// Jobs pre 0.5.5 will not have this
if p.location != nil {
return p.location
}

return time.UTC
}

const (
// PeriodicLaunchSuffix is the string appended to the periodic jobs ID
// when launching derived instances of it.
Expand Down
48 changes: 48 additions & 0 deletions nomad/structs/structs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1217,12 +1217,19 @@ func TestPeriodicConfig_EnabledInvalid(t *testing.T) {
if err := p.Validate(); err == nil {
t.Fatal("Enabled PeriodicConfig with no spec shouldn't be valid")
}

// Create a config that is enabled, with a bad time zone.
p = &PeriodicConfig{Enabled: true, TimeZone: "FOO"}
if err := p.Validate(); err == nil || !strings.Contains(err.Error(), "time zone") {
t.Fatal("Enabled PeriodicConfig with bad time zone shouldn't be valid: %v", err)
}
}

func TestPeriodicConfig_InvalidCron(t *testing.T) {
specs := []string{"foo", "* *", "@foo"}
for _, spec := range specs {
p := &PeriodicConfig{Enabled: true, SpecType: PeriodicSpecCron, Spec: spec}
p.Canonicalize()
if err := p.Validate(); err == nil {
t.Fatal("Invalid cron spec")
}
Expand All @@ -1233,6 +1240,7 @@ func TestPeriodicConfig_ValidCron(t *testing.T) {
specs := []string{"0 0 29 2 *", "@hourly", "0 0-15 * * *"}
for _, spec := range specs {
p := &PeriodicConfig{Enabled: true, SpecType: PeriodicSpecCron, Spec: spec}
p.Canonicalize()
if err := p.Validate(); err != nil {
t.Fatal("Passed valid cron")
}
Expand All @@ -1245,13 +1253,53 @@ func TestPeriodicConfig_NextCron(t *testing.T) {
expected := []time.Time{time.Time{}, time.Date(2009, time.November, 10, 23, 25, 0, 0, time.UTC)}
for i, spec := range specs {
p := &PeriodicConfig{Enabled: true, SpecType: PeriodicSpecCron, Spec: spec}
p.Canonicalize()
n := p.Next(from)
if expected[i] != n {
t.Fatalf("Next(%v) returned %v; want %v", from, n, expected[i])
}
}
}

func TestPeriodicConfig_ValidTimeZone(t *testing.T) {
zones := []string{"Africa/Abidjan", "America/Chicago", "Europe/Minsk", "UTC"}
for _, zone := range zones {
p := &PeriodicConfig{Enabled: true, SpecType: PeriodicSpecCron, Spec: "0 0 29 2 * 1980", TimeZone: zone}
p.Canonicalize()
if err := p.Validate(); err != nil {
t.Fatal("Valid tz errored: %v", err)
}
}
}

func TestPeriodicConfig_DST(t *testing.T) {
// On Sun, Mar 12, 2:00 am 2017: +1 hour UTC
p := &PeriodicConfig{
Enabled: true,
SpecType: PeriodicSpecCron,
Spec: "0 2 11-12 3 * 2017",
TimeZone: "America/Los_Angeles",
}
p.Canonicalize()

t1 := time.Date(2017, time.March, 11, 1, 0, 0, 0, p.location)
t2 := time.Date(2017, time.March, 12, 1, 0, 0, 0, p.location)

// E1 is an 8 hour adjustment, E2 is a 7 hour adjustment
e1 := time.Date(2017, time.March, 11, 10, 0, 0, 0, time.UTC)
e2 := time.Date(2017, time.March, 12, 9, 0, 0, 0, time.UTC)

n1 := p.Next(t1).UTC()
n2 := p.Next(t2).UTC()

if !reflect.DeepEqual(e1, n1) {
t.Fatalf("Got %v; want %v", n1, e1)
}
if !reflect.DeepEqual(e2, n2) {
t.Fatalf("Got %v; want %v", n1, e1)
}
}

func TestRestartPolicy_Validate(t *testing.T) {
// Policy with acceptable restart options passes
p := &RestartPolicy{
Expand Down
6 changes: 6 additions & 0 deletions website/source/docs/http/json-jobs.html.md
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,12 @@ The `Job` object supports the following keys:
* `Enabled` - `Enabled` determines whether the periodic job will spawn child
jobs.

* `time_zone` - Specifies the time zone to evaluate the next launch interval
against. This is useful when wanting to account for day light savings in
various time zones. The time zone must be parsable by Golang's
[LoadLocation](https://golang.org/pkg/time/#LoadLocation). The default is
UTC.

* `SpecType` - `SpecType` determines how Nomad is going to interpret the
periodic expression. `cron` is the only supported `SpecType` currently.

Expand Down
Loading