Skip to content

Commit f0a4c37

Browse files
authored
fix: handle maintenance windows that cross day boundaries (#7494)
1 parent 7972261 commit f0a4c37

File tree

2 files changed

+596
-110
lines changed

2 files changed

+596
-110
lines changed

pkg/query-service/rules/maintenance.go

Lines changed: 158 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,6 @@ package rules
33
import (
44
"database/sql/driver"
55
"encoding/json"
6-
"slices"
7-
"strings"
86
"time"
97

108
"github.com/pkg/errors"
@@ -84,6 +82,16 @@ const (
8482
RepeatOnSaturday RepeatOn = "saturday"
8583
)
8684

85+
var RepeatOnAllMap = map[RepeatOn]time.Weekday{
86+
RepeatOnSunday: time.Sunday,
87+
RepeatOnMonday: time.Monday,
88+
RepeatOnTuesday: time.Tuesday,
89+
RepeatOnWednesday: time.Wednesday,
90+
RepeatOnThursday: time.Thursday,
91+
RepeatOnFriday: time.Friday,
92+
RepeatOnSaturday: time.Saturday,
93+
}
94+
8795
type Recurrence struct {
8896
StartTime time.Time `json:"startTime"`
8997
EndTime *time.Time `json:"endTime,omitempty"`
@@ -211,7 +219,7 @@ func (s *Schedule) UnmarshalJSON(data []byte) error {
211219
}
212220

213221
func (m *PlannedMaintenance) shouldSkip(ruleID string, now time.Time) bool {
214-
222+
// Check if the alert ID is in the maintenance window
215223
found := false
216224
if m.AlertIds != nil {
217225
for _, alertID := range *m.AlertIds {
@@ -227,97 +235,162 @@ func (m *PlannedMaintenance) shouldSkip(ruleID string, now time.Time) bool {
227235
found = true
228236
}
229237

230-
if found {
238+
if !found {
239+
return false
240+
}
231241

232-
zap.L().Info("alert found in maintenance", zap.String("alert", ruleID), zap.Any("maintenance", m.Name))
242+
zap.L().Info("alert found in maintenance", zap.String("alert", ruleID), zap.String("maintenance", m.Name))
233243

234-
// If alert is found, we check if it should be skipped based on the schedule
235-
// If it should be skipped, we return true
236-
// If it should not be skipped, we return false
244+
// If alert is found, we check if it should be skipped based on the schedule
245+
loc, err := time.LoadLocation(m.Schedule.Timezone)
246+
if err != nil {
247+
zap.L().Error("Error loading location", zap.String("timezone", m.Schedule.Timezone), zap.Error(err))
248+
return false
249+
}
237250

238-
// fixed schedule
239-
if !m.Schedule.StartTime.IsZero() && !m.Schedule.EndTime.IsZero() {
240-
// if the current time in the timezone is between the start and end time
241-
loc, err := time.LoadLocation(m.Schedule.Timezone)
242-
if err != nil {
243-
zap.L().Error("Error loading location", zap.String("timezone", m.Schedule.Timezone), zap.Error(err))
244-
return false
245-
}
251+
currentTime := now.In(loc)
246252

247-
currentTime := now.In(loc)
248-
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))
249-
if currentTime.After(m.Schedule.StartTime) && currentTime.Before(m.Schedule.EndTime) {
250-
return true
251-
}
253+
// fixed schedule
254+
if !m.Schedule.StartTime.IsZero() && !m.Schedule.EndTime.IsZero() {
255+
zap.L().Info("checking fixed schedule",
256+
zap.String("rule", ruleID),
257+
zap.String("maintenance", m.Name),
258+
zap.Time("currentTime", currentTime),
259+
zap.Time("startTime", m.Schedule.StartTime),
260+
zap.Time("endTime", m.Schedule.EndTime))
261+
262+
startTime := m.Schedule.StartTime.In(loc)
263+
endTime := m.Schedule.EndTime.In(loc)
264+
if currentTime.Equal(startTime) || currentTime.Equal(endTime) ||
265+
(currentTime.After(startTime) && currentTime.Before(endTime)) {
266+
return true
252267
}
268+
}
253269

254-
// recurring schedule
255-
if m.Schedule.Recurrence != nil {
256-
zap.L().Info("evaluating recurrence schedule")
257-
start := m.Schedule.Recurrence.StartTime
258-
end := m.Schedule.Recurrence.StartTime.Add(time.Duration(m.Schedule.Recurrence.Duration))
259-
// if the current time in the timezone is between the start and end time
260-
loc, err := time.LoadLocation(m.Schedule.Timezone)
261-
if err != nil {
262-
zap.L().Error("Error loading location", zap.String("timezone", m.Schedule.Timezone), zap.Error(err))
270+
// recurring schedule
271+
if m.Schedule.Recurrence != nil {
272+
start := m.Schedule.Recurrence.StartTime
273+
duration := time.Duration(m.Schedule.Recurrence.Duration)
274+
275+
zap.L().Info("checking recurring schedule base info",
276+
zap.String("rule", ruleID),
277+
zap.String("maintenance", m.Name),
278+
zap.Time("startTime", start),
279+
zap.Duration("duration", duration))
280+
281+
// Make sure the recurrence has started
282+
if currentTime.Before(start.In(loc)) {
283+
zap.L().Info("current time is before recurrence start time",
284+
zap.String("rule", ruleID),
285+
zap.String("maintenance", m.Name))
286+
return false
287+
}
288+
289+
// Check if recurrence has expired
290+
if m.Schedule.Recurrence.EndTime != nil {
291+
endTime := *m.Schedule.Recurrence.EndTime
292+
if !endTime.IsZero() && currentTime.After(endTime.In(loc)) {
293+
zap.L().Info("current time is after recurrence end time",
294+
zap.String("rule", ruleID),
295+
zap.String("maintenance", m.Name))
263296
return false
264297
}
265-
currentTime := now.In(loc)
298+
}
266299

267-
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))
300+
switch m.Schedule.Recurrence.RepeatType {
301+
case RepeatTypeDaily:
302+
return m.checkDaily(currentTime, m.Schedule.Recurrence, loc)
303+
case RepeatTypeWeekly:
304+
return m.checkWeekly(currentTime, m.Schedule.Recurrence, loc)
305+
case RepeatTypeMonthly:
306+
return m.checkMonthly(currentTime, m.Schedule.Recurrence, loc)
307+
}
308+
}
268309

269-
// make sure the start time is not after the current time
270-
if currentTime.Before(start.In(loc)) {
271-
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)))
272-
return false
273-
}
310+
return false
311+
}
274312

275-
var endTime time.Time
276-
if m.Schedule.Recurrence.EndTime != nil {
277-
endTime = *m.Schedule.Recurrence.EndTime
278-
}
279-
if !endTime.IsZero() && currentTime.After(endTime.In(loc)) {
280-
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)))
281-
return false
282-
}
313+
// checkDaily rebases the recurrence start to today (or yesterday if needed)
314+
// and returns true if currentTime is within [candidate, candidate+Duration].
315+
func (m *PlannedMaintenance) checkDaily(currentTime time.Time, rec *Recurrence, loc *time.Location) bool {
316+
candidate := time.Date(
317+
currentTime.Year(), currentTime.Month(), currentTime.Day(),
318+
rec.StartTime.Hour(), rec.StartTime.Minute(), 0, 0,
319+
loc,
320+
)
321+
if candidate.After(currentTime) {
322+
candidate = candidate.AddDate(0, 0, -1)
323+
}
324+
return currentTime.Sub(candidate) <= time.Duration(rec.Duration)
325+
}
283326

