Compare commits

...

6 Commits

Author SHA1 Message Date
srikanthccv
be13ced422 chore(clickhouseprometheusv2): render the samples budget in the built statement
Capture records what buildSamplesQuery returns, so an append in
selectSamples left preview SQL without the cap. The builder is now the one
rendering point for both paths.

Assisted-by: Claude Fable 5
2026-11-07 19:44:19 +05:30
srikanthccv
7d558888d8 chore(clickhouseprometheusv2): name method receivers after their role
Assisted-by: Claude Fable 5
2026-11-07 19:26:10 +05:30
srikanthccv
5237c19eef chore(clickhouseprometheusv2): give budget refusals specific error codes
Also render the budget setting with clickhousesql.Literal and state the
per-statement cap semantics in the config and the doc.

Assisted-by: Claude Fable 5
2026-11-07 18:48:04 +05:30
Srikanth Chekuri
323d16a4ed Merge branch 'main' into promql-restore-budgets 2026-11-05 02:11:20 +05:30
srikanthccv
a1d83d566d docs(prometheus): describe hybrid synthetic series as nameless
The series stopped carrying the synthetic __name__ in #13031; only the
rewritten selectors use it.

Assisted-by: Claude Fable 5
2026-10-09 15:01:14 +05:30
srikanthccv
470c8e1185 feat(clickhouseprometheusv2): enforce fetch budgets in clickhouse on the engine read path
Assisted-by: Claude Fable 5
2026-10-09 13:11:32 +05:30
18 changed files with 440 additions and 143 deletions

View File

@@ -60,6 +60,7 @@ jobs:
- rawexportdata
- promqlconformance
- promapiconformance
- promqlbudget
- promote
- querierauthz
- role

View File

@@ -105,8 +105,8 @@ server-side:
would silently use the wrong offset or range.
- Steps or ranges that are not whole seconds. The grid functions take
whole-second parameters.
- Grouping by `__name__`, or matching on it, in hybrid plans. The synthetic
name would leak into results.
- Grouping by `__name__`, or matching on it, in hybrid plans. That needs
name bookkeeping the compiler does not do.
- Name-keeping units in hybrid plans. Bare and comparison-filtered instant
selectors and `last_over_time` keep their real `__name__` (`keepsName`).
Substitution would replace that name. These units transpile only as full
@@ -290,14 +290,13 @@ the engine applies, to the same slot value, in the same operator order the
AST dictates. Go instead of another SQL layer changes where, not what.
A full plan's arrays map straight to the result matrix. A hybrid plan
materializes each unit's arrays as synthetic series under its
`__signoz_transpiled_N__` name. The engine evaluates the rewritten
expression over a storage that serves synthetic names from memory and
everything else live. Substitution is sound because a unit's output is a
plain instant vector to the engine: same values at same timestamps, under a
different name. The name cannot matter. Plans that group by or match on
`__name__` were refused at classification. Name-keeping units are never
substituted. One subtlety makes it exact: we write stale markers at absent
materializes each unit's arrays as synthetic in-memory series. The engine
evaluates the rewritten expression over a storage that resolves
`__signoz_transpiled_N__` selectors from memory and everything else live.
The synthetic series carry no `__name__`: substituted units all drop it, so
the engine sees the replaced expression's exact output, same labelsets and
same values at same timestamps. Plans that group by or match on `__name__`
were refused at classification. Name-keeping units are never substituted. One subtlety makes it exact: we write stale markers at absent
grid points. Without them, the engine's lookback would resurrect a point
from up to `lookback` earlier. The marker encodes "absent here" the way the
engine itself encodes it. Units evaluate concurrently. Each unit is one
@@ -307,6 +306,20 @@ selector without a static `__name__` runs the series lookup first, to learn
the concrete metric names. A step of 0 is an instant query: a single
evaluation at `end`.
The engine path enforces fetch budgets in ClickHouse
(`prometheus::clickhousev2::max_fetched_series` and
`::max_fetched_samples`; 0 disables). The series lookup and the samples
query carry `max_result_rows` with `result_overflow_mode = 'throw'`, so an
over-budget query stops in the database instead of streaming into the
service. The caps are per statement, not per PromQL query: a query runs one
samples statement per selector, so a query with N selectors can fetch up to
N times the samples cap. The client maps the refusal (`TOO_MANY_ROWS_OR_BYTES`) to a typed
invalid-input error. The error pierces the
engine's `promql.ErrStorage` wrapper (`prometheus.TypedStorageError`), so
the APIs report a user error, not an internal one. Transpiled statements
carry no result budget: their result rows are output series, which the
querier fleet caps by other means.
A note on the window sliver: when the window is narrower than the step, the
grid windows cover only `window/step` of the timeline. A sample in a gap
belongs to no window. It cannot move any grid point, but the grid aggregate

View File

