Files
signoz/docs/contributing/prometheus.md
srikanthccv a627a7395f feat(promql): transpile allowlisted query shapes to ClickHouse grid statements
An allowlist compiler (classify/rewrite) evaluates proven PromQL shapes
entirely inside ClickHouse on the timeSeries*ToGrid aggregate functions
(CH >= 25.6): one row per output series comes back instead of every raw
sample. Everything not provably equivalent falls back to the engine over
the native querier; transpilable subtrees under non-transpilable nodes run
hybrid (materialized as synthetic series, engine on top). TryExecuteRange
slots into the serve/shadow paths, which until now ran engine-only.

Window-sliver filtering folded in: when the window is narrower than the
step, a lattice predicate admits only the samples any grid point can see -
measured 74s/28GiB -> 16s/4.3GiB on a 36k-series 1w rate, and a
2.67B-sample 1w case that died at 150GiB completes in 19s/17GiB. Over
slivered rows the last-style gates lift and disjoint over_time forms drop
the divisibility gate.

The classification golden freezes the routing decision (full/hybrid/
fallback + reason) for every conformance-corpus expression: silently
falling back costs the pushdown, silently transpiling an unproven shape
risks wrong numbers - both now surface in review as a golden diff.

The dual-leg conformance suite already earned its keep on its first
transpiled run:

- It caught the classifier reading a duration expression's offset
  (x offset step()) as zero - offset expressions parse WITHOUT the
  experimental-parser flag, so they reach production. Such selectors are
  now refused and the engine evaluates them exactly.
- It caught name-drop assembly treating temporally-disjoint same-labelset
  twins as separate series (-{job="api"} spanning http_requests and
  http_errors 400'd; hybrid -metric_a or -metric_b returned duplicate {}
  series). Both paths now merge by labelset slot-wise, raising the
  engine's duplicate error only on a same-timestamp conflict - the
  engine's actual rule.
- The 12 remaining divergences are one class, recorded with causes in
  known_divergences_v2.json (the swap scorecard): the engine sums with
  Kahan compensation and an overflow-free incremental mean, ClickHouse's
  sum/avg/arraySum are naive - visible only at 1e100-class cancellation
  and near-max-float overflow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 00:05:22 +05:30

21 KiB
Raw Permalink Blame History

PromQL Serving — clickhouseprometheusv2

This document gives the context for pkg/prometheus/clickhouseprometheusv2. This package is the second-generation ClickHouse-backed Prometheus provider. The document tells you why the package exists. It tells you the correctness rules that shaped it. It shows how we prove that each construct does not change results. Keep these invariants when you change the provider. If your change breaks an invariant, flag it and discuss it first.


Why a second provider

The v1 provider (pkg/prometheus/clickhouseprometheus) serves the promql engine through the remote-read protobuf adapter. It fetches every raw sample of a query's union window. It serializes all of them and gives them to the engine. The cost follows the ingested data, not the question. This is how a dashboard of PromQL panels can take an instance down.

In v2, each query runs in one of two ways. The classifier decides per query:

  • Transpiled: ClickHouse evaluates the query. Only final (or near-final) per-group grid arrays come back. The statements use the timeSeries*ToGrid aggregate functions. The supported ClickHouse floor is 25.6 or later, so these functions are assumed available.
  • Engine: the stock promql engine evaluates over this package's native storage.Querier. Every shape that does not transpile takes this path.

The core rule: a PromQL result that differs from upstream Prometheus is a lost user. A construct that cannot reproduce engine semantics exactly falls back. It does not approximate. The conformance suite (tests/integration/tests/promqlconformance/) replays Prometheus' own test corpus against both providers. It is the arbiter. The classification golden (testdata/classification_golden.json) freezes the route of each corpus expression. The rest of this document is the PromQL-to-SQL story. That mapping is where correctness is won or lost.


The evaluation model the SQL must reproduce

A PromQL range query is an instant query evaluated at each grid point t_i = start + i*step, for i = 0..(end-start)/step. At each t_i:

  • An instant selector resolves to the latest sample in the left-open lookback window (t_i - lookback, t_i]. If that latest sample is a stale marker, the selector resolves to nothing. Older real samples in the window do not change this.
  • A range selector [r] collects every sample in (t_i - r, t_i]. Stale markers are excluded.
  • offset d shifts both windows to (t_i - d - w, t_i - d].

The transpilation invariant follows from this model. Each transpiled construct produces one array per output series. The array has exactly one slot per grid point. Slot i holds the value at t_i. NULL means absent. This makes composition correct, not only convenient. The engine evaluates these operators independently per t_i. A representation that gets every slot right gets the whole query right. Spatial aggregation over arrays is sound because it combines values that belong to the same t_i by construction. Scan time maps slot i back to t_i = start + i*step (toMatrix). The sections below fill those slots with exactly the numbers the engine computes. We validated each equivalence against the vendored engine on live data before its shape entered the allowlist. An unproven shape stays on the engine path.

