Alerting: Add rule_limit parameter to the list rules API (#114055)

This commit is contained in:
Alexander Akhmetov
2025-11-18 15:56:40 +01:00
committed by GitHub
parent 66259bdede
commit 633c9a9cb0
6 changed files with 333 additions and 3 deletions
@@ -1069,6 +1069,154 @@ func TestRouteGetRuleStatuses(t *testing.T) {
})
})
t.Run("when requesting rules with rule_limit pagination", func(t *testing.T) {
const namespaceUID = "namespace_0"
ruleStore := fakes.NewRuleStore(t)
fakeAIM := NewFakeAlertInstanceManager(t)
// Generate 3 rule groups with 10 rules each
allRules := make([]*ngmodels.AlertRule, 0, 30)
for i := range 3 {
rules := gen.With(gen.WithGroupKey(ngmodels.AlertRuleGroupKey{
RuleGroup: fmt.Sprintf("rule_group_%d", i),
NamespaceUID: namespaceUID,
OrgID: orgID,
})).GenerateManyRef(10)
allRules = append(allRules, rules...)
ruleStore.PutRule(context.Background(), rules...)
}
api := NewPrometheusSrv(
log.NewNopLogger(),
fakeAIM,
newFakeSchedulerReader(t).setupStates(fakeAIM),
ruleStore,
accesscontrol.NewRuleService(acimpl.ProvideAccessControl(featuremgmt.WithFeatures())),
fakes.NewFakeProvisioningStore(),
)
permissions := createPermissionsForRules(allRules, orgID)
user := &user.SignedInUser{
OrgID: orgID,
Permissions: permissions,
}
c := &contextmodel.ReqContext{
SignedInUser: user,
}
t.Run("should return complete groups until rule_limit is met", func(t *testing.T) {
// With rule_limit=15, should return group-0 (10) + group-1 (10)
// Even though 20 > 15, we never return partial groups
r, err := http.NewRequest("GET", "/api/v1/rules?rule_limit=15", nil)
require.NoError(t, err)
c.Context = &web.Context{Req: r}
resp := api.RouteGetRuleStatuses(c)
require.Equal(t, http.StatusOK, resp.Status())
result := &apimodels.RuleResponse{}
require.NoError(t, json.Unmarshal(resp.Body(), result))
require.Len(t, result.Data.RuleGroups, 2, "should return 2 groups")
require.Len(t, result.Data.Totals, 0)
require.Equal(t, "rule_group_0", result.Data.RuleGroups[0].Name)
require.Equal(t, "rule_group_1", result.Data.RuleGroups[1].Name)
require.Len(t, result.Data.RuleGroups[0].Rules, 10)
require.Len(t, result.Data.RuleGroups[1].Rules, 10)
expectedToken := ngmodels.EncodeGroupCursor(ngmodels.GroupCursor{
NamespaceUID: namespaceUID,
RuleGroup: "rule_group_1",
})
require.Equal(t, expectedToken, result.Data.NextToken)
})
t.Run("should return two groups when the number of alerts == limit", func(t *testing.T) {
r, err := http.NewRequest("GET", "/api/v1/rules?rule_limit=20", nil)
require.NoError(t, err)
c.Context = &web.Context{Req: r}
resp := api.RouteGetRuleStatuses(c)
require.Equal(t, http.StatusOK, resp.Status())
result := &apimodels.RuleResponse{}
require.NoError(t, json.Unmarshal(resp.Body(), result))
require.Len(t, result.Data.RuleGroups, 2, "should return 2 groups")
require.Len(t, result.Data.Totals, 0)
require.Equal(t, "rule_group_0", result.Data.RuleGroups[0].Name)
require.Equal(t, "rule_group_1", result.Data.RuleGroups[1].Name)
require.Len(t, result.Data.RuleGroups[0].Rules, 10)
require.Len(t, result.Data.RuleGroups[1].Rules, 10)
expectedToken := ngmodels.EncodeGroupCursor(ngmodels.GroupCursor{
NamespaceUID: namespaceUID,
RuleGroup: "rule_group_1",
})
require.Equal(t, expectedToken, result.Data.NextToken)
})
t.Run("should respect group_limit when it is reached first", func(t *testing.T) {
// group_limit=1 with rule_limit=100: group limit reached first
r, err := http.NewRequest("GET", "/api/v1/rules?group_limit=1&rule_limit=100", nil)
require.NoError(t, err)
c.Context = &web.Context{Req: r}
resp := api.RouteGetRuleStatuses(c)
require.Equal(t, http.StatusOK, resp.Status())
result := &apimodels.RuleResponse{}
require.NoError(t, json.Unmarshal(resp.Body(), result))
require.Len(t, result.Data.RuleGroups, 1, "should return 1 group (group_limit=1)")
require.Len(t, result.Data.Totals, 0)
expectedToken := ngmodels.EncodeGroupCursor(ngmodels.GroupCursor{
NamespaceUID: namespaceUID,
RuleGroup: "rule_group_0",
})
require.Equal(t, expectedToken, result.Data.NextToken)
})
t.Run("should respect rule_limit when it is reached first", func(t *testing.T) {
// rule_limit=15 with group_limit=5: rule limit reached first (returns 2 groups, 20 rules)
r, err := http.NewRequest("GET", "/api/v1/rules?group_limit=5&rule_limit=15", nil)
require.NoError(t, err)
c.Context = &web.Context{Req: r}
resp := api.RouteGetRuleStatuses(c)
require.Equal(t, http.StatusOK, resp.Status())
result := &apimodels.RuleResponse{}
require.NoError(t, json.Unmarshal(resp.Body(), result))
require.Len(t, result.Data.RuleGroups, 2, "should return 2 groups (20 rules exceeds rule_limit=15)")
require.Len(t, result.Data.Totals, 0)
expectedToken := ngmodels.EncodeGroupCursor(ngmodels.GroupCursor{
NamespaceUID: namespaceUID,
RuleGroup: "rule_group_1",
})
require.Equal(t, expectedToken, result.Data.NextToken)
})
t.Run("should return nothing when using rule_limit=0", func(t *testing.T) {
r, err := http.NewRequest("GET", "/api/v1/rules?rule_limit=0", nil)
require.NoError(t, err)
c.Context = &web.Context{Req: r}
resp := api.RouteGetRuleStatuses(c)
require.Equal(t, http.StatusOK, resp.Status())
result := &apimodels.RuleResponse{}
require.NoError(t, json.Unmarshal(resp.Body(), result))
require.Len(t, result.Data.RuleGroups, 0)
})
})
t.Run("when fine-grained access is enabled", func(t *testing.T) {
t.Run("should return only rules if the user can query all data sources", func(t *testing.T) {
ruleStore := fakes.NewRuleStore(t)
@@ -492,10 +492,22 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt
ruleType = ngmodels.RuleTypeFilterAll
}
// Pagination limits
//
// group_limit: Maximum number of rule groups to return
// - Returns exactly this many groups or fewer if not enough exist
//
// rule_limit: Maximum number of rules to return across all groups
// - Returns complete groups until total rules meets or exceeds this limit
// - May exceed the rule limit if needed to include the final complete group
// - Example: rule_limit=15 with groups of [10, 10, 10] rules returns first 2 groups, 20 rules total
//
// When both limits are specified, whichever limit is reached first takes precedence.
maxGroups := getInt64WithDefault(opts.Query, "group_limit", -1)
maxRules := getInt64WithDefault(opts.Query, "rule_limit", -1)
nextToken := opts.Query.Get("group_next_token")
if maxGroups == 0 {
if maxGroups == 0 || maxRules == 0 {
return ruleResponse
}
@@ -512,6 +524,7 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt
},
RuleType: ruleType,
Limit: maxGroups,
RuleLimit: maxRules,
ContinueToken: nextToken,
}
ruleList, continueToken, err := store.ListAlertRulesByGroup(opts.Ctx, &byGroupQuery)
@@ -557,7 +570,7 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt
ruleResponse.Data.NextToken = continueToken
// Only return Totals if there is no pagination
if maxGroups == -1 {
if maxGroups == -1 && maxRules == -1 {
ruleResponse.Data.Totals = rulesTotals
}
@@ -994,6 +994,7 @@ type ListAlertRulesExtendedQuery struct {
RuleType RuleTypeFilter
Limit int64
RuleLimit int64
ContinueToken string
}
+7
View File
@@ -618,6 +618,7 @@ func (st DBstore) ListAlertRulesByGroup(ctx context.Context, query *ngmodels.Lis
// Process rules and implement per-group pagination
var groupsFetched int64
var rulesFetched int64
for rows.Next() {
rule := new(alertRule)
err = rows.Scan(rule)
@@ -644,6 +645,11 @@ func (st DBstore) ListAlertRulesByGroup(ctx context.Context, query *ngmodels.Lis
nextToken = ngmodels.EncodeGroupCursor(cursor)
break
}
// Check if we've reached the rule limit
if query.RuleLimit > 0 && rulesFetched >= query.RuleLimit {
nextToken = ngmodels.EncodeGroupCursor(cursor)
break
}
// Reset for new group
cursor = key
@@ -656,6 +662,7 @@ func (st DBstore) ListAlertRulesByGroup(ctx context.Context, query *ngmodels.Lis
}
alertRules = append(alertRules, &converted)
rulesFetched++
}
result = alertRules
+7 -1
View File
@@ -234,12 +234,13 @@ func (f *RuleStore) ListAlertRulesByGroup(_ context.Context, q *models.ListAlert
}
}
if q.Limit < 0 {
if q.Limit < 0 && q.RuleLimit < 0 {
return ruleList, "", nil
}
outputRules := make([]*models.AlertRule, 0, len(ruleList))
var groupsFetched int64
var rulesFetched int64
initialCursor := cursor
for _, r := range ruleList {
// skip rules before the initial cursor
@@ -258,11 +259,16 @@ func (f *RuleStore) ListAlertRulesByGroup(_ context.Context, q *models.ListAlert
nextToken = models.EncodeGroupCursor(cursor)
break
}
if q.RuleLimit > 0 && rulesFetched >= q.RuleLimit {
nextToken = models.EncodeGroupCursor(cursor)
break
}
cursor = key
groupsFetched++
}
outputRules = append(outputRules, r)
rulesFetched++
}
return outputRules, nextToken, nil
@@ -361,6 +361,161 @@ func TestIntegrationPrometheusRules(t *testing.T) {
}
}
func TestIntegrationPrometheusRulesPagination(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
testinfra.SQLiteIntegrationTest(t)
dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{
DisableLegacyAlerting: true,
EnableUnifiedAlerting: true,
DisableAnonymous: true,
AppModeProduction: true,
})
grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path)
createUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{
DefaultOrgRole: string(org.RoleEditor),
Password: "password",
Login: "grafana",
})
apiClient := newAlertingApiClient(grafanaListedAddr, "grafana", "password")
apiClient.CreateFolder(t, "default", "default")
interval, err := model.ParseDuration("10s")
require.NoError(t, err)
// Create 3 rule groups with different numbers of rules
// Group 1: 5 rules, Group 2: 3 rules, Group 3: 2 rules (total: 10 rules)
for groupIdx := 1; groupIdx <= 3; groupIdx++ {
var rulesCount int
switch groupIdx {
case 1:
rulesCount = 5
case 2:
rulesCount = 3
case 3:
rulesCount = 2
}
rules := make([]apimodels.PostableExtendedRuleNode, rulesCount)
for i := 0; i < rulesCount; i++ {
rules[i] = apimodels.PostableExtendedRuleNode{
ApiRuleNode: &apimodels.ApiRuleNode{
For: &interval,
},
GrafanaManagedAlert: &apimodels.PostableGrafanaRule{
Title: fmt.Sprintf("rule-%d-%d", groupIdx, i+1),
Condition: "A",
Data: []apimodels.AlertQuery{
{
RefID: "A",
RelativeTimeRange: apimodels.RelativeTimeRange{
From: apimodels.Duration(time.Duration(5) * time.Hour),
To: apimodels.Duration(time.Duration(3) * time.Hour),
},
DatasourceUID: expr.DatasourceUID,
Model: json.RawMessage(`{
"type": "math",
"expression": "0 > 1"
}`),
},
},
},
}
}
ruleGroup := apimodels.PostableRuleGroupConfig{
Name: fmt.Sprintf("group-%d", groupIdx),
Rules: rules,
}
apiClient.PostRulesGroup(t, "default", &ruleGroup, false)
}
t.Run("with group_limit should return only 2 groups", func(t *testing.T) {
promRulesURL := fmt.Sprintf("http://grafana:password@%s/api/prometheus/grafana/api/v1/rules?group_limit=2", grafanaListedAddr)
// nolint:gosec
resp, err := http.Get(promRulesURL)
require.NoError(t, err)
t.Cleanup(func() {
err := resp.Body.Close()
require.NoError(t, err)
})
var result apimodels.RuleResponse
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Len(t, result.Data.RuleGroups, 2)
require.NotEmpty(t, result.Data.NextToken)
})
// Test rule_limit: with limit of 7, should return groups 1 and 2 (5+3=8 rules in total expected)
t.Run("with rule_limit should return full groups with rules limit", func(t *testing.T) {
promRulesURL := fmt.Sprintf("http://grafana:password@%s/api/prometheus/grafana/api/v1/rules?rule_limit=7", grafanaListedAddr)
// nolint:gosec
resp, err := http.Get(promRulesURL)
require.NoError(t, err)
t.Cleanup(func() {
err := resp.Body.Close()
require.NoError(t, err)
})
var result apimodels.RuleResponse
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Len(t, result.Data.RuleGroups, 2)
totalRules := 0
for _, group := range result.Data.RuleGroups {
totalRules += len(group.Rules)
}
require.Equal(t, 8, totalRules)
require.NotEmpty(t, result.Data.NextToken)
})
// With both group_limit and rule_limit set, the API should return
// data with respect to whichever limit is reached first.
t.Run("both limits respect whichever is reached first", func(t *testing.T) {
// group_limit=1 with rule_limit=100: group limit reached first
promRulesURL := fmt.Sprintf("http://grafana:password@%s/api/prometheus/grafana/api/v1/rules?group_limit=1&rule_limit=100", grafanaListedAddr)
// nolint:gosec
resp, err := http.Get(promRulesURL)
require.NoError(t, err)
t.Cleanup(func() {
err := resp.Body.Close()
require.NoError(t, err)
})
var result apimodels.RuleResponse
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Len(t, result.Data.RuleGroups, 1)
})
t.Run("rule_limit=0 returns empty results", func(t *testing.T) {
promRulesURL := fmt.Sprintf("http://grafana:password@%s/api/prometheus/grafana/api/v1/rules?rule_limit=0", grafanaListedAddr)
// nolint:gosec
resp, err := http.Get(promRulesURL)
require.NoError(t, err)
t.Cleanup(func() {
err := resp.Body.Close()
require.NoError(t, err)
})
var result apimodels.RuleResponse
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Len(t, result.Data.RuleGroups, 0, "should return no groups")
})
}
func TestIntegrationPrometheusRulesFilterByDashboard(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)