From 8eeb4384739e832658fee67c7ac69a6114a8dbfe Mon Sep 17 00:00:00 2001 From: Tim Van Baak Date: Wed, 19 Feb 2025 07:52:49 -0800 Subject: [PATCH] Begin adding logic for first-party cron --- core/cron.go | 64 +++++++++++++++++++++++++++ core/cron_test.go | 109 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 core/cron.go create mode 100644 core/cron_test.go diff --git a/core/cron.go b/core/cron.go new file mode 100644 index 0000000..0f5474f --- /dev/null +++ b/core/cron.go @@ -0,0 +1,64 @@ +package core + +import ( + "fmt" + "strings" + "time" +) + +func GetNextUpdate(lastUpdated time.Time, spec string) (nextUpdate time.Time, err error) { + var nextUpdates []time.Time + switch { + case strings.HasPrefix(spec, "every "): + nextUpdates, err = parseEverySpec(lastUpdated, spec[len("every "):]) + case strings.HasPrefix(spec, "at "): + nextUpdates, err = parseAtSpec(lastUpdated, spec[len("at "):]) + default: + return time.Time{}, fmt.Errorf("unknown spec format: %v", spec) + } + if err != nil { + return time.Time{}, err + } + for _, next := range nextUpdates { + if next.After(lastUpdated) && (nextUpdate.IsZero() || next.Before(nextUpdate)) { + nextUpdate = next + } + } + return +} + +// Get the next instance of the every-spec after the base time. +// An every-spec is a Go duration string. +func parseEverySpec(base time.Time, everySpec string) (nextUpdates []time.Time, err error) { + var duration time.Duration + duration, err = time.ParseDuration(everySpec) + if err == nil { + next := base.Round(duration) + if !next.After(base) { + next = next.Add(duration) + } + nextUpdates = []time.Time{next} + } + return +} + +// Get the next instances of the at-spec times after the base time. +// An at-spec is in the patterm HH:MM[,HH:MM,[...]]. +func parseAtSpec(base time.Time, atSpec string) (nextUpdates []time.Time, err error) { + timeSpecs := strings.Split(atSpec, ",") + for _, timeSpec := range timeSpecs { + var hour, minute int + _, err = fmt.Sscanf(timeSpec, "%d:%d", &hour, &minute) + if err != nil { + return nil, fmt.Errorf("could not parse %s: %v", timeSpec, err) + } + // The time instance on the same day as the base time + specOfDay := time.Date(base.Year(), base.Month(), base.Day(), hour, minute, 0, 0, base.Location()) + // Bump it forward one day if it's before the base time + if !specOfDay.After(base) { + specOfDay = specOfDay.Add(24 * time.Hour) + } + nextUpdates = append(nextUpdates, specOfDay) + } + return +} diff --git a/core/cron_test.go b/core/cron_test.go new file mode 100644 index 0000000..6f56aca --- /dev/null +++ b/core/cron_test.go @@ -0,0 +1,109 @@ +package core + +import ( + "strings" + "testing" + "time" +) + +func TestGetNextUpdate(t *testing.T) { + tests := []struct { + lastUpdated time.Time + spec string + expected time.Time + expectErr bool + }{ + { + lastUpdated: time.Date(2020, 10, 22, 12, 0, 0, 0, time.UTC), + spec: "every 1h#1", + expected: time.Date(2020, 10, 22, 13, 0, 0, 0, time.UTC), + expectErr: false, + }, + { + lastUpdated: time.Date(2020, 10, 22, 12, 0, 5, 0, time.UTC), + spec: "every 1h#2", + expected: time.Date(2020, 10, 22, 13, 0, 0, 0, time.UTC), + expectErr: false, + }, + { + lastUpdated: time.Date(2020, 10, 22, 11, 0, 0, 0, time.UTC), + spec: "every 2h30m#1", + expected: time.Date(2020, 10, 22, 11, 30, 0, 0, time.UTC), + expectErr: false, + }, + { + lastUpdated: time.Date(2020, 10, 22, 11, 30, 0, 0, time.UTC), + spec: "every 2h30m#2", + expected: time.Date(2020, 10, 22, 14, 00, 0, 0, time.UTC), + expectErr: false, + }, + { + lastUpdated: time.Date(2020, 10, 22, 11, 30, 0, 0, time.UTC), + spec: "every 3", + expected: time.Time{}, + expectErr: true, + }, + { + lastUpdated: time.Date(2020, 10, 22, 12, 0, 0, 0, time.UTC), + spec: "at 14:00#1", + expected: time.Date(2020, 10, 22, 14, 0, 0, 0, time.UTC), + expectErr: false, + }, + { + lastUpdated: time.Date(2020, 10, 22, 14, 0, 0, 0, time.UTC), + spec: "at 14:00#2", + expected: time.Date(2020, 10, 23, 14, 0, 0, 0, time.UTC), + expectErr: false, + }, + { + lastUpdated: time.Date(2020, 10, 22, 15, 0, 0, 0, time.UTC), + spec: "at 14:00#3", + expected: time.Date(2020, 10, 23, 14, 0, 0, 0, time.UTC), + expectErr: false, + }, + { + lastUpdated: time.Date(2020, 10, 22, 9, 0, 0, 0, time.UTC), + spec: "at 10:00,15:00#1", + expected: time.Date(2020, 10, 22, 10, 0, 0, 0, time.UTC), + expectErr: false, + }, + { + lastUpdated: time.Date(2020, 10, 22, 12, 0, 0, 0, time.UTC), + spec: "at 10:00,15:00#2", + expected: time.Date(2020, 10, 22, 15, 0, 0, 0, time.UTC), + expectErr: false, + }, + { + lastUpdated: time.Date(2020, 10, 22, 15, 0, 0, 0, time.UTC), + spec: "at 10:00,15:00#3", + expected: time.Date(2020, 10, 23, 10, 0, 0, 0, time.UTC), + expectErr: false, + }, + { + lastUpdated: time.Date(2020, 10, 22, 16, 0, 0, 0, time.UTC), + spec: "at 10:00,15:00#4", + expected: time.Date(2020, 10, 23, 10, 0, 0, 0, time.UTC), + expectErr: false, + }, + } + + for _, test := range tests { + t.Run(test.spec, func(t *testing.T) { + spec := test.spec + if comment := strings.Index(spec, "#"); comment > -1 { + spec = spec[:comment] + } + nextUpdate, err := GetNextUpdate(test.lastUpdated, spec) + if test.expectErr && err == nil { + t.Error("test did not fail as expected") + } + if !test.expectErr && err != nil { + t.Errorf("error: %v", err) + } + if !nextUpdate.Equal(test.expected) { + t.Errorf("\nexpected: %v\n got: %v", test.expected, nextUpdate) + } + }) + } + +}