284-
switch m.Schedule.Recurrence.RepeatType {
285-
case RepeatTypeDaily:
286-
// take the hours and minutes from the start time and add them to the current time
287-
startTime := time.Date(currentTime.Year(), currentTime.Month(), currentTime.Day(), start.Hour(), start.Minute(), 0, 0, loc)
288-
endTime := time.Date(currentTime.Year(), currentTime.Month(), currentTime.Day(), end.Hour(), end.Minute(), 0, 0, loc)
289-
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))
290-
291-
if currentTime.After(startTime) && currentTime.Before(endTime) {
292-
return true
293-
}
294-
case RepeatTypeWeekly:
295-
// if the current time in the timezone is between the start and end time on the RepeatOn day
296-
startTime := time.Date(currentTime.Year(), currentTime.Month(), currentTime.Day(), start.Hour(), start.Minute(), 0, 0, loc)
297-
endTime := time.Date(currentTime.Year(), currentTime.Month(), currentTime.Day(), end.Hour(), end.Minute(), 0, 0, loc)
298-
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))
299-
if currentTime.After(startTime) && currentTime.Before(endTime) {
300-
if len(m.Schedule.Recurrence.RepeatOn) == 0 {
301-
return true
302-
} else if slices.Contains(m.Schedule.Recurrence.RepeatOn, RepeatOn(strings.ToLower(currentTime.Weekday().String()))) {
303-
return true
304-
}
305-
}
306-
case RepeatTypeMonthly:
307-
// if the current time in the timezone is between the start and end time on the day of the current month
308-
startTime := time.Date(currentTime.Year(), currentTime.Month(), start.Day(), start.Hour(), start.Minute(), 0, 0, loc)
309-
endTime := time.Date(currentTime.Year(), currentTime.Month(), end.Day(), end.Hour(), end.Minute(), 0, 0, loc)
310-
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))
311-
if currentTime.After(startTime) && currentTime.Before(endTime) && currentTime.Day() == start.Day() {
312-
return true
313-
}
314-
}
327+
// checkWeekly finds the most recent allowed occurrence by rebasing the recurrence’s
328+
// time-of-day onto the allowed weekday. It does this for each allowed day and returns true
329+
// if the current time falls within the candidate window.
330+
func (m *PlannedMaintenance) checkWeekly(currentTime time.Time, rec *Recurrence, loc *time.Location) bool {
331+
// If no days specified, treat as every day (like daily).
332+
if len(rec.RepeatOn) == 0 {
333+
return m.checkDaily(currentTime, rec, loc)
334+
}
335+
336+
for _, day := range rec.RepeatOn {
337+
allowedDay, ok := RepeatOnAllMap[day]
338+
if !ok {
339+
continue // skip invalid days
340+
}
341+
// Compute the day difference: allowedDay - current weekday.
342+
delta := int(allowedDay) - int(currentTime.Weekday())
343+
// Build a candidate occurrence by rebasing today's date to the allowed weekday.
344+
candidate := time.Date(
345+
currentTime.Year(), currentTime.Month(), currentTime.Day(),
346+
rec.StartTime.Hour(), rec.StartTime.Minute(), 0, 0,
347+
loc,
348+
).AddDate(0, 0, delta)
349+
// If the candidate is in the future, subtract 7 days.
350+
if candidate.After(currentTime) {
351+
candidate = candidate.AddDate(0, 0, -7)
352+
}
353+
if currentTime.Sub(candidate) <= time.Duration(rec.Duration) {
354+
return true
315355
}
316356
}
317-
// If alert is not found, we return false
318357
return false
319358
}
320359

