Skip to content

Commit eeacbb5

Browse files
committed
feat(ruler): add select merger — parse rules and compute merge plan
1 parent d5dee41 commit eeacbb5

2 files changed

Lines changed: 227 additions & 0 deletions

File tree

pkg/ruler/select_merger.go

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
package ruler
2+
3+
import (
4+
"github.com/prometheus/prometheus/model/labels"
5+
"github.com/prometheus/prometheus/promql/parser"
6+
"github.com/prometheus/prometheus/rules"
7+
)
8+
9+
type mergedSelect struct {
10+
metricName string
11+
mergedMatchers []*labels.Matcher
12+
originalEntries [][]*labels.Matcher // per-rule matchers
13+
}
14+
15+
func planMergedSelects(rls []rules.Rule, minRules int) []mergedSelect {
16+
// Group selectors by metric name.
17+
type entry struct {
18+
matchers []*labels.Matcher
19+
}
20+
groups := map[string][]entry{}
21+
22+
for _, r := range rls {
23+
extractSelectors(r.Query(), func(vs *parser.VectorSelector) {
24+
name := metricNameFromMatchers(vs.LabelMatchers)
25+
if name == "" {
26+
return
27+
}
28+
groups[name] = append(groups[name], entry{matchers: vs.LabelMatchers})
29+
})
30+
}
31+
32+
var result []mergedSelect
33+
for name, entries := range groups {
34+
if len(entries) < minRules {
35+
continue
36+
}
37+
originals := make([][]*labels.Matcher, len(entries))
38+
for i, e := range entries {
39+
originals[i] = e.matchers
40+
}
41+
result = append(result, mergedSelect{
42+
metricName: name,
43+
mergedMatchers: computeMergedMatchers(originals),
44+
originalEntries: originals,
45+
})
46+
}
47+
return result
48+
}
49+
50+
func extractSelectors(expr parser.Expr, fn func(*parser.VectorSelector)) {
51+
parser.Inspect(expr, func(node parser.Node, _ []parser.Node) error {
52+
if vs, ok := node.(*parser.VectorSelector); ok {
53+
fn(vs)
54+
}
55+
return nil
56+
})
57+
}
58+
59+
func metricNameFromMatchers(ms []*labels.Matcher) string {
60+
for _, m := range ms {
61+
if m.Name == labels.MetricName && m.Type == labels.MatchEqual {
62+
return m.Value
63+
}
64+
}
65+
return ""
66+
}
67+
68+
func computeMergedMatchers(entries [][]*labels.Matcher) []*labels.Matcher {
69+
// Collect all label names across entries.
70+
labelMatchers := map[string][]*labels.Matcher{}
71+
for _, ms := range entries {
72+
for _, m := range ms {
73+
labelMatchers[m.Name] = append(labelMatchers[m.Name], m)
74+
}
75+
}
76+
77+
var result []*labels.Matcher
78+
for _, ms := range labelMatchers {
79+
if sup := findSuperset(ms); sup != nil {
80+
result = append(result, sup)
81+
}
82+
}
83+
return result
84+
}
85+
86+
func findSuperset(ms []*labels.Matcher) *labels.Matcher {
87+
for _, candidate := range ms {
88+
coversAll := true
89+
for _, other := range ms {
90+
if !isMatcherSuperset(candidate, other) {
91+
coversAll = false
92+
break
93+
}
94+
}
95+
if coversAll {
96+
return candidate
97+
}
98+
}
99+
return nil
100+
}
101+
102+
func isMatcherSuperset(a, b *labels.Matcher) bool {
103+
if a.Name != b.Name {
104+
return false
105+
}
106+
// =~".*" covers everything.
107+
if a.Type == labels.MatchRegexp && a.Value == ".*" {
108+
return true
109+
}
110+
// =~".+" covers any non-empty value.
111+
if a.Type == labels.MatchRegexp && a.Value == ".+" {
112+
if b.Type == labels.MatchEqual && b.Value == "" {
113+
return false
114+
}
115+
return true
116+
}
117+
// Same type and value covers itself.
118+
return a.Type == b.Type && a.Value == b.Value
119+
}