Classification: finding what a statement can answer

classify walks the parsed AST and looks for "core units". A core unit is a maximal subtree of this shape:

[agg by/without (...)] [fn(] selector[range] [offset d] [)] [op scalar]...

classifyCore peels that chain from the outside in. It takes an optional sum/min/max/avg/count aggregation. It then takes one allowlisted function or a bare instant selector. It then takes the selector with its offset. On the way out, it collects number-literal arithmetic, comparisons (including bool), and unary minus into a scalar-op pipeline. A node qualifies only if its type, arguments, and children are in the proven set. This is an allowlist. An overlooked construct becomes a fallback, not a wrong number.

Three unit kinds come out. Each kind has its own SQL form:

  • unitRange: rate, irate, increase, delta, idelta over a range selector.
  • unitInstant: instant vector selection, bare or comparison-filtered.
  • unitOverTime: avg/min/max/sum/count/last _over_time.

If the whole tree is one unit, the plan is "full". The statement's rows are the query result. Otherwise, rewrite cuts out each maximal unit and puts a synthetic selector __signoz_transpiled_N__ in its place. The engine then runs the rewritten expression over the units' materialized results. This is a "hybrid" plan. histogram_quantile, topk, or/and/unless, and vector matching keep exact engine semantics. Their expensive inputs were aggregated server-side.

Classification refuses a shape when it cannot guarantee exact semantics server-side:

  • The @ modifier, anywhere.
  • Default-resolution subqueries. Their resolution is a server runtime setting that the transpiler cannot see.
  • Duration expressions (offset step(), [range()], ...), anywhere. The engine resolves them into the selector's static fields only at evaluation time. At classification time those fields hold zero values. A transpile 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.
  • 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 plans.
  • Every function outside the allowlist: changes, resets, quantile_over_time, absent, native-histogram functions, and more.

Units inside a fixed-resolution subquery evaluate on the subquery's own grid, not the query grid. That grid is the set of epoch-aligned multiples of the resolution strictly after outerStart - offset - range, ending at outer end - offset. This is the exact derivation the engine uses. A grid shifted by one step changes which samples every window sees.

From one unit to one statement

buildUnitSQL renders each unit as one statement. For sum by (pod) (rate(m{job="api"}[5m])) the skeleton is:

SELECT g0, sumForEach(grid) AS grid FROM (
    SELECT any(series.g0) AS g0,
           timeSeriesRateToGrid(<start>, <end>, <step>, <range>)(fromUnixTimestamp64Milli(unix_milli), value) AS grid
    FROM signoz_metrics.distributed_samples_v4 AS points
    INNER JOIN (
        SELECT fingerprint, JSONExtractString(labels, 'pod') AS g0
        FROM signoz_metrics.time_series_v4
        WHERE <series predicates>
        GROUP BY fingerprint, g0
    ) AS series ON points.fingerprint = series.fingerprint
    WHERE metric_name = ? AND temporality IN ['Cumulative', 'Unspecified']
      AND unix_milli > <start - range> AND unix_milli <= <end>
      AND bitAnd(flags, 1) = 0
    GROUP BY points.fingerprint
) GROUP BY g0
SETTINGS allow_experimental_ts_to_grid_aggregate_function = 1

Read it from the inside out.

The time window is the selector's semantics, verbatim. Strict > on the lower bound and <= on the upper bound is the left-open (t - w, t] rule. The offset shifts the whole window. bitAnd(flags, 1) = 0 drops stale markers. PromQL excludes them from range vectors.

The inner GROUP BY computes one grid array per series. timeSeriesRateToGrid(start, end, step, range) is a parametric aggregate. It takes (timestamp, value) pairs and produces Array(Nullable(Float64)) with one slot per grid point. It is correct because it implements the engine's extrapolatedRate, decision for decision: counter resets, the zero-point clamp, the extrapolation thresholds, the two-samples rule, and the left-open window. We verified this: we fed identical samples to both and compared slot for slot. The only observed difference is the last bit. ClickHouse's C++ and Go round the same formula differently. That is the floating-point floor, not a semantic gap. irate/delta/idelta map to their own timeSeries*ToGrid functions, with the same verification. increase has no function of its own. We emit arrayMap(x -> x * <range seconds>, <rate expr>). This is exact by definition: extrapolatedRate computes the same extrapolated delta for both and divides by the range only when isRate. The multiplication reverses it exactly. The grid parameters render as literals, not bound args. They are aggregate-function parameters. The experimental gate rides as a SETTINGS clause on the statement itself, so telemetrystore hooks cannot remove it.

