Skip to content
Open
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
41 changes: 41 additions & 0 deletions extcron/after.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package extcron

import (
"time"
)

// AfterSchedule represents a schedule that runs once at a specific time,
// with a grace period during which it can still run immediately if missed.
type AfterSchedule struct {
Date time.Time
GracePeriod time.Duration
}

// After creates an AfterSchedule with the given date and grace period.
func After(date time.Time, gracePeriod time.Duration) AfterSchedule {
return AfterSchedule{
Date: date,
GracePeriod: gracePeriod,
}
}

// Next conforms to the Schedule interface.
// It returns:
// - The scheduled date if current time is before the scheduled date
// - Current time (immediate execution) if current time is within grace period after the scheduled date
// - Zero time (never runs) if current time is beyond the grace period
func (schedule AfterSchedule) Next(t time.Time) time.Time {
// If the date is after the reference time, return it
if schedule.Date.After(t) {
return schedule.Date
}

// If we're within the grace period (including the exact end moment), run immediately
gracePeriodEnd := schedule.Date.Add(schedule.GracePeriod)
if !t.After(gracePeriodEnd) {
return t
}

// Beyond grace period, never run
return time.Time{}
}
88 changes: 88 additions & 0 deletions extcron/after_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package extcron

import (
"testing"
"time"

"github.com/stretchr/testify/assert"
)

func TestAfterScheduleNext(t *testing.T) {
tests := []struct {
name string
scheduleAt string
gracePeriod time.Duration
currentTime string
expected string
}{
{
name: "before scheduled time - should return scheduled time",
scheduleAt: "2020-01-01T00:00:00Z",
gracePeriod: 2 * time.Hour,
currentTime: "2019-12-31T23:00:00Z",
expected: "2020-01-01T00:00:00Z",
},
{
name: "within grace period - should run immediately",
scheduleAt: "2020-01-01T00:00:00Z",
gracePeriod: 2 * time.Hour,
currentTime: "2020-01-01T01:00:00Z",
expected: "2020-01-01T01:00:00Z", // Returns current time (immediate)
},
{
name: "at end of grace period - should run immediately",
scheduleAt: "2020-01-01T00:00:00Z",
gracePeriod: 2 * time.Hour,
currentTime: "2020-01-01T01:59:59Z",
expected: "2020-01-01T01:59:59Z", // Returns current time (immediate)
},
{
name: "exactly at end of grace period - should run immediately",
scheduleAt: "2020-01-01T00:00:00Z",
gracePeriod: 2 * time.Hour,
currentTime: "2020-01-01T02:00:00Z",
expected: "2020-01-01T02:00:00Z", // Returns current time (immediate)
},
{
name: "after grace period - should never run",
scheduleAt: "2020-01-01T00:00:00Z",
gracePeriod: 2 * time.Hour,
currentTime: "2020-01-01T02:00:01Z",
expected: "0001-01-01T00:00:00Z", // Zero time
},
{
name: "exactly at scheduled time - should run immediately",
scheduleAt: "2020-01-01T00:00:00Z",
gracePeriod: 2 * time.Hour,
currentTime: "2020-01-01T00:00:00Z",
expected: "2020-01-01T00:00:00Z", // Returns current time (immediate)
},
{
name: "small grace period",
scheduleAt: "2020-01-01T12:00:00Z",
gracePeriod: 5 * time.Minute,
currentTime: "2020-01-01T12:03:00Z",
expected: "2020-01-01T12:03:00Z", // Returns current time (immediate)
},
{
name: "past small grace period",
scheduleAt: "2020-01-01T12:00:00Z",
gracePeriod: 5 * time.Minute,
currentTime: "2020-01-01T12:06:00Z",
expected: "0001-01-01T00:00:00Z", // Zero time
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
scheduleAt, _ := time.Parse(time.RFC3339, tt.scheduleAt)
currentTime, _ := time.Parse(time.RFC3339, tt.currentTime)
expected, _ := time.Parse(time.RFC3339, tt.expected)

schedule := After(scheduleAt, tt.gracePeriod)
actual := schedule.Next(currentTime)

assert.Equal(t, expected, actual, "Expected %v, got %v", expected, actual)
})
}
}
87 changes: 87 additions & 0 deletions extcron/duration.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package extcron

import (
"errors"
"fmt"
"regexp"
"strconv"
"time"
)

var iso8601DurationRegex = regexp.MustCompile(`^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$`)

