From f0a4c3707381fcd88e68f9fdd52447696cccfd4e Mon Sep 17 00:00:00 2001 From: Srikanth Chekuri Date: Thu, 3 Apr 2025 02:18:01 +0530 Subject: [PATCH] fix: handle maintenance windows that cross day boundaries (#7494) --- pkg/query-service/rules/maintenance.go | 250 +++++++---- pkg/query-service/rules/maintenance_test.go | 470 ++++++++++++++++++-- 2 files changed, 603 insertions(+), 117 deletions(-) diff --git a/pkg/query-service/rules/maintenance.go b/pkg/query-service/rules/maintenance.go index 014e8ac669..a1955e9e7f 100644 --- a/pkg/query-service/rules/maintenance.go +++ b/pkg/query-service/rules/maintenance.go @@ -3,8 +3,6 @@ package rules import ( "database/sql/driver" "encoding/json" - "slices" - "strings" "time" "github.com/pkg/errors" @@ -84,6 +82,16 @@ const ( RepeatOnSaturday RepeatOn = "saturday" ) +var RepeatOnAllMap = map[RepeatOn]time.Weekday{ + RepeatOnSunday: time.Sunday, + RepeatOnMonday: time.Monday, + RepeatOnTuesday: time.Tuesday, + RepeatOnWednesday: time.Wednesday, + RepeatOnThursday: time.Thursday, + RepeatOnFriday: time.Friday, + RepeatOnSaturday: time.Saturday, +} + type Recurrence struct { StartTime time.Time `json:"startTime"` EndTime *time.Time `json:"endTime,omitempty"` @@ -211,7 +219,7 @@ func (s *Schedule) UnmarshalJSON(data []byte) error { } func (m *PlannedMaintenance) shouldSkip(ruleID string, now time.Time) bool { - + // Check if the alert ID is in the maintenance window found := false if m.AlertIds != nil { for _, alertID := range *m.AlertIds { @@ -227,97 +235,162 @@ func (m *PlannedMaintenance) shouldSkip(ruleID string, now time.Time) bool { found = true } - if found { + if !found { + return false + } - zap.L().Info("alert found in maintenance", zap.String("alert", ruleID), zap.Any("maintenance", m.Name)) + zap.L().Info("alert found in maintenance", zap.String("alert", ruleID), zap.String("maintenance", m.Name)) - // If alert is found, we check if it should be skipped based on the schedule - // If it should be skipped, we return true - // If it should not be skipped, we return false + // If alert is found, we check if it should be skipped based on the schedule + loc, err := time.LoadLocation(m.Schedule.Timezone) + if err != nil { + zap.L().Error("Error loading location", zap.String("timezone", m.Schedule.Timezone), zap.Error(err)) + return false + } - // fixed schedule - if !m.Schedule.StartTime.IsZero() && !m.Schedule.EndTime.IsZero() { - // if the current time in the timezone is between the start and end time - loc, err := time.LoadLocation(m.Schedule.Timezone) - if err != nil { - zap.L().Error("Error loading location", zap.String("timezone", m.Schedule.Timezone), zap.Error(err)) - return false - } + currentTime := now.In(loc) - currentTime := now.In(loc) - zap.L().Info("checking fixed schedule", zap.Any("rule", ruleID), zap.String("maintenance", m.Name), zap.Time("currentTime", currentTime), zap.Time("startTime", m.Schedule.StartTime), zap.Time("endTime", m.Schedule.EndTime)) - if currentTime.After(m.Schedule.StartTime) && currentTime.Before(m.Schedule.EndTime) { - return true - } - } + // fixed schedule + if !m.Schedule.StartTime.IsZero() && !m.Schedule.EndTime.IsZero() { + zap.L().Info("checking fixed schedule", + zap.String("rule", ruleID), + zap.String("maintenance", m.Name), + zap.Time("currentTime", currentTime), + zap.Time("startTime", m.Schedule.StartTime), + zap.Time("endTime", m.Schedule.EndTime)) - // recurring schedule - if m.Schedule.Recurrence != nil { - zap.L().Info("evaluating recurrence schedule") - start := m.Schedule.Recurrence.StartTime - end := m.Schedule.Recurrence.StartTime.Add(time.Duration(m.Schedule.Recurrence.Duration)) - // if the current time in the timezone is between the start and end time - loc, err := time.LoadLocation(m.Schedule.Timezone) - if err != nil { - zap.L().Error("Error loading location", zap.String("timezone", m.Schedule.Timezone), zap.Error(err)) - return false - } - currentTime := now.In(loc) - - zap.L().Info("checking recurring schedule", zap.Any("rule", ruleID), zap.String("maintenance", m.Name), zap.Time("currentTime", currentTime), zap.Time("startTime", start), zap.Time("endTime", end)) - - // make sure the start time is not after the current time - if currentTime.Before(start.In(loc)) { - zap.L().Info("current time is before start time", zap.Any("rule", ruleID), zap.String("maintenance", m.Name), zap.Time("currentTime", currentTime), zap.Time("startTime", start.In(loc))) - return false - } - - var endTime time.Time - if m.Schedule.Recurrence.EndTime != nil { - endTime = *m.Schedule.Recurrence.EndTime - } - if !endTime.IsZero() && currentTime.After(endTime.In(loc)) { - zap.L().Info("current time is after end time", zap.Any("rule", ruleID), zap.String("maintenance", m.Name), zap.Time("currentTime", currentTime), zap.Time("endTime", end.In(loc))) - return false - } - - switch m.Schedule.Recurrence.RepeatType { - case RepeatTypeDaily: - // take the hours and minutes from the start time and add them to the current time - startTime := time.Date(currentTime.Year(), currentTime.Month(), currentTime.Day(), start.Hour(), start.Minute(), 0, 0, loc) - endTime := time.Date(currentTime.Year(), currentTime.Month(), currentTime.Day(), end.Hour(), end.Minute(), 0, 0, loc) - zap.L().Info("checking daily schedule", zap.Any("rule", ruleID), zap.String("maintenance", m.Name), zap.Time("currentTime", currentTime), zap.Time("startTime", startTime), zap.Time("endTime", endTime)) - - if currentTime.After(startTime) && currentTime.Before(endTime) { - return true - } - case RepeatTypeWeekly: - // if the current time in the timezone is between the start and end time on the RepeatOn day - startTime := time.Date(currentTime.Year(), currentTime.Month(), currentTime.Day(), start.Hour(), start.Minute(), 0, 0, loc) - endTime := time.Date(currentTime.Year(), currentTime.Month(), currentTime.Day(), end.Hour(), end.Minute(), 0, 0, loc) - zap.L().Info("checking weekly schedule", zap.Any("rule", ruleID), zap.String("maintenance", m.Name), zap.Time("currentTime", currentTime), zap.Time("startTime", startTime), zap.Time("endTime", endTime)) - if currentTime.After(startTime) && currentTime.Before(endTime) { - if len(m.Schedule.Recurrence.RepeatOn) == 0 { - return true - } else if slices.Contains(m.Schedule.Recurrence.RepeatOn, RepeatOn(strings.ToLower(currentTime.Weekday().String()))) { - return true - } - } - case RepeatTypeMonthly: - // if the current time in the timezone is between the start and end time on the day of the current month - startTime := time.Date(currentTime.Year(), currentTime.Month(), start.Day(), start.Hour(), start.Minute(), 0, 0, loc) - endTime := time.Date(currentTime.Year(), currentTime.Month(), end.Day(), end.Hour(), end.Minute(), 0, 0, loc) - zap.L().Info("checking monthly schedule", zap.Any("rule", ruleID), zap.String("maintenance", m.Name), zap.Time("currentTime", currentTime), zap.Time("startTime", startTime), zap.Time("endTime", endTime)) - if currentTime.After(startTime) && currentTime.Before(endTime) && currentTime.Day() == start.Day() { - return true - } - } + startTime := m.Schedule.StartTime.In(loc) + endTime := m.Schedule.EndTime.In(loc) + if currentTime.Equal(startTime) || currentTime.Equal(endTime) || + (currentTime.After(startTime) && currentTime.Before(endTime)) { + return true } } - // If alert is not found, we return false + + // recurring schedule + if m.Schedule.Recurrence != nil { + start := m.Schedule.Recurrence.StartTime + duration := time.Duration(m.Schedule.Recurrence.Duration) + + zap.L().Info("checking recurring schedule base info", + zap.String("rule", ruleID), + zap.String("maintenance", m.Name), + zap.Time("startTime", start), + zap.Duration("duration", duration)) + + // Make sure the recurrence has started + if currentTime.Before(start.In(loc)) { + zap.L().Info("current time is before recurrence start time", + zap.String("rule", ruleID), + zap.String("maintenance", m.Name)) + return false + } + + // Check if recurrence has expired + if m.Schedule.Recurrence.EndTime != nil { + endTime := *m.Schedule.Recurrence.EndTime + if !endTime.IsZero() && currentTime.After(endTime.In(loc)) { + zap.L().Info("current time is after recurrence end time", + zap.String("rule", ruleID), + zap.String("maintenance", m.Name)) + return false + } + } + + switch m.Schedule.Recurrence.RepeatType { + case RepeatTypeDaily: + return m.checkDaily(currentTime, m.Schedule.Recurrence, loc) + case RepeatTypeWeekly: + return m.checkWeekly(currentTime, m.Schedule.Recurrence, loc) + case RepeatTypeMonthly: + return m.checkMonthly(currentTime, m.Schedule.Recurrence, loc) + } + } + return false } +// checkDaily rebases the recurrence start to today (or yesterday if needed) +// and returns true if currentTime is within [candidate, candidate+Duration]. +func (m *PlannedMaintenance) checkDaily(currentTime time.Time, rec *Recurrence, loc *time.Location) bool { + candidate := time.Date( + currentTime.Year(), currentTime.Month(), currentTime.Day(), + rec.StartTime.Hour(), rec.StartTime.Minute(), 0, 0, + loc, + ) + if candidate.After(currentTime) { + candidate = candidate.AddDate(0, 0, -1) + } + return currentTime.Sub(candidate) <= time.Duration(rec.Duration) +} + +// checkWeekly finds the most recent allowed occurrence by rebasing the recurrence’s +// time-of-day onto the allowed weekday. It does this for each allowed day and returns true +// if the current time falls within the candidate window. +func (m *PlannedMaintenance) checkWeekly(currentTime time.Time, rec *Recurrence, loc *time.Location) bool { + // If no days specified, treat as every day (like daily). + if len(rec.RepeatOn) == 0 { + return m.checkDaily(currentTime, rec, loc) + } + + for _, day := range rec.RepeatOn { + allowedDay, ok := RepeatOnAllMap[day] + if !ok { + continue // skip invalid days + } + // Compute the day difference: allowedDay - current weekday. + delta := int(allowedDay) - int(currentTime.Weekday()) + // Build a candidate occurrence by rebasing today's date to the allowed weekday. + candidate := time.Date( + currentTime.Year(), currentTime.Month(), currentTime.Day(), + rec.StartTime.Hour(), rec.StartTime.Minute(), 0, 0, + loc, + ).AddDate(0, 0, delta) + // If the candidate is in the future, subtract 7 days. + if candidate.After(currentTime) { + candidate = candidate.AddDate(0, 0, -7) + } + if currentTime.Sub(candidate) <= time.Duration(rec.Duration) { + return true + } + } + return false +} + +// checkMonthly rebases the candidate occurrence using the recurrence's day-of-month. +// If the candidate for the current month is in the future, it uses the previous month. +func (m *PlannedMaintenance) checkMonthly(currentTime time.Time, rec *Recurrence, loc *time.Location) bool { + refDay := rec.StartTime.Day() + year, month, _ := currentTime.Date() + lastDay := time.Date(year, month+1, 0, 0, 0, 0, 0, loc).Day() + day := refDay + if refDay > lastDay { + day = lastDay + } + candidate := time.Date(year, month, day, + rec.StartTime.Hour(), rec.StartTime.Minute(), rec.StartTime.Second(), rec.StartTime.Nanosecond(), + loc, + ) + if candidate.After(currentTime) { + // Use previous month. + candidate = candidate.AddDate(0, -1, 0) + y, m, _ := candidate.Date() + lastDayPrev := time.Date(y, m+1, 0, 0, 0, 0, 0, loc).Day() + if refDay > lastDayPrev { + candidate = time.Date(y, m, lastDayPrev, + rec.StartTime.Hour(), rec.StartTime.Minute(), rec.StartTime.Second(), rec.StartTime.Nanosecond(), + loc, + ) + } else { + candidate = time.Date(y, m, refDay, + rec.StartTime.Hour(), rec.StartTime.Minute(), rec.StartTime.Second(), rec.StartTime.Nanosecond(), + loc, + ) + } + } + return currentTime.Sub(candidate) <= time.Duration(rec.Duration) +} + func (m *PlannedMaintenance) IsActive(now time.Time) bool { ruleID := "maintenance" if m.AlertIds != nil && len(*m.AlertIds) > 0 { @@ -327,7 +400,14 @@ func (m *PlannedMaintenance) IsActive(now time.Time) bool { } func (m *PlannedMaintenance) IsUpcoming() bool { - now := time.Now().In(time.FixedZone(m.Schedule.Timezone, 0)) + loc, err := time.LoadLocation(m.Schedule.Timezone) + if err != nil { + // handle error appropriately, for example log and return false or fallback to UTC + zap.L().Error("Error loading timezone", zap.String("timezone", m.Schedule.Timezone), zap.Error(err)) + return false + } + now := time.Now().In(loc) + if !m.Schedule.StartTime.IsZero() && !m.Schedule.EndTime.IsZero() { return now.Before(m.Schedule.StartTime) } diff --git a/pkg/query-service/rules/maintenance_test.go b/pkg/query-service/rules/maintenance_test.go index aaf5edbb91..f3244f6b46 100644 --- a/pkg/query-service/rules/maintenance_test.go +++ b/pkg/query-service/rules/maintenance_test.go @@ -5,14 +5,404 @@ import ( "time" ) +// Helper function to create a time pointer +func timePtr(t time.Time) *time.Time { + return &t +} + func TestShouldSkipMaintenance(t *testing.T) { cases := []struct { name string maintenance *PlannedMaintenance ts time.Time - expected bool + skip bool }{ + { + name: "only-on-saturday", + maintenance: &PlannedMaintenance{ + Schedule: &Schedule{ + Timezone: "Europe/London", + Recurrence: &Recurrence{ + StartTime: time.Date(2025, 3, 1, 0, 0, 0, 0, time.UTC), + Duration: Duration(time.Hour * 24), + RepeatType: RepeatTypeWeekly, + RepeatOn: []RepeatOn{RepeatOnMonday, RepeatOnTuesday, RepeatOnWednesday, RepeatOnThursday, RepeatOnFriday, RepeatOnSunday}, + }, + }, + }, + ts: time.Date(2025, 3, 20, 12, 0, 0, 0, time.UTC), + skip: true, + }, + // Testing weekly recurrence with midnight crossing + { + name: "weekly-across-midnight-previous-day", + maintenance: &PlannedMaintenance{ + Schedule: &Schedule{ + Timezone: "UTC", + Recurrence: &Recurrence{ + StartTime: time.Date(2024, 4, 1, 22, 0, 0, 0, time.UTC), // Monday 22:00 + Duration: Duration(time.Hour * 4), // Until Tuesday 02:00 + RepeatType: RepeatTypeWeekly, + RepeatOn: []RepeatOn{RepeatOnMonday}, // Only Monday + }, + }, + }, + ts: time.Date(2024, 4, 2, 1, 30, 0, 0, time.UTC), // Tuesday 01:30 + skip: true, + }, + // Testing weekly recurrence with midnight crossing + { + name: "weekly-across-midnight-previous-day", + maintenance: &PlannedMaintenance{ + Schedule: &Schedule{ + Timezone: "UTC", + Recurrence: &Recurrence{ + StartTime: time.Date(2024, 4, 1, 22, 0, 0, 0, time.UTC), // Monday 22:00 + Duration: Duration(time.Hour * 4), // Until Tuesday 02:00 + RepeatType: RepeatTypeWeekly, + RepeatOn: []RepeatOn{RepeatOnMonday}, // Only Monday + }, + }, + }, + ts: time.Date(2024, 4, 23, 1, 30, 0, 0, time.UTC), // Tuesday 01:30 + skip: true, + }, + // Testing weekly recurrence with multi day duration + { + name: "weekly-across-midnight-previous-day", + maintenance: &PlannedMaintenance{ + Schedule: &Schedule{ + Timezone: "UTC", + Recurrence: &Recurrence{ + StartTime: time.Date(2024, 4, 1, 22, 0, 0, 0, time.UTC), // Monday 22:00 + Duration: Duration(time.Hour * 52), // Until Thursday 02:00 + RepeatType: RepeatTypeWeekly, + RepeatOn: []RepeatOn{RepeatOnMonday}, // Only Monday + }, + }, + }, + ts: time.Date(2024, 4, 25, 1, 30, 0, 0, time.UTC), // Tuesday 01:30 + skip: true, + }, + // Weekly recurrence where the previous day is not in RepeatOn + { + name: "weekly-across-midnight-previous-day-not-in-repeaton", + maintenance: &PlannedMaintenance{ + Schedule: &Schedule{ + Timezone: "UTC", + Recurrence: &Recurrence{ + StartTime: time.Date(2024, 4, 2, 22, 0, 0, 0, time.UTC), // Tuesday 22:00 + Duration: Duration(time.Hour * 4), // Until Wednesday 02:00 + RepeatType: RepeatTypeWeekly, + RepeatOn: []RepeatOn{RepeatOnTuesday}, // Only Tuesday + }, + }, + }, + ts: time.Date(2024, 4, 3, 1, 30, 0, 0, time.UTC), // Wednesday 01:30 + skip: true, + }, + // Daily recurrence with midnight crossing + { + name: "daily-maintenance-across-midnight", + maintenance: &PlannedMaintenance{ + Schedule: &Schedule{ + Timezone: "UTC", + Recurrence: &Recurrence{ + StartTime: time.Date(2024, 1, 1, 23, 0, 0, 0, time.UTC), // 23:00 + Duration: Duration(time.Hour * 2), // Until 01:00 next day + RepeatType: RepeatTypeDaily, + }, + }, + }, + ts: time.Date(2024, 1, 2, 0, 30, 0, 0, time.UTC), // 00:30 next day + skip: true, + }, + // Exactly at start time boundary + { + name: "at-start-time-boundary", + maintenance: &PlannedMaintenance{ + Schedule: &Schedule{ + Timezone: "UTC", + Recurrence: &Recurrence{ + StartTime: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC), + Duration: Duration(time.Hour * 2), + RepeatType: RepeatTypeDaily, + }, + }, + }, + ts: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC), // Exactly at start time + skip: true, + }, + // Exactly at end time boundary + { + name: "at-end-time-boundary", + maintenance: &PlannedMaintenance{ + Schedule: &Schedule{ + Timezone: "UTC", + Recurrence: &Recurrence{ + StartTime: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC), + Duration: Duration(time.Hour * 2), + RepeatType: RepeatTypeDaily, + }, + }, + }, + ts: time.Date(2024, 1, 1, 14, 0, 0, 0, time.UTC), // Exactly at end time + skip: true, + }, + // Monthly maintenance with multi-day duration + { + name: "monthly-multi-day-duration", + maintenance: &PlannedMaintenance{ + Schedule: &Schedule{ + Timezone: "UTC", + Recurrence: &Recurrence{ + StartTime: time.Date(2024, 1, 28, 12, 0, 0, 0, time.UTC), + Duration: Duration(time.Hour * 72), // 3 days + RepeatType: RepeatTypeMonthly, + }, + }, + }, + ts: time.Date(2024, 1, 30, 12, 30, 0, 0, time.UTC), // Within the 3-day window + skip: true, + }, + // Weekly maintenance with multi-day duration + { + name: "weekly-multi-day-duration", + maintenance: &PlannedMaintenance{ + Schedule: &Schedule{ + Timezone: "UTC", + Recurrence: &Recurrence{ + StartTime: time.Date(2024, 1, 28, 12, 0, 0, 0, time.UTC), + Duration: Duration(time.Hour * 72), // 3 days + RepeatType: RepeatTypeWeekly, + RepeatOn: []RepeatOn{RepeatOnSunday}, + }, + }, + }, + ts: time.Date(2024, 1, 30, 12, 30, 0, 0, time.UTC), // Within the 3-day window + skip: true, + }, + // Monthly maintenance that crosses to next month + { + name: "monthly-crosses-to-next-month", + maintenance: &PlannedMaintenance{ + Schedule: &Schedule{ + Timezone: "UTC", + Recurrence: &Recurrence{ + StartTime: time.Date(2024, 1, 30, 12, 0, 0, 0, time.UTC), + Duration: Duration(time.Hour * 48), // 2 days, crosses to Feb 1 + RepeatType: RepeatTypeMonthly, + }, + }, + }, + ts: time.Date(2024, 2, 1, 11, 0, 0, 0, time.UTC), // Feb 1, 11:00 + skip: true, + }, + // Different timezone tests + { + name: "timezone-offset-test", + maintenance: &PlannedMaintenance{ + Schedule: &Schedule{ + Timezone: "America/New_York", // UTC-5 or UTC-4 depending on DST + Recurrence: &Recurrence{ + StartTime: time.Date(2024, 1, 1, 22, 0, 0, 0, time.FixedZone("America/New_York", -5*3600)), + Duration: Duration(time.Hour * 4), + RepeatType: RepeatTypeDaily, + }, + }, + }, + ts: time.Date(2024, 1, 2, 3, 30, 0, 0, time.UTC), // 22:30 NY time on Jan 1 + skip: true, + }, + // Test negative case - time well outside window + { + name: "daily-maintenance-time-outside-window", + maintenance: &PlannedMaintenance{ + Schedule: &Schedule{ + Timezone: "UTC", + Recurrence: &Recurrence{ + StartTime: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC), + Duration: Duration(time.Hour * 2), + RepeatType: RepeatTypeDaily, + }, + }, + }, + ts: time.Date(2024, 1, 1, 16, 0, 0, 0, time.UTC), // 4 hours after start, 2 hours after end + skip: false, + }, + // Test for recurring maintenance with an end date that is before the current time + { + name: "recurring-maintenance-with-past-end-date", + maintenance: &PlannedMaintenance{ + Schedule: &Schedule{ + Timezone: "UTC", + Recurrence: &Recurrence{ + StartTime: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC), + EndTime: timePtr(time.Date(2024, 1, 10, 12, 0, 0, 0, time.UTC)), + Duration: Duration(time.Hour * 2), + RepeatType: RepeatTypeDaily, + }, + }, + }, + ts: time.Date(2024, 1, 15, 12, 30, 0, 0, time.UTC), // After the end date + skip: false, + }, + // Monthly recurring maintenance spanning end of month into beginning of next month + { + name: "monthly-maintenance-spans-month-end", + maintenance: &PlannedMaintenance{ + Schedule: &Schedule{ + Timezone: "UTC", + Recurrence: &Recurrence{ + StartTime: time.Date(2024, 3, 31, 22, 0, 0, 0, time.UTC), // March 31, 22:00 + Duration: Duration(time.Hour * 6), // Until April 1, 04:00 + RepeatType: RepeatTypeMonthly, + }, + }, + }, + ts: time.Date(2024, 4, 1, 2, 0, 0, 0, time.UTC), // April 1, 02:00 + skip: true, + }, + // Test for RepeatOn with empty array (should apply to all days) + { + name: "weekly-empty-repeaton", + maintenance: &PlannedMaintenance{ + Schedule: &Schedule{ + Timezone: "UTC", + Recurrence: &Recurrence{ + StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC), + Duration: Duration(time.Hour * 2), + RepeatType: RepeatTypeWeekly, + RepeatOn: []RepeatOn{}, // Empty - should apply to all days + }, + }, + }, + ts: time.Date(2024, 4, 7, 12, 30, 0, 0, time.UTC), // Sunday + skip: true, + }, + // February has fewer days than January - test the edge case when maintenance is on 31st + { + name: "monthly-maintenance-february-fewer-days", + maintenance: &PlannedMaintenance{ + Schedule: &Schedule{ + Timezone: "UTC", + Recurrence: &Recurrence{ + StartTime: time.Date(2024, 1, 31, 12, 0, 0, 0, time.UTC), // January 31st + Duration: Duration(time.Hour * 2), + RepeatType: RepeatTypeMonthly, + }, + }, + }, + ts: time.Date(2024, 2, 28, 12, 30, 0, 0, time.UTC), // February 28th (not 29th in this test) + skip: false, + }, + { + name: "daily-maintenance-crosses-midnight", + maintenance: &PlannedMaintenance{ + Schedule: &Schedule{ + Timezone: "UTC", + Recurrence: &Recurrence{ + StartTime: time.Date(2024, 1, 1, 23, 30, 0, 0, time.UTC), + Duration: Duration(time.Hour * 1), // Crosses to 00:30 next day + RepeatType: RepeatTypeDaily, + }, + }, + }, + ts: time.Date(2024, 1, 2, 0, 15, 0, 0, time.UTC), + skip: true, + }, + { + name: "monthly-maintenance-crosses-month-end", + maintenance: &PlannedMaintenance{ + Schedule: &Schedule{ + Timezone: "UTC", + Recurrence: &Recurrence{ + StartTime: time.Date(2024, 1, 31, 12, 0, 0, 0, time.UTC), // January 31st + Duration: Duration(time.Hour * 2), + RepeatType: RepeatTypeMonthly, + }, + }, + }, + ts: time.Date(2024, 2, 29, 12, 30, 0, 0, time.UTC), + skip: true, + }, + { + name: "monthly-maintenance-crosses-month-end-and-duration-is-2-days", + maintenance: &PlannedMaintenance{ + Schedule: &Schedule{ + Timezone: "UTC", + Recurrence: &Recurrence{ + StartTime: time.Date(2024, 1, 30, 12, 0, 0, 0, time.UTC), + Duration: Duration(time.Hour * 48), // 2 days duration + RepeatType: RepeatTypeMonthly, + }, + }, + }, + ts: time.Date(2024, 2, 1, 11, 0, 0, 0, time.UTC), + skip: true, + }, + { + name: "weekly-maintenance-crosses-midnight", + maintenance: &PlannedMaintenance{ + Schedule: &Schedule{ + Timezone: "UTC", + Recurrence: &Recurrence{ + StartTime: time.Date(2024, 4, 1, 23, 0, 0, 0, time.UTC), // Monday 23:00 + Duration: Duration(time.Hour * 2), // Until Tuesday 01:00 + RepeatType: RepeatTypeWeekly, + RepeatOn: []RepeatOn{RepeatOnMonday}, // Only Monday + }, + }, + }, + ts: time.Date(2024, 4, 2, 0, 30, 0, 0, time.UTC), + skip: true, + }, + { + name: "monthly-maintenance-crosses-month-end-and-duration-is-2-days", + maintenance: &PlannedMaintenance{ + Schedule: &Schedule{ + Timezone: "UTC", + Recurrence: &Recurrence{ + StartTime: time.Date(2024, 1, 31, 12, 0, 0, 0, time.UTC), // January 31st + Duration: Duration(time.Hour * 2), + RepeatType: RepeatTypeMonthly, + }, + }, + }, + ts: time.Date(2024, 4, 30, 12, 30, 0, 0, time.UTC), + skip: true, + }, + { + name: "daily-maintenance-crosses-midnight", + maintenance: &PlannedMaintenance{ + Schedule: &Schedule{ + Timezone: "UTC", + Recurrence: &Recurrence{ + StartTime: time.Date(2024, 1, 1, 22, 0, 0, 0, time.UTC), + Duration: Duration(time.Hour * 4), // Until 02:00 next day + RepeatType: RepeatTypeDaily, + }, + }, + }, + ts: time.Date(2024, 1, 2, 1, 0, 0, 0, time.UTC), + skip: true, + }, + { + name: "monthly-maintenance-crosses-month-end-and-duration-is-2-hours", + maintenance: &PlannedMaintenance{ + Schedule: &Schedule{ + Timezone: "UTC", + Recurrence: &Recurrence{ + StartTime: time.Date(2024, 1, 31, 12, 0, 0, 0, time.UTC), + Duration: Duration(time.Hour * 2), + RepeatType: RepeatTypeMonthly, + }, + }, + }, + ts: time.Date(2024, 2, 29, 12, 30, 0, 0, time.UTC), + skip: true, + }, { name: "fixed planned maintenance start <= ts <= end", maintenance: &PlannedMaintenance{ @@ -22,8 +412,8 @@ func TestShouldSkipMaintenance(t *testing.T) { EndTime: time.Now().UTC().Add(time.Hour * 2), }, }, - ts: time.Now().UTC(), - expected: true, + ts: time.Now().UTC(), + skip: true, }, { name: "fixed planned maintenance start >= ts", @@ -34,8 +424,8 @@ func TestShouldSkipMaintenance(t *testing.T) { EndTime: time.Now().UTC().Add(time.Hour * 2), }, }, - ts: time.Now().UTC(), - expected: false, + ts: time.Now().UTC(), + skip: false, }, { name: "fixed planned maintenance ts < start", @@ -46,8 +436,24 @@ func TestShouldSkipMaintenance(t *testing.T) { EndTime: time.Now().UTC().Add(time.Hour * 2), }, }, - ts: time.Now().UTC().Add(-time.Hour), - expected: false, + ts: time.Now().UTC().Add(-time.Hour), + skip: false, + }, + { + name: "recurring maintenance, repeat sunday, saturday, weekly for 24 hours, in Us/Eastern timezone", + maintenance: &PlannedMaintenance{ + Schedule: &Schedule{ + Timezone: "US/Eastern", + Recurrence: &Recurrence{ + StartTime: time.Date(2025, 3, 29, 20, 0, 0, 0, time.FixedZone("US/Eastern", -4*3600)), + Duration: Duration(time.Hour * 24), + RepeatType: RepeatTypeWeekly, + RepeatOn: []RepeatOn{RepeatOnSunday, RepeatOnSaturday}, + }, + }, + }, + ts: time.Unix(1743343105, 0), + skip: true, }, { name: "recurring maintenance, repeat daily from 12:00 to 14:00", @@ -61,8 +467,8 @@ func TestShouldSkipMaintenance(t *testing.T) { }, }, }, - ts: time.Date(2024, 1, 1, 12, 10, 0, 0, time.UTC), - expected: true, + ts: time.Date(2024, 1, 1, 12, 10, 0, 0, time.UTC), + skip: true, }, { name: "recurring maintenance, repeat daily from 12:00 to 14:00", @@ -76,8 +482,8 @@ func TestShouldSkipMaintenance(t *testing.T) { }, }, }, - ts: time.Date(2024, 1, 1, 14, 0, 0, 0, time.UTC), - expected: false, + ts: time.Date(2024, 1, 1, 14, 0, 0, 0, time.UTC), + skip: true, }, { name: "recurring maintenance, repeat daily from 12:00 to 14:00", @@ -91,8 +497,8 @@ func TestShouldSkipMaintenance(t *testing.T) { }, }, }, - ts: time.Date(2024, 04, 1, 12, 10, 0, 0, time.UTC), - expected: true, + ts: time.Date(2024, 04, 1, 12, 10, 0, 0, time.UTC), + skip: true, }, { name: "recurring maintenance, repeat weekly on monday from 12:00 to 14:00", @@ -107,8 +513,8 @@ func TestShouldSkipMaintenance(t *testing.T) { }, }, }, - ts: time.Date(2024, 04, 15, 12, 10, 0, 0, time.UTC), - expected: true, + ts: time.Date(2024, 04, 15, 12, 10, 0, 0, time.UTC), + skip: true, }, { name: "recurring maintenance, repeat weekly on monday from 12:00 to 14:00", @@ -123,8 +529,8 @@ func TestShouldSkipMaintenance(t *testing.T) { }, }, }, - ts: time.Date(2024, 04, 14, 12, 10, 0, 0, time.UTC), // 14th 04 is sunday - expected: false, + ts: time.Date(2024, 04, 14, 12, 10, 0, 0, time.UTC), // 14th 04 is sunday + skip: false, }, { name: "recurring maintenance, repeat weekly on monday from 12:00 to 14:00", @@ -139,8 +545,8 @@ func TestShouldSkipMaintenance(t *testing.T) { }, }, }, - ts: time.Date(2024, 04, 16, 12, 10, 0, 0, time.UTC), // 16th 04 is tuesday - expected: false, + ts: time.Date(2024, 04, 16, 12, 10, 0, 0, time.UTC), // 16th 04 is tuesday + skip: false, }, { name: "recurring maintenance, repeat weekly on monday from 12:00 to 14:00", @@ -155,8 +561,8 @@ func TestShouldSkipMaintenance(t *testing.T) { }, }, }, - ts: time.Date(2024, 05, 06, 12, 10, 0, 0, time.UTC), - expected: true, + ts: time.Date(2024, 05, 06, 12, 10, 0, 0, time.UTC), + skip: true, }, { name: "recurring maintenance, repeat weekly on monday from 12:00 to 14:00", @@ -171,8 +577,8 @@ func TestShouldSkipMaintenance(t *testing.T) { }, }, }, - ts: time.Date(2024, 05, 06, 14, 00, 0, 0, time.UTC), - expected: false, + ts: time.Date(2024, 05, 06, 14, 00, 0, 0, time.UTC), + skip: true, }, { name: "recurring maintenance, repeat monthly on 4th from 12:00 to 14:00", @@ -186,8 +592,8 @@ func TestShouldSkipMaintenance(t *testing.T) { }, }, }, - ts: time.Date(2024, 04, 04, 12, 10, 0, 0, time.UTC), - expected: true, + ts: time.Date(2024, 04, 04, 12, 10, 0, 0, time.UTC), + skip: true, }, { name: "recurring maintenance, repeat monthly on 4th from 12:00 to 14:00", @@ -201,8 +607,8 @@ func TestShouldSkipMaintenance(t *testing.T) { }, }, }, - ts: time.Date(2024, 04, 04, 14, 10, 0, 0, time.UTC), - expected: false, + ts: time.Date(2024, 04, 04, 14, 10, 0, 0, time.UTC), + skip: false, }, { name: "recurring maintenance, repeat monthly on 4th from 12:00 to 14:00", @@ -216,15 +622,15 @@ func TestShouldSkipMaintenance(t *testing.T) { }, }, }, - ts: time.Date(2024, 05, 04, 12, 10, 0, 0, time.UTC), - expected: true, + ts: time.Date(2024, 05, 04, 12, 10, 0, 0, time.UTC), + skip: true, }, } - for _, c := range cases { + for idx, c := range cases { result := c.maintenance.shouldSkip(c.name, c.ts) - if result != c.expected { - t.Errorf("expected %v, got %v", c.expected, result) + if result != c.skip { + t.Errorf("skip %v, got %v, case:%d - %s", c.skip, result, idx, c.name) } } }