The group key is functionally dependent on the fingerprint: one fingerprint is the hash of one labelset. So the inner query groups by the fingerprint alone and reads the key columns with any(). This is exact, and it makes the per-row hash key smaller.

The join gives each series its group key, in one of two forms. by (...) extracts each listed label as a plain column (JSONExtractString(labels, 'pod') AS g0) and groups on the columns. The projection is a known short list, and the label names live in Go. To build, sort, and stringify every label pair per row would be waste. This is correct because column-tuple equality is label-set equality on the projection. An extracted '' means the label is absent. That is Prometheus semantics for by() over missing labels. The empties are skipped when the columns turn back into labels. without and no-aggregation project a label set that varies per series. They get the canonical key: toJSONString of the sorted [label, value] pairs that the unit projects. without excludes the listed labels plus __name__. No-aggregation keeps everything; the name comes off in Go, per the engine's name-dropping rules. Here the sort is load-bearing. Stored JSON key order is not canonical across fingerprints. Two orderings of the same labels must land in one group. Empty values are filtered for the same absent-label reason. The same string parses back into the output label set (labelsFromGroupKey).

The outer GROUP BY is the spatial aggregation. sum/min/max/avg/count by/without become the -ForEach combinators. Element-wise aggregation over grid arrays is the engine's per-t_i aggregation: slot i of every input array refers to the same t_i. The combinators skip NULLs. That is the engine aggregating only the series present at t_i. An index where every series is absent stays NULL. Two edges need explicit handling. First, countForEach wraps in a map of 0 back to NULL. A count over an all-absent index is an absent point, not 0. Second, a unit without aggregation still passes through maxForEach. That is the identity for the common one-fingerprint group. It is a deterministic NULL-skipping merge when a regex __name__ selector collapses distinct metrics onto one projected label set. One caveat is inherent: the summation order over series differs from the engine's. Spatial aggregates can differ in the last ULP. Float addition is not associative. No ordering reproduces the engine's result bit-exactly from inside a GROUP BY.

Instant selectors: staleness needs two aggregates

unitInstant uses window = lookback. It must reproduce the shadowing rule: the point is absent when the latest in-window sample is a stale marker. timeSeriesLastToGrid alone cannot express that. To skip stale rows in WHERE would resurrect the older real sample that the marker buried. So stale rows stay in the scan for this kind only. The grid expression compares three aggregates per slot:

arrayMap((tall, tok, vok) -> if(tall IS NULL OR tok IS NULL OR tall != tok, NULL, vok),
         timeSeriesLastToGrid(...)(ts, toFloat64(unix_milli)),                           -- last sample overall
         timeSeriesLastToGridIf(...)(ts, toFloat64(unix_milli), bitAnd(flags, 1) = 0),   -- last non-stale, its timestamp
         timeSeriesLastToGridIf(...)(ts, value, bitAnd(flags, 1) = 0))                   -- last non-stale, its value

This is correct by cases on a slot's window. No samples at all: both timestamp aggregates are NULL, so the slot is NULL. That is absent, as the engine says. Latest sample non-stale: it is the latest overall and the latest non-stale. The timestamps agree. The slot takes its value. That is the engine's pick. Latest sample stale: the last-overall timestamp is the marker's. The last-non-stale timestamp is older, or NULL when the window holds only markers. They disagree. The slot is NULL. The marker shadows, exactly as the engine's rule says. Timestamps are unique per series (ingest dedups). So timestamp equality identifies "the same sample" without ambiguity. We probed the -If combinator against these experimental aggregates before we trusted it.

Windowed *_over_time: whole buckets instead of a grid function

avg/min/max/sum/count _over_time aggregate every raw sample in the window. No timeSeries*ToGrid function computes them. (last_over_time is the exception. The last sample of a range vector is exactly timeSeriesLastToGrid. PromQL excludes stale markers from range vectors; we exclude them in WHERE.) These shapes transpile only when the range is a whole multiple of the step. Then the window needs no per-sample fan-out. With W = range/step, the window (t_k - range, t_k] is exactly the union of W step buckets. Both are left-open on the same boundaries. So bucket membership fully determines window membership. Each sample lands in exactly one bucket:

intDiv(unix_milli - <start> + <range> - 1, <step>)

This is ceil((ts - start)/step) shifted by W-1, so the earliest in-window sample sits at 0. Slot k's window is buckets in [k, k+W-1]. The alternative fans each sample into all W windows that cover it. That multiplies rows by W. For a long range over a short step, that is a row explosion measured in billions. The bucketed form's row count is series × buckets: the size of the output, for any W.

