Alerting: Add store level pagination of rules
This reintroduces store level pagination, without using it in the prometheus API yet. Related to #108633 Co-authored-by: William Wernert <william.wernert@grafana.com>
This commit is contained in:
committed by
Moustafa Baiou
co-authored by
William Wernert
parent
26fbb553f3
commit
a4edc27044
@@ -23,6 +23,7 @@ type RuleStore interface {
|
||||
GetAlertRuleByUID(ctx context.Context, query *ngmodels.GetAlertRuleByUIDQuery) (*ngmodels.AlertRule, error)
|
||||
GetAlertRulesGroupByRuleUID(ctx context.Context, query *ngmodels.GetAlertRulesGroupByRuleUIDQuery) ([]*ngmodels.AlertRule, error)
|
||||
ListAlertRules(ctx context.Context, query *ngmodels.ListAlertRulesQuery) (ngmodels.RulesGroup, error)
|
||||
ListAlertRulesByGroup(ctx context.Context, query *ngmodels.ListAlertRulesByGroupQuery) (ngmodels.RulesGroup, string, error)
|
||||
ListDeletedRules(ctx context.Context, orgID int64) ([]*ngmodels.AlertRule, error)
|
||||
|
||||
// InsertAlertRules will insert all alert rules passed into the function
|
||||
|
||||
@@ -30,7 +30,7 @@ import (
|
||||
|
||||
type RuleStoreReader interface {
|
||||
GetUserVisibleNamespaces(context.Context, int64, identity.Requester) (map[string]*folder.Folder, error)
|
||||
ListAlertRules(ctx context.Context, query *ngmodels.ListAlertRulesQuery) (ngmodels.RulesGroup, error)
|
||||
ListAlertRulesStore
|
||||
}
|
||||
|
||||
type RuleGroupAccessControlService interface {
|
||||
@@ -244,6 +244,10 @@ type ListAlertRulesStore interface {
|
||||
ListAlertRules(ctx context.Context, query *ngmodels.ListAlertRulesQuery) (ngmodels.RulesGroup, error)
|
||||
}
|
||||
|
||||
type ListAlertRulesStoreV2 interface {
|
||||
ListAlertRulesByGroup(ctx context.Context, query *ngmodels.ListAlertRulesByGroupQuery) (ngmodels.RulesGroup, string, error)
|
||||
}
|
||||
|
||||
func (srv PrometheusSrv) RouteGetRuleStatuses(c *contextmodel.ReqContext) response.Response {
|
||||
// As we are using req.Form directly, this triggers a call to ParseForm() if needed.
|
||||
c.Query("")
|
||||
@@ -400,6 +404,151 @@ func RuleAlertStateMutatorGenerator(manager state.AlertInstanceManager) RuleAler
|
||||
}
|
||||
}
|
||||
|
||||
func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opts RuleGroupStatusesOptions, ruleStatusMutator RuleStatusMutator, alertStateMutator RuleAlertStateMutator, provenanceRecords map[string]ngmodels.Provenance) apimodels.RuleResponse {
|
||||
ruleResponse := apimodels.RuleResponse{
|
||||
DiscoveryBase: apimodels.DiscoveryBase{
|
||||
Status: "success",
|
||||
},
|
||||
Data: apimodels.RuleDiscovery{
|
||||
RuleGroups: []apimodels.RuleGroup{},
|
||||
},
|
||||
}
|
||||
|
||||
dashboardUID := opts.Query.Get("dashboard_uid")
|
||||
panelID, err := getPanelIDFromQuery(opts.Query)
|
||||
if err != nil {
|
||||
ruleResponse.Status = "error"
|
||||
ruleResponse.Error = fmt.Sprintf("invalid panel_id: %s", err.Error())
|
||||
ruleResponse.ErrorType = apiv1.ErrBadData
|
||||
return ruleResponse
|
||||
}
|
||||
if dashboardUID == "" && panelID != 0 {
|
||||
ruleResponse.Status = "error"
|
||||
ruleResponse.Error = "panel_id must be set with dashboard_uid"
|
||||
ruleResponse.ErrorType = apiv1.ErrBadData
|
||||
return ruleResponse
|
||||
}
|
||||
|
||||
limitRulesPerGroup := getInt64WithDefault(opts.Query, "limit_rules", -1)
|
||||
limitAlertsPerRule := getInt64WithDefault(opts.Query, "limit_alerts", -1)
|
||||
matchers, err := getMatchersFromQuery(opts.Query)
|
||||
if err != nil {
|
||||
ruleResponse.Status = "error"
|
||||
ruleResponse.Error = err.Error()
|
||||
ruleResponse.ErrorType = apiv1.ErrBadData
|
||||
return ruleResponse
|
||||
}
|
||||
stateFilterSet, err := getStatesFromQuery(opts.Query)
|
||||
if err != nil {
|
||||
ruleResponse.Status = "error"
|
||||
ruleResponse.Error = err.Error()
|
||||
ruleResponse.ErrorType = apiv1.ErrBadData
|
||||
return ruleResponse
|
||||
}
|
||||
|
||||
healthFilterSet, err := getHealthFromQuery(opts.Query)
|
||||
if err != nil {
|
||||
ruleResponse.Status = "error"
|
||||
ruleResponse.Error = err.Error()
|
||||
ruleResponse.ErrorType = apiv1.ErrBadData
|
||||
return ruleResponse
|
||||
}
|
||||
|
||||
var labelOptions []ngmodels.LabelOption
|
||||
if !getBoolWithDefault(opts.Query, queryIncludeInternalLabels, false) {
|
||||
labelOptions = append(labelOptions, ngmodels.WithoutInternalLabels())
|
||||
}
|
||||
|
||||
if len(opts.AllowedNamespaces) == 0 {
|
||||
log.Debug("User does not have access to any namespaces")
|
||||
return ruleResponse
|
||||
}
|
||||
|
||||
namespaceUIDs := make([]string, 0, len(opts.AllowedNamespaces))
|
||||
|
||||
folderUID := opts.Query.Get("folder_uid")
|
||||
_, exists := opts.AllowedNamespaces[folderUID]
|
||||
if folderUID != "" && exists {
|
||||
namespaceUIDs = append(namespaceUIDs, folderUID)
|
||||
} else {
|
||||
for k := range opts.AllowedNamespaces {
|
||||
namespaceUIDs = append(namespaceUIDs, k)
|
||||
}
|
||||
}
|
||||
|
||||
ruleGroups := opts.Query["rule_group"]
|
||||
|
||||
receiverName := opts.Query.Get("receiver_name")
|
||||
|
||||
maxGroups := getInt64WithDefault(opts.Query, "group_limit", -1)
|
||||
nextToken := opts.Query.Get("group_next_token")
|
||||
|
||||
if maxGroups == 0 {
|
||||
return ruleResponse
|
||||
}
|
||||
|
||||
byGroupQuery := ngmodels.ListAlertRulesByGroupQuery{
|
||||
ListAlertRulesQuery: ngmodels.ListAlertRulesQuery{
|
||||
OrgID: opts.OrgID,
|
||||
NamespaceUIDs: namespaceUIDs,
|
||||
DashboardUID: dashboardUID,
|
||||
PanelID: panelID,
|
||||
RuleGroups: ruleGroups,
|
||||
ReceiverName: receiverName,
|
||||
},
|
||||
GroupLimit: maxGroups,
|
||||
GroupContinueToken: nextToken,
|
||||
}
|
||||
ruleList, continueToken, err := store.ListAlertRulesByGroup(opts.Ctx, &byGroupQuery)
|
||||
if err != nil {
|
||||
ruleResponse.Status = "error"
|
||||
ruleResponse.Error = fmt.Sprintf("failure getting rules: %s", err.Error())
|
||||
ruleResponse.ErrorType = apiv1.ErrServer
|
||||
return ruleResponse
|
||||
}
|
||||
|
||||
ruleNames := opts.Query["rule_name"]
|
||||
ruleNamesSet := make(map[string]struct{}, len(ruleNames))
|
||||
for _, rn := range ruleNames {
|
||||
ruleNamesSet[rn] = struct{}{}
|
||||
}
|
||||
|
||||
groupedRules := getGroupedRules(log, ruleList, ruleNamesSet, opts.AllowedNamespaces)
|
||||
rulesTotals := make(map[string]int64, len(groupedRules))
|
||||
for _, rg := range groupedRules {
|
||||
ruleGroup, totals := toRuleGroup(log, rg.GroupKey, rg.Folder, rg.Rules, provenanceRecords, limitAlertsPerRule, stateFilterSet, matchers, labelOptions, ruleStatusMutator, alertStateMutator)
|
||||
ruleGroup.Totals = totals
|
||||
for k, v := range totals {
|
||||
rulesTotals[k] += v
|
||||
}
|
||||
|
||||
if len(stateFilterSet) > 0 {
|
||||
filterRulesByState(ruleGroup, stateFilterSet)
|
||||
}
|
||||
|
||||
if len(healthFilterSet) > 0 {
|
||||
filterRulesByHealth(ruleGroup, healthFilterSet)
|
||||
}
|
||||
|
||||
if limitRulesPerGroup > -1 && int64(len(ruleGroup.Rules)) > limitRulesPerGroup {
|
||||
ruleGroup.Rules = ruleGroup.Rules[0:limitRulesPerGroup]
|
||||
}
|
||||
|
||||
if len(ruleGroup.Rules) > 0 {
|
||||
ruleResponse.Data.RuleGroups = append(ruleResponse.Data.RuleGroups, *ruleGroup)
|
||||
}
|
||||
}
|
||||
|
||||
ruleResponse.Data.NextToken = continueToken
|
||||
|
||||
// Only return Totals if there is no pagination
|
||||
if maxGroups == -1 {
|
||||
ruleResponse.Data.Totals = rulesTotals
|
||||
}
|
||||
|
||||
return ruleResponse
|
||||
}
|
||||
|
||||
func PrepareRuleGroupStatuses(log log.Logger, store ListAlertRulesStore, opts RuleGroupStatusesOptions, ruleStatusMutator RuleStatusMutator, alertStateMutator RuleAlertStateMutator, provenanceRecords map[string]ngmodels.Provenance) apimodels.RuleResponse {
|
||||
ruleResponse := apimodels.RuleResponse{
|
||||
DiscoveryBase: apimodels.DiscoveryBase{
|
||||
|
||||
@@ -2,6 +2,7 @@ package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -879,6 +880,46 @@ type GetAlertRulesGroupByRuleUIDQuery struct {
|
||||
OrgID int64
|
||||
}
|
||||
|
||||
type RuleTypeFilter int
|
||||
|
||||
const (
|
||||
RuleTypeFilterAll RuleTypeFilter = iota
|
||||
RuleTypeFilterAlerting
|
||||
RuleTypeFilterRecording
|
||||
)
|
||||
|
||||
type ListAlertRulesByGroupQuery struct {
|
||||
ListAlertRulesQuery
|
||||
RuleType RuleTypeFilter
|
||||
|
||||
GroupLimit int64 // Number of groups to fetch
|
||||
GroupContinueToken string // Token for per-group pagination
|
||||
}
|
||||
|
||||
type GroupCursor struct {
|
||||
NamespaceUID string `json:"n"`
|
||||
RuleGroup string `json:"g"`
|
||||
}
|
||||
|
||||
func EncodeGroupCursor(c GroupCursor) string {
|
||||
data, _ := json.Marshal(c)
|
||||
return base64.URLEncoding.EncodeToString(data)
|
||||
}
|
||||
|
||||
func DecodeGroupCursor(token string) (GroupCursor, error) {
|
||||
var c GroupCursor
|
||||
data, err := base64.URLEncoding.DecodeString(token)
|
||||
if err != nil {
|
||||
return c, fmt.Errorf("failed to decode group token: %w", err)
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &c); err != nil {
|
||||
return c, fmt.Errorf("failed to unmarshal group cursor: %w", err)
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// ListAlertRulesQuery is the query for listing alert rules
|
||||
type ListAlertRulesQuery struct {
|
||||
OrgID int64
|
||||
|
||||
@@ -582,6 +582,186 @@ func (st DBstore) CountInFolders(ctx context.Context, orgID int64, folderUIDs []
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (st DBstore) ListAlertRulesByGroup(ctx context.Context, query *ngmodels.ListAlertRulesByGroupQuery) (result ngmodels.RulesGroup, nextToken string, err error) {
|
||||
err = st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error {
|
||||
q := sess.Table("alert_rule")
|
||||
|
||||
if query.OrgID >= 0 {
|
||||
q = q.Where("org_id = ?", query.OrgID)
|
||||
}
|
||||
|
||||
if query.DashboardUID != "" {
|
||||
q = q.Where("dashboard_uid = ?", query.DashboardUID)
|
||||
if query.PanelID != 0 {
|
||||
q = q.Where("panel_id = ?", query.PanelID)
|
||||
}
|
||||
}
|
||||
|
||||
if len(query.NamespaceUIDs) > 0 {
|
||||
args, in := getINSubQueryArgs(query.NamespaceUIDs)
|
||||
q = q.Where(fmt.Sprintf("namespace_uid IN (%s)", strings.Join(in, ",")), args...)
|
||||
}
|
||||
|
||||
if len(query.RuleUIDs) > 0 {
|
||||
args, in := getINSubQueryArgs(query.RuleUIDs)
|
||||
q = q.Where(fmt.Sprintf("uid IN (%s)", strings.Join(in, ",")), args...)
|
||||
}
|
||||
|
||||
var groupsMap map[string]struct{}
|
||||
if len(query.RuleGroups) > 0 {
|
||||
groupsMap = make(map[string]struct{})
|
||||
args, in := getINSubQueryArgs(query.RuleGroups)
|
||||
q = q.Where(fmt.Sprintf("rule_group IN (%s)", strings.Join(in, ",")), args...)
|
||||
for _, group := range query.RuleGroups {
|
||||
groupsMap[group] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
if query.ReceiverName != "" {
|
||||
q, err = st.filterByContentInNotificationSettings(query.ReceiverName, q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if query.TimeIntervalName != "" {
|
||||
q, err = st.filterByContentInNotificationSettings(query.TimeIntervalName, q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if query.HasPrometheusRuleDefinition != nil {
|
||||
q, err = st.filterWithPrometheusRuleDefinition(*query.HasPrometheusRuleDefinition, q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
switch query.RuleType {
|
||||
case ngmodels.RuleTypeFilterAlerting:
|
||||
q = q.Where("record = ''")
|
||||
case ngmodels.RuleTypeFilterRecording:
|
||||
q = q.Where("record != ''")
|
||||
case ngmodels.RuleTypeFilterAll:
|
||||
// no additional filter
|
||||
default:
|
||||
return fmt.Errorf("unknown rule type filter %q", query.RuleType)
|
||||
}
|
||||
|
||||
// Order by group first, then by rule index within group
|
||||
q = q.Asc("namespace_uid", "rule_group", "rule_group_idx", "id")
|
||||
|
||||
var cursor ngmodels.GroupCursor
|
||||
if query.GroupContinueToken != "" {
|
||||
// only set the cursor if it's valid, otherwise we'll start from the beginning
|
||||
if cur, err := ngmodels.DecodeGroupCursor(query.GroupContinueToken); err == nil {
|
||||
cursor = cur
|
||||
}
|
||||
}
|
||||
|
||||
// Build group cursor condition
|
||||
if cursor.NamespaceUID != "" {
|
||||
q = buildGroupCursorCondition(q, cursor)
|
||||
}
|
||||
|
||||
// No arbitrary fetch limit - let the loop control pagination
|
||||
alertRules := make([]*ngmodels.AlertRule, 0)
|
||||
rule := new(alertRule)
|
||||
rows, err := q.Rows(rule)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
_ = rows.Close()
|
||||
}()
|
||||
|
||||
// Process rules and implement per-group pagination
|
||||
var groupsFetched int64
|
||||
for rows.Next() {
|
||||
rule := new(alertRule)
|
||||
err = rows.Scan(rule)
|
||||
if err != nil {
|
||||
st.Logger.Error("Invalid rule found in DB store, ignoring it", "func", "ListAlertRulesByGroup", "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
converted, err := alertRuleToModelsAlertRule(*rule, st.Logger)
|
||||
if err != nil {
|
||||
st.Logger.Error("Invalid rule found in DB store, cannot convert, ignoring it", "func", "ListAlertRulesByGroup", "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if we've moved to a new group
|
||||
key := ngmodels.GroupCursor{
|
||||
NamespaceUID: converted.NamespaceUID,
|
||||
RuleGroup: converted.RuleGroup,
|
||||
}
|
||||
if key != cursor {
|
||||
// Check if we've reached the group limit
|
||||
if query.GroupLimit > 0 && groupsFetched == query.GroupLimit {
|
||||
// Generate next token for the next group
|
||||
nextToken = ngmodels.EncodeGroupCursor(cursor)
|
||||
break
|
||||
}
|
||||
|
||||
// Reset for new group
|
||||
cursor = key
|
||||
groupsFetched++
|
||||
}
|
||||
|
||||
// Apply post-query filters
|
||||
if !shouldIncludeRule(&converted, query, groupsMap) {
|
||||
continue
|
||||
}
|
||||
|
||||
alertRules = append(alertRules, &converted)
|
||||
}
|
||||
|
||||
result = alertRules
|
||||
return nil
|
||||
})
|
||||
return result, nextToken, err
|
||||
}
|
||||
|
||||
func buildGroupCursorCondition(sess *xorm.Session, c ngmodels.GroupCursor) *xorm.Session {
|
||||
return sess.Where("(namespace_uid > ?)", c.NamespaceUID).
|
||||
Or("(namespace_uid = ? AND rule_group > ?)", c.NamespaceUID, c.RuleGroup)
|
||||
}
|
||||
|
||||
func shouldIncludeRule(rule *ngmodels.AlertRule, query *ngmodels.ListAlertRulesByGroupQuery, groupsMap map[string]struct{}) bool {
|
||||
if query.ReceiverName != "" {
|
||||
if !slices.ContainsFunc(rule.NotificationSettings, func(settings ngmodels.NotificationSettings) bool {
|
||||
return settings.Receiver == query.ReceiverName
|
||||
}) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if query.TimeIntervalName != "" {
|
||||
if !slices.ContainsFunc(rule.NotificationSettings, func(settings ngmodels.NotificationSettings) bool {
|
||||
return slices.Contains(settings.MuteTimeIntervals, query.TimeIntervalName) ||
|
||||
slices.Contains(settings.ActiveTimeIntervals, query.TimeIntervalName)
|
||||
}) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if query.HasPrometheusRuleDefinition != nil {
|
||||
if *query.HasPrometheusRuleDefinition != rule.HasPrometheusRuleDefinition() {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if groupsMap != nil {
|
||||
if _, ok := groupsMap[rule.RuleGroup]; !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// ListAlertRules is a handler for retrieving alert rules of specific organisation.
|
||||
func (st DBstore) ListAlertRules(ctx context.Context, query *ngmodels.ListAlertRulesQuery) (result ngmodels.RulesGroup, err error) {
|
||||
err = st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error {
|
||||
|
||||
@@ -1596,14 +1596,14 @@ func TestIntegrationGetRuleVersions(t *testing.T) {
|
||||
|
||||
// createAlertRule creates an alert rule in the database and returns it.
|
||||
// If a generator is not specified, uniqueness of primary key is not guaranteed.
|
||||
func createRule(t *testing.T, store *DBstore, generator *models.AlertRuleGenerator) *models.AlertRule {
|
||||
t.Helper()
|
||||
func createRule(tb testing.TB, store *DBstore, generator *models.AlertRuleGenerator) *models.AlertRule {
|
||||
tb.Helper()
|
||||
if generator == nil {
|
||||
generator = models.RuleGen.With(models.RuleMuts.WithIntervalMatching(store.Cfg.BaseInterval))
|
||||
}
|
||||
rule := generator.GenerateRef()
|
||||
converted, err := alertRuleFromModelsAlertRule(*rule)
|
||||
require.NoError(t, err)
|
||||
require.NoError(tb, err)
|
||||
err = store.SQLStore.WithDbSession(context.Background(), func(sess *db.Session) error {
|
||||
converted.ID = 0
|
||||
_, err := sess.Table(alertRule{}).InsertOne(&converted)
|
||||
@@ -1622,12 +1622,12 @@ func createRule(t *testing.T, store *DBstore, generator *models.AlertRuleGenerat
|
||||
rule = &r
|
||||
return err
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NoError(tb, err)
|
||||
|
||||
return rule
|
||||
}
|
||||
|
||||
func setupFolderService(t *testing.T, sqlStore db.DB, cfg *setting.Cfg, features featuremgmt.FeatureToggles) folder.Service {
|
||||
func setupFolderService(t testing.TB, sqlStore db.DB, cfg *setting.Cfg, features featuremgmt.FeatureToggles) folder.Service {
|
||||
tracer := tracing.InitializeTracerForTest()
|
||||
inProcBus := bus.ProvideBus(tracer)
|
||||
folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore)
|
||||
@@ -1735,6 +1735,169 @@ func TestIntegration_AlertRuleVersionsCleanup(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegration_ListAlertRulesByGroup(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
sqlStore := db.InitTestDB(t)
|
||||
cfg := setting.NewCfg()
|
||||
cfg.UnifiedAlerting = setting.UnifiedAlertingSettings{
|
||||
BaseInterval: time.Duration(rand.Int64N(100)+1) * time.Second,
|
||||
}
|
||||
folderService := setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures())
|
||||
bus := &fakeBus{}
|
||||
orgID := int64(1)
|
||||
ruleGen := models.RuleGen.With(
|
||||
models.RuleMuts.WithIntervalMatching(cfg.UnifiedAlerting.BaseInterval),
|
||||
models.RuleMuts.WithOrgID(orgID),
|
||||
)
|
||||
store := createTestStore(sqlStore, folderService, &logtest.Fake{}, cfg.UnifiedAlerting, bus)
|
||||
|
||||
// set test params
|
||||
numFolders := 10
|
||||
numRules := 50
|
||||
rulesPerGroup := 5
|
||||
totalGroups := numRules / rulesPerGroup // 10
|
||||
|
||||
// create rules with different group names
|
||||
rules, _ := createManyRules(t,
|
||||
store,
|
||||
ruleGen,
|
||||
numFolders,
|
||||
numRules,
|
||||
rulesPerGroup,
|
||||
)
|
||||
// sort rules by folder, then group, then group index
|
||||
slices.SortStableFunc(rules, func(a, b *models.AlertRule) int {
|
||||
if a.NamespaceUID != b.NamespaceUID {
|
||||
return strings.Compare(a.NamespaceUID, b.NamespaceUID)
|
||||
}
|
||||
if a.RuleGroup != b.RuleGroup {
|
||||
return strings.Compare(a.RuleGroup, b.RuleGroup)
|
||||
}
|
||||
return a.RuleGroupIndex - b.RuleGroupIndex
|
||||
})
|
||||
|
||||
t.Run("should return all rules when no limit passed", func(t *testing.T) {
|
||||
result, continueToken, err := store.ListAlertRulesByGroup(context.Background(), &models.ListAlertRulesByGroupQuery{
|
||||
ListAlertRulesQuery: models.ListAlertRulesQuery{OrgID: orgID},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, result, 50, "should return all rules when no limit is set")
|
||||
require.Empty(t, continueToken, "continue token should be empty when no limit is set")
|
||||
})
|
||||
|
||||
t.Run("should return paginated results when group limit is set", func(t *testing.T) {
|
||||
// random number from 1 to totalGroups - 1 (to ensure we always receive less than totalGroups)
|
||||
groupLimit := rand.Int64N(int64(totalGroups)-1) + 1
|
||||
result, continueToken, err := store.ListAlertRulesByGroup(context.Background(), &models.ListAlertRulesByGroupQuery{
|
||||
ListAlertRulesQuery: models.ListAlertRulesQuery{OrgID: orgID},
|
||||
GroupLimit: groupLimit,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
expectedRuleCount := groupLimit * int64(rulesPerGroup)
|
||||
require.Len(t, result, int(expectedRuleCount), fmt.Sprintf("should return %d rules when group limit is set", expectedRuleCount))
|
||||
require.NotEmpty(t, continueToken, "continue token should not be empty when limit is set")
|
||||
})
|
||||
|
||||
t.Run("pagination should all for continuation", func(t *testing.T) {
|
||||
groupLimit := int64(2) // fixed group limit for this test
|
||||
result, continueToken, err := store.ListAlertRulesByGroup(context.Background(), &models.ListAlertRulesByGroupQuery{
|
||||
ListAlertRulesQuery: models.ListAlertRulesQuery{OrgID: orgID},
|
||||
GroupLimit: groupLimit,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, result, int(groupLimit*int64(rulesPerGroup)), "should return rules for the first two groups")
|
||||
require.NotEmpty(t, continueToken, "continue token should not be empty")
|
||||
|
||||
for i, rule := range result {
|
||||
expected := rules[i].RuleGroup
|
||||
actual := rule.RuleGroup
|
||||
require.Equal(t, expected, actual, "rules should be ordered by group name")
|
||||
}
|
||||
|
||||
resultRules := make([]*models.AlertRule, 0, len(result))
|
||||
resultRules = append(resultRules, result...)
|
||||
|
||||
// Continue from previous, fetching the rest of the rules
|
||||
result, continueToken, err = store.ListAlertRulesByGroup(context.Background(), &models.ListAlertRulesByGroupQuery{
|
||||
ListAlertRulesQuery: models.ListAlertRulesQuery{OrgID: orgID},
|
||||
GroupContinueToken: continueToken,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
resultRules = append(resultRules, result...)
|
||||
require.Len(t, resultRules, numRules, "should return all rules when continuing from the last token")
|
||||
require.Empty(t, continueToken, "continue token should be empty when all rules are fetched")
|
||||
for i, rule := range resultRules {
|
||||
expected := rules[i].RuleGroup
|
||||
actual := rule.RuleGroup
|
||||
require.Equal(t, expected, actual, "rules should be ordered by group name")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func Benchmark_ListAlertRules(b *testing.B) {
|
||||
orgID := int64(1)
|
||||
ruleGen := models.RuleGen
|
||||
|
||||
// init
|
||||
sqlStore := db.InitTestDB(b)
|
||||
cfg := setting.NewCfg()
|
||||
cfg.UnifiedAlerting = setting.UnifiedAlertingSettings{
|
||||
BaseInterval: time.Duration(rand.Int64N(100)) * time.Second,
|
||||
}
|
||||
folderService := setupFolderService(b, sqlStore, cfg, featuremgmt.WithFeatures())
|
||||
bus := &fakeBus{}
|
||||
store := createTestStore(sqlStore, folderService, &logtest.Fake{}, cfg.UnifiedAlerting, bus)
|
||||
|
||||
ruleGen = ruleGen.With(
|
||||
ruleGen.WithIntervalMatching(cfg.UnifiedAlerting.BaseInterval),
|
||||
ruleGen.WithOrgID(orgID),
|
||||
)
|
||||
|
||||
// define benchmark parameters
|
||||
numFolders := 5
|
||||
numRules := 10000
|
||||
rulesPerGroup := 100
|
||||
assert.Greater(b, numRules, rulesPerGroup, "n must be greater than rulesPerGroup")
|
||||
assert.Equal(b, 0, numRules%rulesPerGroup, "n % rulesPerGroup must be zero to create equal groups")
|
||||
|
||||
// create rules and folders (5 folders, each with n/rulesPerGroup rules)
|
||||
_, _ = createManyRules(b,
|
||||
store,
|
||||
ruleGen,
|
||||
numFolders, // number of folders
|
||||
numRules, // total number of rules
|
||||
rulesPerGroup, // rules per group
|
||||
)
|
||||
|
||||
b.Run(fmt.Sprintf("list %d rules unpaginated", numRules), func(b *testing.B) {
|
||||
for b.Loop() {
|
||||
_, err := store.ListAlertRules(context.Background(), &models.ListAlertRulesQuery{
|
||||
OrgID: orgID,
|
||||
})
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
for _, groupLimit := range []int{1, 2, 5, 10, 50, 100} {
|
||||
b.Run(fmt.Sprintf("list %d groups paginated", groupLimit), func(b *testing.B) {
|
||||
for b.Loop() {
|
||||
_, _, err := store.ListAlertRulesByGroup(context.Background(), &models.ListAlertRulesByGroupQuery{
|
||||
ListAlertRulesQuery: models.ListAlertRulesQuery{OrgID: orgID},
|
||||
GroupLimit: int64(groupLimit),
|
||||
})
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_ListAlertRules(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
@@ -1975,3 +2138,27 @@ func (f *fakeBus) Publish(ctx context.Context, msg bus.Msg) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func createManyRules(tb testing.TB, store *DBstore, ruleGen *models.AlertRuleGenerator, numFolders, numRules, rulesPerGroup int) ([]*models.AlertRule, []string) {
|
||||
tb.Helper()
|
||||
|
||||
require.Greater(tb, numRules, 0, "numRules must be greater than 0")
|
||||
require.Greater(tb, numFolders, 0, "numFolders must be greater than 0")
|
||||
require.Greater(tb, numRules, rulesPerGroup, "numRules must be greater than rulesPerGroup")
|
||||
require.Greater(tb, rulesPerGroup, 0, "rulesPerGroup must be greater than 0")
|
||||
require.Equal(tb, numRules%rulesPerGroup, 0, "numRules % rulesPerGroup must be zero to create equal groups")
|
||||
|
||||
rules := make([]*models.AlertRule, 0, numRules)
|
||||
namespaceUIDs := make([]string, numFolders)
|
||||
for i := range namespaceUIDs {
|
||||
namespaceUIDs[i] = fmt.Sprintf("ns-%d", i)
|
||||
}
|
||||
for i := 0; i < numRules; i++ {
|
||||
gen := ruleGen.With(
|
||||
ruleGen.WithNamespaceUID(namespaceUIDs[i%numFolders]),
|
||||
ruleGen.WithGroupName(fmt.Sprintf("group_%d", i%(numRules/rulesPerGroup))),
|
||||
)
|
||||
rules = append(rules, createRule(tb, store, gen))
|
||||
}
|
||||
return rules, namespaceUIDs
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"math/rand"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -185,6 +186,88 @@ func (f *RuleStore) GetAlertRulesGroupByRuleUID(_ context.Context, q *models.Get
|
||||
return ruleList, nil
|
||||
}
|
||||
|
||||
func (f *RuleStore) ListAlertRulesByGroup(_ context.Context, q *models.ListAlertRulesByGroupQuery) (models.RulesGroup, string, error) {
|
||||
f.mtx.Lock()
|
||||
defer f.mtx.Unlock()
|
||||
f.RecordedOps = append(f.RecordedOps, *q)
|
||||
|
||||
if err := f.Hook(*q); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
query := &models.ListAlertRulesQuery{
|
||||
OrgID: q.OrgID,
|
||||
NamespaceUIDs: q.NamespaceUIDs,
|
||||
DashboardUID: q.DashboardUID,
|
||||
PanelID: q.PanelID,
|
||||
RuleGroups: q.RuleGroups,
|
||||
RuleUIDs: q.RuleUIDs,
|
||||
ReceiverName: q.ReceiverName,
|
||||
HasPrometheusRuleDefinition: q.HasPrometheusRuleDefinition,
|
||||
}
|
||||
|
||||
ruleList, err := f.listAlertRules(query)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
// < group limit logic >
|
||||
|
||||
// sort rules to ensure order is consistent, pagination depends on this
|
||||
slices.SortFunc(ruleList, func(a, b *models.AlertRule) int {
|
||||
nsCmp := strings.Compare(a.NamespaceUID, b.NamespaceUID)
|
||||
if nsCmp != 0 {
|
||||
return nsCmp
|
||||
}
|
||||
rgCmp := strings.Compare(a.RuleGroup, b.RuleGroup)
|
||||
if rgCmp != 0 {
|
||||
return rgCmp
|
||||
}
|
||||
return models.RulesGroupComparer(a, b)
|
||||
})
|
||||
|
||||
var nextToken string
|
||||
var cursor models.GroupCursor
|
||||
if q.GroupContinueToken != "" {
|
||||
if cur, err := models.DecodeGroupCursor(q.GroupContinueToken); err == nil {
|
||||
cursor = cur
|
||||
}
|
||||
}
|
||||
|
||||
if q.GroupLimit < 0 {
|
||||
return ruleList, "", nil
|
||||
}
|
||||
|
||||
outputRules := make([]*models.AlertRule, 0, len(ruleList))
|
||||
var groupsFetched int64
|
||||
initialCursor := cursor
|
||||
for _, r := range ruleList {
|
||||
// skip rules before the initial cursor
|
||||
if initialCursor.NamespaceUID != "" &&
|
||||
(strings.Compare(r.NamespaceUID, initialCursor.NamespaceUID) < 0 ||
|
||||
(strings.Compare(r.NamespaceUID, initialCursor.NamespaceUID) == 0 && strings.Compare(r.RuleGroup, initialCursor.RuleGroup) <= 0)) {
|
||||
continue
|
||||
}
|
||||
|
||||
key := models.GroupCursor{
|
||||
NamespaceUID: r.NamespaceUID,
|
||||
RuleGroup: r.RuleGroup,
|
||||
}
|
||||
if key != cursor {
|
||||
if q.GroupLimit > 0 && groupsFetched == q.GroupLimit {
|
||||
nextToken = models.EncodeGroupCursor(cursor)
|
||||
break
|
||||
}
|
||||
cursor = key
|
||||
groupsFetched++
|
||||
}
|
||||
|
||||
outputRules = append(outputRules, r)
|
||||
}
|
||||
|
||||
return outputRules, nextToken, nil
|
||||
}
|
||||
|
||||
func (f *RuleStore) ListAlertRules(_ context.Context, q *models.ListAlertRulesQuery) (models.RulesGroup, error) {
|
||||
f.mtx.Lock()
|
||||
defer f.mtx.Unlock()
|
||||
@@ -194,6 +277,10 @@ func (f *RuleStore) ListAlertRules(_ context.Context, q *models.ListAlertRulesQu
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return f.listAlertRules(q)
|
||||
}
|
||||
|
||||
func (f *RuleStore) listAlertRules(q *models.ListAlertRulesQuery) (models.RulesGroup, error) {
|
||||
hasDashboard := func(r *models.AlertRule, dashboardUID string, panelID int64) bool {
|
||||
if dashboardUID != "" {
|
||||
if r.DashboardUID == nil || *r.DashboardUID != dashboardUID {
|
||||
|
||||
Reference in New Issue
Block a user