// ParseISO8601Duration parses an ISO8601 duration string (e.g., "P2H", "PT2H", "P1DT2H30M")
// into a time.Duration. Note that for month and year durations, we use approximations:
// 1 year = 365 days, 1 month = 30 days
func ParseISO8601Duration(s string) (time.Duration, error) {
if s == "" {
return 0, errors.New("empty duration string")
}

matches := iso8601DurationRegex.FindStringSubmatch(s)
if matches == nil {
return 0, fmt.Errorf("invalid ISO8601 duration format: %s", s)
}

var duration time.Duration

// Years (approximate: 365 days)
if matches[1] != "" {
years, err := strconv.Atoi(matches[1])
if err != nil {
return 0, fmt.Errorf("invalid year value: %s", matches[1])
}
duration += time.Duration(years) * 365 * 24 * time.Hour
}

// Months (approximate: 30 days)
if matches[2] != "" {
months, err := strconv.Atoi(matches[2])
if err != nil {
return 0, fmt.Errorf("invalid month value: %s", matches[2])
}
duration += time.Duration(months) * 30 * 24 * time.Hour
}

// Days
if matches[3] != "" {
days, err := strconv.Atoi(matches[3])
if err != nil {
return 0, fmt.Errorf("invalid day value: %s", matches[3])
}
duration += time.Duration(days) * 24 * time.Hour
}

// Hours
if matches[4] != "" {
hours, err := strconv.Atoi(matches[4])
if err != nil {
return 0, fmt.Errorf("invalid hour value: %s", matches[4])
}
duration += time.Duration(hours) * time.Hour
}

// Minutes
if matches[5] != "" {
minutes, err := strconv.Atoi(matches[5])
if err != nil {
return 0, fmt.Errorf("invalid minute value: %s", matches[5])
}
duration += time.Duration(minutes) * time.Minute
}

// Seconds (can be fractional)
if matches[6] != "" {
seconds, err := strconv.ParseFloat(matches[6], 64)
if err != nil {
return 0, fmt.Errorf("invalid second value: %s", matches[6])
}
duration += time.Duration(seconds * float64(time.Second))
}

if duration == 0 {
return 0, errors.New("duration must be greater than zero")
}

return duration, nil
}
109 changes: 109 additions & 0 deletions extcron/duration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package extcron

import (
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestParseISO8601Duration(t *testing.T) {
tests := []struct {
name string
input string
expected time.Duration
wantErr bool
}{
{
name: "2 hours",
input: "PT2H",
expected: 2 * time.Hour,
wantErr: false,
},
{
name: "2 hours without T",
input: "P2H",
expected: 0,
wantErr: true, // Without T, hours are not recognized
},
{
name: "1 day",
input: "P1D",
expected: 24 * time.Hour,
wantErr: false,
},
{
name: "1 day and 2 hours",
input: "P1DT2H",
expected: 26 * time.Hour,
wantErr: false,
},
{
name: "30 minutes",
input: "PT30M",
expected: 30 * time.Minute,
wantErr: false,
},
{
name: "1 hour 30 minutes",
input: "PT1H30M",
expected: 90 * time.Minute,
wantErr: false,
},
{
name: "45 seconds",
input: "PT45S",
expected: 45 * time.Second,
wantErr: false,
},
{
name: "1 month (30 days approximation)",
input: "P1M",
expected: 30 * 24 * time.Hour,
wantErr: false,
},
{
name: "1 year (365 days approximation)",
input: "P1Y",
expected: 365 * 24 * time.Hour,
wantErr: false,
},
{
name: "complex duration",
input: "P1Y2M3DT4H5M6S",
expected: (365*24 + 60*24 + 3*24 + 4) * time.Hour + 5*time.Minute + 6*time.Second,
wantErr: false,
},
{
name: "invalid format",
input: "invalid",
expected: 0,
wantErr: true,
},
{
name: "empty string",
input: "",
expected: 0,
wantErr: true,
},
{
name: "zero duration",
input: "P",
expected: 0,
wantErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := ParseISO8601Duration(tt.input)
if tt.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
assert.Equal(t, tt.expected, result)
}
})
}
}
31 changes: 30 additions & 1 deletion extcron/extparser.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ func NewParser() cron.ScheduleParser {

// Parse parses a cron schedule specification. It accepts the cron spec with
// mandatory seconds parameter, descriptors and the custom descriptors
// "@at <date>", "@manually" and "@minutely".
// "@at <date>", "@after <date> <duration>", "@manually" and "@minutely".
func (p ExtParser) Parse(spec string) (cron.Schedule, error) {
switch spec {
case "@manually":
Expand All @@ -40,6 +40,35 @@ func (p ExtParser) Parse(spec string) (cron.Schedule, error) {
return At(date), nil
}

const after = "@after "
if strings.HasPrefix(spec, after) {
// Parse "@after 2020-01-01T00:00:00Z <P2H"
// Format: @after <RFC3339 datetime> <ISO8601 duration>
parts := strings.Fields(spec[len(after):])
if len(parts) != 2 {
return nil, fmt.Errorf("@after requires format: @after <RFC3339 datetime> <ISO8601 duration>, got: %s", spec)
}

// Parse the datetime
date, err := time.Parse(time.RFC3339, parts[0])
if err != nil {
return nil, fmt.Errorf("failed to parse date in @after: %s: %s", parts[0], err)
}

// Parse the grace period (must start with '<')
if !strings.HasPrefix(parts[1], "<") {
return nil, fmt.Errorf("grace period must start with '<', got: %s", parts[1])
}
durationStr := parts[1][1:] // Remove the '<' prefix

gracePeriod, err := ParseISO8601Duration(durationStr)
if err != nil {
return nil, fmt.Errorf("failed to parse grace period in @after: %s: %s", durationStr, err)
}

return After(date, gracePeriod), nil
}

// It's not a dkron specific spec: Let the regular cron schedule parser have it
return p.parser.Parse(spec)
}
Expand Down
Loading
Loading