mirror of
https://github.com/SigNoz/signoz.git
synced 2026-11-08 01:10:57 +00:00
Compare commits
6 Commits
fix/genera
...
promql-res
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be13ced422 | ||
|
|
7d558888d8 | ||
|
|
5237c19eef | ||
|
|
323d16a4ed | ||
|
|
a1d83d566d | ||
|
|
470c8e1185 |
1
.github/workflows/integrationci.yaml
vendored
1
.github/workflows/integrationci.yaml
vendored
@@ -60,6 +60,7 @@ jobs:
|
||||
- rawexportdata
|
||||
- promqlconformance
|
||||
- promapiconformance
|
||||
- promqlbudget
|
||||
- promote
|
||||
- querierauthz
|
||||
- role
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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...)
|
||||
}
|
||||
|
||||
@@ -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
31
pkg/prometheus/errors.go
Normal 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
|
||||
}
|
||||
29
pkg/prometheus/errors_test.go
Normal file
29
pkg/prometheus/errors_test.go
Normal 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))
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
93
tests/integration/tests/promqlbudget/01_budgets.py
Normal file
93
tests/integration/tests/promqlbudget/01_budgets.py
Normal 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
|
||||
36
tests/integration/tests/promqlbudget/conftest.py
Normal file
36
tests/integration/tests/promqlbudget/conftest.py
Normal 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",
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user