Integrate reviewed local-clock quiet-hours repair

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot] 2026-10-01 22:23:20 +01:00
commit 452ca8ed10
4 changed files with 318 additions and 77 deletions

View file

@ -1982,6 +1982,20 @@ keep schedules such as `00:00` to `23:59` active through the full final
minute instead of expiring at `23:59:00`. Alert quiet-hours proofs should
control time through the alert manager clock hook instead of depending on wall
clock execution at whatever second the test runner happens to hit.
The schedule compares local civil clock minutes on the selected calendar day,
not `time.Date` instants. Both occurrences of a repeated minute have the same
quiet-hours policy; nonexistent clock minutes do not shift the configured
start or end. Initial queued replay uses the first real minute outside the
current non-full-day window, including an unselected day reached at midnight.
This does not reinterpret the day toggles as the previous evening's ownership.
Full-day windows retain the existing daily replay boundary; this clock repair
is not current-policy revalidation of already queued work or external receipt.
`TestQuietHoursDaylightSavingClockAndReplay` in
`internal/alerts/quiet_hours_test.go` checks London, New York and Lord Howe
(one-hour and half-hour changes), inclusive boundaries and exact UTC replay.
`TestQuietHoursOvernightSelectedCalendarDays` checks overnight day selection.
Quiet-hours location fallback is read-only; configuration updates own the
location cache, including when public suppression helpers run concurrently.
Quiet-hours suppression also applies to alert delivery lifecycle, not only the
initial raised notification. Resolved notifications must not fan out when the
alert was never notified or was already acknowledged, and monitoring-driven

View file

@ -2778,6 +2778,7 @@
"internal/alerts/incident_synthesis_test.go",
"internal/alerts/intent_policy_test.go",
"internal/alerts/operational_contract_test.go",
"internal/alerts/quiet_hours_test.go",
"internal/alerts/reducer/ack_test.go",
"internal/alerts/reducer/discrete_test.go",
"internal/alerts/reducer/intent_test.go",

View file

