diff --git a/grafana-alertcheck/internal/gate/resolve_test.go b/grafana-alertcheck/internal/gate/resolve_test.go index 209b84bd5..77fd3bb5d 100644 --- a/grafana-alertcheck/internal/gate/resolve_test.go +++ b/grafana-alertcheck/internal/gate/resolve_test.go @@ -40,6 +40,17 @@ func TestResolve_UIDForm(t *testing.T) { } } +func TestResolve_FolderTitleForm(t *testing.T) { + defs := rulerDefs(t) + resolved, _, err := Resolve(defs, []string{"ExampleFeeds/TEMP - Example depeg alert"}, "") + if err != nil { + t.Fatalf("Resolve: unexpected error: %v", err) + } + if len(resolved) != 1 || resolved[0].UID != "rule0000008" { + t.Fatalf("resolved = %+v, want [rule0000008]", resolved) + } +} + func TestResolve_FolderGroupTitleForm(t *testing.T) { defs := rulerDefs(t) resolved, _, err := Resolve(defs, []string{"Example-Zone-A/Gateway/Example No Gateways Available"}, "") diff --git a/grafana-alertcheck/internal/gate/schedule.go b/grafana-alertcheck/internal/gate/schedule.go new file mode 100644 index 000000000..bf860ddef --- /dev/null +++ b/grafana-alertcheck/internal/gate/schedule.go @@ -0,0 +1,293 @@ +package gate + +import ( + "fmt" + "math/rand/v2" + "sort" + "strings" + "time" +) + +// skewHardLimit is one of §5's filled-in values (basis: §16; §22.11 asserts +// 120s errors, 30s does not). Defined here, in schedule.go's named-constants +// block, per §5's instruction — it moved out of source.go now that P4 exists; +// P2 needed it before this file did, so it started there. +const skewHardLimit = 60 * time.Second + +// minDrainTimeout is §5's floor on drainTimeout: max(2 x max(intervalSeconds), +// 2m). Without the floor, a fleet of very tight rules would derive a +// drainTimeout too short to let a healthy in-flight poll land. +const minDrainTimeout = 2 * time.Minute + +// graceWarnFraction is §13.2's threshold for warning that transitionGrace eats +// too much of the requested window: "approximately one quarter of the window". +const graceWarnFraction = 0.25 + +// ruleTimings groups the per-rule threshold values §5/§10.1/§14.1 derive from +// a rule's poll cadence and its own evaluation interval. +type ruleTimings struct { + pollEvery time.Duration + maxGap time.Duration + healthGrace time.Duration + evalStaleAfter time.Duration +} + +// globalTimings groups the values that apply to the whole run rather than to +// one rule: §13.1's transitionGrace and §19's drainTimeout are each derived +// once, across every non-skipped watched rule, not per rule. +type globalTimings struct { + transitionGrace time.Duration + // graceSource names, and already carries the `for` value of, the rule + // that set transitionGrace (§13.2 requires printing both) — one string + // field rather than a second (rule, duration) pair, matching this + // struct's fixed shape. "none" when no rule contributed (transitionGrace + // is then 0). + graceSource string + drainTimeout time.Duration +} + +// newRuleTimings derives one rule's thresholds from its fully-resolved poll +// cadence and its evaluation interval (§5, §10.1, §14.1). pollEvery arrives +// already resolved for the caller's mode — the §5 default, the operator's +// --poll-interval override, or (in log mode, a later phase) the cadence +// recorded in the log header. Deriving pollEvery inline here, instead of +// accepting it as an input, would let a caller in the wrong mode compute +// maxGap against the wrong authority — see the "Two authorities" note in P5. +func newRuleTimings(pollEvery time.Duration, intervalSeconds int) ruleTimings { + interval := time.Duration(intervalSeconds) * time.Second + maxGap := 2 * pollEvery + healthGrace := max(maxGap, interval) + return ruleTimings{ + pollEvery: pollEvery, + maxGap: maxGap, + healthGrace: healthGrace, + evalStaleAfter: 2 * interval, + } +} + +// defaultPollEvery is §5's default per-rule cadence: half the rule's own +// evaluation interval. +func defaultPollEvery(intervalSeconds int) time.Duration { + return time.Duration(intervalSeconds) * time.Second / 2 +} + +// DeriveTimings computes every resolved rule's ruleTimings, keyed by UID, +// plus the shared globalTimings, from resolved definitions and watch's +// optional --poll-interval override (0 = no override: use each rule's §5 +// default of half its own interval). Per §5.1, a supplied override is used +// verbatim for every rule and is never clamped down to the default even when +// it exceeds intervalSeconds/2 — that case is reported back as a note, not +// silently corrected or refused, because clamping would defeat the one knob +// §5.1 gives an operator for making a tight schedule fit. +func DeriveTimings(defs []Definition, override time.Duration) (rules map[string]ruleTimings, global globalTimings, notes []string) { + rules = make(map[string]ruleTimings, len(defs)) + for _, d := range defs { + def := defaultPollEvery(d.IntervalSeconds) + pollEvery := def + if override > 0 { + pollEvery = override + if override > def { + notes = append(notes, fmt.Sprintf( + "rule %s: --poll-interval %s exceeds half its %ds evaluation interval (%s); maxGap widens accordingly", + d.Title, override, d.IntervalSeconds, def)) + } + } + rules[d.UID] = newRuleTimings(pollEvery, d.IntervalSeconds) + } + return rules, deriveGlobalTimings(defs), notes +} + +// deriveGlobalTimings computes transitionGrace and drainTimeout over defs +// (§5, §13.1, §19). A rule paused before the window opened — skipped, §12 — +// is excluded from the transitionGrace max: its `for` value can never fire +// during the window, so counting it would only inflate the wait past what any +// watched rule actually needs (a judgment call the v2 plan makes explicitly +// for this formula; §19's drainTimeout carries no such exclusion, so it still +// runs over every resolved rule). +func deriveGlobalTimings(defs []Definition) globalTimings { + var g globalTimings + var maxInterval time.Duration + for _, d := range defs { + interval := time.Duration(d.IntervalSeconds) * time.Second + if interval > maxInterval { + maxInterval = interval + } + if d.IsPaused { + continue + } + if candidate := d.For + interval; candidate > g.transitionGrace { + g.transitionGrace = candidate + g.graceSource = fmt.Sprintf("%s (for=%s, interval=%s)", d.Title, d.For, interval) + } + } + g.drainTimeout = max(2*maxInterval, minDrainTimeout) + return g +} + +// Scheduler drives one per-rule schedule, never a global cycle (§5): a rule +// at intervalSeconds=10 alongside twenty at 300 keeps its own 5s cadence +// without forcing the same cadence onto the other twenty. +type Scheduler struct { + next map[string]time.Time + every map[string]time.Duration +} + +// NewScheduler builds a Scheduler over rules (keyed by UID), staggering each +// rule's initial next-due time across [0, pollEvery) so the fleet does not +// start phase-aligned (§5's burst-bound proof depends on this: an +// already-staggered fleet only re-aligns by chance, briefly, not by +// construction). +func NewScheduler(rules map[string]ruleTimings, now time.Time) *Scheduler { + s := &Scheduler{ + next: make(map[string]time.Time, len(rules)), + every: make(map[string]time.Duration, len(rules)), + } + for uid, rt := range rules { + s.every[uid] = rt.pollEvery + var offset time.Duration + if rt.pollEvery > 0 { + offset = rand.N(rt.pollEvery) + } + s.next[uid] = now.Add(offset) + } + return s +} + +// Due returns the UIDs whose next-due time has arrived, earliest-due-first. +// Ties (equal next-due time) break by tightest cadence first: the burst-bound +// proof in §5 assumes a newly-due tight rule waits at most for one in-flight +// request, which only holds if a simultaneous batch serves the tightest rule +// ahead of slacker ones. A tie-break that instead followed map iteration +// order would silently void that proof — nothing else would fail until a +// phase-aligned fleet opened a mid-run gap in production. +func (s *Scheduler) Due(now time.Time) []string { + var due []string + for uid, t := range s.next { + if !t.After(now) { + due = append(due, uid) + } + } + sort.Slice(due, func(i, j int) bool { + a, b := due[i], due[j] + if !s.next[a].Equal(s.next[b]) { + return s.next[a].Before(s.next[b]) + } + if s.every[a] != s.every[b] { + return s.every[a] < s.every[b] + } + return a < b // stable, deterministic fallback for an exact tie + }) + return due +} + +// Mark records that uid was just polled at now, scheduling its next poll one +// cadence later. +func (s *Scheduler) Mark(uid string, now time.Time) { + s.next[uid] = now.Add(s.every[uid]) +} + +// CheckBudget applies §5's error-at-start check to a fully resolved schedule. +// t and measured are both keyed by rule UID; measured must carry every UID in +// t; a rule this run never measured can't have its budget proved, and a +// silent zero-duration default would be exactly the kind of pass-on-an- +// unproven-window bug §5 exists to catch. CheckBudget fails when any of three +// conditions holds (sanity-checked against §22.3's mixed-interval regression +// in this phase's tests): +// +// - utilization: the long-run request rate exceeds what concurrency serves; +// - a single rule's own request cannot fit inside its own cadence; +// - the burst bound: the slowest measured request is slower than the +// fleet's tightest cadence, which — even under earliest-due-first +// ordering — can open a mid-run gap bigger than that rule's maxGap. +// +// The message never suggests a single interval (§5.1) — only the three +// controls an operator actually has: concurrency, poll-interval, and the +// alert list. +func CheckBudget(t map[string]ruleTimings, measured map[string]time.Duration, concurrency int) error { + if len(t) == 0 { + return nil + } + + uids := make([]string, 0, len(t)) + for uid := range t { + uids = append(uids, uid) + } + sort.Strings(uids) // deterministic message order + + for _, uid := range uids { + if _, ok := measured[uid]; !ok { + return fmt.Errorf("schedule budget: rule %s was never measured", uid) + } + } + + var utilization float64 + tightestUID := uids[0] // the rule with the smallest pollEvery seen so far — a UID, not a duration + var maxMeasuredUID string + var maxMeasured time.Duration + var overCadence []string + for _, uid := range uids { + rt, m := t[uid], measured[uid] + utilization += float64(m) / float64(rt.pollEvery) + if t[tightestUID].pollEvery > rt.pollEvery { + tightestUID = uid + } + if m > maxMeasured { + maxMeasured, maxMeasuredUID = m, uid + } + if m > rt.pollEvery { + overCadence = append(overCadence, uid) + } + } + + var problems []string + if utilization > float64(concurrency) { + problems = append(problems, fmt.Sprintf("utilization %.2f exceeds concurrency %d", utilization, concurrency)) + } + for _, uid := range overCadence { + problems = append(problems, fmt.Sprintf( + "rule %s: measured %s exceeds its own poll-interval %s", uid, measured[uid], t[uid].pollEvery)) + } + if maxMeasured > t[tightestUID].pollEvery { + problems = append(problems, fmt.Sprintf( + "burst bound: rule %s's measured %s exceeds the fleet's tightest poll-interval %s (rule %s)", + maxMeasuredUID, maxMeasured, t[tightestUID].pollEvery, tightestUID)) + } + + if len(problems) == 0 { + return nil + } + + var b strings.Builder + fmt.Fprintf(&b, "schedule does not fit at concurrency %d:\n", concurrency) + for _, uid := range uids { + fmt.Fprintf(&b, " rule %s: measured %s, poll-interval %s\n", uid, measured[uid], t[uid].pollEvery) + } + for _, p := range problems { + fmt.Fprintf(&b, " - %s\n", p) + } + b.WriteString("fix by: raising concurrency, raising poll-interval, or watching fewer alerts") + return fmt.Errorf("%s", b.String()) +} + +// StartupSummary formats §13.2's required pre-run print: the total planned +// run time and the rule (with its `for` value) that set transitionGrace, plus +// a warning when the grace eats more than graceWarnFraction of the requested +// window. from/to are the requested classification window. +func StartupSummary(from, to time.Time, global globalTimings) (summary, warning string) { + window := to.Sub(from) + total := window + global.transitionGrace + global.drainTimeout + source := global.graceSource + if source == "" { + source = "none" + } + summary = fmt.Sprintf( + "planned run time: %s (window %s + transitionGrace %s [source: %s] + drainTimeout %s)", + total, window, global.transitionGrace, source, global.drainTimeout) + + if window > 0 && float64(global.transitionGrace) > float64(window)*graceWarnFraction { + warning = fmt.Sprintf( + "transitionGrace %s is more than %.0f%% of the window %s (source: %s) — the window may be too short for this alert's `for`", + global.transitionGrace, graceWarnFraction*100, window, source) + } + return summary, warning +} diff --git a/grafana-alertcheck/internal/gate/schedule_test.go b/grafana-alertcheck/internal/gate/schedule_test.go new file mode 100644 index 000000000..972300f77 --- /dev/null +++ b/grafana-alertcheck/internal/gate/schedule_test.go @@ -0,0 +1,351 @@ +package gate + +import ( + "strings" + "testing" + "time" +) + +func TestDeriveTimings_Default(t *testing.T) { + defs := []Definition{ + {UID: "r1", Title: "R1", IntervalSeconds: 60}, + } + rules, _, notes := DeriveTimings(defs, 0) + if len(notes) != 0 { + t.Fatalf("notes = %v, want none", notes) + } + rt := rules["r1"] + if rt.pollEvery != 30*time.Second { + t.Errorf("pollEvery = %s, want 30s", rt.pollEvery) + } + if rt.maxGap != 60*time.Second { + t.Errorf("maxGap = %s, want 60s", rt.maxGap) + } + if rt.healthGrace != 60*time.Second { + t.Errorf("healthGrace = %s, want 60s", rt.healthGrace) + } + if rt.evalStaleAfter != 120*time.Second { + t.Errorf("evalStaleAfter = %s, want 120s", rt.evalStaleAfter) + } +} + +func TestDeriveTimings_OverrideVerbatimNoClamp(t *testing.T) { + defs := []Definition{ + {UID: "r1", Title: "R1", IntervalSeconds: 10}, // default pollEvery = 5s + } + rules, _, notes := DeriveTimings(defs, 20*time.Second) + rt := rules["r1"] + if rt.pollEvery != 20*time.Second { + t.Fatalf("pollEvery = %s, want the override verbatim (20s), never clamped down to the 5s default", rt.pollEvery) + } + if rt.maxGap != 40*time.Second { + t.Errorf("maxGap = %s, want 2x the override (40s)", rt.maxGap) + } + if len(notes) != 1 || !strings.Contains(notes[0], "R1") { + t.Fatalf("notes = %v, want one note naming R1's exceeded default", notes) + } +} + +func TestDeriveTimings_OverrideBelowDefaultNoNote(t *testing.T) { + defs := []Definition{{UID: "r1", Title: "R1", IntervalSeconds: 60}} // default pollEvery = 30s + _, _, notes := DeriveTimings(defs, 5*time.Second) + if len(notes) != 0 { + t.Fatalf("notes = %v, want none when the override tightens rather than exceeds the default", notes) + } +} + +func TestDeriveTimings_TransitionGraceExcludesSkippedRule(t *testing.T) { + defs := []Definition{ + {UID: "r1", Title: "Tight", IntervalSeconds: 60, For: time.Minute}, + {UID: "r2", Title: "PausedLongFor", IntervalSeconds: 60, For: time.Hour, IsPaused: true}, + } + _, global, _ := DeriveTimings(defs, 0) + want := time.Minute + 60*time.Second // r1's for+interval; r2 (skipped) must not win despite its huge `for` + if global.transitionGrace != want { + t.Fatalf("transitionGrace = %s, want %s (paused rule r2 must be excluded from the max)", global.transitionGrace, want) + } + if !strings.Contains(global.graceSource, "Tight") { + t.Errorf("graceSource = %q, want it to name the contributing rule Tight", global.graceSource) + } +} + +func TestDeriveTimings_TransitionGraceZeroWhenAllSkipped(t *testing.T) { + defs := []Definition{{UID: "r1", Title: "R1", IntervalSeconds: 60, For: time.Hour, IsPaused: true}} + _, global, _ := DeriveTimings(defs, 0) + if global.transitionGrace != 0 { + t.Fatalf("transitionGrace = %s, want 0 when every rule is skipped", global.transitionGrace) + } +} + +func TestDeriveTimings_DrainTimeoutIncludesPaused(t *testing.T) { + defs := []Definition{{UID: "r1", Title: "R1", IntervalSeconds: 10}} + _, global, _ := DeriveTimings(defs, 0) + if global.drainTimeout != minDrainTimeout { + t.Fatalf("drainTimeout = %s, want the %s floor", global.drainTimeout, minDrainTimeout) + } +} + +func TestDeriveTimings_DrainTimeoutFloor(t *testing.T) { + defs := []Definition{ + {UID: "r1", Title: "Tight", IntervalSeconds: 60, For: time.Minute}, + {UID: "r2", Title: "PausedLongFor", IntervalSeconds: 180, For: time.Hour, IsPaused: true}, + } + _, global, _ := DeriveTimings(defs, 0) + // double the longest interval (2 * 180s) should be the drain timeout + if global.drainTimeout != 2*180*time.Second { + t.Fatalf("drainTimeout = %s, want %s", global.drainTimeout, 180*time.Second) + } +} + +func TestDeriveTimings_DrainTimeoutAboveFloor(t *testing.T) { + defs := []Definition{{UID: "r1", Title: "R1", IntervalSeconds: 300}} // 2x300s = 600s > 2m floor + _, global, _ := DeriveTimings(defs, 0) + if global.drainTimeout != 600*time.Second { + t.Fatalf("drainTimeout = %s, want 600s", global.drainTimeout) + } +} + +// TestScheduler_DueOrderingTiesBreakByTightestCadence pins the ordering +// invariant the burst bound depends on (§5): when several rules become due at +// the exact same instant, Due must serve the tightest cadence first, not +// whatever order the underlying map happens to iterate in. A refactor that +// loses this ordering must fail here, not in a production phase-aligned gap. +func TestScheduler_DueOrderingTiesBreakByTightestCadence(t *testing.T) { + now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + s := &Scheduler{ + next: map[string]time.Time{ + "slack1": now, "slack2": now, "tight": now, "slack3": now, + }, + every: map[string]time.Duration{ + "slack1": 300 * time.Second, + "slack2": 300 * time.Second, + "tight": 10 * time.Second, + "slack3": 300 * time.Second, + }, + } + due := s.Due(now) + if len(due) != 4 || due[0] != "tight" { + t.Fatalf("Due = %v, want the tightest-cadence rule (tight) first when all are simultaneously due", due) + } +} + +func TestScheduler_DueExcludesNotYetDue(t *testing.T) { + now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + s := &Scheduler{ + next: map[string]time.Time{"soon": now.Add(-time.Second), "later": now.Add(time.Minute)}, + every: map[string]time.Duration{"soon": 10 * time.Second, "later": 10 * time.Second}, + } + due := s.Due(now) + if len(due) != 1 || due[0] != "soon" { + t.Fatalf("Due = %v, want only [soon]", due) + } +} + +func TestScheduler_MarkAdvancesNextDue(t *testing.T) { + now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + s := &Scheduler{ + next: map[string]time.Time{"r1": now}, + every: map[string]time.Duration{"r1": 30 * time.Second}, + } + s.Mark("r1", now) + if got := s.Due(now); len(got) != 0 { + t.Fatalf("Due right after Mark = %v, want none (next due is 30s out)", got) + } + if got := s.Due(now.Add(30 * time.Second)); len(got) != 1 { + t.Fatalf("Due at next-due time = %v, want [r1]", got) + } +} + +// TestScheduler_PerRuleCadenceOverTime simulates a run and counts how often +// each rule comes due, pinning §5's core claim: schedules are per rule, never +// a global cycle. A tight rule must be polled at its own cadence regardless +// of what slower rules in the same fleet need, and a slack rule must never be +// forced onto the tight rule's cadence. +func TestScheduler_PerRuleCadenceOverTime(t *testing.T) { + start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + rules := map[string]ruleTimings{ + "tight": {pollEvery: 10 * time.Second}, + "slack": {pollEvery: 300 * time.Second}, + } + s := NewScheduler(rules, start) + + const runFor = 900 * time.Second + const step = time.Second + counts := map[string]int{} + for elapsed := time.Duration(0); elapsed <= runFor; elapsed += step { + now := start.Add(elapsed) + for _, uid := range s.Due(now) { + counts[uid]++ + s.Mark(uid, now) + } + } + + // 900s of runtime: "tight" (10s cadence) polls ~90 times, "slack" (300s + // cadence) ~3 times. Assert the ratio holds rather than an exact count, + // since the staggered initial offset shifts each by up to one cadence. + if counts["tight"] < 85 || counts["tight"] > 91 { + t.Errorf("tight polled %d times over 900s, want ~90 (its own 10s cadence)", counts["tight"]) + } + if counts["slack"] < 2 || counts["slack"] > 4 { + t.Errorf("slack polled %d times over 900s, want ~3 (its own 300s cadence, not tight's)", counts["slack"]) + } + if counts["slack"] >= counts["tight"] { + t.Fatalf("slack polled as often as tight (%d vs %d) — schedules must be per rule, not a shared global cycle", counts["slack"], counts["tight"]) + } +} + +func TestNewScheduler_StaggersWithinPollEvery(t *testing.T) { + now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + rules := map[string]ruleTimings{"r1": {pollEvery: 100 * time.Second}} + s := NewScheduler(rules, now) + offset := s.next["r1"].Sub(now) + if offset < 0 || offset >= 100*time.Second { + t.Fatalf("initial offset = %s, want within [0, 100s)", offset) + } +} + +// TestCheckBudget_MixedIntervalRegression is §22.3's sanity check from the +// plan: one rule at 10s beside twenty at 300s, all measured ~1.8s, must not +// error at any reasonable concurrency — the exact case a naive worst-case-slot +// simulation would wrongly fail. +func TestCheckBudget_MixedIntervalRegression(t *testing.T) { + timings := map[string]ruleTimings{"tight": {pollEvery: 5 * time.Second}} + measured := map[string]time.Duration{"tight": 1800 * time.Millisecond} + for i := range 20 { + uid := uidN(i) + timings[uid] = ruleTimings{pollEvery: 150 * time.Second} + measured[uid] = 1800 * time.Millisecond + } + if err := CheckBudget(timings, measured, 1); err != nil { + t.Fatalf("CheckBudget = %v, want nil (utilization 0.6, burst bound 1.8s <= 5s)", err) + } +} + +func TestCheckBudget_UtilizationExceeded(t *testing.T) { + timings := map[string]ruleTimings{ + "a": {pollEvery: 10 * time.Second}, + "b": {pollEvery: 10 * time.Second}, + } + measured := map[string]time.Duration{"a": 9 * time.Second, "b": 9 * time.Second} + err := CheckBudget(timings, measured, 1) + if err == nil { + t.Fatal("CheckBudget = nil, want an error: utilization 1.8 > concurrency 1") + } + assertBudgetMessage(t, err.Error()) +} + +func TestCheckBudget_SingleRuleExceedsOwnCadence(t *testing.T) { + timings := map[string]ruleTimings{"slow": {pollEvery: 5 * time.Second}} + measured := map[string]time.Duration{"slow": 6 * time.Second} + err := CheckBudget(timings, measured, 10) + if err == nil { + t.Fatal("CheckBudget = nil, want an error: measured 6s exceeds its own 5s poll-interval") + } + assertBudgetMessage(t, err.Error()) +} + +func TestCheckBudget_BurstBoundViolation(t *testing.T) { + // Utilization is trivially fine, but the slower rule's request time (3s) + // exceeds the tighter rule's cadence (2s) — a mid-run gap risk even + // though no single rule breaches its own cadence and utilization is low. + timings := map[string]ruleTimings{ + "tight": {pollEvery: 2 * time.Second}, + "slow": {pollEvery: 100 * time.Second}, + } + measured := map[string]time.Duration{"tight": 100 * time.Millisecond, "slow": 3 * time.Second} + err := CheckBudget(timings, measured, 10) + if err == nil { + t.Fatal("CheckBudget = nil, want a burst-bound error: slow's 3s measured exceeds tight's 2s cadence") + } + if !strings.Contains(err.Error(), "burst bound") { + t.Errorf("error = %q, want it to name the burst bound", err.Error()) + } + assertBudgetMessage(t, err.Error()) +} + +func TestCheckBudget_BurstBoundOKWhenNotExceeded(t *testing.T) { + timings := map[string]ruleTimings{ + "tight": {pollEvery: 5 * time.Second}, + "slow": {pollEvery: 100 * time.Second}, + } + measured := map[string]time.Duration{"tight": 100 * time.Millisecond, "slow": 1800 * time.Millisecond} + if err := CheckBudget(timings, measured, 10); err != nil { + t.Fatalf("CheckBudget = %v, want nil (1.8s <= 5s tightest cadence)", err) + } +} + +func TestCheckBudget_MissingMeasurementIsAnError(t *testing.T) { + timings := map[string]ruleTimings{"r1": {pollEvery: 30 * time.Second}} + err := CheckBudget(timings, map[string]time.Duration{}, 10) + if err == nil { + t.Fatal("CheckBudget = nil, want an error: r1 was never measured (fail closed, not a silent zero)") + } +} + +func TestCheckBudget_MissingMixedMeasurementIsAnError(t *testing.T) { + timings := map[string]ruleTimings{ + "tight": {pollEvery: 5 * time.Second}, + "slow": {pollEvery: 100 * time.Second}, + } + measured := map[string]time.Duration{"tight": 100 * time.Millisecond} + if err := CheckBudget(timings, measured, 10); err == nil { + t.Fatal("CheckBudget = nil, want an error: slow was never measured (fail closed, not a silent zero)") + } +} + +func TestCheckBudget_EmptyScheduleIsFine(t *testing.T) { + if err := CheckBudget(nil, nil, 1); err != nil { + t.Fatalf("CheckBudget = %v, want nil for an empty schedule", err) + } +} + +// assertBudgetMessage checks §5.1's required message contents: a measured +// duration is present, and all three controls are named — never a single +// suggested interval. +func assertBudgetMessage(t *testing.T, msg string) { + t.Helper() + for _, want := range []string{"measured", "concurrency", "poll-interval", "fewer"} { + if !strings.Contains(msg, want) { + t.Errorf("message %q missing %q", msg, want) + } + } +} + +func uidN(i int) string { + return "slack" + string(rune('a'+i)) +} + +func TestStartupSummary_WarningWhenGraceTooLarge(t *testing.T) { + from := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + to := from.Add(10 * time.Minute) + global := globalTimings{transitionGrace: 5 * time.Minute, graceSource: "R (for=4m30s, interval=30s)", drainTimeout: time.Minute} + summary, warning := StartupSummary(from, to, global) + if !strings.Contains(summary, "planned run time") { + t.Errorf("summary = %q, want it to name the planned run time", summary) + } + if warning == "" { + t.Fatal("warning = \"\", want one: transitionGrace (5m) > 1/4 of the 10m window") + } + if !strings.Contains(warning, "R (for=4m30s, interval=30s)") { + t.Errorf("warning = %q, want it to name the grace source", warning) + } +} + +func TestStartupSummary_NoWarningWhenGraceSmall(t *testing.T) { + from := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + to := from.Add(time.Hour) + global := globalTimings{transitionGrace: time.Minute, graceSource: "R (for=30s, interval=30s)", drainTimeout: time.Minute} + _, warning := StartupSummary(from, to, global) + if warning != "" { + t.Fatalf("warning = %q, want none: 1m grace is well under 1/4 of a 1h window", warning) + } +} + +func TestStartupSummary_NoGraceSourceReadsNone(t *testing.T) { + from := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + to := from.Add(time.Hour) + summary, _ := StartupSummary(from, to, globalTimings{}) + if !strings.Contains(summary, "none") { + t.Fatalf("summary = %q, want it to read \"none\" when no rule set the grace", summary) + } +} diff --git a/grafana-alertcheck/internal/gate/source.go b/grafana-alertcheck/internal/gate/source.go index 46de818bb..656c80558 100644 --- a/grafana-alertcheck/internal/gate/source.go +++ b/grafana-alertcheck/internal/gate/source.go @@ -14,11 +14,6 @@ import ( "time" ) -// skewHardLimit is one of §5's filled-in values (basis: §16; §22.11 asserts -// 120s errors, 30s does not). It belongs in schedule.go's named-constants -// block once P4 exists; defined here because P2 needs it first. -const skewHardLimit = 60 * time.Second - // Clock is the seam that lets tests advance time without sleeping (§22) — the // only two operations the gate ever needs from a clock. type Clock interface {