Each series aggregates in one group. The -Resample combinator (sumResample, countResample) holds the dense per-bucket partials inside one group state: a bucket count, plus the function's value aggregate (sum for sum/avg, min, max). An earlier form grouped by (series, bucket) and assembled with groupArrayInsertAt. At scale that made 37M hash groups, and per-thread partials scaled memory with the thread count. The slide then combines each slot's at-most-W bucket partials by direct aggregation (arraySum(arraySlice(...))). Window sums are added the way the engine adds them. There is no prefix-sum differencing: its large-minus-large cancellation would drift past the shadow tolerance on counter-sized values. This is correct per slot because the bucket union is the exact window multiset, and avg/min/max/sum/count are order-insensitive on a multiset (sum/avg up to summation order; see the float caveat above). A slot with zero window count is absent. min/max filter their slices on the bucket counts. An empty bucket's default can never look like a value: a real sample can legitimately be +Inf.

Two shapes fall back to the engine path, which is exact: a range that does not divide the step, and a window wider than maxWindowBuckets buckets (the slide costs W combines per slot). A range narrower than the step needs neither gate: the windows are pairwise disjoint, one bucket per slot, no slide. That form is exact only together with the window-sliver predicate below.

Scalar ops, full plans, hybrid plans

The scalar-op pipeline runs in Go on the returned arrays (applyScalarOps), slot by slot. Arithmetic operators compute. Comparisons filter: the slot keeps the vector-side value or becomes NULL. Under bool they return 0/1. This is trivially correct. It is the same float64 operation 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 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 series lookup plus one grid statement. A step of 0 is an instant query: a single evaluation at end.

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 would buffer it. A WHERE predicate keeps only the in-window rows: positiveModulo(selStart - unix_milli, step) < window, with the scan capped at the last grid point. The lattice anchors at the selector start, because the end can sit off-lattice on unaligned grids. This cut a 36k-series one-week rate from 74s/28GiB to 16s/4.3GiB on fleet data. Over slivered rows, timeSeriesLastToGrid's window widening is harmless, so instant selectors and last_over_time transpile at window < step too.

Series lookup

Both paths resolve matchers the same way, once per selector (selectSeries). The series tables hold one row per (fingerprint, bucket) at 1h/6h/1d/1w granularities. The shared schema package (pkg/telemetryschema/metricstelemetryschema) picks the table whose bucket fits the window. It rounds the window start down to the bucket boundary, so a window that begins mid-bucket still matches the bucket's row. How matchers become SQL, and why regexes are anchored, is documented at applySeriesConditions. Empty-valued labels come off at this boundary. An empty value means "label absent" in Prometheus, but stored attribute JSON can carry them.

The engine path

Queries that do not transpile run in the stock engine over this package's storage.Querier. This is still not the v1 path. Samples are fetched per selector with the engine's per-selector hints, not the query-wide union window. So foo / foo offset 1d reads two narrow windows, not the widest one twice. Instant selectors of subquery-free queries fetch only the last sample per step bucket (lastSamplePerStep). Buckets anchor at the selector's first evaluation timestamp. The code recovers it from the hints as hints.Start + lookback - 1ms, the inverse of how the engine derives hints.Start. Bucket boundaries then coincide with evaluation timestamps. A non-final sample of a bucket can never be the latest sample in (t - lookback, t] for any grid t. Real timestamps are preserved, so the engine's own lookback and staleness handling stay exact. Range selectors always fetch raw: every sample feeds the range function. The subquery-free proof travels in the context as prometheus.QueryTraits. Subquery selectors evaluate at the subquery's step, while the hints carry the top-level step. Row assembly maps stale flags to the engine's StaleNaN. It merges series with identical label sets (sortAndMerge): the engine assumes storages never emit duplicates.

Sharding

samples_v4 and time_series_v4 (and all their rollups) shard on the same key: cityHash64(env, temporality, metric_name, fingerprint). So a series' samples and catalog rows live on the same shard. The transpiled statement exploits that. The distributed samples table at the top-level FROM makes ClickHouse rewrite the whole inner query per shard. The join against the shard-local series table and the per-series grid aggregation run next to the data. The initiator only merges aggregate states and applies the spatial -ForEach step. This is the same layout as the telemetrymetrics statement builder. The group-key join alone restricts the transpiled scan to the matched series. The engine path's samples fetch restricts by the same predicates as a shard-local semi-join, not a GLOBAL broadcast of the matched set. The temporality filter on every samples statement is a semantic no-op: the matched fingerprints already come from those temporalities. It engages the leading samples primary-key column. Delta-temporality series stay invisible to PromQL here, exactly as in v1. The rollout gate is parity with v1. To make Delta visible is its own change with its own semantics to design. A Delta stream fed to rate() as-if-cumulative would be wrong, not just new.

Observability

Every statement carries a log_comment with code.namespace=clickhouse-prometheus-v2 and code.function.name naming the call site (selectSeries, selectSamples, transpiledUnit, LabelValues, LabelNames). This provider's work is attributable in system.query_log without guessing from query text.