360+
// checkMonthly rebases the candidate occurrence using the recurrence's day-of-month.
361+
// If the candidate for the current month is in the future, it uses the previous month.
362+
func (m *PlannedMaintenance) checkMonthly(currentTime time.Time, rec *Recurrence, loc *time.Location) bool {
363+
refDay := rec.StartTime.Day()
364+
year, month, _ := currentTime.Date()
365+
lastDay := time.Date(year, month+1, 0, 0, 0, 0, 0, loc).Day()
366+
day := refDay
367+
if refDay > lastDay {
368+
day = lastDay
369+
}
370+
candidate := time.Date(year, month, day,
371+
rec.StartTime.Hour(), rec.StartTime.Minute(), rec.StartTime.Second(), rec.StartTime.Nanosecond(),
372+
loc,
373+
)
374+
if candidate.After(currentTime) {
375+
// Use previous month.
376+
candidate = candidate.AddDate(0, -1, 0)
377+
y, m, _ := candidate.Date()
378+
lastDayPrev := time.Date(y, m+1, 0, 0, 0, 0, 0, loc).Day()
379+
if refDay > lastDayPrev {
380+
candidate = time.Date(y, m, lastDayPrev,
381+
rec.StartTime.Hour(), rec.StartTime.Minute(), rec.StartTime.Second(), rec.StartTime.Nanosecond(),
382+
loc,
383+
)
384+
} else {
385+
candidate = time.Date(y, m, refDay,
386+
rec.StartTime.Hour(), rec.StartTime.Minute(), rec.StartTime.Second(), rec.StartTime.Nanosecond(),
387+
loc,
388+
)
389+
}
390+
}
391+
return currentTime.Sub(candidate) <= time.Duration(rec.Duration)
392+
}
393+
321394
func (m *PlannedMaintenance) IsActive(now time.Time) bool {
322395
ruleID := "maintenance"
323396
if m.AlertIds != nil && len(*m.AlertIds) > 0 {
@@ -327,7 +400,14 @@ func (m *PlannedMaintenance) IsActive(now time.Time) bool {
327400
}
328401

329402
func (m *PlannedMaintenance) IsUpcoming() bool {
330-
now := time.Now().In(time.FixedZone(m.Schedule.Timezone, 0))
403+
loc, err := time.LoadLocation(m.Schedule.Timezone)
404+
if err != nil {
405+
// handle error appropriately, for example log and return false or fallback to UTC
406+
zap.L().Error("Error loading timezone", zap.String("timezone", m.Schedule.Timezone), zap.Error(err))
407+
return false
408+
}
409+
now := time.Now().In(loc)
410+
331411
if !m.Schedule.StartTime.IsZero() && !m.Schedule.EndTime.IsZero() {
332412
return now.Before(m.Schedule.StartTime)
333413
}

0 commit comments

Comments
 (0)