mirror of https://github.com/grafana/grafana
Alerting: Sequential evaluation of rules in group (#98829)
* introduce RulesGroupComparer * extract runJob method * implement sequential evaluation * Make sequence building testable & add comments * Also run callback in recording rules + add tests * Improve tests * Address PR comments --------- Co-authored-by: William Wernert <william.wernert@grafana.com>pull/103250/head
parent
ba5c38b078
commit
dc0083d879
@ -0,0 +1,127 @@ |
||||
package schedule |
||||
|
||||
import ( |
||||
"cmp" |
||||
"slices" |
||||
"strings" |
||||
|
||||
models "github.com/grafana/grafana/pkg/services/ngalert/models" |
||||
) |
||||
|
||||
// sequence represents a chain of rules that should be evaluated in order.
|
||||
// It is a convience type that wraps readyToRunItem as an indicator of what
|
||||
// is being represented.
|
||||
type sequence readyToRunItem |
||||
|
||||
type groupKey struct { |
||||
folderTitle string |
||||
folderUID string |
||||
groupName string |
||||
} |
||||
|
||||
// buildSequences organizes rules into evaluation sequences where rules in the same group
|
||||
// are chained together. The first rule in each group will trigger the evaluation of subsequent
|
||||
// rules in that group through the afterEval callback.
|
||||
//
|
||||
// For example, if we have rules A, B, C in group G1 and rules D, E in group G2:
|
||||
// - A will have afterEval set to evaluate B
|
||||
// - B will have afterEval set to evaluate C
|
||||
// - D will have afterEval set to evaluate E
|
||||
//
|
||||
// The function returns a slice of sequences, where each sequence represents a chain of rules
|
||||
// that should be evaluated in order.
|
||||
//
|
||||
// NOTE: This currently only chains rules in imported groups.
|
||||
func (sch *schedule) buildSequences(items []readyToRunItem, runJobFn func(next readyToRunItem, prev ...readyToRunItem) func()) []sequence { |
||||
// Step 1: Group rules by their folder and group name
|
||||
groups := map[groupKey][]readyToRunItem{} |
||||
var keys []groupKey |
||||
for _, item := range items { |
||||
g := groupKey{ |
||||
folderTitle: item.folderTitle, |
||||
folderUID: item.rule.NamespaceUID, |
||||
groupName: item.rule.RuleGroup, |
||||
} |
||||
i, ok := groups[g] |
||||
if !ok { |
||||
keys = append(keys, g) |
||||
} |
||||
groups[g] = append(i, item) |
||||
} |
||||
|
||||
// Step 2: Sort group keys to ensure consistent ordering
|
||||
slices.SortFunc(keys, func(a, b groupKey) int { |
||||
return cmp.Or( |
||||
cmp.Compare(a.folderTitle, b.folderTitle), |
||||
cmp.Compare(a.folderUID, b.folderUID), |
||||
cmp.Compare(a.groupName, b.groupName), |
||||
) |
||||
}) |
||||
|
||||
// Step 3: Build evaluation sequences for each group
|
||||
result := make([]sequence, 0, len(items)) |
||||
for _, key := range keys { |
||||
groupItems := groups[key] |
||||
|
||||
if sch.shouldEvaluateSequentially(groupItems) { |
||||
result = append(result, sch.buildSequence(key, groupItems, runJobFn)) |
||||
continue |
||||
} |
||||
|
||||
for _, item := range groupItems { |
||||
result = append(result, sequence(item)) |
||||
} |
||||
} |
||||
|
||||
// sort the sequences by UID
|
||||
slices.SortFunc(result, func(a, b sequence) int { |
||||
return strings.Compare(a.rule.UID, b.rule.UID) |
||||
}) |
||||
|
||||
return result |
||||
} |
||||
|
||||
func (sch *schedule) buildSequence(groupKey groupKey, groupItems []readyToRunItem, runJobFn func(next readyToRunItem, prev ...readyToRunItem) func()) sequence { |
||||
if len(groupItems) < 2 { |
||||
return sequence(groupItems[0]) |
||||
} |
||||
|
||||
slices.SortFunc(groupItems, func(a, b readyToRunItem) int { |
||||
return models.RulesGroupComparer(a.rule, b.rule) |
||||
}) |
||||
|
||||
// iterate over the group items backwards to set the afterEval callback
|
||||
for i := len(groupItems) - 2; i >= 0; i-- { |
||||
groupItems[i].Evaluation.afterEval = runJobFn(groupItems[i+1], groupItems[i]) |
||||
} |
||||
|
||||
uids := make([]string, 0, len(groupItems)) |
||||
for _, item := range groupItems { |
||||
uids = append(uids, item.rule.UID) |
||||
} |
||||
sch.log.Debug("Sequence created", "folder", groupKey.folderTitle, "group", groupKey.groupName, "sequence", strings.Join(uids, "->")) |
||||
|
||||
return sequence(groupItems[0]) |
||||
} |
||||
|
||||
func (sch *schedule) shouldEvaluateSequentially(groupItems []readyToRunItem) bool { |
||||
// if jitter by rule is enabled, we can't evaluate rules sequentially
|
||||
if sch.jitterEvaluations == JitterByRule { |
||||
return false |
||||
} |
||||
|
||||
// if there is only one rule, there are no rules to chain
|
||||
if len(groupItems) == 1 { |
||||
return false |
||||
} |
||||
|
||||
// only evaluate rules in imported groups sequentially
|
||||
for _, item := range groupItems { |
||||
if item.rule.ImportedFromPrometheus() { |
||||
return true |
||||
} |
||||
} |
||||
|
||||
// default to false
|
||||
return false |
||||
} |
@ -0,0 +1,153 @@ |
||||
package schedule |
||||
|
||||
import ( |
||||
"testing" |
||||
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/models" |
||||
"github.com/prometheus/client_golang/prometheus" |
||||
"github.com/stretchr/testify/require" |
||||
) |
||||
|
||||
type fakeSequenceRule struct { |
||||
// these fields help with debugging tests
|
||||
UID string |
||||
Group string |
||||
} |
||||
|
||||
func (r *fakeSequenceRule) Eval(e *Evaluation) (bool, *Evaluation) { |
||||
if e.afterEval != nil { |
||||
e.afterEval() |
||||
} |
||||
return true, nil |
||||
} |
||||
|
||||
func (r *fakeSequenceRule) Run() error { |
||||
return nil |
||||
} |
||||
|
||||
func (r *fakeSequenceRule) Stop(reason error) { |
||||
} |
||||
|
||||
func (r *fakeSequenceRule) Update(e *Evaluation) bool { |
||||
return true |
||||
} |
||||
|
||||
func (r *fakeSequenceRule) Type() models.RuleType { |
||||
return models.RuleTypeAlerting |
||||
} |
||||
|
||||
func (r *fakeSequenceRule) Identifier() models.AlertRuleKeyWithGroup { |
||||
return models.AlertRuleKeyWithGroup{ |
||||
AlertRuleKey: models.AlertRuleKey{ |
||||
UID: r.UID, |
||||
}, |
||||
RuleGroup: r.Group, |
||||
} |
||||
} |
||||
|
||||
func (r *fakeSequenceRule) Status() models.RuleStatus { |
||||
return models.RuleStatus{} |
||||
} |
||||
|
||||
func TestSequence(t *testing.T) { |
||||
ruleStore := newFakeRulesStore() |
||||
reg := prometheus.NewPedanticRegistry() |
||||
sch := setupScheduler(t, ruleStore, nil, reg, nil, nil, nil) |
||||
gen := models.RuleGen.With(models.RuleGen.WithNamespaceUID("ns1")) |
||||
|
||||
t.Run("should set callbacks in correct order", func(t *testing.T) { |
||||
nextByGroup := map[string][]string{} |
||||
prevByGroup := map[string][]string{} |
||||
callback := func(next readyToRunItem, prev ...readyToRunItem) func() { |
||||
return func() { |
||||
group := next.rule.RuleGroup |
||||
nextByGroup[group] = append(nextByGroup[group], next.rule.UID) |
||||
if len(prev) > 0 { |
||||
prevByGroup[group] = append(prevByGroup[group], prev[0].rule.UID) |
||||
} |
||||
// Ensure we call the eval the next rule
|
||||
next.ruleRoutine.Eval(&next.Evaluation) |
||||
} |
||||
} |
||||
// rg1 : 1, 2
|
||||
// rg2 : 3, 4 (prometheus), 5 (prometheus)
|
||||
items := []readyToRunItem{ |
||||
{ |
||||
ruleRoutine: &fakeSequenceRule{UID: "3", Group: "rg2"}, |
||||
Evaluation: Evaluation{ |
||||
rule: gen.With( |
||||
models.RuleGen.WithUID("3"), |
||||
models.RuleGen.WithGroupIndex(1), |
||||
models.RuleGen.WithGroupName("rg2"), |
||||
).GenerateRef(), |
||||
folderTitle: "folder1", |
||||
}, |
||||
}, |
||||
{ |
||||
ruleRoutine: &fakeSequenceRule{UID: "4", Group: "rg2"}, |
||||
Evaluation: Evaluation{ |
||||
rule: gen.With( |
||||
models.RuleGen.WithUID("4"), |
||||
models.RuleGen.WithGroupIndex(2), |
||||
models.RuleGen.WithGroupName("rg2"), |
||||
models.RuleGen.WithPrometheusOriginalRuleDefinition("test"), |
||||
).GenerateRef(), |
||||
folderTitle: "folder1", |
||||
}, |
||||
}, |
||||
{ |
||||
ruleRoutine: &fakeSequenceRule{UID: "5", Group: "rg2"}, |
||||
Evaluation: Evaluation{ |
||||
rule: gen.With( |
||||
models.RuleGen.WithUID("5"), |
||||
models.RuleGen.WithGroupIndex(3), |
||||
models.RuleGen.WithGroupName("rg2"), |
||||
models.RuleGen.WithPrometheusOriginalRuleDefinition("test"), |
||||
).GenerateRef(), |
||||
folderTitle: "folder1", |
||||
}, |
||||
}, |
||||
{ |
||||
ruleRoutine: &fakeSequenceRule{UID: "1", Group: "rg1"}, |
||||
Evaluation: Evaluation{ |
||||
rule: gen.With( |
||||
models.RuleGen.WithUID("1"), |
||||
models.RuleGen.WithGroupIndex(1), |
||||
models.RuleGen.WithGroupName("rg1"), |
||||
).GenerateRef(), |
||||
folderTitle: "folder1", |
||||
}, |
||||
}, |
||||
{ |
||||
ruleRoutine: &fakeSequenceRule{UID: "2", Group: "rg1"}, |
||||
Evaluation: Evaluation{ |
||||
rule: gen.With( |
||||
models.RuleGen.WithUID("2"), |
||||
models.RuleGen.WithGroupIndex(2), |
||||
models.RuleGen.WithGroupName("rg1"), |
||||
).GenerateRef(), |
||||
folderTitle: "folder1", |
||||
}, |
||||
}, |
||||
} |
||||
sequences := sch.buildSequences(items, callback) |
||||
require.Equal(t, 3, len(sequences)) |
||||
|
||||
// Ensure sequences are sorted by UID
|
||||
require.Equal(t, "1", sequences[0].rule.UID) |
||||
require.Equal(t, "2", sequences[1].rule.UID) |
||||
require.Equal(t, "3", sequences[2].rule.UID) |
||||
|
||||
// Run the sequences
|
||||
for _, sequence := range sequences { |
||||
sequence.ruleRoutine.Eval(&sequence.Evaluation) |
||||
} |
||||
|
||||
// Verify the callbacks were called in the correct order. Since we dont sort these slices they should
|
||||
// be in the same order as the items that were added to the sequences
|
||||
require.Nil(t, nextByGroup["rg1"]) |
||||
require.Nil(t, prevByGroup["rg1"]) |
||||
require.Equal(t, []string{"4", "5"}, nextByGroup["rg2"]) |
||||
require.Equal(t, []string{"3", "4"}, prevByGroup["rg2"]) |
||||
}) |
||||
} |
Loading…
Reference in new issue