Merge pull request #121327 from soltysh/fix_nextScheduleTimeDuration

Fix next schedule time duration
This commit is contained in:
Kubernetes Prow Robot 2023-10-24 12:18:35 +02:00 committed by GitHub
commit 015297a577
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 167 additions and 39 deletions

View File

@ -36,6 +36,27 @@ import (
// Utilities for dealing with Jobs and CronJobs and time. // Utilities for dealing with Jobs and CronJobs and time.
type missedSchedulesType int
const (
noneMissed missedSchedulesType = iota
fewMissed
manyMissed
)
func (e missedSchedulesType) String() string {
switch e {
case noneMissed:
return "none"
case fewMissed:
return "few"
case manyMissed:
return "many"
default:
return fmt.Sprintf("unknown(%d)", int(e))
}
}
// inActiveList checks if cronjob's .status.active has a job with the same UID. // inActiveList checks if cronjob's .status.active has a job with the same UID.
func inActiveList(cj *batchv1.CronJob, uid types.UID) bool { func inActiveList(cj *batchv1.CronJob, uid types.UID) bool {
for _, j := range cj.Status.Active { for _, j := range cj.Status.Active {
@ -75,11 +96,12 @@ func deleteFromActiveList(cj *batchv1.CronJob, uid types.UID) {
// mostRecentScheduleTime returns: // mostRecentScheduleTime returns:
// - the last schedule time or CronJob's creation time, // - the last schedule time or CronJob's creation time,
// - the most recent time a Job should be created or nil, if that's after now, // - the most recent time a Job should be created or nil, if that's after now,
// - boolean indicating an excessive number of missed schedules, // - value indicating either none missed schedules, a few missed or many missed
// - error in an edge case where the schedule specification is grammatically correct, // - error in an edge case where the schedule specification is grammatically correct,
// but logically doesn't make sense (31st day for months with only 30 days, for example). // but logically doesn't make sense (31st day for months with only 30 days, for example).
func mostRecentScheduleTime(cj *batchv1.CronJob, now time.Time, schedule cron.Schedule, includeStartingDeadlineSeconds bool) (time.Time, *time.Time, bool, error) { func mostRecentScheduleTime(cj *batchv1.CronJob, now time.Time, schedule cron.Schedule, includeStartingDeadlineSeconds bool) (time.Time, *time.Time, missedSchedulesType, error) {
earliestTime := cj.ObjectMeta.CreationTimestamp.Time earliestTime := cj.ObjectMeta.CreationTimestamp.Time
missedSchedules := noneMissed
if cj.Status.LastScheduleTime != nil { if cj.Status.LastScheduleTime != nil {
earliestTime = cj.Status.LastScheduleTime.Time earliestTime = cj.Status.LastScheduleTime.Time
} }
@ -96,10 +118,10 @@ func mostRecentScheduleTime(cj *batchv1.CronJob, now time.Time, schedule cron.Sc
t2 := schedule.Next(t1) t2 := schedule.Next(t1)
if now.Before(t1) { if now.Before(t1) {
return earliestTime, nil, false, nil return earliestTime, nil, missedSchedules, nil
} }
if now.Before(t2) { if now.Before(t2) {
return earliestTime, &t1, false, nil return earliestTime, &t1, missedSchedules, nil
} }
// It is possible for cron.ParseStandard("59 23 31 2 *") to return an invalid schedule // It is possible for cron.ParseStandard("59 23 31 2 *") to return an invalid schedule
@ -107,7 +129,7 @@ func mostRecentScheduleTime(cj *batchv1.CronJob, now time.Time, schedule cron.Sc
// In this case the timeBetweenTwoSchedules will be 0, and we error out the invalid schedule // In this case the timeBetweenTwoSchedules will be 0, and we error out the invalid schedule
timeBetweenTwoSchedules := int64(t2.Sub(t1).Round(time.Second).Seconds()) timeBetweenTwoSchedules := int64(t2.Sub(t1).Round(time.Second).Seconds())
if timeBetweenTwoSchedules < 1 { if timeBetweenTwoSchedules < 1 {
return earliestTime, nil, false, fmt.Errorf("time difference between two schedules is less than 1 second") return earliestTime, nil, missedSchedules, fmt.Errorf("time difference between two schedules is less than 1 second")
} }
// this logic used for calculating number of missed schedules does a rough // this logic used for calculating number of missed schedules does a rough
// approximation, by calculating a diff between two schedules (t1 and t2), // approximation, by calculating a diff between two schedules (t1 and t2),
@ -146,12 +168,18 @@ func mostRecentScheduleTime(cj *batchv1.CronJob, now time.Time, schedule cron.Sc
// //
// I've somewhat arbitrarily picked 100, as more than 80, // I've somewhat arbitrarily picked 100, as more than 80,
// but less than "lots". // but less than "lots".
tooManyMissed := numberOfMissedSchedules > 100 switch {
case numberOfMissedSchedules > 100:
missedSchedules = manyMissed
// inform about few missed, still
case numberOfMissedSchedules > 0:
missedSchedules = fewMissed
}
if mostRecentTime.IsZero() { if mostRecentTime.IsZero() {
return earliestTime, nil, tooManyMissed, nil return earliestTime, nil, missedSchedules, nil
} }
return earliestTime, &mostRecentTime, tooManyMissed, nil return earliestTime, &mostRecentTime, missedSchedules, nil
} }
// nextScheduleTimeDuration returns the time duration to requeue based on // nextScheduleTimeDuration returns the time duration to requeue based on
@ -160,13 +188,18 @@ func mostRecentScheduleTime(cj *batchv1.CronJob, now time.Time, schedule cron.Sc
// realistic cases should be around 100s, the job will still be executed without missing // realistic cases should be around 100s, the job will still be executed without missing
// the schedule. // the schedule.
func nextScheduleTimeDuration(cj *batchv1.CronJob, now time.Time, schedule cron.Schedule) *time.Duration { func nextScheduleTimeDuration(cj *batchv1.CronJob, now time.Time, schedule cron.Schedule) *time.Duration {
earliestTime, mostRecentTime, _, err := mostRecentScheduleTime(cj, now, schedule, false) earliestTime, mostRecentTime, missedSchedules, err := mostRecentScheduleTime(cj, now, schedule, false)
if err != nil { if err != nil {
// we still have to requeue at some point, so aim for the next scheduling slot from now // we still have to requeue at some point, so aim for the next scheduling slot from now
mostRecentTime = &now mostRecentTime = &now
} else if mostRecentTime == nil { } else if mostRecentTime == nil {
if missedSchedules == noneMissed {
// no missed schedules since earliestTime // no missed schedules since earliestTime
mostRecentTime = &earliestTime mostRecentTime = &earliestTime
} else {
// if there are missed schedules since earliestTime, always use now
mostRecentTime = &now
}
} }
t := schedule.Next(*mostRecentTime).Add(nextScheduleDelta).Sub(now) t := schedule.Next(*mostRecentTime).Add(nextScheduleDelta).Sub(now)
@ -177,13 +210,13 @@ func nextScheduleTimeDuration(cj *batchv1.CronJob, now time.Time, schedule cron.
// and before now, or nil if no unmet schedule times, and an error. // and before now, or nil if no unmet schedule times, and an error.
// If there are too many (>100) unstarted times, it will also record a warning. // If there are too many (>100) unstarted times, it will also record a warning.
func nextScheduleTime(logger klog.Logger, cj *batchv1.CronJob, now time.Time, schedule cron.Schedule, recorder record.EventRecorder) (*time.Time, error) { func nextScheduleTime(logger klog.Logger, cj *batchv1.CronJob, now time.Time, schedule cron.Schedule, recorder record.EventRecorder) (*time.Time, error) {
_, mostRecentTime, tooManyMissed, err := mostRecentScheduleTime(cj, now, schedule, true) _, mostRecentTime, missedSchedules, err := mostRecentScheduleTime(cj, now, schedule, true)
if mostRecentTime == nil || mostRecentTime.After(now) { if mostRecentTime == nil || mostRecentTime.After(now) {
return nil, err return nil, err
} }
if tooManyMissed { if missedSchedules == manyMissed {
recorder.Eventf(cj, corev1.EventTypeWarning, "TooManyMissedTimes", "too many missed start times. Set or decrease .spec.startingDeadlineSeconds or check clock skew") recorder.Eventf(cj, corev1.EventTypeWarning, "TooManyMissedTimes", "too many missed start times. Set or decrease .spec.startingDeadlineSeconds or check clock skew")
logger.Info("too many missed times", "cronjob", klog.KObj(cj)) logger.Info("too many missed times", "cronjob", klog.KObj(cj))
} }

View File

@ -157,7 +157,7 @@ func TestNextScheduleTime(t *testing.T) {
// schedule is hourly on the hour // schedule is hourly on the hour
schedule := "0 * * * ?" schedule := "0 * * * ?"
PraseSchedule := func(schedule string) cron.Schedule { ParseSchedule := func(schedule string) cron.Schedule {
sched, err := cron.ParseStandard(schedule) sched, err := cron.ParseStandard(schedule)
if err != nil { if err != nil {
t.Errorf("Error parsing schedule: %#v", err) t.Errorf("Error parsing schedule: %#v", err)
@ -189,7 +189,7 @@ func TestNextScheduleTime(t *testing.T) {
cj.ObjectMeta.CreationTimestamp = metav1.Time{Time: T1.Add(-10 * time.Minute)} cj.ObjectMeta.CreationTimestamp = metav1.Time{Time: T1.Add(-10 * time.Minute)}
// Current time is more than creation time, but less than T1. // Current time is more than creation time, but less than T1.
now := T1.Add(-7 * time.Minute) now := T1.Add(-7 * time.Minute)
schedule, _ := nextScheduleTime(logger, &cj, now, PraseSchedule(cj.Spec.Schedule), recorder) schedule, _ := nextScheduleTime(logger, &cj, now, ParseSchedule(cj.Spec.Schedule), recorder)
if schedule != nil { if schedule != nil {
t.Errorf("expected no start time, got: %v", schedule) t.Errorf("expected no start time, got: %v", schedule)
} }
@ -200,7 +200,7 @@ func TestNextScheduleTime(t *testing.T) {
cj.ObjectMeta.CreationTimestamp = metav1.Time{Time: T1.Add(-10 * time.Minute)} cj.ObjectMeta.CreationTimestamp = metav1.Time{Time: T1.Add(-10 * time.Minute)}
// Current time is after T1 // Current time is after T1
now := T1.Add(2 * time.Second) now := T1.Add(2 * time.Second)
schedule, _ := nextScheduleTime(logger, &cj, now, PraseSchedule(cj.Spec.Schedule), recorder) schedule, _ := nextScheduleTime(logger, &cj, now, ParseSchedule(cj.Spec.Schedule), recorder)
if schedule == nil { if schedule == nil {
t.Errorf("expected 1 start time, got nil") t.Errorf("expected 1 start time, got nil")
} else if !schedule.Equal(T1) { } else if !schedule.Equal(T1) {
@ -215,7 +215,7 @@ func TestNextScheduleTime(t *testing.T) {
cj.Status.LastScheduleTime = &metav1.Time{Time: T1} cj.Status.LastScheduleTime = &metav1.Time{Time: T1}
// Current time is after T1 // Current time is after T1
now := T1.Add(2 * time.Minute) now := T1.Add(2 * time.Minute)
schedule, _ := nextScheduleTime(logger, &cj, now, PraseSchedule(cj.Spec.Schedule), recorder) schedule, _ := nextScheduleTime(logger, &cj, now, ParseSchedule(cj.Spec.Schedule), recorder)
if schedule != nil { if schedule != nil {
t.Errorf("expected 0 start times, got: %v", schedule) t.Errorf("expected 0 start times, got: %v", schedule)
} }
@ -228,7 +228,7 @@ func TestNextScheduleTime(t *testing.T) {
cj.Status.LastScheduleTime = &metav1.Time{Time: T1} cj.Status.LastScheduleTime = &metav1.Time{Time: T1}
// Current time is after T1 and after T2 // Current time is after T1 and after T2
now := T2.Add(5 * time.Minute) now := T2.Add(5 * time.Minute)
schedule, _ := nextScheduleTime(logger, &cj, now, PraseSchedule(cj.Spec.Schedule), recorder) schedule, _ := nextScheduleTime(logger, &cj, now, ParseSchedule(cj.Spec.Schedule), recorder)
if schedule == nil { if schedule == nil {
t.Errorf("expected 1 start times, got nil") t.Errorf("expected 1 start times, got nil")
} else if !schedule.Equal(T2) { } else if !schedule.Equal(T2) {
@ -241,7 +241,7 @@ func TestNextScheduleTime(t *testing.T) {
cj.Status.LastScheduleTime = &metav1.Time{Time: T1.Add(-1 * time.Hour)} cj.Status.LastScheduleTime = &metav1.Time{Time: T1.Add(-1 * time.Hour)}
// Current time is after T1 and after T2 // Current time is after T1 and after T2
now := T2.Add(5 * time.Minute) now := T2.Add(5 * time.Minute)
schedule, _ := nextScheduleTime(logger, &cj, now, PraseSchedule(cj.Spec.Schedule), recorder) schedule, _ := nextScheduleTime(logger, &cj, now, ParseSchedule(cj.Spec.Schedule), recorder)
if schedule == nil { if schedule == nil {
t.Errorf("expected 1 start times, got nil") t.Errorf("expected 1 start times, got nil")
} else if !schedule.Equal(T2) { } else if !schedule.Equal(T2) {
@ -253,7 +253,7 @@ func TestNextScheduleTime(t *testing.T) {
cj.ObjectMeta.CreationTimestamp = metav1.Time{Time: T1.Add(-2 * time.Hour)} cj.ObjectMeta.CreationTimestamp = metav1.Time{Time: T1.Add(-2 * time.Hour)}
cj.Status.LastScheduleTime = &metav1.Time{Time: T1.Add(-1 * time.Hour)} cj.Status.LastScheduleTime = &metav1.Time{Time: T1.Add(-1 * time.Hour)}
now := T2.Add(10 * 24 * time.Hour) now := T2.Add(10 * 24 * time.Hour)
schedule, _ := nextScheduleTime(logger, &cj, now, PraseSchedule(cj.Spec.Schedule), recorder) schedule, _ := nextScheduleTime(logger, &cj, now, ParseSchedule(cj.Spec.Schedule), recorder)
if schedule == nil { if schedule == nil {
t.Errorf("expected more than 0 missed times") t.Errorf("expected more than 0 missed times")
} }
@ -266,7 +266,7 @@ func TestNextScheduleTime(t *testing.T) {
// Deadline is short // Deadline is short
deadline := int64(2 * 60 * 60) deadline := int64(2 * 60 * 60)
cj.Spec.StartingDeadlineSeconds = &deadline cj.Spec.StartingDeadlineSeconds = &deadline
schedule, _ := nextScheduleTime(logger, &cj, now, PraseSchedule(cj.Spec.Schedule), recorder) schedule, _ := nextScheduleTime(logger, &cj, now, ParseSchedule(cj.Spec.Schedule), recorder)
if schedule == nil { if schedule == nil {
t.Errorf("expected more than 0 missed times") t.Errorf("expected more than 0 missed times")
} }
@ -277,7 +277,7 @@ func TestNextScheduleTime(t *testing.T) {
cj.Status.LastScheduleTime = nil cj.Status.LastScheduleTime = nil
now := *deltaTimeAfterTopOfTheHour(1 * time.Hour) now := *deltaTimeAfterTopOfTheHour(1 * time.Hour)
// rouge schedule // rouge schedule
schedule, err := nextScheduleTime(logger, &cj, now, PraseSchedule("59 23 31 2 *"), recorder) schedule, err := nextScheduleTime(logger, &cj, now, ParseSchedule("59 23 31 2 *"), recorder)
if schedule != nil { if schedule != nil {
t.Errorf("expected no start time, got: %v", schedule) t.Errorf("expected no start time, got: %v", schedule)
} }
@ -364,7 +364,7 @@ func TestMostRecentScheduleTime(t *testing.T) {
now time.Time now time.Time
expectedEarliestTime time.Time expectedEarliestTime time.Time
expectedRecentTime *time.Time expectedRecentTime *time.Time
expectedTooManyMissed bool expectedTooManyMissed missedSchedulesType
wantErr bool wantErr bool
}{ }{
{ {
@ -408,6 +408,7 @@ func TestMostRecentScheduleTime(t *testing.T) {
now: *deltaTimeAfterTopOfTheHour(301 * time.Minute), now: *deltaTimeAfterTopOfTheHour(301 * time.Minute),
expectedRecentTime: deltaTimeAfterTopOfTheHour(300 * time.Minute), expectedRecentTime: deltaTimeAfterTopOfTheHour(300 * time.Minute),
expectedEarliestTime: *deltaTimeAfterTopOfTheHour(10 * time.Second), expectedEarliestTime: *deltaTimeAfterTopOfTheHour(10 * time.Second),
expectedTooManyMissed: fewMissed,
}, },
{ {
name: "complex schedule", name: "complex schedule",
@ -425,6 +426,7 @@ func TestMostRecentScheduleTime(t *testing.T) {
now: *deltaTimeAfterTopOfTheHour(24*time.Hour + 31*time.Minute), now: *deltaTimeAfterTopOfTheHour(24*time.Hour + 31*time.Minute),
expectedRecentTime: deltaTimeAfterTopOfTheHour(24*time.Hour + 30*time.Minute), expectedRecentTime: deltaTimeAfterTopOfTheHour(24*time.Hour + 30*time.Minute),
expectedEarliestTime: *deltaTimeAfterTopOfTheHour(30 * time.Minute), expectedEarliestTime: *deltaTimeAfterTopOfTheHour(30 * time.Minute),
expectedTooManyMissed: fewMissed,
}, },
{ {
name: "another complex schedule", name: "another complex schedule",
@ -442,6 +444,7 @@ func TestMostRecentScheduleTime(t *testing.T) {
now: *deltaTimeAfterTopOfTheHour(30*time.Hour + 30*time.Minute), now: *deltaTimeAfterTopOfTheHour(30*time.Hour + 30*time.Minute),
expectedRecentTime: nil, expectedRecentTime: nil,
expectedEarliestTime: *deltaTimeAfterTopOfTheHour(30 * time.Minute), expectedEarliestTime: *deltaTimeAfterTopOfTheHour(30 * time.Minute),
expectedTooManyMissed: fewMissed,
}, },
{ {
name: "complex schedule with longer diff between executions", name: "complex schedule with longer diff between executions",
@ -459,6 +462,7 @@ func TestMostRecentScheduleTime(t *testing.T) {
now: *deltaTimeAfterTopOfTheHour(96*time.Hour + 31*time.Minute), now: *deltaTimeAfterTopOfTheHour(96*time.Hour + 31*time.Minute),
expectedRecentTime: deltaTimeAfterTopOfTheHour(96*time.Hour + 30*time.Minute), expectedRecentTime: deltaTimeAfterTopOfTheHour(96*time.Hour + 30*time.Minute),
expectedEarliestTime: *deltaTimeAfterTopOfTheHour(30 * time.Minute), expectedEarliestTime: *deltaTimeAfterTopOfTheHour(30 * time.Minute),
expectedTooManyMissed: fewMissed,
}, },
{ {
name: "complex schedule with shorter diff between executions", name: "complex schedule with shorter diff between executions",
@ -473,6 +477,7 @@ func TestMostRecentScheduleTime(t *testing.T) {
now: *deltaTimeAfterTopOfTheHour(24*time.Hour + 31*time.Minute), now: *deltaTimeAfterTopOfTheHour(24*time.Hour + 31*time.Minute),
expectedRecentTime: deltaTimeAfterTopOfTheHour(24*time.Hour + 30*time.Minute), expectedRecentTime: deltaTimeAfterTopOfTheHour(24*time.Hour + 30*time.Minute),
expectedEarliestTime: *topOfTheHour(), expectedEarliestTime: *topOfTheHour(),
expectedTooManyMissed: fewMissed,
}, },
{ {
name: "@every schedule", name: "@every schedule",
@ -491,7 +496,7 @@ func TestMostRecentScheduleTime(t *testing.T) {
now: *deltaTimeAfterTopOfTheHour(7 * 24 * time.Hour), now: *deltaTimeAfterTopOfTheHour(7 * 24 * time.Hour),
expectedRecentTime: deltaTimeAfterTopOfTheHour((6 * 24 * time.Hour) + 23*time.Hour + 1*time.Minute), expectedRecentTime: deltaTimeAfterTopOfTheHour((6 * 24 * time.Hour) + 23*time.Hour + 1*time.Minute),
expectedEarliestTime: *deltaTimeAfterTopOfTheHour(1 * time.Minute), expectedEarliestTime: *deltaTimeAfterTopOfTheHour(1 * time.Minute),
expectedTooManyMissed: true, expectedTooManyMissed: manyMissed,
}, },
{ {
name: "rogue cronjob", name: "rogue cronjob",
@ -611,6 +616,96 @@ func TestMostRecentScheduleTime(t *testing.T) {
} }
} }
func TestNextScheduleTimeDuration(t *testing.T) {
metav1TopOfTheHour := metav1.NewTime(*topOfTheHour())
metav1HalfPastTheHour := metav1.NewTime(*deltaTimeAfterTopOfTheHour(30 * time.Minute))
metav1TwoHoursLater := metav1.NewTime(*deltaTimeAfterTopOfTheHour(2 * time.Hour))
tests := []struct {
name string
cj *batchv1.CronJob
now time.Time
expectedDuration time.Duration
}{
{
name: "complex schedule skipping weekend",
cj: &batchv1.CronJob{
ObjectMeta: metav1.ObjectMeta{
CreationTimestamp: metav1TopOfTheHour,
},
Spec: batchv1.CronJobSpec{
Schedule: "30 6-16/4 * * 1-5",
},
Status: batchv1.CronJobStatus{
LastScheduleTime: &metav1HalfPastTheHour,
},
},
now: *deltaTimeAfterTopOfTheHour(24*time.Hour + 31*time.Minute),
expectedDuration: 3*time.Hour + 59*time.Minute + nextScheduleDelta,
},
{
name: "another complex schedule skipping weekend",
cj: &batchv1.CronJob{
ObjectMeta: metav1.ObjectMeta{
CreationTimestamp: metav1TopOfTheHour,
},
Spec: batchv1.CronJobSpec{
Schedule: "30 10,11,12 * * 1-5",
},
Status: batchv1.CronJobStatus{
LastScheduleTime: &metav1HalfPastTheHour,
},
},
now: *deltaTimeAfterTopOfTheHour(30*time.Hour + 30*time.Minute),
expectedDuration: 66*time.Hour + nextScheduleDelta,
},
{
name: "once a week cronjob, missed two runs",
cj: &batchv1.CronJob{
ObjectMeta: metav1.ObjectMeta{
CreationTimestamp: metav1TopOfTheHour,
},
Spec: batchv1.CronJobSpec{
Schedule: "0 12 * * 4",
},
Status: batchv1.CronJobStatus{
LastScheduleTime: &metav1TwoHoursLater,
},
},
now: *deltaTimeAfterTopOfTheHour(19*24*time.Hour + 1*time.Hour + 30*time.Minute),
expectedDuration: 48*time.Hour + 30*time.Minute + nextScheduleDelta,
},
{
name: "no previous run of a cronjob",
cj: &batchv1.CronJob{
ObjectMeta: metav1.ObjectMeta{
CreationTimestamp: metav1TopOfTheHour,
},
Spec: batchv1.CronJobSpec{
Schedule: "0 12 * * 5",
},
},
now: *deltaTimeAfterTopOfTheHour(6 * time.Hour),
expectedDuration: 20*time.Hour + nextScheduleDelta,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
sched, err := cron.ParseStandard(tt.cj.Spec.Schedule)
if err != nil {
t.Errorf("error setting up the test, %s", err)
}
gotScheduleTimeDuration := nextScheduleTimeDuration(tt.cj, tt.now, sched)
if *gotScheduleTimeDuration < 0 {
t.Errorf("scheduleTimeDuration should never be less than 0, got %s", gotScheduleTimeDuration)
}
if !reflect.DeepEqual(gotScheduleTimeDuration, &tt.expectedDuration) {
t.Errorf("scheduleTimeDuration - got %s, want %s", gotScheduleTimeDuration, tt.expectedDuration)
}
})
}
}
func topOfTheHour() *time.Time { func topOfTheHour() *time.Time {
T1, err := time.Parse(time.RFC3339, "2016-05-19T10:00:00Z") T1, err := time.Parse(time.RFC3339, "2016-05-19T10:00:00Z")
if err != nil { if err != nil {