pkg/ruler/select_merger_test.go

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package ruler
2+
3+
import (
4+
"testing"
5+
6+
"github.com/prometheus/prometheus/model/labels"
7+
"github.com/prometheus/prometheus/promql/parser"
8+
"github.com/prometheus/prometheus/rules"
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
type fakeRule struct {
14+
rules.Rule
15+
expr parser.Expr
16+
}
17+
18+
func (f *fakeRule) Query() parser.Expr { return f.expr }
19+
20+
func mustParseRule(t *testing.T, expr string) rules.Rule {
21+
t.Helper()
22+
e, err := parser.ParseExpr(expr)
23+
require.NoError(t, err)
24+
return &fakeRule{expr: e}
25+
}
26+
27+
func TestSelectMerger_Plan_GroupsByMetric(t *testing.T) {
28+
rls := []rules.Rule{
29+
mustParseRule(t, `sum(http_requests_total{job="api"})`),
30+
mustParseRule(t, `sum(http_requests_total{job="web"})`),
31+
mustParseRule(t, `avg(cpu_usage{host="a"})`),
32+
mustParseRule(t, `avg(cpu_usage{host="b"})`),
33+
}
34+
35+
result := planMergedSelects(rls, 2)
36+
37+
// Should produce 2 merged selects: one for http_requests_total, one for cpu_usage
38+
assert.Len(t, result, 2)
39+
40+
metricNames := map[string]bool{}
41+
for _, ms := range result {
42+
metricNames[ms.metricName] = true
43+
}
44+
assert.True(t, metricNames["http_requests_total"])
45+
assert.True(t, metricNames["cpu_usage"])
46+
}
47+
48+
func TestSelectMerger_SupersetDetection(t *testing.T) {
49+
tests := []struct {
50+
name string
51+
a, b *labels.Matcher
52+
aCoversB bool
53+
}{
54+
{
55+
name: "regex .* covers equality",
56+
a: labels.MustNewMatcher(labels.MatchRegexp, "job", ".*"),
57+
b: labels.MustNewMatcher(labels.MatchEqual, "job", "blue"),
58+
aCoversB: true,
59+
},
60+
{
61+
name: "regex .+ covers non-empty equality",
62+
a: labels.MustNewMatcher(labels.MatchRegexp, "job", ".+"),
63+
b: labels.MustNewMatcher(labels.MatchEqual, "job", "blue"),
64+
aCoversB: true,
65+
},
66+
{
67+
name: "regex .+ does not cover empty",
68+
a: labels.MustNewMatcher(labels.MatchRegexp, "job", ".+"),
69+
b: labels.MustNewMatcher(labels.MatchEqual, "job", ""),
70+
aCoversB: false,
71+
},
72+
{
73+
name: "same equality covers itself",
74+
a: labels.MustNewMatcher(labels.MatchEqual, "job", "api"),
75+
b: labels.MustNewMatcher(labels.MatchEqual, "job", "api"),
76+
aCoversB: true,
77+
},
78+
{
79+
name: "different equality does not cover",
80+
a: labels.MustNewMatcher(labels.MatchEqual, "job", "api"),
81+
b: labels.MustNewMatcher(labels.MatchEqual, "job", "web"),
82+
aCoversB: false,
83+
},
84+
}
85+
86+
for _, tc := range tests {
87+
t.Run(tc.name, func(t *testing.T) {
88+
assert.Equal(t, tc.aCoversB, isMatcherSuperset(tc.a, tc.b))
89+
})
90+
}
91+
}
92+
93+
func TestSelectMerger_MinRulesThreshold(t *testing.T) {
94+
rls := []rules.Rule{
95+
mustParseRule(t, `sum(http_requests_total{job="api"})`),
96+
mustParseRule(t, `sum(http_requests_total{job="web"})`),
97+
mustParseRule(t, `avg(cpu_usage{host="a"})`),
98+
}
99+
100+
// minRules=2: http_requests_total has 2 rules, cpu_usage has 1
101+
result := planMergedSelects(rls, 2)
102+
assert.Len(t, result, 1)
103+
assert.Equal(t, "http_requests_total", result[0].metricName)
104+
105+
// minRules=3: nothing qualifies
106+
result = planMergedSelects(rls, 3)
107+
assert.Len(t, result, 0)
108+
}

0 commit comments

Comments
 (0)