Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

enforce max series for metrics queries #4525

Open
wants to merge 18 commits into
base: main
Choose a base branch
from

Conversation

ie-pham
Copy link
Contributor

@ie-pham ie-pham commented Jan 7, 2025

What this PR does: Add config to enforce max time series returned in a metrics query. This is enforced at 4 levels: front-end combiner, querier combiner, metrics-generator local blocks, and metrics evaluation. The configuration is set in the query-frontend config and is passed to all levels as maxSeries in the QueryRangeRequest proto.

new config: max_response_series <default 1000>
Setting the value to 0 will disable this feature.

approach : Keep track of number of series for every observe and observeSeries call and exit as soon as the limit is reached. Whatever series were generated up to this point will be truncated at the limit and returned as partial results. This may mean that partial results are not useful as each series could potentially contain just one data point.

Which issue(s) this PR fixes:
Fixes #4219

Checklist

  • Tests updated
  • Documentation added
  • CHANGELOG.md updated - the order of entries should be [CHANGE], [FEATURE], [ENHANCEMENT], [BUGFIX]

Copy link
Contributor

@knylander-grafana knylander-grafana left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for adding docs.


metrics:
# Maximum number of time series returned for a metrics query.
[max_response_series: <int> | default = 1000]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is an interesting choice. normally we would communicate the max series through a query param from the frontend to the queriers. the negative of your approach is that we have to make sure that 2 settings are aligned or tempo may appear subtly broken. the advantage is that we don't repeatedly marshal something like series=1000 once for every subquery.

can you bring this up with the team and see if we have consensus either way?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this does make me wonder if we should have a shared section of the config for querying like we do for storage. that feels like overkill for one setting tho.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

switching to passing the config in the request proto since it needs to get passed all the way to the metrics evaluator

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this be removed from the docs then?

Copy link
Contributor

@javiermolinar javiermolinar left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How does the metric query look now? Are the series evenly distributed? I'm asking because we have an issue with exemplars where we enforce a similar limit, and they appear to be skewed


metrics:
# Maximum number of time series returned for a metrics query.
[max_response_series: <int> | default = 1000]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this be removed from the docs then?

