Skip to content

Commit fd56451

Browse files
authored
Add limits to reject unoptimized regex queries on Ingester (#7194)
1 parent bd66858 commit fd56451

12 files changed

Lines changed: 535 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
* [ENHANCEMENT] Upgraded container base images to `alpine:3.23`. #7163
3434
* [ENHANCEMENT] Ingester: Instrument Ingester CPU profile with userID for read APIs. #7184
3535
* [ENHANCEMENT] Ingester: Add fetch timeout for Ingester expanded postings cache. #7185
36+
* [ENHANCEMENT] Ingester: Add feature flag to collect metrics of how expensive an unoptimized regex matcher is and new limits to protect Ingester query path against expensive unoptimized regex matchers. #7194
3637
* [BUGFIX] Ring: Change DynamoDB KV to retry indefinitely for WatchKey. #7088
3738
* [BUGFIX] Ruler: Add XFunctions validation support. #7111
3839
* [BUGFIX] Querier: propagate Prometheus info annotations in protobuf responses. #7132

docs/configuration/config-file-reference.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3822,6 +3822,12 @@ instance_limits:
38223822
# CLI flag: -ingester.enable-matcher-optimization
38233823
[enable_matcher_optimization: <boolean> | default = false]
38243824
3825+
# Enable regex matcher limits and metrics collection for unoptimized regex
3826+
# queries. When enabled, the ingester will track pattern length, label
3827+
# cardinality, and total value length for unoptimized regex matchers.
3828+
# CLI flag: -ingester.enable-regex-matcher-limits
3829+
[enable_regex_matcher_limits: <boolean> | default = false]
3830+
38253831
query_protection:
38263832
rejection:
38273833
threshold:
@@ -4117,6 +4123,24 @@ The `limits_config` configures default and per-tenant limits imposed by Cortex s
41174123
# CLI flag: -blocks-storage.tsdb.enable-native-histograms
41184124
[enable_native_histograms: <boolean> | default = false]
41194125
4126+
# Maximum length (in bytes) of an unoptimized regex pattern. This is a
4127+
# pre-flight check to reject expensive regex queries. 0 to disable. This is only
4128+
# enforced in Ingester.
4129+
# CLI flag: -validation.max-regex-pattern-length
4130+
[max_regex_pattern_length: <int> | default = 0]
4131+
4132+
# Maximum cardinality of a label that can be queried with an unoptimized regex
4133+
# matcher. If exceeded, the query will be rejected with a limit error. 0 to
4134+
# disable. This is only enforced in Ingester.
4135+
# CLI flag: -validation.max-label-cardinality-for-unoptimized-regex
4136+
[max_label_cardinality_for_unoptimized_regex: <int> | default = 0]
4137+
4138+
# Maximum total length (in bytes) of all label values combined for an
4139+
# unoptimized regex matcher. If exceeded, the query will be rejected with a
4140+
# limit error. 0 to disable. This is only enforced in Ingester.
4141+
# CLI flag: -validation.max-total-label-value-length-for-unoptimized-regex
4142+
[max_total_label_value_length_for_unoptimized_regex: <int> | default = 0]
4143+
41204144
# The maximum number of active metrics with metadata per user, per ingester. 0
41214145
# to disable.
41224146
# CLI flag: -ingester.max-metadata-per-user

docs/configuration/v1-guarantees.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,3 +139,9 @@ Currently experimental features are:
139139
- `-ingester.active-queried-series-metrics-update-period` metric update interval
140140
- `-ingester.active-queried-series-metrics-window-duration` each HyperLogLog time window size
141141
- `-ingester.active-queried-series-metrics-sample-rate` query sampling rate
142+
- Ingester: Regex Matcher Limits
143+
- Enable regex matcher limits and metrics collection via `-ingester.enable-regex-matcher-limits=true`
144+
- Per-tenant limits for unoptimized regex matchers:
145+
- `-validation.max-regex-pattern-length` (int) - maximum pattern length in bytes
146+
- `-validation.max-label-cardinality-for-unoptimized-regex` (int) - maximum label cardinality
147+
- `-validation.max-total-label-value-length-for-unoptimized-regex` (int) - maximum total length of all label values in bytes

pkg/frontend/transport/handler.go

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -64,14 +64,16 @@ const (
6464
reasonSeriesLimitStoreGateway = "store_gateway_series_limit"
6565
reasonChunksLimitStoreGateway = "store_gateway_chunks_limit"
6666
reasonBytesLimitStoreGateway = "store_gateway_bytes_limit"
67+
reasonUnOptimizedRegexMatcher = `unoptimized_regex_matcher`
6768

68-
limitTooManySamples = `query processing would load too many samples into memory`
69-
limitTimeRangeExceeded = `the query time range exceeds the limit`
70-
limitResponseSizeExceeded = `the query response size exceeds limit`
71-
limitSeriesFetched = `the query hit the max number of series limit`
72-
limitChunksFetched = `the query hit the max number of chunks limit`
73-
limitChunkBytesFetched = `the query hit the aggregated chunks size limit`
74-
limitDataBytesFetched = `the query hit the aggregated data size limit`
69+
limitTooManySamples = `query processing would load too many samples into memory`
70+
limitTimeRangeExceeded = `the query time range exceeds the limit`
71+
limitResponseSizeExceeded = `the query response size exceeds limit`
72+
limitSeriesFetched = `the query hit the max number of series limit`
73+
limitChunksFetched = `the query hit the max number of chunks limit`
74+
limitChunkBytesFetched = `the query hit the aggregated chunks size limit`
75+
limitDataBytesFetched = `the query hit the aggregated data size limit`
76+
limitUnOptimizedRegexMatcher = `unoptimized regex matcher`
7577

7678
// Store gateway limits.
7779
limitSeriesStoreGateway = `exceeded series limit`
@@ -585,6 +587,8 @@ func (f *Handler) reportQueryStats(r *http.Request, source, userID string, query
585587
reason = reasonChunksLimitStoreGateway
586588
} else if strings.Contains(errMsg, limitBytesStoreGateway) {
587589
reason = reasonBytesLimitStoreGateway
590+
} else if strings.Contains(errMsg, limitUnOptimizedRegexMatcher) {
591+
reason = reasonUnOptimizedRegexMatcher
588592
}
589593
} else if statusCode == http.StatusServiceUnavailable && error != nil {
590594
errMsg := error.Error()

pkg/ingester/ingester.go

Lines changed: 113 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,11 @@ type Config struct {
174174
// instead of being used for postings selection.
175175
EnableMatcherOptimization bool `yaml:"enable_matcher_optimization"`
176176

177+
// Enable regex matcher limits and metrics collection for unoptimized regex queries.
178+
// When enabled, the ingester will track pattern length, label cardinality, and total value length
179+
// for unoptimized regex matchers, and enforce per-tenant limits if configured.
180+
EnableRegexMatcherLimits bool `yaml:"enable_regex_matcher_limits"`
181+
177182
QueryProtection configs.QueryProtection `yaml:"query_protection"`
178183
}
179184

@@ -205,7 +210,7 @@ func (cfg *Config) RegisterFlags(f *flag.FlagSet) {
205210
f.IntVar(&cfg.MatchersCacheMaxItems, "ingester.matchers-cache-max-items", 0, "Maximum number of entries in the regex matchers cache. 0 to disable.")
206211
f.BoolVar(&cfg.SkipMetadataLimits, "ingester.skip-metadata-limits", true, "If enabled, the metadata API returns all metadata regardless of the limits.")
207212
f.BoolVar(&cfg.EnableMatcherOptimization, "ingester.enable-matcher-optimization", false, "Enable optimization of label matchers when query chunks. When enabled, matchers with low selectivity such as =~.+ are applied lazily during series scanning instead of being used for postings matching.")
208-
213+
f.BoolVar(&cfg.EnableRegexMatcherLimits, "ingester.enable-regex-matcher-limits", false, "Enable regex matcher limits and metrics collection for unoptimized regex queries. When enabled, the ingester will track pattern length, label cardinality, and total value length for unoptimized regex matchers.")
209214
cfg.DefaultLimits.RegisterFlagsWithPrefix(f, "ingester.")
210215
cfg.QueryProtection.RegisterFlagsWithPrefix(f, "ingester.")
211216
}
@@ -813,7 +818,8 @@ func New(cfg Config, limits *validation.Overrides, registerer prometheus.Registe
813818
i.ingestionRate,
814819
&i.maxInflightPushRequests,
815820
&i.maxInflightQueryRequests,
816-
cfg.BlocksStorageConfig.TSDB.PostingsCache.Blocks.Enabled || cfg.BlocksStorageConfig.TSDB.PostingsCache.Head.Enabled)
821+
cfg.BlocksStorageConfig.TSDB.PostingsCache.Blocks.Enabled || cfg.BlocksStorageConfig.TSDB.PostingsCache.Head.Enabled,
822+
cfg.EnableRegexMatcherLimits)
817823
i.validateMetrics = validation.NewValidateMetrics(registerer)
818824

819825
// Replace specific metrics which we can't directly track but we need to read
@@ -911,6 +917,7 @@ func NewForFlusher(cfg Config, limits *validation.Overrides, registerer promethe
911917
&i.maxInflightPushRequests,
912918
&i.maxInflightQueryRequests,
913919
cfg.BlocksStorageConfig.TSDB.PostingsCache.Blocks.Enabled || cfg.BlocksStorageConfig.TSDB.PostingsCache.Head.Enabled,
920+
cfg.EnableRegexMatcherLimits,
914921
)
915922

916923
i.TSDBState.shipperIngesterID = "flusher"
@@ -2428,7 +2435,7 @@ func (i *Ingester) queryStream(ctx context.Context, userID string, req *client.Q
24282435
numSeries := 0
24292436
totalDataBytes := 0
24302437
numChunks := 0
2431-
numSeries, numSamples, totalDataBytes, numChunks, err = i.queryStreamChunks(ctx, db, int64(from), int64(through), matchers, shardMatcher, stream)
2438+
numSeries, numSamples, totalDataBytes, numChunks, err = i.queryStreamChunks(ctx, userID, db, int64(from), int64(through), matchers, shardMatcher, stream)
24322439

24332440
if err != nil {
24342441
return err
@@ -2467,14 +2474,116 @@ func (i *Ingester) trackInflightQueryRequest() (func(), error) {
24672474
}, nil
24682475
}
24692476

2477+
func isRegexUnOptimized(matcher *labels.Matcher) bool {
2478+
if matcher.Type != labels.MatchRegexp {
2479+
return false
2480+
}
2481+
// PostingsForMatchers will optimize .* and .+ matchers, so we don't need to check them.
2482+
if matcher.Value == ".*" || matcher.Value == ".+" {
2483+
return false
2484+
}
2485+
return !matcher.IsRegexOptimized()
2486+
}
2487+
2488+
// checkRegexMatcherLimits validates regex matchers against configured limits to prevent expensive queries.
2489+
func (i *Ingester) checkRegexMatcherLimits(ctx context.Context, userID string, db *userTSDB, matchers []*labels.Matcher, from, through int64) error {
2490+
// Collect all unoptimized regex matchers upfront
2491+
var unoptimizedMatchers []*labels.Matcher
2492+
for _, matcher := range matchers {
2493+
if isRegexUnOptimized(matcher) {
2494+
unoptimizedMatchers = append(unoptimizedMatchers, matcher)
2495+
// Record pattern length metric
2496+
if i.metrics.unoptimizedRegexPatternLength != nil {
2497+
i.metrics.unoptimizedRegexPatternLength.Observe(float64(len(matcher.Value)))
2498+
}
2499+
}
2500+
}
2501+
2502+
if len(unoptimizedMatchers) == 0 {
2503+
return nil
2504+
}
2505+
2506+
// Check pattern length limit if configured
2507+
maxPatternLength := i.limits.MaxRegexPatternLength(userID)
2508+
if maxPatternLength > 0 {
2509+
for _, matcher := range unoptimizedMatchers {
2510+
patternLength := len(matcher.Value)
2511+
if patternLength > maxPatternLength {
2512+
return validation.LimitError(fmt.Sprintf(
2513+
"regex pattern length %d exceeds limit %d for unoptimized regex matcher %q. Consider using a more specific pattern.",
2514+
patternLength, maxPatternLength, matcher.String(),
2515+
))
2516+
}
2517+
}
2518+
}
2519+
2520+
// Query TSDB to collect cardinality and total value length metrics and check limits.
2521+
labelQuerier, err := db.Querier(from, through)
2522+
if err != nil {
2523+
return err
2524+
}
2525+
defer labelQuerier.Close()
2526+
2527+
maxCardinality := i.limits.MaxLabelCardinalityForUnoptimizedRegex(userID)
2528+
maxTotalValueLength := i.limits.MaxTotalLabelValueLengthForUnoptimizedRegex(userID)
2529+
2530+
for _, matcher := range unoptimizedMatchers {
2531+
labelVals, _, err := labelQuerier.LabelValues(ctx, matcher.Name, nil)
2532+
if err != nil {
2533+
// If we can't get label values, skip this matcher and continue checking others
2534+
continue
2535+
}
2536+
2537+
cardinality := len(labelVals)
2538+
2539+
// Calculate total length of all values
2540+
var totalValueLength int
2541+
for _, val := range labelVals {
2542+
totalValueLength += len(val)
2543+
}
2544+
2545+
// Always record metrics regardless of whether limits are configured (if metrics are enabled)
2546+
if i.metrics.unoptimizedRegexLabelCardinality != nil {
2547+
i.metrics.unoptimizedRegexLabelCardinality.Observe(float64(cardinality))
2548+
}
2549+
if i.metrics.unoptimizedRegexTotalValueLength != nil {
2550+
i.metrics.unoptimizedRegexTotalValueLength.Observe(float64(totalValueLength))
2551+
}
2552+
2553+
// Check limits only if configured
2554+
if maxCardinality > 0 && cardinality > maxCardinality {
2555+
return validation.LimitError(fmt.Sprintf(
2556+
"label %q has cardinality %d which exceeds limit %d for unoptimized regex matcher %q. Consider using a more specific matcher.",
2557+
matcher.Name, cardinality, maxCardinality, matcher.String(),
2558+
))
2559+
}
2560+
2561+
if maxTotalValueLength > 0 && totalValueLength > maxTotalValueLength {
2562+
return validation.LimitError(fmt.Sprintf(
2563+
"label %q has total value length %d bytes (across %d values) which exceeds limit %d for unoptimized regex matcher %q. Consider using a more specific matcher.",
2564+
matcher.Name, totalValueLength, cardinality, maxTotalValueLength, matcher.String(),
2565+
))
2566+
}
2567+
}
2568+
2569+
return nil
2570+
}
2571+
24702572
// queryStreamChunks streams metrics from a TSDB. This implements the client.IngesterServer interface
2471-
func (i *Ingester) queryStreamChunks(ctx context.Context, db *userTSDB, from, through int64, matchers []*labels.Matcher, sm *storepb.ShardMatcher, stream client.Ingester_QueryStreamServer) (numSeries, numSamples, totalBatchSizeBytes, numChunks int, _ error) {
2573+
func (i *Ingester) queryStreamChunks(ctx context.Context, userID string, db *userTSDB, from, through int64, matchers []*labels.Matcher, sm *storepb.ShardMatcher, stream client.Ingester_QueryStreamServer) (numSeries, numSamples, totalBatchSizeBytes, numChunks int, _ error) {
24722574
q, err := db.ChunkQuerier(from, through)
24732575
if err != nil {
24742576
return 0, 0, 0, 0, err
24752577
}
24762578
defer q.Close()
24772579

2580+
// Check regex matcher limits before executing query if enabled
2581+
if i.cfg.EnableRegexMatcherLimits {
2582+
if err := i.checkRegexMatcherLimits(ctx, userID, db, matchers, from, through); err != nil {
2583+
return 0, 0, 0, 0, err
2584+
}
2585+
}
2586+
24782587
c, err := i.trackInflightQueryRequest()
24792588
if err != nil {
24802589
return 0, 0, 0, 0, err

0 commit comments

Comments
 (0)