@ -385,70 +385,66 @@ func isSupportedInfrastructureSymptom(alert *Alert) bool {
strings.TrimSpace(alert.Correlation.PrimaryAlertID) != ""
}
// isInQuietHours checks if the current time is within quiet hours
func (m *Manager) isInQuietHours() bool {
if !m.config.Schedule.QuietHours.Enabled {
return false
}
// quietHoursClock compares civil clock minutes, not date-constructed instants.
// time.Date chooses only one side of a repeated hour and normalizes missing
// minutes to a different hour, neither of which is the user's daily schedule.
type quietHoursClock struct {
location *time.Location
start int
end int
days [7]bool
}
// Use cached location if available
func (m *Manager) quietHoursClock() (quietHoursClock, bool) {
quiet := m.config.Schedule.QuietHours
if !quiet.Enabled {
return quietHoursClock{}, false
}
loc := m.quietHoursLoc
if loc == nil {
// Fallback to loading if not cached yet (shouldn't happen with UpdateConfig)
var err error
loc, err = time.LoadLocation(m.config.Schedule.QuietHours.Timezone)
loc, err = time.LoadLocation(quiet.Timezone)
if err != nil {
log.Warn().Err(err).Str("timezone", m.config.Schedule.QuietHours.Timezone).Msg("failed to load timezone, using local time")
log.Warn().Err(err).Str("timezone", quiet.Timezone).Msg("failed to load timezone, using local time")
loc = time.Local
}
m.quietHoursLoc = loc
// Do not populate the cache here: public suppression helpers can call
// this path while holding only a read lock. UpdateConfig owns the cache.
}
nowFn := m.now
if nowFn == nil {
nowFn = time.Now
}
now := nowFn().In(loc).Truncate(time.Minute)
dayName := strings.ToLower(now.Format("Monday"))
// Check if today is enabled for quiet hours
if enabled, ok := m.config.Schedule.QuietHours.Days[dayName]; !ok || !enabled {
return false
}
// Parse start and end times
startTime, err := time.ParseInLocation("15:04", m.config.Schedule.QuietHours.Start, loc)
start, err := time.Parse("15:04", quiet.Start)
if err != nil {
log.Warn().Err(err).Str("start", m.config.Schedule.QuietHours.Start).Msg("failed to parse quiet hours start time")
return false
log.Warn().Err(err).Str("start", quiet.Start).Msg("failed to parse quiet hours start time")
return quietHoursClock{}, false
}
endTime, err := time.ParseInLocation("15:04", m.config.Schedule.QuietHours.End, loc)
end, err := time.Parse("15:04", quiet.End)
if err != nil {
log.Warn().Err(err).Str("end", m.config.Schedule.QuietHours.End).Msg("failed to parse quiet hours end time")
log.Warn().Err(err).Str("end", quiet.End).Msg("failed to parse quiet hours end time")
return quietHoursClock{}, false
}
clock := quietHoursClock{location: loc, start: start.Hour()*60 + start.Minute(), end: end.Hour()*60 + end.Minute()}
for i, day := range [...]string{"sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"} {
clock.days[i] = quiet.Days[day]
}
return clock, true
}
func (clock quietHoursClock) contains(at time.Time) bool {
local := at.In(clock.location)
if !clock.days[local.Weekday()] {
return false
}
// Set to today's date
startTime = time.Date(now.Year(), now.Month(), now.Day(), startTime.Hour(), startTime.Minute(), 0, 0, loc)
endTime = time.Date(now.Year(), now.Month(), now.Day(), endTime.Hour(), endTime.Minute(), 0, 0, loc)
// Quiet hours are configured with minute precision, so treat the start and
// end minute as inclusive for user-facing schedules such as 00:00-23:59.
endExclusive := endTime.Add(time.Minute)
// Handle overnight quiet hours (e.g., 22:00 to 08:00)
if endTime.Before(startTime) {
if !now.Before(startTime) || now.Before(endExclusive) {
return true
}
} else {
if !now.Before(startTime) && now.Before(endExclusive) {
return true
}
minute := local.Hour()*60 + local.Minute()
if clock.start > clock.end {
return minute >= clock.start || minute <= clock.end
}
return minute >= clock.start && minute <= clock.end
}
return false
// isInQuietHours checks the selected local calendar day and includes the whole
// configured end minute, including both copies of it during a clock rollback.
func (m *Manager) isInQuietHours() bool {
clock, valid := m.quietHoursClock()
return valid && clock.contains(m.policyNow())
}
func quietHoursCategoryForAlert(alert *Alert) string {
@ -518,41 +514,42 @@ func (m *Manager) shouldSuppressNotification(alert *Alert) (bool, string) {
}
func (m *Manager) quietHoursReplayAt() time.Time {
nowFn := m.now
if nowFn == nil {
nowFn = time.Now
now := m.policyNow()
clock, valid := m.quietHoursClock()
if !valid || !clock.contains(now) {
return now.Add(time.Minute).UTC()
}
now := nowFn()
loc := m.quietHoursLoc
if loc == nil {
var err error
loc, err = time.LoadLocation(m.config.Schedule.QuietHours.Timezone)
if err != nil {
log.Warn().Err(err).Str("timezone", m.config.Schedule.QuietHours.Timezone).Msg("failed to load timezone for quiet-hours replay, using local time")
loc = time.Local
// A full-day window has no non-quiet clock minute. Preserve its existing
// daily replay boundary; queued-policy revalidation is a separate delivery
// concern, not permission to invent an end to a continuous schedule.
if (clock.end-clock.start+24*60)%(24*60) == 24*60-1 {
local := now.In(clock.location)
endExclusive := time.Date(local.Year(), local.Month(), local.Day(), clock.end/60, clock.end%60, 0, 0, clock.location).Add(time.Minute)
if clock.start > clock.end && local.Hour()*60+local.Minute() >= clock.start {
endExclusive = endExclusive.AddDate(0, 0, 1)
}
if endExclusive.After(now) {
return endExclusive.UTC()
}
m.quietHoursLoc = loc
}
localNow := now.In(loc).Truncate(time.Minute)
startTime, startErr := time.ParseInLocation("15:04", m.config.Schedule.QuietHours.Start, loc)
endTime, endErr := time.ParseInLocation("15:04", m.config.Schedule.QuietHours.End, loc)
if startErr != nil || endErr != nil {
return now.Add(time.Minute).UTC()
}
startTime = time.Date(localNow.Year(), localNow.Month(), localNow.Day(), startTime.Hour(), startTime.Minute(), 0, 0, loc)
endTime = time.Date(localNow.Year(), localNow.Month(), localNow.Day(), endTime.Hour(), endTime.Minute(), 0, 0, loc)
endExclusive := endTime.Add(time.Minute)
if endTime.Before(startTime) && !localNow.Before(startTime) {
endExclusive = endExclusive.AddDate(0, 0, 1)
// Walk real minutes through the current window, rather than constructing
// its end on a local date. This handles repeated ends, skipped ends, half-
// hour DST transitions and a midnight leading into an unselected day. A
// non-full-day window has an exit within three real days even when a
// forward clock change skips its entire gap on the first day.
candidate := now.Truncate(time.Minute).Add(time.Minute)
limit := candidate.Add(72 * time.Hour)
for candidate.Before(limit) {
if !clock.contains(candidate) {
return candidate.UTC()
}
candidate = candidate.Add(time.Minute)
}
if !endExclusive.After(localNow) {
return now.Add(time.Minute).UTC()
}
return endExclusive.UTC()
log.Warn().Msg("could not find quiet-hours window end within three days")
return now.Add(time.Minute).UTC()
}
func markQuietHoursNotificationReplay(alert *Alert, reason string, replayAt time.Time) {

View file

@ -1,6 +1,7 @@
package alerts
import (
"sync"
"testing"
"time"
@ -391,3 +392,231 @@ func TestIsInQuietHours(t *testing.T) {
}
})
}
// Local-clock schedules apply to both copies of a repeated minute. Missing
// minutes do not move a configured start or end into some other wall-clock hour.
func TestQuietHoursDaylightSavingClockAndReplay(t *testing.T) {
for _, tc := range []struct {
name, zone, start, end, now, replay string
quiet bool
}{
{"london-first-repeated-hour", "Europe/London", "01:00", "02:00", "2026-10-25T00:30:00Z", "2026-10-25T02:01:00Z", true},
{"london-second-repeated-hour", "Europe/London", "01:00", "02:00", "2026-10-25T01:30:00Z", "2026-10-25T02:01:00Z", true},
{"london-first-repeated-end", "Europe/London", "00:00", "01:30", "2026-10-25T00:15:00Z", "2026-10-25T00:31:00Z", true},
{"london-second-repeated-end", "Europe/London", "00:00", "01:30", "2026-10-25T01:15:00Z", "2026-10-25T01:31:00Z", true},
{"london-after-first-end", "Europe/London", "00:00", "01:30", "2026-10-25T00:45:00Z", "", false},
{"london-after-second-end", "Europe/London", "00:00", "01:30", "2026-10-25T01:45:00Z", "", false},
{"london-missing-start", "Europe/London", "01:30", "02:30", "2026-03-29T01:15:00Z", "2026-03-29T01:31:00Z", true},
{"london-missing-end-replay", "Europe/London", "00:00", "01:30", "2026-03-29T00:55:30Z", "2026-03-29T01:00:00Z", true},
{"london-after-missing-end", "Europe/London", "00:00", "01:30", "2026-03-29T01:15:00Z", "", false},
{"london-overnight-missing-end", "Europe/London", "22:00", "01:30", "2026-03-28T23:00:00Z", "2026-03-29T01:00:00Z", true},
{"london-overnight-repeated-end", "Europe/London", "22:00", "01:30", "2026-10-24T23:15:00Z", "2026-10-25T00:31:00Z", true},
{"london-end-minute-before-jump", "Europe/London", "00:00", "00:59", "2026-03-29T00:59:59Z", "2026-03-29T01:00:00Z", true},
{"new-york-first-repeated-end", "America/New_York", "00:00", "01:30", "2026-11-01T05:15:00Z", "2026-11-01T05:31:00Z", true},
{"new-york-second-repeated-end", "America/New_York", "00:00", "01:30", "2026-11-01T06:15:00Z", "2026-11-01T06:31:00Z", true},
{"new-york-after-first-end", "America/New_York", "00:00", "01:30", "2026-11-01T05:45:00Z", "", false},
{"new-york-after-second-end", "America/New_York", "00:00", "01:30", "2026-11-01T06:45:00Z", "", false},
{"new-york-missing-start", "America/New_York", "02:30", "03:30", "2026-03-08T07:15:00Z", "2026-03-08T07:31:00Z", true},
{"new-york-missing-end", "America/New_York", "00:00", "02:30", "2026-03-08T06:55:00Z", "2026-03-08T07:00:00Z", true},
{"new-york-after-missing-end", "America/New_York", "00:00", "02:30", "2026-03-08T07:15:00Z", "", false},
{"lord-howe-first-repeated-half-hour", "Australia/Lord_Howe", "01:30", "02:00", "2026-04-04T14:40:00Z", "2026-04-04T15:31:00Z", true},
{"lord-howe-second-repeated-half-hour", "Australia/Lord_Howe", "01:30", "02:00", "2026-04-04T15:10:00Z", "2026-04-04T15:31:00Z", true},
{"lord-howe-missing-start", "Australia/Lord_Howe", "02:00", "02:45", "2026-10-03T15:35:00Z", "2026-10-03T15:46:00Z", true},
{"lord-howe-after-missing-end", "Australia/Lord_Howe", "00:00", "02:15", "2026-10-03T15:40:00Z", "", false},
} {
t.Run(tc.name, func(t *testing.T) {
now, err := time.Parse(time.RFC3339, tc.now)
if err != nil {
t.Fatal(err)
}
m := newTestManager(t)
m.now = func() time.Time { return now }
cfg := m.GetConfig()
cfg.Schedule.QuietHours = QuietHours{Enabled: true, Start: tc.start, End: tc.end, Timezone: tc.zone,
Days: map[string]bool{"monday": true, "tuesday": true, "wednesday": true, "thursday": true, "friday": true, "saturday": true, "sunday": true}}
m.UpdateConfig(cfg)
m.mu.Lock()
defer m.mu.Unlock()
if got := m.isInQuietHours(); got != tc.quiet {
t.Errorf("quiet at %s for %s %s-%s = %v, want %v", now, tc.zone, tc.start, tc.end, got, tc.quiet)
}
if tc.replay == "" {
return
}
want, err := time.Parse(time.RFC3339, tc.replay)
if err != nil {
t.Fatal(err)
}
got := m.quietHoursReplayAt()
if !got.Equal(want) {
t.Errorf("replay = %s, want first eligible real minute %s", got, want)
}
if !got.After(now) {
t.Error("replay must be in the future")
}
now = want.Add(-time.Nanosecond)
if !m.isInQuietHours() {
t.Error("quiet hours ended before the final included minute finished")
}
now = want
if m.isInQuietHours() {
t.Error("computed replay minute is still quiet")
}
})
}
}
func TestQuietHoursOvernightSelectedCalendarDays(t *testing.T) {
m := newTestManager(t)
now := time.Date(2026, 10, 2, 23, 30, 0, 0, time.UTC) // Friday
m.now = func() time.Time { return now }
cfg := m.GetConfig()
cfg.Schedule.QuietHours = QuietHours{Enabled: true, Start: "22:00", End: "06:00", Timezone: "UTC", Days: map[string]bool{"friday": true}}
m.UpdateConfig(cfg)
m.mu.Lock()
defer m.mu.Unlock()
if !m.isInQuietHours() {
t.Fatal("selected Friday evening must be quiet")
}
want := time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC)
if got := m.quietHoursReplayAt(); !got.Equal(want) {
t.Fatalf("unselected Saturday must end suppression at midnight: got %s, want %s", got, want)
}
now = want
if m.isInQuietHours() {
t.Fatal("overnight settings must not silently enable an unselected day")
}
now = now.AddDate(0, 0, 6).Add(6*time.Hour + 59*time.Second) // Friday's inclusive 06:00 minute
if !m.isInQuietHours() || !m.quietHoursReplayAt().Equal(now.Truncate(time.Minute).Add(time.Minute)) {
t.Fatal("selected morning must keep the inclusive end minute")
}
}
func TestQuietHoursReplaySkipsNonexistentGap(t *testing.T) {
// This overnight window has a one-minute daily gap, 01:29. London's
// spring jump skips that entire gap, so the next real exit is Monday.
now := time.Date(2026, 3, 28, 23, 0, 0, 0, time.UTC)
m := fixedQuietHoursTestManager(now, QuietHours{Enabled: true, Start: "01:30", End: "01:28", Timezone: "Europe/London",
Days: map[string]bool{"saturday": true, "sunday": true, "monday": true}})
want := time.Date(2026, 3, 30, 0, 29, 0, 0, time.UTC)
if !m.isInQuietHours() || !m.quietHoursReplayAt().Equal(want) {
t.Fatalf("skipped gap must not release a notification into quiet hours: replay=%s want=%s", m.quietHoursReplayAt(), want)
}
}
func TestQuietHoursFullDayRetainsDailyReplayBoundary(t *testing.T) {
for _, tc := range []struct{ start, end, now, replay string }{
{"00:00", "23:59", "2026-10-01T12:30:00Z", "2026-10-02T00:00:00Z"},
{"22:00", "21:59", "2026-10-01T23:30:00Z", "2026-10-02T22:00:00Z"},
{"22:00", "21:59", "2026-10-01T12:30:00Z", "2026-10-01T22:00:00Z"},
} {
t.Run(tc.start+"/"+tc.now, func(t *testing.T) {
now, _ := time.Parse(time.RFC3339, tc.now)
want, _ := time.Parse(time.RFC3339, tc.replay)
m := fixedQuietHoursTestManager(now, QuietHours{Enabled: true, Start: tc.start, End: tc.end, Timezone: "UTC", Days: map[string]bool{"thursday": true, "friday": true}})
if !m.isInQuietHours() || !m.quietHoursReplayAt().Equal(want) {
t.Fatalf("daily replay changed: %s, want %s", m.quietHoursReplayAt(), want)
}
})
}
}
func TestQuietHoursClockDispatchAndDiagnosis(t *testing.T) {
for _, tc := range []struct {
name, now, start, end, replay string
quiet bool
}{
{"repeated-hour", "2026-10-25T00:30:00Z", "01:00", "02:00", "2026-10-25T02:01:00Z", true},
{"skipped-end", "2026-03-29T00:55:00Z", "00:00", "01:30", "2026-03-29T01:00:00Z", true},
{"after-skipped-end", "2026-03-29T01:15:00Z", "00:00", "01:30", "", false},
} {
for _, critical := range []bool{false, true} {
name := tc.name + "/warning"
if critical {
name = tc.name + "/unsuppressed-critical"
}
t.Run(name, func(t *testing.T) {
now, _ := time.Parse(time.RFC3339, tc.now)
m := newTestManager(t)
m.now = func() time.Time { return now }
cfg := m.GetConfig()
cfg.Enabled = true
cfg.ActivationState = ActivationActive
cfg.FlappingEnabled = false
cfg.Schedule.QuietHours = QuietHours{Enabled: true, Start: tc.start, End: tc.end, Timezone: "Europe/London", Days: map[string]bool{"sunday": true}}
m.UpdateConfig(cfg)
_, alert := testNewCanonicalAlert("vm-100", "vm-100-cpu", "vm", "cpu")
alert.Level = AlertLevelWarning
if critical {
alert.Level = AlertLevelCritical
}
alert.StartTime = now
alert.LastSeen = now
// Old metadata must be cleared when quiet hours no longer apply.
alert.Metadata = map[string]interface{}{MetadataQuietHoursSuppressed: true, MetadataQuietHoursReplayAt: "2099-01-01T00:00:00Z"}
m.mu.Lock()
m.setActiveAlertNoLock(alert.ID, alert)
m.mu.Unlock()
deferred := tc.quiet && !critical
diagnosis, found := m.DiagnoseAlertDelivery(alert.ID)
if !found {
t.Fatal("active occurrence unavailable")
}
if deferred {
want, _ := time.Parse(time.RFC3339, tc.replay)
if diagnosis.Status != AlertDeliveryStatusDeferred || diagnosis.Reason != AlertDeliveryReasonQuietHours+":non-critical" || diagnosis.QuietHoursReplayAt == nil || !diagnosis.QuietHoursReplayAt.Equal(want) {
t.Fatalf("diagnosis disagrees with local-clock policy: %+v", diagnosis)
}
} else if diagnosis.Status != AlertDeliveryStatusWouldSend || diagnosis.Reason != AlertDeliveryReasonReady {
t.Fatalf("eligible alert was diagnosed as held: %+v", diagnosis)
}
var dispatched *Alert
m.SetAlertCallback(func(a *Alert) { dispatched = a })
m.mu.Lock()
admitted := m.dispatchAlert(alert, false)
m.mu.Unlock()
if !admitted || dispatched == nil {
t.Fatal("quiet-hours replay must enter the existing delivery pipeline, not be dropped")
}
if deferred {
if dispatched.Metadata[MetadataQuietHoursSuppressed] != true || dispatched.Metadata[MetadataQuietHoursReplayAt] != tc.replay {
t.Fatalf("pipeline replay timestamp = %#v, want %s", dispatched.Metadata, tc.replay)
}
} else if hasQuietHoursNotificationReplay(dispatched) {
t.Fatalf("eligible alert kept stale deferral metadata: %#v", dispatched.Metadata)
}
// Escalation/bypass sends consult this same public helper.
bypass := cloneAlertForOutput(dispatched)
if m.ShouldSuppressNotification(bypass) || hasQuietHoursNotificationReplay(bypass) != deferred {
t.Fatal("public bypass helper did not apply the same clock policy")
}
})
}
}
}
func TestQuietHoursConcurrentUncachedReaders(t *testing.T) {
now := time.Date(2026, 10, 25, 0, 30, 0, 0, time.UTC)
m := fixedQuietHoursTestManager(now, QuietHours{Enabled: true, Start: "01:00", End: "02:00", Timezone: "Europe/London", Days: map[string]bool{"sunday": true}})
start := make(chan struct{})
var wg sync.WaitGroup
for worker := 0; worker < 32; worker++ {
wg.Add(1)
go func() {
defer wg.Done()
<-start
for i := 0; i < 25; i++ {
alert := &Alert{ID: "uncached-reader", Type: "cpu", Level: AlertLevelWarning}
if m.ShouldSuppressNotification(alert) || alert.Metadata[MetadataQuietHoursReplayAt] != "2026-10-25T02:01:00Z" {
t.Error("concurrent reader disagrees with quiet-hours replay")
return
}
}
}()
}
close(start)
wg.Wait()
if m.quietHoursLoc != nil {
t.Fatal("read-locked evaluation mutated the configuration-owned location cache")
}
}