@@ -17,17 +17,17 @@ type statementRecorder struct {
statements []prometheus.CapturedStatement
}
func (r *statementRecorder) record(query string, args []any) {
r.mu.Lock()
defer r.mu.Unlock()
r.statements = append(r.statements, prometheus.CapturedStatement{Query: query, Args: args})
func (recorder *statementRecorder) record(query string, args []any) {
recorder.mu.Lock()
defer recorder.mu.Unlock()
recorder.statements = append(recorder.statements, prometheus.CapturedStatement{Query: query, Args: args})
}
func (r *statementRecorder) Statements() []prometheus.CapturedStatement {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]prometheus.CapturedStatement, len(r.statements))
copy(out, r.statements)
func (recorder *statementRecorder) Statements() []prometheus.CapturedStatement {
recorder.mu.Lock()
defer recorder.mu.Unlock()
out := make([]prometheus.CapturedStatement, len(recorder.statements))
copy(out, recorder.statements)
return out
}
@@ -36,10 +36,10 @@ type captureQueryable struct {
recorder *statementRecorder
}
func (c *captureQueryable) Querier(mint, maxt int64) (storage.Querier, error) {
func (queryable *captureQueryable) Querier(mint, maxt int64) (storage.Querier, error) {
return &captureQuerier{
querier: querier{mint: mint, maxt: maxt, client: c.client},
recorder: c.recorder,
querier: querier{mint: mint, maxt: maxt, client: queryable.client},
recorder: queryable.recorder,
}, nil
}
@@ -51,24 +51,24 @@ type captureQuerier struct {
recorder *statementRecorder
}
func (c *captureQuerier) Select(ctx context.Context, _ bool, hints *storage.SelectHints, matchers ...*labels.Matcher) storage.SeriesSet {
func (capture *captureQuerier) Select(ctx context.Context, _ bool, hints *storage.SelectHints, matchers ...*labels.Matcher) storage.SeriesSet {
start, end := c.window(hints)
start, end := capture.window(hints)
samplesQuery, args, err := buildSamplesQuery(start, end, metricNamesFromMatchers(matchers), matchers, c.lastSamplePerStepFor(ctx, hints))
samplesQuery, args, err := buildSamplesQuery(start, end, metricNamesFromMatchers(matchers), matchers, capture.lastSamplePerStepFor(ctx, hints), capture.client.cfg.MaxFetchedSamples)
if err != nil {
return storage.ErrSeriesSet(err)
}
c.recorder.record(samplesQuery, args)
capture.recorder.record(samplesQuery, args)
return storage.EmptySeriesSet()
}
func (c *captureQuerier) LabelValues(context.Context, string, *storage.LabelHints, ...*labels.Matcher) ([]string, annotations.Annotations, error) {
func (capture *captureQuerier) LabelValues(context.Context, string, *storage.LabelHints, ...*labels.Matcher) ([]string, annotations.Annotations, error) {
return nil, nil, nil
}
func (c *captureQuerier) LabelNames(context.Context, *storage.LabelHints, ...*labels.Matcher) ([]string, annotations.Annotations, error) {
func (capture *captureQuerier) LabelNames(context.Context, *storage.LabelHints, ...*labels.Matcher) ([]string, annotations.Annotations, error) {
return nil, nil, nil
}

View File

@@ -7,6 +7,10 @@ import (
"math"
"slices"
chproto "github.com/ClickHouse/ch-go/proto"
"github.com/ClickHouse/clickhouse-go/v2"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/prometheus"
"github.com/SigNoz/signoz/pkg/telemetrystore"
@@ -17,6 +21,11 @@ import (
promValue "github.com/prometheus/prometheus/model/value"
)
var (
CodeSeriesBudgetExceeded = errors.MustNewCode("series_budget_exceeded")
CodeSamplesBudgetExceeded = errors.MustNewCode("samples_budget_exceeded")
)
// seriesLookup holds a series lookup's result: matched fingerprints with
// their labels, and the distinct metric names seen on them.
type seriesLookup struct {
@@ -29,6 +38,7 @@ type client struct {
settings factory.ScopedProviderSettings
telemetryStore telemetrystore.TelemetryStore
lookbackMs int64
cfg prometheus.ClickhouseV2Config
}
func newClient(settings factory.ScopedProviderSettings, telemetryStore telemetrystore.TelemetryStore, cfg prometheus.Config) *client {
@@ -41,10 +51,11 @@ func newClient(settings factory.ScopedProviderSettings, telemetryStore telemetry
settings: settings,
telemetryStore: telemetryStore,
lookbackMs: lookback.Milliseconds(),
cfg: cfg.ClickhouseV2,
}
}
func (c *client) withContext(ctx context.Context, functionName string) context.Context {
func (client *client) withContext(ctx context.Context, functionName string) context.Context {
return ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalMetrics.StringValue(),
instrumentationtypes.CodeNamespace: "clickhouse-prometheus-v2",
@@ -52,11 +63,43 @@ func (c *client) withContext(ctx context.Context, functionName string) context.C
})
}
func (c *client) selectSeries(ctx context.Context, query string, args []any) (*seriesLookup, error) {
ctx = c.withContext(ctx, "selectSeries")
rows, err := c.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
// budgetExceeded reports whether err is ClickHouse refusing a query over its
// result limit (the budgets above).
func budgetExceeded(err error) bool {
var chErr *clickhouse.Exception
return errors.As(err, &chErr) && chErr.Code == int32(chproto.ErrTooManyRowsOrBytes)
}
func (client *client) seriesError(err error) error {
if budgetExceeded(err) {
return errors.NewInvalidInputf(
CodeSeriesBudgetExceeded,
"promql selector matched more than %d series; narrow the label matchers",
client.cfg.MaxFetchedSeries,
)
}
return err
}
func (client *client) samplesError(err error) error {
if budgetExceeded(err) {
return errors.NewInvalidInputf(
CodeSamplesBudgetExceeded,
"promql query would fetch more than %d samples; narrow the selector or time range",
client.cfg.MaxFetchedSamples,
)
}
return err
}
func (client *client) selectSeries(ctx context.Context, query string, args []any) (*seriesLookup, error) {
ctx = client.withContext(ctx, "selectSeries")
if client.cfg.MaxFetchedSeries > 0 {
query += budgetSettings(int64(client.cfg.MaxFetchedSeries))
}
rows, err := client.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
if err != nil {
return nil, err
return nil, client.seriesError(err)
}
defer rows.Close()
@@ -79,7 +122,7 @@ func (c *client) selectSeries(ctx context.Context, query string, args []any) (*s
}
}
if err := rows.Err(); err != nil {
return nil, err
return nil, client.seriesError(err)
}
for name := range names {
@@ -117,11 +160,11 @@ func unmarshalLabels(s string) (labels.Labels, error) {
// Stale flags map to the engine's StaleNaN. Duplicate
// timestamps pass through: uniqueness is ingest's job, and v1 feeds them to
// the engine as-is over the same dirty data.
func (c *client) selectSamples(ctx context.Context, query string, args []any, lookup *seriesLookup) ([]*series, error) {
ctx = c.withContext(ctx, "selectSamples")
rows, err := c.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
func (client *client) selectSamples(ctx context.Context, query string, args []any, lookup *seriesLookup) ([]*series, error) {
ctx = client.withContext(ctx, "selectSamples")
rows, err := client.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
if err != nil {
return nil, err
return nil, client.samplesError(err)
}
defer rows.Close()
@@ -168,11 +211,11 @@ func (c *client) selectSamples(ctx context.Context, query string, args []any, lo
current.vs = append(current.vs, val)
}
if err := rows.Err(); err != nil {
return nil, err
return nil, client.samplesError(err)
}
if unknownCount > 0 {
c.settings.Logger().DebugContext(ctx, "skipped samples of fingerprints missing from series lookup",
client.settings.Logger().DebugContext(ctx, "skipped samples of fingerprints missing from series lookup",
slog.Int("unknown_fingerprints", unknownCount))
}

View File

@@ -43,8 +43,8 @@ func New(_ context.Context, providerSettings factory.ProviderSettings, config pr
}, nil
}
func (p *provider) QueryRange(ctx context.Context, query string, start, end time.Time, step time.Duration) (*prometheus.Result, error) {
matrix, served, err := p.executor.TryExecuteRange(ctx, query, start, end, step)
func (provider *provider) QueryRange(ctx context.Context, query string, start, end time.Time, step time.Duration) (*prometheus.Result, error) {
matrix, served, err := provider.executor.TryExecuteRange(ctx, query, start, end, step)
if err != nil {
return nil, err
}
@@ -52,15 +52,15 @@ func (p *provider) QueryRange(ctx context.Context, query string, start, end time
return &prometheus.Result{Value: matrix}, nil
}
qry, err := p.engine.NewRangeQuery(p.traitsContext(ctx, query), p, nil, query, start, end, step)
qry, err := provider.engine.NewRangeQuery(provider.traitsContext(ctx, query), provider, nil, query, start, end, step)
if err != nil {
return nil, err
}
return finishQuery(ctx, qry)
}
func (p *provider) Query(ctx context.Context, query string, ts time.Time) (*prometheus.Result, error) {
qry, err := p.engine.NewInstantQuery(p.traitsContext(ctx, query), p, nil, query, ts)
func (provider *provider) Query(ctx context.Context, query string, ts time.Time) (*prometheus.Result, error) {
qry, err := provider.engine.NewInstantQuery(provider.traitsContext(ctx, query), provider, nil, query, ts)
if err != nil {
return nil, err
}
@@ -69,10 +69,10 @@ func (p *provider) Query(ctx context.Context, query string, ts time.Time) (*prom
// A fresh recorder per call keeps concurrent dry-runs isolated. Exec drives
// a Select per selector (recording SQL) but reads no data.
func (p *provider) Statements(ctx context.Context, query string, start, end time.Time, step time.Duration) ([]prometheus.CapturedStatement, error) {
func (provider *provider) Statements(ctx context.Context, query string, start, end time.Time, step time.Duration) ([]prometheus.CapturedStatement, error) {
recorder := &statementRecorder{}
capture := &captureQueryable{client: p.client, recorder: recorder}
qry, err := p.engine.NewRangeQuery(p.traitsContext(ctx, query), capture, nil, query, start, end, step)
capture := &captureQueryable{client: provider.client, recorder: recorder}
qry, err := provider.engine.NewRangeQuery(provider.traitsContext(ctx, query), capture, nil, query, start, end, step)
if err != nil {
return nil, err
}
@@ -86,8 +86,8 @@ func (p *provider) Statements(ctx context.Context, query string, start, end time
// traitsContext attaches the query's traits so the storage can prove
// step-aligned optimizations safe (see prometheus.QueryTraits). A parse
// failure surfaces from the engine with its own error.
func (p *provider) traitsContext(ctx context.Context, query string) context.Context {
expr, err := p.parser.ParseExpr(query)
func (provider *provider) traitsContext(ctx context.Context, query string) context.Context {
expr, err := provider.parser.ParseExpr(query)
if err != nil {
return ctx
}
@@ -106,6 +106,6 @@ func finishQuery(ctx context.Context, qry promql.Query) (*prometheus.Result, err
return &prometheus.Result{Value: res.Value, Warnings: res.Warnings, Stats: qry.Stats()}, nil
}
func (p *provider) Querier(mint, maxt int64) (storage.Querier, error) {
return &querier{mint: mint, maxt: maxt, client: p.client}, nil
func (provider *provider) Querier(mint, maxt int64) (storage.Querier, error) {
return &querier{mint: mint, maxt: maxt, client: provider.client}, nil
}

View File

@@ -28,14 +28,14 @@ type querier struct {
var _ storage.Querier = (*querier)(nil)
func (q *querier) Select(ctx context.Context, sortSeries bool, hints *storage.SelectHints, matchers ...*labels.Matcher) storage.SeriesSet {
start, end := q.window(hints)
func (querier *querier) Select(ctx context.Context, sortSeries bool, hints *storage.SelectHints, matchers ...*labels.Matcher) storage.SeriesSet {
start, end := querier.window(hints)
seriesQuery, seriesArgs, err := buildSeriesQuery(start, end, matchers)
if err != nil {
return storage.ErrSeriesSet(err)
}
lookup, err := q.client.selectSeries(ctx, seriesQuery, seriesArgs)
lookup, err := querier.client.selectSeries(ctx, seriesQuery, seriesArgs)
if err != nil {
return storage.ErrSeriesSet(err)
}
@@ -43,7 +43,7 @@ func (q *querier) Select(ctx context.Context, sortSeries bool, hints *storage.Se
return storage.EmptySeriesSet()
}
list, err := q.fetchSamples(ctx, start, end, matchers, lookup, q.lastSamplePerStepFor(ctx, hints))
list, err := querier.fetchSamples(ctx, start, end, matchers, lookup, querier.lastSamplePerStepFor(ctx, hints))
if err != nil {
return storage.ErrSeriesSet(err)
}
@@ -53,16 +53,16 @@ func (q *querier) Select(ctx context.Context, sortSeries bool, hints *storage.Se
return newSeriesSet(list)
}
func (q *querier) LabelValues(ctx context.Context, name string, hints *storage.LabelHints, matchers ...*labels.Matcher) ([]string, annotations.Annotations, error) {
func (querier *querier) LabelValues(ctx context.Context, name string, hints *storage.LabelHints, matchers ...*labels.Matcher) ([]string, annotations.Annotations, error) {
sb := sqlbuilder.NewSelectBuilder()
if name == metricNameLabel {
sb.Select("DISTINCT metric_name AS value")
} else {
sb.Select(fmt.Sprintf("DISTINCT JSONExtractString(labels, %s) AS value", sb.Var(name)))
}
adjustedStart, _, table, _ := metricstelemetryschema.WhichTSTableToUse(uint64(q.mint), uint64(q.maxt), false, nil)
adjustedStart, _, table, _ := metricstelemetryschema.WhichTSTableToUse(uint64(querier.mint), uint64(querier.maxt), false, nil)
sb.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, table))
if err := applySeriesConditions(sb, int64(adjustedStart), q.maxt, matchers); err != nil {
if err := applySeriesConditions(sb, int64(adjustedStart), querier.maxt, matchers); err != nil {
return nil, nil, err
}
sb.Where("value != ''")
@@ -71,7 +71,7 @@ func (q *querier) LabelValues(ctx context.Context, name string, hints *storage.L
}
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
values, err := q.selectStrings(ctx, "LabelValues", query, args)
values, err := querier.selectStrings(ctx, "LabelValues", query, args)
if err != nil {
return nil, nil, err
}
@@ -79,12 +79,12 @@ func (q *querier) LabelValues(ctx context.Context, name string, hints *storage.L
return values, nil, nil
}
func (q *querier) LabelNames(ctx context.Context, hints *storage.LabelHints, matchers ...*labels.Matcher) ([]string, annotations.Annotations, error) {
func (querier *querier) LabelNames(ctx context.Context, hints *storage.LabelHints, matchers ...*labels.Matcher) ([]string, annotations.Annotations, error) {
sb := sqlbuilder.NewSelectBuilder()
sb.Select("DISTINCT arrayJoin(JSONExtractKeys(labels)) AS name")
adjustedStart, _, table, _ := metricstelemetryschema.WhichTSTableToUse(uint64(q.mint), uint64(q.maxt), false, nil)
adjustedStart, _, table, _ := metricstelemetryschema.WhichTSTableToUse(uint64(querier.mint), uint64(querier.maxt), false, nil)
sb.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, table))
if err := applySeriesConditions(sb, int64(adjustedStart), q.maxt, matchers); err != nil {
if err := applySeriesConditions(sb, int64(adjustedStart), querier.maxt, matchers); err != nil {
return nil, nil, err
}
if hints != nil && hints.Limit > 0 {
@@ -92,7 +92,7 @@ func (q *querier) LabelNames(ctx context.Context, hints *storage.LabelHints, mat
}
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
names, err := q.selectStrings(ctx, "LabelNames", query, args)
names, err := querier.selectStrings(ctx, "LabelNames", query, args)
if err != nil {
return nil, nil, err
}
@@ -100,18 +100,18 @@ func (q *querier) LabelNames(ctx context.Context, hints *storage.LabelHints, mat
return names, nil, nil
}
func (q *querier) Close() error {
func (querier *querier) Close() error {
return nil
}
// window returns the per-selector fetch window from the hints (already
// adjusted for offset, @, range and lookback); mint/maxt span the union of
// all selectors and are the fallback.
func (q *querier) window(hints *storage.SelectHints) (int64, int64) {
func (querier *querier) window(hints *storage.SelectHints) (int64, int64) {
if hints != nil && hints.Start != 0 && hints.End != 0 && hints.Start <= hints.End {
return hints.Start, hints.End
}
return q.mint, q.maxt
return querier.mint, querier.maxt
}
// lastSamplePerStepFor decides whether the fetch can keep only the last
@@ -122,7 +122,7 @@ func (q *querier) window(hints *storage.SelectHints) (int64, int64) {
// arrives as QueryTraits in the context. The first evaluation timestamp is
// recovered as hints.Start + lookback - 1ms, inverting how the engine
// derives hints.Start.
func (q *querier) lastSamplePerStepFor(ctx context.Context, hints *storage.SelectHints) *lastSamplePerStep {
func (querier *querier) lastSamplePerStepFor(ctx context.Context, hints *storage.SelectHints) *lastSamplePerStep {
if hints == nil || hints.Range != 0 || hints.Start <= 0 {
return nil
}
@@ -130,7 +130,7 @@ func (q *querier) lastSamplePerStepFor(ctx context.Context, hints *storage.Selec
if !ok || !traits.SubqueryFree {
return nil
}
firstEval := hints.Start + q.client.lookbackMs - 1
firstEval := hints.Start + querier.client.lookbackMs - 1
if firstEval > hints.End {
// Defensive: never anchor a bucket past the window.
firstEval = hints.End
@@ -140,17 +140,17 @@ func (q *querier) lastSamplePerStepFor(ctx context.Context, hints *storage.Selec
// fetchSamples runs the samples query for the matched series (see
// buildSamplesQuery).
func (q *querier) fetchSamples(ctx context.Context, start, end int64, matchers []*labels.Matcher, lookup *seriesLookup, lastPerStep *lastSamplePerStep) ([]*series, error) {
query, args, err := buildSamplesQuery(start, end, lookup.metricNames, matchers, lastPerStep)
func (querier *querier) fetchSamples(ctx context.Context, start, end int64, matchers []*labels.Matcher, lookup *seriesLookup, lastPerStep *lastSamplePerStep) ([]*series, error) {
query, args, err := buildSamplesQuery(start, end, lookup.metricNames, matchers, lastPerStep, querier.client.cfg.MaxFetchedSamples)
if err != nil {
return nil, err
}
return q.client.selectSamples(ctx, query, args, lookup)
return querier.client.selectSamples(ctx, query, args, lookup)
}
func (q *querier) selectStrings(ctx context.Context, fn, query string, args []any) ([]string, error) {
ctx = q.client.withContext(ctx, fn)
rows, err := q.client.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
func (querier *querier) selectStrings(ctx context.Context, fn, query string, args []any) ([]string, error) {
ctx = querier.client.withContext(ctx, fn)
rows, err := querier.client.telemetryStore.ClickhouseDB().Query(ctx, query, args...)
if err != nil {
return nil, err
}

View File

@@ -22,17 +22,17 @@ type series struct {
var _ storage.Series = (*series)(nil)
func (s *series) Labels() labels.Labels {
return s.lset
func (series *series) Labels() labels.Labels {
return series.lset
}
func (s *series) Iterator(it chunkenc.Iterator) chunkenc.Iterator {
func (series *series) Iterator(it chunkenc.Iterator) chunkenc.Iterator {
if fit, ok := it.(*floatIterator); ok {
fit.reset(s)
fit.reset(series)
return fit
}
fit := &floatIterator{}
fit.reset(s)
fit.reset(series)
return fit
}
@@ -44,61 +44,61 @@ type floatIterator struct {
var _ chunkenc.Iterator = (*floatIterator)(nil)
func (it *floatIterator) reset(s *series) {
it.s = s
it.i = -1
func (iterator *floatIterator) reset(s *series) {
iterator.s = s
iterator.i = -1
}
func (it *floatIterator) Next() chunkenc.ValueType {
it.i++
if it.i >= len(it.s.ts) {
func (iterator *floatIterator) Next() chunkenc.ValueType {
iterator.i++
if iterator.i >= len(iterator.s.ts) {
return chunkenc.ValNone
}
return chunkenc.ValFloat
}
func (it *floatIterator) Seek(t int64) chunkenc.ValueType { //nolint:govet // stdmethods flags io.Seeker; this is chunkenc.Iterator's Seek
if it.i < 0 {
it.i = 0
func (iterator *floatIterator) Seek(t int64) chunkenc.ValueType { //nolint:govet // stdmethods flags io.Seeker; this is chunkenc.Iterator's Seek
if iterator.i < 0 {
iterator.i = 0
}
if it.i >= len(it.s.ts) {
if iterator.i >= len(iterator.s.ts) {
return chunkenc.ValNone
}
// The current position, once valid, must not move backwards.
if it.s.ts[it.i] >= t {
if iterator.s.ts[iterator.i] >= t {
return chunkenc.ValFloat
}
it.i += sort.Search(len(it.s.ts)-it.i, func(j int) bool {
return it.s.ts[it.i+j] >= t
iterator.i += sort.Search(len(iterator.s.ts)-iterator.i, func(j int) bool {
return iterator.s.ts[iterator.i+j] >= t
})
if it.i >= len(it.s.ts) {
if iterator.i >= len(iterator.s.ts) {
return chunkenc.ValNone
}
return chunkenc.ValFloat
}
func (it *floatIterator) At() (int64, float64) {
return it.s.ts[it.i], it.s.vs[it.i]
func (iterator *floatIterator) At() (int64, float64) {
return iterator.s.ts[iterator.i], iterator.s.vs[iterator.i]
}
func (it *floatIterator) AtHistogram(*histogram.Histogram) (int64, *histogram.Histogram) {
func (iterator *floatIterator) AtHistogram(*histogram.Histogram) (int64, *histogram.Histogram) {
return 0, nil
}
func (it *floatIterator) AtFloatHistogram(*histogram.FloatHistogram) (int64, *histogram.FloatHistogram) {
func (iterator *floatIterator) AtFloatHistogram(*histogram.FloatHistogram) (int64, *histogram.FloatHistogram) {
return 0, nil
}
func (it *floatIterator) AtT() int64 {
return it.s.ts[it.i]
func (iterator *floatIterator) AtT() int64 {
return iterator.s.ts[iterator.i]
}
// AtST returns the current start timestamp; not tracked by this storage.
func (it *floatIterator) AtST() int64 {
func (iterator *floatIterator) AtST() int64 {
return 0
}
func (it *floatIterator) Err() error {
func (iterator *floatIterator) Err() error {
return nil
}
@@ -114,20 +114,20 @@ func newSeriesSet(list []*series) *seriesSet {
return &seriesSet{series: list, i: -1}
}
func (s *seriesSet) Next() bool {
s.i++
return s.i < len(s.series)
func (set *seriesSet) Next() bool {
set.i++
return set.i < len(set.series)
}
func (s *seriesSet) At() storage.Series {
return s.series[s.i]
func (set *seriesSet) At() storage.Series {
return set.series[set.i]
}
func (s *seriesSet) Err() error {
func (set *seriesSet) Err() error {
return nil
}
func (s *seriesSet) Warnings() annotations.Annotations {
func (set *seriesSet) Warnings() annotations.Annotations {
return nil
}

View File

@@ -3,6 +3,8 @@ package clickhouseprometheusv2
import (
"fmt"
"github.com/SigNoz/signoz/pkg/clickhousesql"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/telemetryschema/metricstelemetryschema"
"github.com/huandu/go-sqlbuilder"
@@ -40,7 +42,7 @@ func buildSeriesQuery(start, end int64, matchers []*labels.Matcher) (string, []a
// matched series when the selector had no __name__ equality) narrows the
// primary-key scan. A non-nil lastPerStep groups to the last sample per
// step bucket.
func buildSamplesQuery(start, end int64, metricNames []string, matchers []*labels.Matcher, lastPerStep *lastSamplePerStep) (string, []any, error) {
func buildSamplesQuery(start, end int64, metricNames []string, matchers []*labels.Matcher, lastPerStep *lastSamplePerStep, maxFetchedSamples int64) (string, []any, error) {
sb := sqlbuilder.NewSelectBuilder()
if lastPerStep != nil {
// Aliases must not shadow source columns: ClickHouse resolves aliases
@@ -84,9 +86,18 @@ func buildSamplesQuery(start, end int64, metricNames []string, matchers []*label
}
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
if maxFetchedSamples > 0 {
query += budgetSettings(maxFetchedSamples)
}
return query, args, nil
}
// budgetSettings caps the result server-side; 'throw' refuses the query
// instead of silently truncating the result.
func budgetSettings(maxRows int64) string {
return fmt.Sprintf(" SETTINGS max_result_rows = %s, result_overflow_mode = 'throw'", clickhousesql.Literal(maxRows))
}
// applySeriesConditions adds the WHERE conditions of a series table scan:
// __name__ matchers (all four types) translate to the metric_name column,
// every other matcher to a JSONExtractString condition on the labels column.
@@ -160,13 +171,13 @@ type lastSamplePerStep struct {
stepMs int64
}
func (t *lastSamplePerStep) bucketExpr() string {
if t.stepMs <= 0 {
func (lastPerStep *lastSamplePerStep) bucketExpr() string {
if lastPerStep.stepMs <= 0 {
// Instant query: a single evaluation at firstEval; one bucket.
return ""
}
return fmt.Sprintf(
"if(unix_milli <= %d, 0, intDiv(unix_milli - %d - 1, %d) + 1)",
t.firstEvalMs, t.firstEvalMs, t.stepMs,
lastPerStep.firstEvalMs, lastPerStep.firstEvalMs, lastPerStep.stepMs,
)
}

View File

@@ -74,7 +74,7 @@ func TestBuildSamplesQuery(t *testing.T) {
}
t.Run("raw fetch filters by a shard-local semi-join", func(t *testing.T) {
query, args, err := buildSamplesQuery(start, end, []string{"up"}, matchers, nil)
query, args, err := buildSamplesQuery(start, end, []string{"up"}, matchers, nil, 0)
require.NoError(t, err)
assert.Contains(t, query, "fingerprint IN (SELECT fingerprint FROM signoz_metrics.time_series_v4 WHERE ")
assert.NotContains(t, query, "GLOBAL IN")
@@ -86,7 +86,7 @@ func TestBuildSamplesQuery(t *testing.T) {
t.Run("last-sample-per-step groups by step bucket anchored at first eval", func(t *testing.T) {
lastPerStep := &lastSamplePerStep{firstEvalMs: start + 299_999, stepMs: 60_000}
query, _, err := buildSamplesQuery(start, end, []string{"up"}, matchers, lastPerStep)
query, _, err := buildSamplesQuery(start, end, []string{"up"}, matchers, lastPerStep, 0)
require.NoError(t, err)
assert.Contains(t, query, "argMax(value, unix_milli) AS val")
assert.Contains(t, query, "argMax(flags, unix_milli) AS fl")
@@ -100,14 +100,22 @@ func TestBuildSamplesQuery(t *testing.T) {
t.Run("instant query keeps one bucket", func(t *testing.T) {
lastPerStep := &lastSamplePerStep{firstEvalMs: end, stepMs: 0}
query, _, err := buildSamplesQuery(start, end, []string{"up"}, matchers, lastPerStep)
query, _, err := buildSamplesQuery(start, end, []string{"up"}, matchers, lastPerStep, 0)
require.NoError(t, err)
assert.Contains(t, query, "GROUP BY fingerprint ORDER BY fingerprint, ts")
assert.NotContains(t, query, "intDiv")
})
t.Run("samples budget renders in the statement", func(t *testing.T) {
capped, _, err := buildSamplesQuery(start, end, []string{"up"}, matchers, nil, 100)
require.NoError(t, err)
uncapped, _, err := buildSamplesQuery(start, end, []string{"up"}, matchers, nil, 0)
require.NoError(t, err)
assert.Equal(t, uncapped+" SETTINGS max_result_rows = 100, result_overflow_mode = 'throw'", capped)
})
t.Run("multiple metric names from regex selector", func(t *testing.T) {
query, args, err := buildSamplesQuery(start, end, []string{"node_cpu", "node_memory"}, matchers, nil)
query, args, err := buildSamplesQuery(start, end, []string{"node_cpu", "node_memory"}, matchers, nil, 0)
require.NoError(t, err)
assert.Contains(t, query, "metric_name IN (?, ?)")
assert.Equal(t, []any{"node_cpu", "node_memory", "up", adjustedStart, end, "job", "api", start, end}, args)

View File

@@ -42,8 +42,8 @@ type scalarOp struct {
}
// Comparisons preserve the metric name; arithmetic drops it.
func (o scalarOp) isComparison() bool {
return o.op.IsComparisonOperator()
func (operation scalarOp) isComparison() bool {
return operation.op.IsComparisonOperator()
}
type unitKind int
@@ -86,12 +86,12 @@ type coreUnit struct {
// name cannot become a synthetic series in a hybrid plan: the synthetic
// name would replace the real one. It transpiles fine as a full plan, where
// assembly emits the real names.
func (u *coreUnit) keepsName() bool {
nameKeepingSelector := u.kind == unitInstant || (u.kind == unitOverTime && u.overFn == "last")
if !nameKeepingSelector || u.hasAgg {
func (unit *coreUnit) keepsName() bool {
nameKeepingSelector := unit.kind == unitInstant || (unit.kind == unitOverTime && unit.overFn == "last")
if !nameKeepingSelector || unit.hasAgg {
return false
}
for _, op := range u.ops {
for _, op := range unit.ops {
if !op.isComparison() || op.returnBool {
return false
}

View File

@@ -30,8 +30,8 @@ type executor struct {
// path serves the rest.
const maxWindowBuckets = 64
func (e *executor) TryExecuteRange(ctx context.Context, qs string, start, end time.Time, step time.Duration) (promql.Matrix, bool, error) {
expr, err := e.parser.ParseExpr(qs)
func (executor *executor) TryExecuteRange(ctx context.Context, qs string, start, end time.Time, step time.Duration) (promql.Matrix, bool, error) {
expr, err := executor.parser.ParseExpr(qs)
if err != nil {
// Let the engine path produce the (enhanced) parse error.
return nil, false, nil
@@ -71,7 +71,7 @@ func (e *executor) TryExecuteRange(ctx context.Context, qs string, start, end ti
case unit.core.kind == unitInstant || (unit.core.kind == unitOverTime && unit.core.overFn == "last"):
windowMs := unit.core.rangeMs
if unit.core.kind == unitInstant {
windowMs = e.client.lookbackMs
windowMs = executor.client.lookbackMs
}
if windowMs <= 0 {
return nil, false, nil
@@ -94,7 +94,7 @@ func (e *executor) TryExecuteRange(ctx context.Context, qs string, start, end ti
eg, egCtx := errgroup.WithContext(ctx)
for i, unit := range plan.units {
eg.Go(func() error {
res, err := e.executeUnit(egCtx, &unit.core, unit.grid)
res, err := executor.executeUnit(egCtx, &unit.core, unit.grid)
if err != nil {
return err
}
@@ -111,7 +111,7 @@ func (e *executor) TryExecuteRange(ctx context.Context, qs string, start, end ti
return toMatrix(results[0], g.startMs, g.stepMs), true, nil
}
matrix, err := e.executeHybrid(ctx, plan, results)
matrix, err := executor.executeHybrid(ctx, plan, results)
if err != nil {
return nil, true, err
}
@@ -134,11 +134,11 @@ type transpiledSeries struct {
values []*float64
}
func (e *executor) executeUnit(ctx context.Context, unit *coreUnit, grid gridContext) ([]transpiledSeries, error) {
func (executor *executor) executeUnit(ctx context.Context, unit *coreUnit, grid gridContext) ([]transpiledSeries, error) {
startMs, endMs, stepMs := grid.startMs, grid.endMs, grid.stepMs
windowMs := unit.rangeMs
if unit.kind == unitInstant {
windowMs = e.client.lookbackMs
windowMs = executor.client.lookbackMs
}
dataStart := startMs - unit.offsetMs - windowMs
dataEnd := endMs - unit.offsetMs
@@ -153,7 +153,7 @@ func (e *executor) executeUnit(ctx context.Context, unit *coreUnit, grid gridCon
if err != nil {
return nil, err
}
lookup, err := e.client.selectSeries(ctx, seriesQuery, seriesArgs)
lookup, err := executor.client.selectSeries(ctx, seriesQuery, seriesArgs)
if err != nil {
return nil, err
}
@@ -163,12 +163,12 @@ func (e *executor) executeUnit(ctx context.Context, unit *coreUnit, grid gridCon
metricNames = lookup.metricNames
}
query, args, err := buildUnitSQL(unit, metricNames, dataStart, dataEnd, startMs, endMs, stepMs, e.client.lookbackMs)
query, args, err := buildUnitSQL(unit, metricNames, dataStart, dataEnd, startMs, endMs, stepMs, executor.client.lookbackMs)
if err != nil {
return nil, err
}
rows, err := e.client.telemetryStore.ClickhouseDB().Query(e.client.withContext(ctx, "transpiledUnit"), query, args...)
rows, err := executor.client.telemetryStore.ClickhouseDB().Query(executor.client.withContext(ctx, "transpiledUnit"), query, args...)
if err != nil {
return nil, err
}
@@ -387,7 +387,7 @@ func toMatrix(series []transpiledSeries, startMs, stepMs int64) promql.Matrix {
// or arms into per-unit series that a later name drop collides into the
// duplicate-labelset error. hybridQuerier.Select resolves the selector
// from the matcher, not from series labels.
func (e *executor) executeHybrid(ctx context.Context, plan *transpilePlan, results [][]transpiledSeries) (promql.Matrix, error) {
func (executor *executor) executeHybrid(ctx context.Context, plan *transpilePlan, results [][]transpiledSeries) (promql.Matrix, error) {
synthetic := make(map[string][]*series, len(plan.units))
staleMarker := math.Float64frombits(promValue.StaleNaN)
@@ -420,14 +420,14 @@ func (e *executor) executeHybrid(ctx context.Context, plan *transpilePlan, resul
synthetic[unit.name] = list
}
hybrid := &hybridQueryable{client: e.client, synthetic: synthetic}
hybrid := &hybridQueryable{client: executor.client, synthetic: synthetic}
var qry promql.Query
var err error
if queryGrid.stepMs == 0 {
qry, err = e.engine.NewInstantQuery(ctx, hybrid, nil, plan.rewritten, time.UnixMilli(queryGrid.endMs))
qry, err = executor.engine.NewInstantQuery(ctx, hybrid, nil, plan.rewritten, time.UnixMilli(queryGrid.endMs))
} else {
qry, err = e.engine.NewRangeQuery(ctx, hybrid, nil, plan.rewritten, time.UnixMilli(queryGrid.startMs), time.UnixMilli(queryGrid.endMs), time.Duration(queryGrid.stepMs)*time.Millisecond)
qry, err = executor.engine.NewRangeQuery(ctx, hybrid, nil, plan.rewritten, time.UnixMilli(queryGrid.startMs), time.UnixMilli(queryGrid.endMs), time.Duration(queryGrid.stepMs)*time.Millisecond)
}
if err != nil {
return nil, err
@@ -479,10 +479,10 @@ type hybridQueryable struct {
synthetic map[string][]*series
}
func (h *hybridQueryable) Querier(mint, maxt int64) (storage.Querier, error) {
func (queryable *hybridQueryable) Querier(mint, maxt int64) (storage.Querier, error) {
return &hybridQuerier{
querier: querier{mint: mint, maxt: maxt, client: h.client},
synthetic: h.synthetic,
querier: querier{mint: mint, maxt: maxt, client: queryable.client},
synthetic: queryable.synthetic,
}, nil
}
@@ -491,9 +491,9 @@ type hybridQuerier struct {
synthetic map[string][]*series
}
func (h *hybridQuerier) Select(ctx context.Context, sortSeries bool, hints *storage.SelectHints, matchers ...*labels.Matcher) storage.SeriesSet {
func (hybrid *hybridQuerier) Select(ctx context.Context, sortSeries bool, hints *storage.SelectHints, matchers ...*labels.Matcher) storage.SeriesSet {
if name, ok := isSyntheticSelector(matchers); ok {
list := h.synthetic[name]
list := hybrid.synthetic[name]
if sortSeries {
sorted := make([]*series, len(list))
copy(sorted, list)
@@ -502,5 +502,5 @@ func (h *hybridQuerier) Select(ctx context.Context, sortSeries bool, hints *stor
}
return newSeriesSet(list)
}
return h.querier.Select(ctx, sortSeries, hints, matchers...)
return hybrid.querier.Select(ctx, sortSeries, hints, matchers...)
}

View File

@@ -13,6 +13,18 @@ type ActiveQueryTrackerConfig struct {
MaxConcurrent int `mapstructure:"max_concurrent"`
}
type ClickhouseV2Config struct {
// MaxFetchedSeries caps the series one selector may match; 0 disables
// the cap.
MaxFetchedSeries int `mapstructure:"max_fetched_series"`
// MaxFetchedSamples caps the samples one samples statement may fetch;
// 0 disables the cap. The cap is per statement: a query runs one
// statement per selector, so a query with N selectors can fetch up to
// N times this cap.
MaxFetchedSamples int64 `mapstructure:"max_fetched_samples"`
}
type Config struct {
ActiveQueryTrackerConfig ActiveQueryTrackerConfig `mapstructure:"active_query_tracker"`
@@ -24,6 +36,8 @@ type Config struct {
// Timeout is the maximum time a query is allowed to run before being aborted.
Timeout time.Duration `mapstructure:"timeout"`
ClickhouseV2 ClickhouseV2Config `mapstructure:"clickhousev2"`
}
func NewConfigFactory() factory.ConfigFactory {
@@ -38,6 +52,10 @@ func newConfig() factory.Config {
MaxConcurrent: 20,
},
Timeout: 2 * time.Minute,
ClickhouseV2: ClickhouseV2Config{
MaxFetchedSeries: 500_000,
MaxFetchedSamples: 50_000_000,
},
}
}
@@ -45,6 +63,9 @@ func (c Config) Validate() error {
if c.Timeout <= 0 {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "prometheus::timeout must be greater than 0")
}
if c.ClickhouseV2.MaxFetchedSeries < 0 || c.ClickhouseV2.MaxFetchedSamples < 0 {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "prometheus::clickhousev2 limits must not be negative")
}
return nil
}

31
pkg/prometheus/errors.go Normal file
View File

@@ -0,0 +1,31 @@
package prometheus
import (
"github.com/prometheus/prometheus/promql"
"github.com/SigNoz/signoz/pkg/errors"
)
// TypedStorageError walks an engine execution error chain looking for a
// SigNoz-typed invalid-input error raised by the storage layer (the fetch
// budget refusals). Every wrapper level is stepped through by hand: Ast is a
// bare type cast, not an unwrap — it misses a typed error behind the
// engine's "expanding series: %w" — and promql.ErrStorage has no Unwrap
// method at all, so a plain unwrap loop would stop at it.
func TypedStorageError(execErr error) error {
for e := execErr; e != nil; {
if errors.Ast(e, errors.TypeInvalidInput) {
return e
}
if es, ok := e.(promql.ErrStorage); ok {
e = es.Err
continue
}
u, ok := e.(interface{ Unwrap() error })
if !ok {
return nil
}
e = u.Unwrap()
}
return nil
}

View File

@@ -0,0 +1,29 @@
package prometheus
import (
"testing"
"github.com/prometheus/prometheus/promql"
"github.com/stretchr/testify/assert"
"github.com/SigNoz/signoz/pkg/errors"
)
// expandWrap mirrors the engine's "expanding series: %w" wrapper: a plain
// message-carrying layer with a single Unwrap.
type expandWrap struct{ inner error }
func (wrapper expandWrap) Error() string { return "expanding series: " + wrapper.inner.Error() }
func (wrapper expandWrap) Unwrap() error { return wrapper.inner }
func TestTypedStorageError(t *testing.T) {
budget := errors.NewInvalidInputf(errors.CodeInvalidInput, "too many series")
// promql.ErrStorage has no Unwrap method; the walk must pierce it by
// type and then step through the plain wrapper.
wrapped := promql.ErrStorage{Err: expandWrap{inner: budget}}
assert.Equal(t, budget, TypedStorageError(wrapped))
assert.Nil(t, TypedStorageError(promql.ErrStorage{Err: errors.Newf(errors.TypeInternal, errors.CodeInternal, "connection refused")}))
assert.Nil(t, TypedStorageError(nil))
}

View File

@@ -125,6 +125,13 @@ func (h *handler) respondResult(ctx context.Context, w http.ResponseWriter, r *h
case promql.ErrQueryTimeout:
h.respondError(ctx, w, errTimeout, err)
case promql.ErrStorage:
// A fetch-budget refusal is the storage-level twin of the
// engine's own too-many-samples error, which upstream maps to
// "execution", not "internal".
if typed := TypedStorageError(err); typed != nil {
h.respondError(ctx, w, errExec, typed)
return
}
h.respondError(ctx, w, errInternal, err)
default:
h.respondError(ctx, w, errExec, err)

View File

@@ -43,6 +43,10 @@ var quotedMetricOutsideBracesPattern = regexp.MustCompile(`"([^"]+)"\s*\{`)
// tryEnhancePromQLExecError attempts to convert a PromQL execution error into
// a properly typed error. Returns nil if the error is not a recognized execution error.
func tryEnhancePromQLExecError(execErr error) error {
if typed := prometheus.TypedStorageError(execErr); typed != nil {
return typed
}
var eqc promql.ErrQueryCanceled
var eqt promql.ErrQueryTimeout
var es promql.ErrStorage

View File

@@ -0,0 +1,93 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
import requests
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.metrics import Metrics
QUERY_TIMEOUT = 30
def test_fetch_budgets(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
) -> None:
# The container runs with max_fetched_series=10 and
# max_fetched_samples=100 (see conftest.py). Both metrics take the
# engine path: instant queries always do, and changes() is not
# transpilable, so the range query falls back too.
end = datetime.now(tz=UTC).replace(second=0, microsecond=0) - timedelta(minutes=5)
metrics = [
Metrics(
metric_name="budget_series_metric",
labels={"instance": str(i)},
timestamp=end,
value=1.0,
flags=0,
)
for i in range(25)
]
start = end - timedelta(minutes=50)
metrics.extend(
Metrics(
metric_name="budget_samples_metric",
labels={"instance": "0"},
timestamp=start + timedelta(seconds=15 * i),
value=float(i),
flags=0,
)
for i in range(200)
)
insert_metrics(metrics)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
headers = {"authorization": f"Bearer {token}"}
# 25 matched series exceed the series budget of 10: the series lookup is
# refused by ClickHouse and surfaces as a Prometheus execution error.
response = requests.get(
signoz.self.host_configs["8080"].get("/prometheus/api/v1/query"),
params={"query": "budget_series_metric", "time": end.timestamp()},
timeout=QUERY_TIMEOUT,
headers=headers,
)
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
body = response.json()
assert body["status"] == "error"
assert body["errorType"] == "execution"
assert "matched more than 10 series" in body["error"]
# The range selector fetches all 200 raw samples, exceeding the samples
# budget of 100.
response = requests.get(
signoz.self.host_configs["8080"].get("/prometheus/api/v1/query_range"),
params={
"query": "changes(budget_samples_metric[30m])",
"start": start.timestamp(),
"end": end.timestamp(),
"step": 60,
},
timeout=QUERY_TIMEOUT,
headers=headers,
)
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
body = response.json()
assert body["status"] == "error"
assert body["errorType"] == "execution"
assert "more than 100 samples" in body["error"]
# One series and one last-sample fetch sit inside both budgets.
response = requests.get(
signoz.self.host_configs["8080"].get("/prometheus/api/v1/query"),
params={"query": "budget_samples_metric", "time": end.timestamp()},
timeout=QUERY_TIMEOUT,
headers=headers,
)
assert response.status_code == HTTPStatus.OK
body = response.json()
assert body["status"] == "success"
assert len(body["data"]["result"]) == 1

View File

@@ -0,0 +1,36 @@
import pytest
from testcontainers.core.container import Network
from fixtures import types
from fixtures.signoz import create_signoz
@pytest.fixture(name="signoz", scope="package")
def signoz_promql_budget(
network: Network,
migrator: types.Operation, # pylint: disable=unused-argument
zeus: types.TestContainerDocker,
gateway: types.TestContainerDocker,
sqlstore: types.TestContainerSQL,
clickhouse: types.TestContainerClickhouse,
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
) -> types.SigNoz:
"""
SigNoz with tiny fetch budgets, so engine-path queries trip the
ClickHouse-enforced result limits with small fixtures.
"""
return create_signoz(
network=network,
zeus=zeus,
gateway=gateway,
sqlstore=sqlstore,
clickhouse=clickhouse,
request=request,
pytestconfig=pytestconfig,
cache_key="signoz-promql-budget",
env_overrides={
"SIGNOZ_PROMETHEUS_CLICKHOUSEV2_MAX__FETCHED__SERIES": "10",
"SIGNOZ_PROMETHEUS_CLICKHOUSEV2_MAX__FETCHED__SAMPLES": "100",
},
)