@@ -62,9 +72,22 @@ func NewQueryRange(req *tempopb.QueryRangeRequest) (Combiner, error) {
diff := diffResponse(prevResp, resp)
// store resp for next diff
prevResp = resp
prevTotalSeries := totalSeries
totalSeries += len(diff.Series)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i don't think this logic is correct for streaming. this assumes that all of diff.Series is new. i think you should just check resp.Series directly as it represents the complete response at this time.

also, it seems odd that in quit we use combiner.MaxSeriesReached() but we use a different calculation in diff and finalize. can we not use combiner.MaxSeriesReached() in all cases?

@@ -34,6 +34,7 @@ func newQueryRangeStreamingGRPCHandler(cfg Config, next pipeline.AsyncRoundTripp
if req.Step == 0 {
req.Step = traceql.DefaultQueryRangeStep(req.Start, req.End)
}
req.MaxSeries = uint32(cfg.Metrics.Sharder.MaxResponseSeries)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i'm a little weirded out by just overwriting this value. should we 400 if this is set to non-zero b/c we don't accept it being set externally? do we have any other settings that use this pattern we can pull from?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

perhaps we should treat it the same way we do limits? if it's specified in the request (and not larger than the max) honor it else use the max?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this makes sense

@@ -271,6 +273,7 @@ func (s *queryRangeSharder) buildBackendRequests(ctx context.Context, tenantID s
FooterSize: m.FooterSize,
// DedicatedColumns: dc, for perf reason we pass dedicated columns json in directly to not have to realloc object -> proto -> json
Exemplars: exemplars,
MaxSeries: uint32(s.cfg.MaxResponseSeries),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this method is cloning the upstream request. should we do that here as well instead of copying the value from the config

@@ -115,6 +116,7 @@ func (s queryRangeSharder) RoundTrip(pipelineRequest pipeline.Request) (pipeline
}
}
req.Exemplars = maxExemplars
req.MaxSeries = uint32(s.cfg.MaxResponseSeries)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i find the code that is setting the value on the request confusing. we are doing it in the handler, again here, and in the backend requests below. i'm finding this confusing.

@@ -540,16 +541,24 @@ func (i *instance) QueryRange(ctx context.Context, req *tempopb.QueryRangeReques
for _, p := range processors {
err = p.QueryRange(ctx, req, rawEval, jobEval)
if err != nil {
fmt.Printf("error in query range: %v\n", err)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove?

bunch of similar test lines below


// listens for series counts and updates the total series count.
go func() {
for count := range seriesCountCh {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we find a simpler way to do this? there's a lot of moving parts here to just count the current series.

i'm also concerned about the cost of repeatedly calling .Results() on the series. does this require a lot of work? how does it compare for different functions like rates/quantiles/avgs/etc?

also, the metrics aggregated at this level are different than those in the frontend. for instance quantile_over_time will produce considerably more series here than at the frontend b/c it is a histogram here that is aggregated into quantiles in the frontend. how does this work into our solution?

also also, if series are truncated here do we pass that back in the response?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct me if I'm wrong but I thought the series from "jobEval.ObserveSeries" are already aggregated series. So at worst, we would have the truncated amount of series. But if the environment has more than one metrics-generator, that is additional series that would get aggregated again. I think this would be suffice but what do you think?

The .Results() is calling this https://github.com/grafana/tempo/blob/main/pkg/traceql/engine_metrics.go#L716

Since these are the raw not-yet aggregated results, it's a lot harder to count them. Since (I think) we avoided calling them for performance and calling it only once at the end https://github.com/grafana/tempo/blob/main/modules/generator/instance.go#L548 do you think maybe we should just not keep track of the series on the head and wal blocks?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct me if I'm wrong but I thought the series from "jobEval.ObserveSeries" are already aggregated series. So at worst, we would have the truncated amount of series.

It's dependent on the metric type and aggregation level. So when you aggregate quantiles at the lower levels you are actually aggregating histograms and passing those up to the frontend. A histogram has considerably more series than a quantile. Up to N times more where N is the number of buckets in the histogram

When you aggregate a simple rates at all levels it is the same number of series.

The .Results() is calling this https://github.com/grafana/tempo/blob/main/pkg/traceql/engine_metrics.go#L716

yeah, that looks potentially expensive. I did track down all the .Observe calls at the series level and the count they are returning seems a lot cheaper

@@ -326,6 +326,10 @@ type QueryRangeCombiner struct {
req *tempopb.QueryRangeRequest
eval *MetricsFrontendEvaluator
metrics *tempopb.SearchMetrics

maxSeries int
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you only need 2 of these 3, right?

why not remove maxSeriesReached and change the function below to

func (q *QueryRangeCombiner) MaxSeriesReached() bool {
	return q.seriesCount >= q.maxSeries
}

@@ -313,15 +313,15 @@ type VectorAggregator interface {
// RangeAggregator sorts spans into time slots
// TODO - for efficiency we probably combine this with VectorAggregator (see todo about CountOverTimeAggregator)
type RangeAggregator interface {
Observe(s Span)
Observe(s Span) int
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

at the span level the GroupingAggregator is stored directly on the MetricsAggregate object and is the only thing we store there:

	a.agg = NewGroupingAggregator(a.op.String(), func() RangeAggregator {
		return NewStepAggregator(q.Start, q.End, q.Step, innerAgg)
	}, a.by, byFunc, byFuncLabel)

of the span level aggregators it also seems like we actually only need to count series in the GroupingAggregator. should we change a.agg to be a concrete type and only add the ability to count series to that object instead of polluting all these interfaces? and meaningless data (0s) from some Aggregators?

@@ -34,6 +34,7 @@ func newQueryRangeStreamingGRPCHandler(cfg Config, next pipeline.AsyncRoundTripp
if req.Step == 0 {
req.Step = traceql.DefaultQueryRangeStep(req.Start, req.End)
}
req.MaxSeries = uint32(cfg.Metrics.Sharder.MaxResponseSeries)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

perhaps we should treat it the same way we do limits? if it's specified in the request (and not larger than the max) honor it else use the max?

@@ -167,6 +167,7 @@ func (m *MetricsCompare) observe(span Span) {
}
totals[i]++
})
return len(m.seriesAgg.Results())
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we get tests on these all these series aggregators observe's returning total series?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Limit series produced by TraceQL Metrics
5 participants