Compare commits

..

8 Commits

Author SHA1 Message Date
grandwizard28
6dc75b9b82 ci: inject cache tarballs instead of directory trees 2026-08-01 22:45:08 +05:30
grandwizard28
a4f2d7cfd1 ci: restore shared go and pnpm caches in integration and e2e jobs 2026-08-01 21:38:14 +05:30
grandwizard28
798f5776dd revert: drop the testsci workflow and the --signoz-image option 2026-08-01 21:37:26 +05:30
grandwizard28
d76faaa470 ci: use singular --zeus-network-alias derived from a shared env var 2026-08-01 21:25:37 +05:30
grandwizard28
bbd8e74392 ci: rename flag to --zeus-network-aliases 2026-08-01 21:22:53 +05:30
grandwizard28
e59f1178a1 ci: collapse integrationci and e2eci into testsci with a shared image build 2026-08-01 20:56:16 +05:30
grandwizard28
d0531de837 feat(tests): add --signoz-image option to run a prebuilt image 2026-08-01 20:54:50 +05:30
Pandey
5917f9fe31 perf(tests): cache go and pnpm stores across integration image builds (#12366)
* perf(tests): cache go and pnpm stores across integration image builds

Add BuildKit cache mounts for GOCACHE/GOMODCACHE and the pnpm store to the
integration Dockerfiles, and build the image via the docker CLI (docker-py,
used by testcontainers' DockerImage, does not support BuildKit). Embed the
go build command directly so Makefile changes do not invalidate the build
layer, and pin HOME/GOCACHE/GOMODCACHE/PNPM_HOME explicitly so cache-mount
targets match tool defaults by contract. The with-web node stage fetches
dependencies from the lockfile before the source copy, so frontend edits
only re-run the offline install and build.

* feat(tests): add --clean flag to prune buildkit cache mounts

The go and pnpm caches introduced for the integration image build survive
--teardown since they belong to the docker builder, not to any container.
--clean runs docker builder prune with a type=exec.cachemount filter at
session start, forcing the next image build to start cold. Documented in
the integration testing guide.

* feat(tests): add --rebuild flag to refresh the signoz container under --reuse

--reuse keeps the running signoz container, so backend source changes are
never picked up without tearing down the whole stack. --rebuild deletes the
cached signoz container and recreates it from the current sources (an
incremental image build), while databases, mocks and migrations stay reused.
Requires --reuse; combining with --teardown or --clean is a usage error.

* chore(tests): prune comments to non-obvious constraints

* docs(tests): make py-test-setup rebuild signoz and audit the integration guide

py-test-setup now passes --rebuild so re-running it after backend changes
transparently swaps in a signoz container built from the current sources.
The integration guide documents the iteration loop and fixes stale content:
option defaults (clickhouse 25.12.5, schema migrator v0.144.6), the
nonexistent --zookeeper-version option, Zookeeper vs ClickHouse Keeper, the
e2e doc path, and the lint toolchain (ruff).

* docs(tests): wire --rebuild into the e2e setup flow

The e2e bootstrap shares the signoz fixture, so --rebuild already applies;
with --with-web it also picks up frontend changes since the image bakes the
built frontend in. The setup command now passes --rebuild, and the guide
documents the iteration loop, the --rebuild/--clean flags, ClickHouse Keeper
instead of Zookeeper, and the corrected integration doc path.

* docs(tests): qualify --rebuild workflow for suites with custom signoz variants

make py-test-setup only rebuilds the default signoz instance; suites that
create their own via create_signoz(cache_key=...) keep a separately cached
container. Passing --rebuild on the suite run itself rebuilds every variant
that run instantiates.

* docs(tests): describe --clean behaviour instead of its exact command

Keeps the docs from drifting if the prune invocation behind --clean changes.
2026-08-01 13:52:19 +00:00
47 changed files with 644 additions and 9187 deletions

View File

@@ -75,6 +75,30 @@ jobs:
docker rm pw
echo "PLAYWRIGHT_BROWSERS_PATH=$RUNNER_TEMP/ms-playwright" >> "$GITHUB_ENV"
cd tests/e2e && pnpm playwright install-deps ${{ matrix.project }}
# Restore-only: the cacheci workflow owns cache saves. Seeds the
# BuildKit cache mounts so the in-test image build is incremental.
- name: restore
id: restore
uses: actions/cache/restore@v4
with:
path: ${{ runner.temp }}/cacheci
key: tests-primary
restore-keys: |
tests-secondary
- name: inject
if: steps.restore.outputs.cache-matched-key != ''
run: |
cat > "$RUNNER_TEMP/inject.Dockerfile" <<'EOF'
FROM busybox:1.37
RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/pnpm/store \
--mount=type=bind,target=/restored \
tar -xf /restored/go-build.tar -C /root/.cache/go-build && \
tar -xf /restored/go-mod.tar -C /go/pkg/mod && \
tar -xf /restored/pnpm-store.tar -C /pnpm/store
EOF
docker build -f "$RUNNER_TEMP/inject.Dockerfile" "$RUNNER_TEMP/cacheci"
- name: bring-up-stack
run: |
cd tests && \

View File

@@ -110,6 +110,30 @@ jobs:
sudo mv chromedriver-linux64/chromedriver /usr/local/bin/chromedriver
chromedriver -version
google-chrome-stable --version
# Restore-only: the cacheci workflow owns cache saves. Seeds the
# BuildKit cache mounts so the in-test image build is incremental.
- name: restore
id: restore
uses: actions/cache/restore@v4
with:
path: ${{ runner.temp }}/cacheci
key: tests-primary
restore-keys: |
tests-secondary
- name: inject
if: steps.restore.outputs.cache-matched-key != ''
run: |
cat > "$RUNNER_TEMP/inject.Dockerfile" <<'EOF'
FROM busybox:1.37
RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/pnpm/store \
--mount=type=bind,target=/restored \
tar -xf /restored/go-build.tar -C /root/.cache/go-build && \
tar -xf /restored/go-mod.tar -C /go/pkg/mod && \
tar -xf /restored/pnpm-store.tar -C /pnpm/store
EOF
docker build -f "$RUNNER_TEMP/inject.Dockerfile" "$RUNNER_TEMP/cacheci"
- name: run
run: |
cd tests && \

View File

@@ -209,8 +209,8 @@ py-lint: ## Run ruff check across the shared tests project
@cd tests && uv run ruff check --fix .
.PHONY: py-test-setup
py-test-setup: ## Bring up the shared SigNoz backend used by integration and e2e tests
@cd tests && uv run pytest --basetemp=./tmp/ -vv --reuse --capture=no integration/bootstrap/setup.py::test_setup
py-test-setup: ## Bring up the shared SigNoz backend used by integration and e2e tests, rebuilding signoz from the current sources
@cd tests && uv run pytest --basetemp=./tmp/ -vv --reuse --rebuild --capture=no integration/bootstrap/setup.py::test_setup
.PHONY: py-test-teardown
py-test-teardown: ## Tear down the shared SigNoz backend

View File

@@ -4,9 +4,13 @@ ARG OS="linux"
ARG TARGETARCH
ARG ZEUSURL
# HOME comes from the build user, not the image config; declare it so the
# /root paths below trace to it.
ENV HOME=/root
# This path is important for stacktraces
WORKDIR $GOPATH/src/github.com/signoz/signoz
WORKDIR /root
WORKDIR $HOME
RUN set -eux; \
apt-get update; \
@@ -14,23 +18,36 @@ RUN set -eux; \
g++ \
gcc \
libc6-dev \
make \
pkg-config \
; \
rm -rf /var/lib/apt/lists/*
# Keep the literal cache-mount targets below in sync with these. The caches
# are shared with Dockerfile.with-web.integration (same target paths).
ENV GOCACHE=$HOME/.cache/go-build
ENV GOMODCACHE=$GOPATH/pkg/mod
COPY go.mod go.sum ./
RUN go mod download
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY ./cmd/ ./cmd/
COPY ./ee/ ./ee/
COPY ./pkg/ ./pkg/
COPY ./templates /root/templates
COPY Makefile Makefile
RUN TARGET_DIR=/root ARCHS=${TARGETARCH} ZEUS_URL=${ZEUSURL} LICENSE_URL=${ZEUSURL}/api/v1 make go-build-enterprise-race
RUN mv /root/linux-${TARGETARCH}/signoz /root/signoz
# Invoked directly instead of via make so Makefile changes don't invalidate
# this layer; the Makefile's git-derived ldflags resolve to empty in here
# anyway (.git is dockerignored).
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
GOARCH=${TARGETARCH} GOOS=${OS} go build -C ./cmd/enterprise -race -tags timetzdata -o /root/signoz \
-ldflags "-s -w \
-X github.com/SigNoz/signoz/pkg/version.version=integration \
-X github.com/SigNoz/signoz/pkg/version.variant=enterprise \
-X github.com/SigNoz/signoz/ee/zeus.url=${ZEUSURL} \
-X github.com/SigNoz/signoz/ee/zeus.deprecatedURL=${ZEUSURL}/api/v1"
RUN chmod 755 /root /root/signoz

View File

@@ -1,10 +1,23 @@
FROM node:22-bookworm AS build
WORKDIR /opt/
COPY ./frontend/ ./
# HOME comes from the build user, not the image config.
ENV HOME=/root
# pnpm's store lives at $PNPM_HOME/store — a dedicated directory pnpm
# manages. Keep the literal cache-mount targets below in sync.
ENV PNPM_HOME=/pnpm
ENV NODE_OPTIONS=--max-old-space-size=8192
RUN CI=1 npm i -g pnpm@10
RUN CI=1 pnpm install
# pnpm fetch resolves from the lockfile alone and runs no lifecycle scripts;
# the repo's postinstall needs source files that are not copied yet.
COPY ./frontend/package.json ./frontend/pnpm-lock.yaml ./frontend/pnpm-workspace.yaml ./
RUN --mount=type=cache,target=/pnpm/store CI=1 pnpm fetch
COPY ./frontend/ ./
RUN --mount=type=cache,target=/pnpm/store CI=1 pnpm install --offline
RUN CI=1 pnpm build
FROM golang:1.25-bookworm
@@ -13,9 +26,13 @@ ARG OS="linux"
ARG TARGETARCH
ARG ZEUSURL
# HOME comes from the build user, not the image config; declare it so the
# /root paths below trace to it.
ENV HOME=/root
# This path is important for stacktraces
WORKDIR $GOPATH/src/github.com/signoz/signoz
WORKDIR /root
WORKDIR $HOME
RUN set -eux; \
apt-get update; \
@@ -23,23 +40,36 @@ RUN set -eux; \
g++ \
gcc \
libc6-dev \
make \
pkg-config \
; \
rm -rf /var/lib/apt/lists/*
# Keep the literal cache-mount targets below in sync with these. The caches
# are shared with Dockerfile.integration (same target paths).
ENV GOCACHE=$HOME/.cache/go-build
ENV GOMODCACHE=$GOPATH/pkg/mod
COPY go.mod go.sum ./
RUN go mod download
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY ./cmd/ ./cmd/
COPY ./ee/ ./ee/
COPY ./pkg/ ./pkg/
COPY ./templates /root/templates
COPY Makefile Makefile
RUN TARGET_DIR=/root ARCHS=${TARGETARCH} ZEUS_URL=${ZEUSURL} LICENSE_URL=${ZEUSURL}/api/v1 make go-build-enterprise-race
RUN mv /root/linux-${TARGETARCH}/signoz /root/signoz
# Invoked directly instead of via make so Makefile changes don't invalidate
# this layer; the Makefile's git-derived ldflags resolve to empty in here
# anyway (.git is dockerignored).
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
GOARCH=${TARGETARCH} GOOS=${OS} go build -C ./cmd/enterprise -race -tags timetzdata -o /root/signoz \
-ldflags "-s -w \
-X github.com/SigNoz/signoz/pkg/version.version=integration \
-X github.com/SigNoz/signoz/pkg/version.variant=enterprise \
-X github.com/SigNoz/signoz/ee/zeus.url=${ZEUSURL} \
-X github.com/SigNoz/signoz/ee/zeus.deprecatedURL=${ZEUSURL}/api/v1"
COPY --from=build /opt/build ./web/

View File

@@ -24700,149 +24700,6 @@ paths:
summary: Replace variables
tags:
- querier
/prometheus/api/v1/query:
get:
deprecated: false
description: Evaluate a PromQL expression at a single instant. Request and
response follow the Prometheus HTTP API (https://prometheus.io/docs/prometheus/latest/querying/api/);
the /prometheus prefix distinguishes these PromQL-only endpoints from the
SigNoz query APIs. Also accepts POST with form-encoded parameters.
operationId: PrometheusInstantQuery
parameters:
- description: PromQL expression to evaluate
in: query
name: query
required: true
schema:
type: string
- description: 'Evaluation timestamp: float unix seconds or RFC3339. Defaults
to the server''s current time.'
in: query
name: time
schema:
type: string
- description: 'Evaluation timeout: float seconds or a Prometheus duration
string (e.g. 30s).'
in: query
name: timeout
schema:
type: string
- description: Set to any value to include query statistics in the response.
in: query
name: stats
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
properties:
result: {}
resultType:
enum:
- matrix
- vector
- scalar
- string
type: string
stats: {}
type: object
status:
enum:
- success
type: string
type: object
description: Query evaluated successfully
"400":
description: Unparsable expression or parameters (errorType bad_data)
"422":
description: Expression failed to evaluate (errorType execution)
"503":
description: Query timed out or was canceled
summary: Prometheus instant query
tags:
- prometheus
/prometheus/api/v1/query_range:
get:
deprecated: false
description: Evaluate a PromQL expression over a range of time on a fixed
step grid. Request and response follow the Prometheus HTTP API
(https://prometheus.io/docs/prometheus/latest/querying/api/); the
/prometheus prefix distinguishes these PromQL-only endpoints from the
SigNoz query APIs. Grids are capped at 11,000 points per series. Also
accepts POST with form-encoded parameters.
operationId: PrometheusRangeQuery
parameters:
- description: PromQL expression to evaluate
in: query
name: query
required: true
schema:
type: string
- description: 'Start timestamp: float unix seconds or RFC3339.'
in: query
name: start
required: true
schema:
type: string
- description: 'End timestamp: float unix seconds or RFC3339.'
in: query
name: end
required: true
schema:
type: string
- description: 'Grid step: float seconds or a Prometheus duration string
(e.g. 30s). Must be positive.'
in: query
name: step
required: true
schema:
type: string
- description: 'Evaluation timeout: float seconds or a Prometheus duration
string (e.g. 30s).'
in: query
name: timeout
schema:
type: string
- description: Set to any value to include query statistics in the response.
in: query
name: stats
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
properties:
result: {}
resultType:
enum:
- matrix
type: string
stats: {}
type: object
status:
enum:
- success
type: string
type: object
description: Query evaluated successfully
"400":
description: Unparsable expression or parameters, or a grid past the 11,000-point
cap (errorType bad_data)
"422":
description: Expression failed to evaluate (errorType execution)
"503":
description: Query timed out or was canceled
summary: Prometheus range query
tags:
- prometheus
servers:
- description: The fully qualified URL to the SigNoz APIServer.
url: https://{host}:{port}{base_path}

View File

@@ -2,8 +2,8 @@
This document is the subsystem context for `pkg/prometheus/clickhouseprometheusv2`,
the second-generation ClickHouse-backed Prometheus provider. It explains why the
package exists, the correctness constraints that shaped it, and how each
construct is proven not to change results. Any change to the provider must keep
package exists, the correctness constraints that shaped it, and how each fetch
reduction is proven not to change results. Any change to the provider must keep
these invariants; if a change would violate one, it must be flagged and
discussed.
@@ -17,337 +17,107 @@ window is fetched, serialized, and handed to the engine. The cost is a function
of ingested data, not of the question asked — which is how a dashboard of PromQL
panels can take an instance down.
In v2, every query runs in one of two ways, decided per query:
In v2 the stock promql engine evaluates over a native `storage.Querier`: no
translation layer, per-selector fetch windows, and fetch reductions that are
provably invisible to the engine.
- **Transpiled**: the query is evaluated entirely inside ClickHouse and only
final (or near-final) per-group grid arrays come back, built on the
`timeSeries*ToGrid` aggregate functions (the supported ClickHouse floor is
>= 25.6, so they are assumed available).
- **Engine**: the stock promql engine evaluates over this package's native
`storage.Querier`. This is the path for everything not transpilable.
**The core constraint: a PromQL result that differs from upstream Prometheus is
a lost user, so anything that cannot reproduce engine semantics exactly falls
back rather than approximate.** The conformance suite
**The core constraint: every reduction either preserves engine semantics exactly
or is not performed.** A PromQL result that differs from upstream Prometheus is
a lost user. The conformance suite
(`tests/integration/tests/promqlconformance/`) replays Prometheus' own test
corpus against both providers and is the arbiter; the classification golden
(`testdata/classification_golden.json`) freezes which of the two ways each
corpus expression takes. The rest of this document is the PromQL -> SQL story,
because that mapping is where correctness is won or lost.
corpus against both providers and is the arbiter.
---
## The evaluation model the SQL must reproduce
A PromQL range query is an instant query evaluated at every grid point
t_i = start + i*step, 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], and to nothing when that latest
sample is a stale marker — even if older real samples sit inside the
window;
- a range selector [r] collects every sample in (t_i - r, t_i], stale
markers excluded;
- offset d shifts both windows to (t_i - d - w, t_i - d].
The transpilation invariant follows from this: every transpiled construct
produces, per output series, one array with exactly one slot per grid
point — slot i holds the value at t_i, NULL means absent. This is what
makes composition correct, not just convenient: the engine evaluates
these operators independently per t_i, so any representation that gets
every slot right gets the whole query right, and spatial aggregation over
arrays is sound because it combines values that belong to the same t_i by
construction. Slot index i maps back to t_i = start + i*step at scan time
(toMatrix). Everything below is about filling those slots with exactly
the numbers the engine would compute — and each equivalence was validated
against the vendored engine on live data before its shape entered the
allowlist; anything unproven stays on the engine path.
## Classification: finding what a statement can answer
classify walks the parsed AST looking for "core units" — maximal subtrees
of the shape
[agg by/without (...)] [fn(] selector[range] [offset d] [)] [op scalar]...
classifyCore peels that chain from the outside in: an optional
sum/min/max/avg/count aggregation, then one of the allowlisted functions
or a bare instant selector, then the selector with its offset; on the way
out it accumulates 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 — an allowlist, so
an overlooked construct becomes a fallback instead of a wrong number.
Three unit kinds come out of this, each with its own SQL form:
unitRange (rate, irate, increase, delta, idelta over a range selector),
unitInstant (instant vector selection, bare or comparison-filtered) and
unitOverTime (avg/min/max/sum/count/last _over_time).
If the entire tree is one unit, the plan is "full": the statement's rows
are the query result. Otherwise every maximal unit is cut out and replaced
in the expression with a synthetic selector __signoz_transpiled_N__, and
the rewritten expression runs in the engine over the units' materialized
results ("hybrid") — histogram_quantile, topk, or/and/unless and vector
matching keep exact engine semantics while their expensive inputs were
aggregated server-side.
Classification refuses when exact semantics cannot be guaranteed
server-side: the @ modifier anywhere and default-resolution subqueries
(their resolution is a server runtime setting the transpiler cannot see);
duration expressions (offset step(), [range()], ...) anywhere — they are
resolved into the selector's static fields only at evaluation time, so at
classification time those fields still hold their zero values and
transpiling 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 or matching on __name__ in hybrid
plans (the synthetic name would leak into results); name-keeping units —
bare/comparison instant selectors and last_over_time keep their real
__name__ (keepsName), which substitution would replace, so they transpile
only as full plans; and every function outside the allowlist (changes,
resets, quantile_over_time, absent, native-histogram functions, ...).
Units inside a fixed-resolution subquery evaluate on the subquery's own
grid instead of the query grid: epoch-aligned multiples of the resolution
strictly after outerStart - offset - range, ending at outer end - offset —
the exact derivation the engine uses, because a grid shifted by one step
changes which samples every window sees.
## From one unit to one statement
buildUnitSQL renders each unit as a single statement. For
sum by (pod) (rate(m{job="api"}[5m])) the skeleton is:
SELECT gkey, sumForEach(grid) AS grid FROM (
SELECT series.gkey AS gkey,
timeSeriesRateToGrid(<start>, <end>, <step>, <range>)(fromUnixTimestamp64Milli(unix_milli), value) AS grid
FROM signoz_metrics.distributed_samples_v4 AS points
INNER JOIN (
SELECT fingerprint, <group key expr> AS gkey
FROM signoz_metrics.time_series_v4
WHERE <series predicates>
GROUP BY fingerprint, gkey
) AS series ON points.fingerprint = series.fingerprint
WHERE metric_name = ? AND temporality IN ['Cumulative', 'Unspecified']
AND points.fingerprint IN (<matched fingerprints>)
AND unix_milli > <start - range> AND unix_milli <= <end>
AND bitAnd(flags, 1) = 0
GROUP BY points.fingerprint, series.gkey
) GROUP BY gkey
SETTINGS allow_experimental_ts_to_grid_aggregate_function = 1
Reading it inside out:
The time window is the selector's semantics verbatim: strict > on the
lower bound and <= on the upper is the left-open (t - w, t] rule, with the
whole window shifted by the offset. bitAnd(flags, 1) = 0 drops stale
markers, which PromQL excludes from range vectors.
The inner GROUP BY computes one grid array per series.
timeSeriesRateToGrid(start, end, step, range) is a parametric aggregate:
fed (timestamp, value) pairs it produces Array(Nullable(Float64)) with one
slot per grid point. Correct because it implements the engine's
extrapolatedRate decision for decision — counter resets, the zero-point
clamp, the extrapolation thresholds, the >= 2 samples rule, the left-open
window — verified by feeding identical samples to both and comparing
slot for slot: the only difference ever observed is the last bit
(ClickHouse's C++ and Go round the same formula differently), which 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 and is emitted as
arrayMap(x -> x * <range seconds>, <rate expr>), exact by definition —
extrapolatedRate computes the same extrapolated delta for both and
divides by the range only when isRate, so multiplying it back is the
identity, not an approximation. The grid parameters are rendered as
literals, not bound args — they are aggregate-function parameters — and
the experimental gate rides as a SETTINGS clause on the statement itself
so telemetrystore hooks cannot clobber it.
The join annotates each series with 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
directly: the projection is a known short list and the label names live
in Go, so building, sorting and stringifying every label pair per row
would be waste. Correct because column-tuple equality is label-set
equality on the projection, and an extracted '' is the label being
absent — Prometheus semantics for by() over missing labels, and empties
are skipped when the columns turn back into labels. without and
no-aggregation project a label SET that varies per series, so they get
the canonical key: toJSONString of the sorted [label, value] pairs the
unit projects (without excludes the listed labels plus __name__; no
aggregation keeps everything, the name coming off in Go per the engine's
name-dropping rules). There the sort is load-bearing — stored JSON key
order is not canonical across fingerprints, and two orderings of the same
labels must land in one group — empty values are filtered for the same
absent-label reason, and 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, because slot i of every
input array refers to the same t_i; the combinators skip NULLs, which is
the engine aggregating only the series present at t_i, and an index where
every series is absent stays NULL. Two edges need explicit handling:
countForEach wraps in a mapping of 0 back to NULL, because a count over
an all-absent index is an absent point, not 0; and a unit without
aggregation still passes through maxForEach — the identity for the common
one-fingerprint group, and a deterministic NULL-skipping merge when a
regex __name__ selector collapses distinct metrics onto one projected
label set. One caveat is inherent: summation order over series differs
from the engine's, so spatial aggregates can differ in the last ULP —
float addition is not associative; no ordering reproduces the engine's
bit-exactly from inside a GROUP BY.
## Instant selectors: staleness needs two aggregates
unitInstant uses window = lookback and must reproduce the shadowing rule:
the point is absent when the latest in-window sample is a stale marker.
timeSeriesLastToGrid alone cannot express that — skipping stale rows in
WHERE would resurrect the older real sample the marker was written to
bury. So stale rows stay in the scan for this kind only, and 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
Correct by cases on a slot's window. No samples at all: both timestamp
aggregates are NULL, the slot is NULL — 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 — 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 only markers are in
window), they disagree, the slot is NULL — the marker shadows, exactly
the engine's rule. Timestamps are unique per series (ingest dedups), so
timestamp equality identifies "the same sample" without ambiguity. The
-If combinator's applicability to these experimental aggregates was
probed before being trusted, not assumed.
## Windowed *_over_time: whole buckets instead of a grid function
avg/min/max/sum/count _over_time aggregate every raw sample in the window,
and no timeSeries*ToGrid function computes them. (last_over_time is the
exception: the last sample of a range vector — stale markers excluded from
range vectors by PromQL, excluded here in WHERE — is exactly
timeSeriesLastToGrid.) These transpile only when the range is a whole
multiple of the step, and then the window needs no per-sample fan-out at
all: 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 by a plain GROUP BY:
intDiv(unix_milli - <start> + <range> - 1, <step>) AS jj
(ceil((ts - start)/step) shifted by W-1 so the earliest in-window sample
sits at 0; slot k's window is buckets jj in [k, k+W-1]). The alternative —
fanning each sample into all W windows that cover it — multiplies rows by
W, which for a long range over a short step is a row explosion measured
in billions; the bucketed form's row count is series x buckets, the size
of the output, regardless of W.
The shard level aggregates per (series, group key, bucket): a bucket
count plus the function's value aggregate (sum for sum/avg, min, max).
The assembly level places the partials into dense arrays
(groupArrayInsertAt — positions are unique, one row per bucket; counts
and sums default to 0, which contributes nothing) and slides: slot k
combines its at-most-W bucket partials by direct aggregation, so window
sums are added the way the engine adds them — no prefix-sum differencing,
whose large-minus-large cancellation would drift past the shadow
tolerance on counter-sized values. 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, the float
caveat above). A slot with zero window count is absent; min/max filter
their slices on the bucket counts, so an empty bucket's default can never
be mistaken for a value — a real sample can legitimately be +Inf.
Ranges that don't divide the step, and windows wider than
maxWindowBuckets buckets (the slide costs W combines per slot), fall back
to the engine path, which is exact.
## Scalar ops, full plans, hybrid plans
The scalar-op pipeline applies in Go to the returned arrays
(applyScalarOps), slot by slot: arithmetic operators compute, comparisons
filter (the slot keeps the vector-side value or becomes NULL) or return
0/1 under bool. Correct trivially: it is the same float64 operation the
engine would apply to the same slot value, in the same operator order the
AST dictates — running it in 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 and 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, and the name cannot matter: plans that group by or match on
__name__ were refused at classification, and name-keeping units are never
substituted. One subtlety makes it exact: stale markers are written at
absent grid points, because the engine's lookback would otherwise
resurrect a point from up to lookback earlier — the marker encodes
"absent here" the way the engine itself encodes it. Units evaluate
concurrently; each is one series lookup plus one grid statement. A step
of 0 is an instant query: a single evaluation at end.
## Series lookup
Both paths resolve matchers the same way, once per selector
(selectSeries), against the series tables holding one row per
(fingerprint, bucket) at 1h/6h/1d/1w granularities; timeSeriesTableFor
picks the table whose bucket fits the window and rounds the window start
down to the bucket boundary. 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.
Matchers resolve to series once per selector (`selectSeries`) against the series
tables, which hold one row per (fingerprint, bucket) at 1h/6h/1d/1w
granularities. Table selection and window rounding delegate to the shared
metrics schema package (`pkg/telemetryschema/metricstelemetryschema`); the
window start rounds down to the bucket boundary so a window beginning mid-bucket
still matches the bucket's row.
## The engine path
How matchers become SQL is documented at `applySeriesConditions`. The rules that
carry semantics:
Queries that do not transpile run in the stock engine over this package's
storage.Querier, which is still not the v1 path. Samples are fetched per
selector using the engine's per-selector hints, not the query-wide union
window, so foo / foo offset 1d reads two narrow windows instead of 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 — recovered from the hints as
hints.Start + lookback - 1ms, the inverse of how the engine derives
hints.Start — so bucket boundaries coincide with evaluation timestamps and
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 — and the
subquery-free proof travels in the context as prometheus.QueryTraits,
because 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 and merges series with identical label sets
(sortAndMerge) — the engine assumes storages never emit duplicates.
- `__name__` matchers (all four types) translate to the `metric_name` column.
- Every other matcher becomes a `JSONExtractString` condition on the labels
column. An equality matcher against `""` matches series *without* the label,
mirroring PromQL, because `JSONExtractString` returns `""` for missing keys.
- Regexes are anchored (`^(?:...)$`) before they reach `match()`: PromQL
matchers match the whole value, ClickHouse `match()` searches for a
substring.
- The series-lookup upper bound is inclusive (`unix_milli <= end`) because the
exporter floors registration rows to the bucket start: a series first
registered in the bucket beginning exactly at `end` would otherwise be
invisible while its samples are in range.
Empty-valued labels come off at this boundary: an empty value means "label
absent" in Prometheus, but stored attribute JSON can carry them.
---
## Sample fetch
Samples are fetched per selector using the engine's per-selector hints, not the
query-wide union window — `foo / foo offset 1d` reads two narrow windows
instead of the widest one twice.
**Last-sample-per-step reduction.** Instant selectors of subquery-free queries
fetch only the last sample per step bucket. The engine resolves an instant
selector at each grid timestamp `t` to the latest sample in the left-open
lookback window `(t lookback, t]`. Buckets anchor at the selector's first
evaluation timestamp — recovered from the hints as
`hints.Start + lookback 1ms`, the inverse of how the engine derives
`hints.Start` — so bucket boundaries coincide with evaluation timestamps, and 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`, because
subquery selectors evaluate at the subquery's step while the hints carry the
top-level step; call sites that do not attach traits get the conservative raw
fetch.
**Row assembly** maps stale flags to the engine's `StaleNaN` and merges series
with identical label sets (`sortAndMerge`) — the engine assumes storages never
emit duplicates. Duplicate timestamps pass through as stored: uniqueness is
ingest's job, and v1 feeds them to the engine as-is over the same data.
**The fingerprint filter is a shard-local semi-join.** The samples query
restricts to the matched series by re-running the series predicates as an
`IN (SELECT fingerprint FROM <local series table> ...)` subquery, not a GLOBAL
broadcast of the matched set. ClickHouse materializes the subquery's set per
shard before the scan, so it still engages the fingerprint primary-key column.
Because the subquery re-executes the predicates after the lookup ran, it can
match series registered in between; sample rows whose fingerprint the lookup
never saw are skipped — the lookup is the read snapshot.
---
## 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 above exploits that: the distributed samples table at the
top-level FROM makes ClickHouse rewrite the whole inner query per shard,
where 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. 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 — that engages the leading samples
primary-key column. Delta-temporality series stay invisible to PromQL
here exactly as they are in v1: the rollout gate is parity with v1, and
making 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.
`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 semi-join above exploits
that: each shard filters by its own series rows, which are exactly the series
of that shard's samples.
The temporality filter on every samples statement
(`temporality IN ['Cumulative', 'Unspecified']`) is a semantic no-op — the
matched fingerprints already come from those temporalities — that engages the
leading samples primary-key column.
Delta-temporality series stay invisible to PromQL exactly as they are in v1:
the rollout gate is parity with v1, and 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), so this provider's work is attributable in system.query_log
without guessing from query text.
Every statement carries a `log_comment` with
`code.namespace=clickhouse-prometheus-v2` and `code.function.name` naming the
call site, so this provider's work is attributable in `system.query_log`.

View File

@@ -30,11 +30,11 @@ yarn install:browsers # one-time Playwright browser install
### Starting the Test Environment
To spin up the backend stack (SigNoz, ClickHouse, Postgres, Zookeeper, Zeus mock, gateway mock, seeder, migrator-with-web) and keep it running:
To spin up the backend stack (SigNoz, ClickHouse, Postgres, ClickHouse Keeper, Zeus mock, gateway mock, seeder, migrator-with-web) and keep it running:
```bash
cd tests
uv run pytest --basetemp=./tmp/ -vv --reuse --with-web \
uv run pytest --basetemp=./tmp/ -vv --reuse --rebuild --with-web \
e2e/bootstrap/setup.py::test_setup
```
@@ -45,8 +45,13 @@ This command will:
- Start the HTTP seeder container (`tests/seeder/` — exposing `/telemetry/{traces,logs,metrics}` POST + DELETE)
- Write backend coordinates to `tests/e2e/.env.local` (loaded by `playwright.config.ts` via dotenv)
- Keep containers running via the `--reuse` flag
- Rebuild the SigNoz container from the current sources via the `--rebuild` flag
The `--with-web` flag builds the frontend into the SigNoz container — required for E2E. The build takes ~4 mins on a cold start.
The `--with-web` flag builds the frontend into the SigNoz container — required for E2E. The build takes ~4 mins on a cold start; later builds are incremental.
### Rebuilding After Source Changes
The `--with-web` image bakes the built frontend in, so neither backend nor frontend changes are picked up while `--reuse` keeps the container running. `--rebuild` fixes that for both: it kills the SigNoz container, rebuilds the image incrementally (go build cache + pnpm store — a frontend-only change rebuilds in about a minute), and starts a fresh one while databases, mocks, migrations, and the seeder stay reused. The setup command above passes it, so the iteration loop is: change code → re-run the setup command → re-run your specs. `--rebuild` requires `--reuse` and cannot be combined with `--teardown` or `--clean`.
### Stopping the Test Environment
@@ -281,13 +286,16 @@ The full `playwright.config.ts` is the source of truth. Common things to tweak:
The same pytest flags integration tests expose work here, since E2E reuses the shared fixture graph:
- `--reuse` — keep containers warm between runs (required for all iteration).
- `--rebuild` — recreate the SigNoz container from the current sources (backend and, with `--with-web`, frontend) while the rest of the stack stays up. Requires `--reuse`.
- `--teardown` — tear everything down.
- `--clean` — prune the docker build caches, forcing the next image build to start cold.
- `--with-web` — build the frontend into the SigNoz container. **Required for E2E**; integration tests don't need it.
- `--sqlstore-provider`, `--postgres-version`, `--clickhouse-version`, etc. — see `docs/contributing/integration.md`.
- `--sqlstore-provider`, `--postgres-version`, `--clickhouse-version`, etc. — see `docs/contributing/tests/integration.md`.
## What should I remember?
- **Always use the `--reuse` flag** when setting up the E2E stack. `--with-web` adds a ~4 min frontend build; you only want to pay that once.
- **Always use the `--reuse` flag** when setting up the E2E stack. `--with-web` adds a ~4 min frontend build on a cold start; later builds are incremental.
- **Changed backend or frontend code? Re-run the setup command** — it passes `--rebuild`, swapping the SigNoz container for one built from your current sources while the rest of the stack stays up.
- **Don't teardown before setup.** `--reuse` correctly handles partially-set-up state, so chaining teardown → setup wastes time.
- **Prefer UI-driven flows.** Playwright captures BE requests in the trace; a parallel `fetch` probe is almost always redundant. Drop to `page.request.*` only when the UI can't reach what you need.
- **Use `page.waitForResponse` on UI clicks** to assert BE contracts — it still exercises the UI trigger path.

View File

@@ -37,13 +37,34 @@ make py-test-setup
Under the hood this runs, from `tests/`:
```bash
uv run pytest --basetemp=./tmp/ -vv --reuse integration/bootstrap/setup.py::test_setup
uv run pytest --basetemp=./tmp/ -vv --reuse --rebuild --capture=no integration/bootstrap/setup.py::test_setup
```
This command will:
- Start all required services (ClickHouse, PostgreSQL, Zookeeper, SigNoz, Zeus mock, gateway mock)
- Start all required services (ClickHouse, PostgreSQL, ClickHouse Keeper, SigNoz, Zeus mock, gateway mock)
- Register an admin user
- Keep containers running via the `--reuse` flag
- Rebuild the SigNoz container from the current sources via the `--rebuild` flag
### Rebuilding After Source Changes
`--reuse` keeps the running SigNoz container, which means backend source changes are not picked up. `--rebuild` fixes exactly that: it kills the existing SigNoz container, rebuilds the image (incremental — only changed packages recompile thanks to the build cache), and starts a fresh one, while everything else (databases, mocks, migrations) stays reused. `make py-test-setup` passes it by default, so the iteration loop is simply:
```bash
make py-test-setup # (re)build signoz from your current sources
uv run pytest --basetemp=./tmp/ -vv --reuse integration/tests/<suite>/
# ... edit backend code or tests ...
make py-test-setup # pick up the backend changes
uv run pytest --basetemp=./tmp/ -vv --reuse integration/tests/<suite>/
```
The same applies to the e2e stack. `--rebuild` requires `--reuse` and cannot be combined with `--teardown` or `--clean`.
Some suites define their own SigNoz variant in a suite-local `conftest.py` (`create_signoz(..., cache_key=...)` — e.g. `basepath`, `metricreduction`, `querier_json_body`). Those containers are not touched by `make py-test-setup`, which only rebuilds the default instance. For such suites, pass `--rebuild` on the suite run itself — it rebuilds every SigNoz variant the run instantiates:
```bash
uv run pytest --basetemp=./tmp/ -vv --reuse --rebuild integration/tests/<suite>/
```
### Stopping the Test Environment
@@ -56,11 +77,21 @@ make py-test-teardown
Which runs:
```bash
uv run pytest --basetemp=./tmp/ -vv --teardown integration/bootstrap/setup.py::test_teardown
uv run pytest --basetemp=./tmp/ -vv --teardown --capture=no integration/bootstrap/setup.py::test_teardown
```
This destroys the running integration test setup and cleans up resources.
### Cleaning the Image Build Cache
The `signoz:integration` image build keeps its Go build and module caches in BuildKit cache mounts, so rebuilds only recompile what changed. These caches survive `--teardown` (they belong to the Docker builder, not to any container). If a cache ever needs to be nuked — suspected corruption, disk pressure, or to force a genuinely cold build — pass the `--clean` flag:
```bash
uv run pytest --basetemp=./tmp/ -vv --teardown --clean integration/bootstrap/setup.py::test_teardown
```
`--clean` prunes the docker build artifacts backing the incremental image build at session start, so the next build starts from a clean slate. Images and regular layer cache stay intact, but note the pruning is host-wide — it clears build caches for other projects too, not just SigNoz's. The flag composes with any invocation — passing it on a normal `--reuse` run simply makes the next image build start cold (~34 minutes instead of seconds).
## Understanding the Integration Test Framework
Python and pytest form the foundation of the integration testing framework. Testcontainers are used to spin up disposable integration environments. WireMock is used to spin up **test doubles** of external services (Zeus cloud API, gateway, etc.).
@@ -99,7 +130,7 @@ tests/
│ ├── passwordauthn/
│ ├── querier/
│ └── ...
└── e2e/ # Playwright suite (see docs/contributing/e2e.md)
└── e2e/ # Playwright suite (see docs/contributing/tests/e2e.md)
```
Each test suite follows these principles:
@@ -224,9 +255,9 @@ Tests can be configured using pytest options:
- `--sqlstore-provider` — Choose the SQL store provider (default: `postgres`)
- `--sqlite-mode` — SQLite journal mode: `delete` or `wal` (default: `delete`). Only relevant when `--sqlstore-provider=sqlite`.
- `--postgres-version` — PostgreSQL version (default: `15`)
- `--clickhouse-version` — ClickHouse version (default: `25.5.6`)
- `--zookeeper-version` — Zookeeper version (default: `3.7.1`)
- `--schema-migrator-version` — SigNoz schema migrator version (default: `v0.144.2`)
- `--clickhouse-version` — ClickHouse version, also used for ClickHouse Keeper (default: `25.12.5`)
- `--schema-migrator-version` — SigNoz schema migrator version (default: `v0.144.6`)
- `--with-web` — Build the frontend into the SigNoz image (required for e2e)
Example:
@@ -239,6 +270,7 @@ uv run pytest --basetemp=./tmp/ -vv --reuse \
## What should I remember?
- **Always use the `--reuse` flag** when setting up the environment or running tests to keep containers warm. Without it every run rebuilds the stack (~4 mins).
- **Changed backend code? Re-run `make py-test-setup`** — it passes `--rebuild`, swapping the SigNoz container for one built from your current sources while the rest of the stack stays up.
- **Use the `--teardown` flag** only when cleaning up — mixing `--teardown` with `--reuse` is a contradiction.
- **Do not pre-emptively teardown before setup.** If the stack is partially up, `--reuse` picks up from wherever it is. `make py-test-teardown` then `make py-test-setup` wastes minutes.
- **Follow the naming convention** with two-digit numeric prefixes (`01_`, `02_`) for ordered test execution within a suite.
@@ -247,5 +279,5 @@ uv run pytest --basetemp=./tmp/ -vv --reuse \
- **Use descriptive test names** that clearly indicate what is being tested.
- **Leverage fixtures** for common setup. The shared fixture package is at `tests/fixtures/` — reuse before adding new ones.
- **Test both success and failure scenarios** (4xx / 5xx paths) to ensure robust functionality.
- **Run `make py-fmt` and `make py-lint` before committing** Python changes — black + isort + autoflake + pylint.
- **Run `make py-fmt` and `make py-lint` before committing** Python changes — ruff format + ruff check.
- **`--sqlite-mode=wal` does not work on macOS.** The integration test environment runs SigNoz inside a Linux container with the SQLite database file mounted from the macOS host. WAL mode requires shared memory between connections, and connections crossing the VM boundary (macOS host ↔ Linux container) cannot share the WAL index, resulting in `SQLITE_IOERR_SHORT_READ`. WAL mode is tested in CI on Linux only.

View File

@@ -14,8 +14,6 @@ var (
FeatureEnableAIObservability = featuretypes.MustNewName("enable_ai_observability")
FeatureEnableMetricsReduction = featuretypes.MustNewName("enable_metrics_reduction")
FeatureUseInfraMonitoringV2 = featuretypes.MustNewName("use_infra_monitoring_v2")
FeatureUsePrometheusClickhouseV2 = featuretypes.MustNewName("use_prometheus_clickhouse_v2")
)
func MustNewRegistry() featuretypes.Registry {
@@ -108,14 +106,6 @@ func MustNewRegistry() featuretypes.Registry {
DefaultVariant: featuretypes.MustNewName("disabled"),
Variants: featuretypes.NewBooleanVariants(),
},
&featuretypes.Feature{
Name: FeatureUsePrometheusClickhouseV2,
Kind: featuretypes.KindBoolean,
Stage: featuretypes.StageExperimental,
Description: "Runs PromQL queries on the clickhousev2 provider alongside the served engine result and logs any difference; serving is unaffected.",
DefaultVariant: featuretypes.MustNewName("disabled"),
Variants: featuretypes.NewBooleanVariants(),
},
)
if err != nil {
panic(err)

View File

@@ -1,119 +0,0 @@
package clickhouseprometheusv2
import (
"encoding/json"
"flag"
"os"
"path/filepath"
"sort"
"testing"
"github.com/prometheus/prometheus/promql/parser"
"github.com/stretchr/testify/require"
)
var updateGolden = flag.Bool("update", false, "rewrite the classification golden file")
const goldenFile = "testdata/classification_golden.json"
// corpusFile is the conformance corpus the integration suite replays; the
// golden freezes how the classifier routes every one of its expressions.
const corpusFile = "../../../tests/integration/testdata/promqltestcorpus/corpus.json"
type goldenEntry struct {
Expr string `json:"expr"`
StartMs int64 `json:"start_ms"`
EndMs int64 `json:"end_ms"`
StepMs int64 `json:"step_ms"`
// Plan is the routing decision: "full" (whole query in ClickHouse),
// "hybrid" (units substituted, engine on top), "fallback" (engine over
// the native querier).
Plan string `json:"plan"`
// Units is the substituted-unit count for hybrid plans.
Units int `json:"units,omitempty"`
// Reason is the coarse fallback bucket (fallbackShape).
Reason string `json:"reason,omitempty"`
}
// TestClassificationGolden freezes the classifier's routing decision for
// every (expression, grid) of the conformance corpus. Routing is a
// correctness surface of its own: a change that silently sends rate() to the
// engine path costs the pushdown, and one that silently starts transpiling a
// shape never proven equivalent risks wrong numbers — both must show up in
// review as a diff of this file, with the corpus suite's clickhousev2 leg
// judging whether the new routing still returns the reference answers.
//
// Regenerate after intentional classifier changes:
//
// go test ./pkg/prometheus/clickhouseprometheusv2 -run TestClassificationGolden -update
func TestClassificationGolden(t *testing.T) {
raw, err := os.ReadFile(corpusFile)
require.NoError(t, err)
var corpus struct {
Cases []struct {
Expr string `json:"expr"`
StartMs int64 `json:"start_ms"`
EndMs int64 `json:"end_ms"`
StepMs int64 `json:"step_ms"`
} `json:"cases"`
}
require.NoError(t, json.Unmarshal(raw, &corpus))
require.NotEmpty(t, corpus.Cases)
promParser := parser.NewParser(parser.Options{})
seen := map[goldenEntry]bool{}
var entries []goldenEntry
for _, c := range corpus.Cases {
key := goldenEntry{Expr: c.Expr, StartMs: c.StartMs, EndMs: c.EndMs, StepMs: c.StepMs}
if seen[key] {
continue
}
seen[key] = true
expr, err := promParser.ParseExpr(c.Expr)
require.NoError(t, err, "corpus expression must parse: %q", c.Expr)
entry := key
plan, ok := classify(expr, gridContext{startMs: c.StartMs, endMs: c.EndMs, stepMs: c.StepMs})
switch {
case ok && plan.full:
entry.Plan = "full"
case ok:
entry.Plan = "hybrid"
entry.Units = len(plan.units)
default:
entry.Plan = "fallback"
entry.Reason = fallbackShape(expr)
}
entries = append(entries, entry)
}
sort.Slice(entries, func(i, j int) bool {
a, b := entries[i], entries[j]
if a.Expr != b.Expr {
return a.Expr < b.Expr
}
if a.StartMs != b.StartMs {
return a.StartMs < b.StartMs
}
if a.EndMs != b.EndMs {
return a.EndMs < b.EndMs
}
return a.StepMs < b.StepMs
})
got, err := json.MarshalIndent(entries, "", " ")
require.NoError(t, err)
got = append(got, '\n')
if *updateGolden {
require.NoError(t, os.MkdirAll(filepath.Dir(goldenFile), 0o755))
require.NoError(t, os.WriteFile(goldenFile, got, 0o644))
return
}
want, err := os.ReadFile(goldenFile)
require.NoError(t, err, "golden missing — generate it with -update")
require.Equal(t, string(want), string(got),
"classification routing changed; if intentional, regenerate with -update and justify the diff in review")
}

View File

@@ -2,33 +2,27 @@ package clickhouseprometheusv2
import (
"context"
"time"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/prometheus"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/prometheus/prometheus/promql"
"github.com/prometheus/prometheus/storage"
)
// provider ties the package together: its own engine and parser, the
// ClickHouse client behind the native storage.Querier, and the transpiler
// executor. It stays unexported: callers hold the prometheus.Prometheus
// interface, which is the boundary between the two provider implementations,
// and reach the transpiler only through the prometheus.RangeExecutor
// capability.
// provider ties the package together: its own engine and parser, and the
// ClickHouse client behind the native storage.Querier. It stays unexported:
// callers hold the prometheus.Prometheus interface, which is the boundary
// between the two provider implementations.
type provider struct {
settings factory.ScopedProviderSettings
engine *prometheus.Engine
parser prometheus.Parser
client *client
executor *executor
}
var (
_ prometheus.Prometheus = (*provider)(nil)
_ prometheus.StatementCapturer = (*provider)(nil)
_ prometheus.RangeExecutor = (*provider)(nil)
)
func NewFactory(telemetryStore telemetrystore.TelemetryStore) factory.ProviderFactory[prometheus.Prometheus, prometheus.Config] {
@@ -49,17 +43,9 @@ func New(_ context.Context, providerSettings factory.ProviderSettings, config pr
engine: engine,
parser: parser,
client: client,
executor: &executor{client: client, engine: engine, parser: parser},
}, nil
}
// TryExecuteRange evaluates transpilable query shapes directly in ClickHouse
// (see transpiler.go). ok=false means the shape is not transpilable and the
// caller should evaluate through Engine over Storage instead.
func (p *provider) TryExecuteRange(ctx context.Context, query string, start, end time.Time, step time.Duration) (promql.Matrix, bool, error) {
return p.executor.TryExecuteRange(ctx, query, start, end, step)
}
func (p *provider) Engine() *prometheus.Engine {
return p.engine
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,492 +0,0 @@
package clickhouseprometheusv2
import (
"fmt"
"strings"
"github.com/prometheus/prometheus/model/labels"
"github.com/prometheus/prometheus/promql/parser"
)
// The compiler turns PromQL subtrees into single ClickHouse queries built on
// the timeSeries*ToGrid aggregate functions (CH >= 25.6), whose semantics
// were verified against this repo's vendored engine: exact extrapolatedRate
// behavior including counter resets, the counter zero-point clamp, the
// 1.1x-average extrapolation threshold, left-open windows, the >= 2 samples
// rule, stale-marker shadowing, and millisecond grid starts. Sample rows
// never leave ClickHouse: one row per output series comes back, holding the
// whole grid as an array.
//
// Scope (the allowlist): an optional sum/min/max/avg/count by/without
// aggregation over a core unit — a rate/increase/delta/irate/idelta range
// selection, an instant vector selection, or an avg/min/max/sum/count/last
// _over_time window — plus number-literal arithmetic/comparisons and unary
// minus on top. Units inside fixed-resolution subqueries evaluate on the
// subquery's own grid. Everything else either falls back to the engine over
// this package's querier, or — when a transpilable subtree sits under a
// non-transpilable node — runs hybrid: the subtree's grids are computed in
// ClickHouse and substituted into the engine as synthetic series (see
// compiler_exec.go). See doc.go for the fallback list and the reasons behind
// each entry.
// rangeFn is a transpilable range-vector function.
type rangeFn string
const (
fnRate rangeFn = "rate"
fnIncrease rangeFn = "increase"
fnDelta rangeFn = "delta"
fnIRate rangeFn = "irate"
fnIDelta rangeFn = "idelta"
)
var gridFunction = map[rangeFn]string{
fnRate: "timeSeriesRateToGrid",
fnIncrease: "timeSeriesRateToGrid", // increase == rate * range seconds, exactly (same factor algebra)
fnDelta: "timeSeriesDeltaToGrid",
fnIRate: "timeSeriesInstantRateToGrid",
fnIDelta: "timeSeriesInstantDeltaToGrid",
}
// scalarOp is one number-literal arithmetic or comparison applied to a
// compiled vector, evaluated in Go during assembly with the same float64
// operations the engine uses.
type scalarOp struct {
op parser.ItemType
scalar float64
scalarOnLeft bool
returnBool bool
}
// isComparison reports whether the op is a filtering/bool comparison, which
// preserves the metric name (arithmetic drops it).
func (o scalarOp) isComparison() bool {
return o.op.IsComparisonOperator()
}
// unitKind is the selector shape at the bottom of a core unit.
type unitKind int
const (
// unitRange: rate/increase/delta/irate/idelta over a matrix selector.
unitRange unitKind = iota
// unitInstant: a plain vector selector resolved per grid point with
// lookback and stale-marker shadowing.
unitInstant
// unitOverTime: avg/min/max/sum/count/last_over_time over a matrix
// selector (aggregation over the window's samples, stale rows excluded).
unitOverTime
)
// coreUnit is one transpilable subtree: selector [-> range function] ->
// optional aggregation -> scalar op pipeline.
type coreUnit struct {
kind unitKind
matchers []*labels.Matcher
offsetMs int64
fn rangeFn // unitRange
overFn string // unitOverTime: avg|min|max|sum|count|last
rangeMs int64 // unitRange/unitOverTime window
hasAgg bool
aggOp parser.ItemType // SUM MIN MAX AVG COUNT
by bool
grouping []string
ops []scalarOp
}
// keepsName reports whether the unit's output series keep their real
// __name__: bare/comparison-filtered instant selectors and last_over_time do
// (it returns the raw sample, name included); range functions, the other
// *_over_time functions, aggregations, arithmetic and bool comparisons all
// drop it — a bool comparison returns 0/1, not the sample, so the engine
// drops the name there too. Units that keep the name cannot be substituted
// as synthetic series in hybrid plans — the synthetic name would replace
// the real one — but transpile fine as full plans, 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 {
return false
}
for _, op := range u.ops {
if !op.isComparison() || op.returnBool {
return false
}
}
return true
}
// gridContext is the evaluation grid a unit computes on. The query grid for
// top-level units; for units inside subqueries, the subquery's own grid:
// epoch-aligned multiples of its resolution covering the subquery window,
// exactly as the engine derives it (engine.go, *parser.SubqueryExpr case).
type gridContext struct {
startMs int64
endMs int64
stepMs int64
}
// subqueryGrid derives the inner grid for a subquery evaluated on outer:
// interval S, end = outer end offset, start = first multiple of S strictly
// greater than outer start offset range.
func subqueryGrid(outer gridContext, rangeMs, stepMs, offsetMs int64) gridContext {
lower := outer.startMs - offsetMs - rangeMs
start := stepMs * (lower / stepMs)
if start <= lower {
start += stepMs
}
return gridContext{startMs: start, endMs: outer.endMs - offsetMs, stepMs: stepMs}
}
// transpiledUnit is a coreUnit scheduled for execution, named for hybrid
// substitution, carrying the grid it evaluates on.
type transpiledUnit struct {
core coreUnit
name string // __signoz_transpiled_<n>__
grid gridContext
}
// transpilePlan is the outcome of classifying a query.
type transpilePlan struct {
units []*transpiledUnit
grid gridContext // the query's top-level grid
// full is set when the entire query is units[0]; otherwise rewritten
// holds the query with each unit replaced by a synthetic selector, to be
// evaluated by the engine over a hybrid storage.
full bool
rewritten string
}
const syntheticNamePrefix = "__signoz_transpiled_"
func syntheticName(i int) string {
return fmt.Sprintf("%s%d__", syntheticNamePrefix, i)
}
// classifyCore matches a subtree against the transpilable core shape.
// stepMs gates second-granularity: the grid functions take whole-second step
// and window parameters (grid *starts* are millisecond-precise).
func classifyCore(node parser.Expr, stepMs int64) (*coreUnit, bool) {
unit := &coreUnit{}
expr := node
// Peel scalar ops and parens off the top, outermost first; ops apply in
// evaluation order, so prepend while peeling.
for {
switch n := expr.(type) {
case *parser.ParenExpr:
expr = n.Expr
continue
case *parser.UnaryExpr:
if n.Op != parser.SUB {
expr = n.Expr // unary '+' is a no-op
continue
}
// -x == -1 * x for every float64 (incl. NaN and signed zero).
unit.ops = append([]scalarOp{{op: parser.MUL, scalar: -1}}, unit.ops...)
expr = n.Expr
continue
case *parser.StepInvariantExpr:
// @-pinned expressions evaluate on a different grid.
return nil, false
case *parser.BinaryExpr:
lit, litOnLeft, ok := numberLiteralSide(n)
if !ok {
return nil, false
}
if !n.Op.IsOperator() && !n.Op.IsComparisonOperator() {
return nil, false
}
if n.Op == parser.ATAN2 {
// atan2 is arithmetic in PromQL but rarely used; keep the
// allowlist tight.
return nil, false
}
returnBool := n.ReturnBool
unit.ops = append([]scalarOp{{op: n.Op, scalar: lit, scalarOnLeft: litOnLeft, returnBool: returnBool}}, unit.ops...)
if litOnLeft {
expr = n.RHS
} else {
expr = n.LHS
}
continue
}
break
}
// Optional aggregation.
if agg, ok := expr.(*parser.AggregateExpr); ok {
switch agg.Op {
case parser.SUM, parser.MIN, parser.MAX, parser.AVG, parser.COUNT:
default:
return nil, false
}
for _, g := range agg.Grouping {
if g == metricNameLabel {
// by(__name__)/without(__name__) over synthetic or compiled
// output needs name bookkeeping the compiler doesn't do.
return nil, false
}
}
unit.hasAgg = true
unit.aggOp = agg.Op
unit.by = !agg.Without
unit.grouping = agg.Grouping
expr = agg.Expr
for {
if p, ok := expr.(*parser.ParenExpr); ok {
expr = p.Expr
continue
}
break
}
}
// The grid functions take whole-second steps; stepMs == 0 is an instant
// query (single-point grid).
if stepMs < 0 || stepMs%1000 != 0 {
return nil, false
}
// Bare instant selector: resolved per grid point with lookback and
// stale-marker shadowing (see compiler_sql.go).
if vs, ok := expr.(*parser.VectorSelector); ok {
// A duration expression (offset step(), offset range()*2, ...) is
// resolved into OriginalOffset only at evaluation time; at
// classification time the field still holds its zero value, so
// transpiling would silently use the wrong offset.
if vs.Timestamp != nil || vs.StartOrEnd != 0 || vs.Anchored || vs.Smoothed || vs.OriginalOffsetExpr != nil {
return nil, false
}
offsetMs := vs.OriginalOffset.Milliseconds()
if offsetMs < 0 {
return nil, false
}
unit.kind = unitInstant
unit.offsetMs = offsetMs
unit.matchers = vs.LabelMatchers
return unit, true
}
// Range or *_over_time function over a plain matrix selector.
call, ok := expr.(*parser.Call)
if !ok {
return nil, false
}
var fn rangeFn
var overFn string
switch call.Func.Name {
case "rate":
fn = fnRate
case "increase":
fn = fnIncrease
case "delta":
fn = fnDelta
case "irate":
fn = fnIRate
case "idelta":
fn = fnIDelta
case "avg_over_time", "min_over_time", "max_over_time", "sum_over_time", "count_over_time", "last_over_time":
overFn = strings.TrimSuffix(call.Func.Name, "_over_time")
default:
return nil, false
}
if len(call.Args) != 1 {
return nil, false
}
ms, ok := call.Args[0].(*parser.MatrixSelector)
if !ok {
return nil, false
}
vs, ok := ms.VectorSelector.(*parser.VectorSelector)
if !ok {
return nil, false
}
// Duration expressions resolve at evaluation time (see the instant
// selector case above); Range/OriginalOffset would be read as zero here.
if vs.Timestamp != nil || vs.StartOrEnd != 0 || vs.Anchored || vs.Smoothed || vs.OriginalOffsetExpr != nil || ms.RangeExpr != nil {
return nil, false
}
rangeMs := ms.Range.Milliseconds()
offsetMs := vs.OriginalOffset.Milliseconds()
if rangeMs <= 0 || rangeMs%1000 != 0 || offsetMs < 0 {
return nil, false
}
if overFn != "" {
unit.kind = unitOverTime
unit.overFn = overFn
} else {
unit.kind = unitRange
unit.fn = fn
}
unit.rangeMs = rangeMs
unit.offsetMs = offsetMs
unit.matchers = vs.LabelMatchers
return unit, true
}
// numberLiteralSide returns the number literal on one side of a binary
// expression (peeling parens and unary minus), and which side it is on.
func numberLiteralSide(b *parser.BinaryExpr) (float64, bool, bool) {
if v, ok := literalValue(b.LHS); ok {
return v, true, true
}
if v, ok := literalValue(b.RHS); ok {
return v, false, true
}
return 0, false, false
}
func literalValue(e parser.Expr) (float64, bool) {
neg := false
for {
switch n := e.(type) {
case *parser.ParenExpr:
e = n.Expr
continue
case *parser.StepInvariantExpr:
e = n.Expr
continue
case *parser.UnaryExpr:
if n.Op == parser.SUB {
neg = !neg
}
e = n.Expr
continue
case *parser.NumberLiteral:
if neg {
return -n.Val, true
}
return n.Val, true
default:
return 0, false
}
}
}
// classify builds the compile plan for a query: full when the root is a core
// unit, hybrid when core units sit strictly below the root (including inside
// fixed-resolution subqueries, computed on the subquery grid), none
// otherwise.
func classify(root parser.Expr, grid gridContext) (*transpilePlan, bool) {
if unit, ok := classifyCore(root, grid.stepMs); ok {
return &transpilePlan{
units: []*transpiledUnit{{core: *unit, name: syntheticName(0), grid: grid}},
grid: grid,
full: true,
}, true
}
plan := &transpilePlan{grid: grid}
rewritten := rewrite(root, grid, plan, false)
if len(plan.units) == 0 {
return nil, false
}
plan.rewritten = rewritten.String()
return plan, true
}
// rewrite walks top-down replacing maximal transpilable subtrees with synthetic
// vector selectors. nameSensitive marks scopes where an ancestor's semantics
// depend on __name__ (grouping or vector matching on it): synthetic series
// carry a synthetic __name__, so substitution there would change results.
// Fixed-resolution subqueries recurse with the subquery's own grid; scopes
// whose evaluation grid is unknowable (@-pinned, default-resolution
// subqueries) are not entered.
func rewrite(node parser.Expr, grid gridContext, plan *transpilePlan, nameSensitive bool) parser.Expr {
if node == nil {
return nil
}
if !nameSensitive {
// Units whose output keeps the real __name__ (bare instant selectors)
// cannot be substituted: the synthetic name would replace it in the
// engine's output. They still compile as full plans.
if unit, ok := classifyCore(node, grid.stepMs); ok && !unit.keepsName() {
cu := &transpiledUnit{core: *unit, name: syntheticName(len(plan.units)), grid: grid}
plan.units = append(plan.units, cu)
return &parser.VectorSelector{
Name: cu.name,
LabelMatchers: []*labels.Matcher{
labels.MustNewMatcher(labels.MatchEqual, metricNameLabel, cu.name),
},
PosRange: node.PositionRange(),
}
}
}
switch n := node.(type) {
case *parser.ParenExpr:
n.Expr = rewrite(n.Expr, grid, plan, nameSensitive)
case *parser.UnaryExpr:
n.Expr = rewrite(n.Expr, grid, plan, nameSensitive)
case *parser.AggregateExpr:
sensitive := nameSensitive || groupingUsesName(n.Grouping)
n.Expr = rewrite(n.Expr, grid, plan, sensitive)
// n.Param is a scalar/string; nothing transpilable inside for our core.
case *parser.Call:
for i, arg := range n.Args {
n.Args[i] = rewrite(arg, grid, plan, nameSensitive)
}
case *parser.BinaryExpr:
sensitive := nameSensitive || vectorMatchingUsesName(n.VectorMatching)
n.LHS = rewrite(n.LHS, grid, plan, sensitive)
n.RHS = rewrite(n.RHS, grid, plan, sensitive)
case *parser.SubqueryExpr:
// The alert-smoothing idiom fn_over_time((expr)[R:S]) dominates real
// rule fleets; inner units evaluate on the subquery grid, and the
// engine does the smoothing over the synthetic series. Requires an
// explicit whole-second resolution (S == 0 needs the engine's
// default-interval function) and no @ pinning.
stepMs := n.Step.Milliseconds()
rangeMs := n.Range.Milliseconds()
offsetMs := n.OriginalOffset.Milliseconds()
if n.Timestamp == nil && n.StartOrEnd == 0 &&
n.RangeExpr == nil && n.StepExpr == nil && n.OriginalOffsetExpr == nil &&
stepMs > 0 && stepMs%1000 == 0 && rangeMs%1000 == 0 && offsetMs >= 0 {
inner := subqueryGrid(grid, rangeMs, stepMs, offsetMs)
n.Expr = rewrite(n.Expr, inner, plan, nameSensitive)
}
case *parser.StepInvariantExpr, *parser.MatrixSelector,
*parser.VectorSelector, *parser.NumberLiteral, *parser.StringLiteral:
// Leaves, or scopes substitution must not enter.
}
return node
}
func groupingUsesName(grouping []string) bool {
for _, g := range grouping {
if g == metricNameLabel {
return true
}
}
return false
}
func vectorMatchingUsesName(vm *parser.VectorMatching) bool {
if vm == nil {
return false
}
for _, l := range append(append([]string{}, vm.MatchingLabels...), vm.Include...) {
if l == metricNameLabel {
return true
}
}
// Default (all-labels) matching ignores __name__, and by()/ignoring()
// lists were checked above.
return false
}
// isSyntheticSelector reports whether matchers target a compiled unit.
func isSyntheticSelector(matchers []*labels.Matcher) (string, bool) {
for _, m := range matchers {
if m.Name == metricNameLabel && m.Type == labels.MatchEqual && strings.HasPrefix(m.Value, syntheticNamePrefix) {
return m.Value, true
}
}
return "", false
}

View File

@@ -1,161 +0,0 @@
package clickhouseprometheusv2
import (
"bufio"
"encoding/json"
"fmt"
"os"
"regexp"
"sort"
"strings"
"testing"
"github.com/prometheus/prometheus/promql/parser"
"github.com/stretchr/testify/require"
)
// TestClassifyCorpus measures real-workload compiler coverage: it classifies
// every query of a JSON-lines corpus (one JSON-encoded PromQL string per
// line) with the live classifier and reports full / hybrid / fallback
// shares. Skipped unless PROMQL_CORPUS points to one or more files
// (comma-separated). Dashboard template variables are substituted with
// placeholder values before parsing, mirroring the production render step.
//
// PROMQL_CORPUS=corpus-a.jsonl,corpus-b.jsonl go test -run TestClassifyCorpus -v
func TestClassifyCorpus(t *testing.T) {
corpus := os.Getenv("PROMQL_CORPUS")
if corpus == "" {
t.Skip("PROMQL_CORPUS not set")
}
varRe := regexp.MustCompile(`\{\{\s*\.?[\w.]+\s*\}\}|\[\[\s*[\w.]+\s*\]\]|\$[\w.]+`)
promParser := parser.NewParser(parser.Options{})
for _, path := range strings.Split(corpus, ",") {
f, err := os.Open(path)
require.NoError(t, err)
var full, hybrid, fallbackInstant, fallbackOther, parseErrs int
fallbackReasons := map[string]int{}
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
for scanner.Scan() {
var query string
require.NoError(t, json.Unmarshal(scanner.Bytes(), &query))
query = varRe.ReplaceAllString(query, "placeholder")
expr, err := promParser.ParseExpr(query)
if err != nil {
parseErrs++
continue
}
plan, ok := classify(expr, gridContext{startMs: 1_700_000_000_000, endMs: 1_700_007_200_000, stepMs: 60_000})
switch {
case ok && plan.full:
full++
case ok:
hybrid++
default:
reason := fallbackShape(expr)
fallbackReasons[reason]++
if reason == "instant-selector shape (last-sample-per-step engine path)" {
fallbackInstant++
} else {
fallbackOther++
}
}
}
require.NoError(t, scanner.Err())
_ = f.Close()
total := full + hybrid + fallbackInstant + fallbackOther
if total == 0 {
t.Logf("%s: no parseable queries (%d parse errors)", path, parseErrs)
continue
}
t.Logf("%s: %d queries — full=%d (%.0f%%) hybrid=%d (%.0f%%) fallback=%d (%.0f%%; instant-shape=%d) parse_errors=%d",
path, total,
full, 100*float64(full)/float64(total),
hybrid, 100*float64(hybrid)/float64(total),
fallbackInstant+fallbackOther, 100*float64(fallbackInstant+fallbackOther)/float64(total),
fallbackInstant, parseErrs)
reasons := make([]string, 0, len(fallbackReasons))
for r := range fallbackReasons {
reasons = append(reasons, r)
}
sort.Slice(reasons, func(i, j int) bool { return fallbackReasons[reasons[i]] > fallbackReasons[reasons[j]] })
for _, r := range reasons {
t.Logf(" fallback %4d %s", fallbackReasons[r], r)
}
}
}
// fallbackShape buckets a non-transpilable query by why it stays on the engine
// path, to separate "already served well" (instant selectors on the last-sample-per-step
// path) from genuine compiler gaps.
func fallbackShape(expr parser.Expr) string {
var hasMatrix, hasSubquery, hasAt, hasDurationExpr, overTime bool
rangeFns := map[string]bool{"rate": true, "increase": true, "delta": true, "irate": true, "idelta": true}
var unsupportedFns []string
parser.Inspect(expr, func(node parser.Node, _ []parser.Node) error {
switch n := node.(type) {
case *parser.MatrixSelector:
hasMatrix = true
if n.RangeExpr != nil {
hasDurationExpr = true
}
case *parser.SubqueryExpr:
hasSubquery = true
if n.RangeExpr != nil || n.StepExpr != nil || n.OriginalOffsetExpr != nil {
hasDurationExpr = true
}
case *parser.VectorSelector:
if n.Timestamp != nil || n.StartOrEnd != 0 {
hasAt = true
}
if n.OriginalOffsetExpr != nil {
hasDurationExpr = true
}
case *parser.Call:
if strings.HasSuffix(n.Func.Name, "_over_time") {
overTime = true
} else if !rangeFns[n.Func.Name] {
unsupportedFns = append(unsupportedFns, n.Func.Name)
}
}
return nil
})
switch {
case hasDurationExpr:
return "duration expression (resolved only at evaluation time)"
case hasSubquery:
return "subquery"
case hasAt:
return "@ modifier"
case overTime:
return "*_over_time range function"
case !hasMatrix:
return "instant-selector shape (last-sample-per-step engine path)"
case len(unsupportedFns) > 0:
return fmt.Sprintf("range shape with unsupported function(s): %s", strings.Join(dedupe(unsupportedFns), ",")) //nolint:makezero
default:
return "other range shape"
}
}
func dedupe(in []string) []string {
seen := map[string]bool{}
var out []string
for _, s := range in {
if !seen[s] {
seen[s] = true
out = append(out, s)
}
}
sort.Strings(out)
return out
}

View File

@@ -1,550 +0,0 @@
package clickhouseprometheusv2
import (
"context"
"encoding/json"
"math"
"sort"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/prometheus"
"github.com/prometheus/prometheus/model/labels"
promValue "github.com/prometheus/prometheus/model/value"
"github.com/prometheus/prometheus/promql"
"github.com/prometheus/prometheus/promql/parser"
"github.com/prometheus/prometheus/storage"
"golang.org/x/sync/errgroup"
)
// executor evaluates transpilable PromQL directly in ClickHouse, falling
// back (ok=false) whenever the query shape or the step doesn't qualify. The
// timeSeries*ToGrid functions it builds on are assumed available: the
// supported ClickHouse floor is >= 25.6.
type executor struct {
client *client
engine *prometheus.Engine
parser prometheus.Parser
}
// maxWindowBuckets caps range/step for the windowed *_over_time form: every
// grid slot combines that many bucket partials, and the fleet's windows sit
// well under it ([1m]..[17m] at 30-60s steps) — anything larger is a
// long-range query whose step a dashboard scales up anyway, and the engine
// path serves the rest.
const maxWindowBuckets = 64
// TryExecuteRange transpiles and runs the query in ClickHouse when its shape
// is in the allowlist. ok=false means "not transpilable" and carries no
// error; the caller runs the engine path.
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)
if err != nil {
// Let the engine path produce the (enhanced) parse error.
return nil, false, nil
}
plan, ok := classify(expr, queryGrid(start, end, step))
if !ok {
return nil, false, nil
}
// timeSeriesLastToGrid widens its window to max(window, step) — probed: a
// sample aged (window, step] still fills the slot — while the rate/delta
// family enforces the window strictly. The Last-style kinds used to fall
// back when window < step because of that widening; the window-sliver
// filter (see samplesConditions) makes the widening harmless there:
// samples exist only inside (t_k - window, t_k] slivers, so the widened
// window intersected with the data IS the lookback window — and if a
// future ClickHouse stops widening, the unwidened window is the sliver
// too. Correct either way. A non-positive window still falls back: the
// sliver argument needs a real window to filter to.
//
// The windowed *_over_time form gates only the range >= step regime: it
// decomposes the window into whole step buckets (see windowedInner),
// which is exact only when the range is a multiple of the step, and its
// per-slot slide costs range/step bucket combines — bounded by
// maxWindowBuckets so a long-range short-step query cannot turn the
// slide into the bottleneck. range < step needs neither gate: the
// windows are disjoint slivers, aggregated one slot each with no slide.
// Every miss falls back to the engine path, which is exact.
for _, unit := range plan.units {
stepMs := unit.grid.stepMs
if stepMs == 0 {
stepMs = 1000
}
switch {
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
}
if windowMs <= 0 {
return nil, false, nil
}
case unit.core.kind == unitOverTime:
if unit.core.rangeMs < unit.grid.stepMs {
// Disjoint slivers: no divisibility or width requirement.
continue
}
if unit.core.rangeMs%stepMs != 0 || unit.core.rangeMs/stepMs > maxWindowBuckets {
return nil, false, nil
}
}
}
// Evaluate every unit concurrently on its own grid (the query grid, or a
// subquery grid); each is one series lookup plus one grid query.
results := make([][]transpiledSeries, len(plan.units))
eg, egCtx := errgroup.WithContext(ctx)
for i, unit := range plan.units {
eg.Go(func() error {
res, err := e.executeUnit(egCtx, &unit.core, unit.grid)
if err != nil {
return err
}
results[i] = res
return nil
})
}
if err := eg.Wait(); err != nil {
return nil, true, err
}
if plan.full {
g := plan.units[0].grid
return toMatrix(results[0], g.startMs, g.stepMs), true, nil
}
matrix, err := e.executeHybrid(ctx, plan, results)
if err != nil {
return nil, true, err
}
return matrix, true, nil
}
// queryGrid derives the top-level evaluation grid; step 0 is an instant
// query: a single evaluation at end, whatever start was.
func queryGrid(start, end time.Time, step time.Duration) gridContext {
startMs, endMs, stepMs := start.UnixMilli(), end.UnixMilli(), step.Milliseconds()
if stepMs == 0 {
startMs = endMs
}
return gridContext{startMs: startMs, endMs: endMs, stepMs: stepMs}
}
// transpiledSeries is one output series of a unit: projected labels and one
// value pointer per grid point (nil = absent).
type transpiledSeries struct {
lset labels.Labels
values []*float64
}
// executeUnit runs one core unit on its grid: series lookup (budgets,
// fingerprints, metric names), then the single grid query, then the
// scalar-op pipeline.
func (e *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
}
dataStart := startMs - unit.offsetMs - windowMs
dataEnd := endMs - unit.offsetMs
seriesQuery, seriesArgs, err := buildSeriesQuery(dataStart, dataEnd, unit.matchers)
if err != nil {
return nil, err
}
lookup, err := e.client.selectSeries(ctx, seriesQuery, seriesArgs)
if err != nil {
return nil, err
}
if len(lookup.fingerprints) == 0 {
return nil, nil
}
query, args, err := buildUnitSQL(unit, lookup.metricNames, dataStart, dataEnd, startMs, endMs, stepMs, e.client.lookbackMs)
if err != nil {
return nil, err
}
rows, err := e.client.telemetryStore.ClickhouseDB().Query(e.client.withContext(ctx, "transpiledUnit"), query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
// Name-dropping units keep __name__ in the SQL group key so distinct
// metrics never merge server-side; the name comes off here. Two metrics
// can then share a labelset — the engine merges their samples into one
// series when they never overlap in time (a selector spanning metrics
// whose series alternate across lookback windows) and raises the
// duplicate-labelset error only when two samples land on the same
// evaluation timestamp. mergeSameLabelsetSeries reproduces exactly that.
stripName := !unit.hasAgg && !unit.keepsName()
// by (...) units return one plain column per grouped label; everything
// else returns the single canonical JSON key (see groupKeyColumns).
keyNames := groupKeyColumns(unit)
keyVals := make([]string, max(len(keyNames), 1))
targets := make([]any, 0, len(keyVals)+1)
for i := range keyVals {
targets = append(targets, &keyVals[i])
}
var gridValues []*float64
targets = append(targets, &gridValues)
var out []transpiledSeries
for rows.Next() {
if err := rows.Scan(targets...); err != nil {
return nil, err
}
var lset labels.Labels
if keyNames != nil {
builder := labels.NewScratchBuilder(len(keyNames))
for i, name := range keyNames {
// An empty extracted value is the label being absent.
if keyVals[i] != "" {
builder.Add(name, keyVals[i])
}
}
builder.Sort()
lset = builder.Labels()
} else {
lset, err = labelsFromGroupKey(keyVals[0])
if err != nil {
return nil, err
}
}
if stripName {
lset = labels.NewBuilder(lset).Del(metricNameLabel).Labels()
}
values := make([]*float64, len(gridValues))
copy(values, gridValues)
applyScalarOps(unit.ops, values)
out = append(out, transpiledSeries{lset: lset, values: values})
}
if err := rows.Err(); err != nil {
return nil, err
}
if stripName {
if out, err = mergeSameLabelsetSeries(out); err != nil {
return nil, err
}
}
sort.Slice(out, func(i, j int) bool { return labels.Compare(out[i].lset, out[j].lset) < 0 })
return out, nil
}
// mergeSameLabelsetSeries combines series left with identical labelsets by a
// name strip, slot by slot: the engine assembles its result matrix by
// labelset, so post-strip twins whose points interleave in time are one
// series to it, and two values on the same evaluation timestamp are its
// duplicate-labelset error — v1 would have errored there too, so silently
// picking one value would be a divergence.
func mergeSameLabelsetSeries(in []transpiledSeries) ([]transpiledSeries, error) {
index := make(map[uint64]int, len(in))
out := in[:0]
for _, s := range in {
hash := s.lset.Hash()
idx, ok := index[hash]
if ok && labels.Equal(out[idx].lset, s.lset) {
dst := out[idx].values
for k, v := range s.values {
if v == nil {
continue
}
if dst[k] != nil {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "vector cannot contain metrics with the same labelset")
}
dst[k] = v
}
continue
}
index[hash] = len(out)
out = append(out, s)
}
return out, nil
}
// labelsFromGroupKey parses the toJSONString'd sorted [key, value] pairs.
func labelsFromGroupKey(gkey string) (labels.Labels, error) {
var pairs [][]string
if err := json.Unmarshal([]byte(gkey), &pairs); err != nil {
return labels.EmptyLabels(), errors.WrapInternalf(err, errors.CodeInternal, "malformed compiled group key %q", gkey)
}
builder := labels.NewScratchBuilder(len(pairs))
for _, p := range pairs {
if len(p) != 2 {
return labels.EmptyLabels(), errors.NewInternalf(errors.CodeInternal, "malformed compiled group key pair %q", gkey)
}
builder.Add(p[0], p[1])
}
builder.Sort()
return builder.Labels(), nil
}
// applyScalarOps applies the number-literal op pipeline in place, with the
// same float64 arithmetic and comparison-filter semantics as the engine.
func applyScalarOps(ops []scalarOp, values []*float64) {
for _, op := range ops {
for i, v := range values {
if v == nil {
continue
}
lhs, rhs := *v, op.scalar
if op.scalarOnLeft {
lhs, rhs = op.scalar, *v
}
switch op.op {
case parser.ADD:
res := lhs + rhs
values[i] = &res
case parser.SUB:
res := lhs - rhs
values[i] = &res
case parser.MUL:
res := lhs * rhs
values[i] = &res
case parser.DIV:
res := lhs / rhs
values[i] = &res
case parser.MOD:
res := math.Mod(lhs, rhs)
values[i] = &res
case parser.POW:
res := math.Pow(lhs, rhs)
values[i] = &res
default:
keep := compare(op.op, lhs, rhs)
switch {
case op.returnBool:
res := 0.0
if keep {
res = 1.0
}
values[i] = &res
case keep:
// Filter comparisons keep the vector-side value.
vec := *v
values[i] = &vec
default:
values[i] = nil
}
}
}
}
}
func compare(op parser.ItemType, lhs, rhs float64) bool {
switch op {
case parser.EQLC:
return lhs == rhs
case parser.NEQ:
return lhs != rhs
case parser.GTR:
return lhs > rhs
case parser.LSS:
return lhs < rhs
case parser.GTE:
return lhs >= rhs
case parser.LTE:
return lhs <= rhs
}
return false
}
// toMatrix converts a unit result to a promql matrix on the query grid.
func toMatrix(series []transpiledSeries, startMs, stepMs int64) promql.Matrix {
matrix := make(promql.Matrix, 0, len(series))
for _, s := range series {
var floats []promql.FPoint
for i, v := range s.values {
if v == nil {
continue
}
floats = append(floats, promql.FPoint{T: startMs + int64(i)*stepMs, F: *v})
}
if len(floats) == 0 {
continue
}
matrix = append(matrix, promql.Series{Metric: s.lset, Floats: floats})
}
return matrix
}
// executeHybrid substitutes each unit's grids into the engine as synthetic
// series and evaluates the rewritten query over a storage that serves
// synthetic selectors from memory and everything else from the live querier.
// Absent grid points become stale markers so the engine's lookback cannot
// resurrect the previous grid point. Each unit's synthetic samples sit on its
// own grid (query grid, or subquery grid for units inside subqueries).
func (e *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)
queryGrid := plan.grid
for i, unit := range plan.units {
g := unit.grid
gridLen := 1
if g.stepMs > 0 {
gridLen = int((g.endMs-g.startMs)/g.stepMs) + 1
}
list := make([]*series, 0, len(results[i]))
for _, cs := range results[i] {
builder := labels.NewBuilder(cs.lset)
builder.Set(metricNameLabel, unit.name)
s := &series{lset: builder.Labels()}
s.ts = make([]int64, 0, gridLen)
s.vs = make([]float64, 0, gridLen)
for idx := 0; idx < gridLen; idx++ {
t := g.startMs + int64(idx)*g.stepMs
var v float64
if idx < len(cs.values) && cs.values[idx] != nil {
v = *cs.values[idx]
} else {
v = staleMarker
}
s.ts = append(s.ts, t)
s.vs = append(s.vs, v)
}
list = append(list, s)
}
synthetic[unit.name] = list
}
hybrid := &hybridQueryable{client: e.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))
} 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)
}
if err != nil {
return nil, err
}
defer qry.Close()
res := qry.Exec(ctx)
if res.Err != nil {
return nil, res.Err
}
matrix, err := resultToMatrix(res)
if err != nil {
return nil, err
}
// Deep-copy before Close returns the result's slices to the engine pool,
// and drop the synthetic __name__ that filter comparisons preserve.
out := make(promql.Matrix, 0, len(matrix))
for _, s := range matrix {
lset := s.Metric
if name := lset.Get(metricNameLabel); len(name) >= len(syntheticNamePrefix) && name[:len(syntheticNamePrefix)] == syntheticNamePrefix {
builder := labels.NewBuilder(lset)
builder.Del(metricNameLabel)
lset = builder.Labels()
}
floats := make([]promql.FPoint, len(s.Floats))
copy(floats, s.Floats)
out = append(out, promql.Series{Metric: lset.Copy(), Floats: floats})
}
// The strip can leave twins: two units' outputs distinguishable only by
// their synthetic names (e.g. -metric_a or -metric_b, both {} to the
// engine's real evaluation once names dropped). The engine assembles its
// matrix by labelset, merging such temporally-disjoint elements into one
// series; reproduce that, with its duplicate error on same-timestamp
// overlap.
out, err = mergeMatrixByLabelset(out)
if err != nil {
return nil, err
}
sort.Slice(out, func(i, j int) bool { return labels.Compare(out[i].Metric, out[j].Metric) < 0 })
return out, nil
}
// mergeMatrixByLabelset merges series sharing a labelset by interleaving
// their points in timestamp order; a timestamp present in both is the
// engine's duplicate-labelset error.
func mergeMatrixByLabelset(matrix promql.Matrix) (promql.Matrix, error) {
index := make(map[uint64]int, len(matrix))
out := matrix[:0]
for _, s := range matrix {
hash := s.Metric.Hash()
idx, ok := index[hash]
if ok && labels.Equal(out[idx].Metric, s.Metric) {
merged := make([]promql.FPoint, 0, len(out[idx].Floats)+len(s.Floats))
a, b := out[idx].Floats, s.Floats
for len(a) > 0 && len(b) > 0 {
switch {
case a[0].T < b[0].T:
merged, a = append(merged, a[0]), a[1:]
case b[0].T < a[0].T:
merged, b = append(merged, b[0]), b[1:]
default:
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "vector cannot contain metrics with the same labelset")
}
}
out[idx].Floats = append(append(merged, a...), b...)
continue
}
index[hash] = len(out)
out = append(out, s)
}
return out, nil
}
func resultToMatrix(res *promql.Result) (promql.Matrix, error) {
switch v := res.Value.(type) {
case promql.Matrix:
return v, nil
case promql.Vector:
matrix := make(promql.Matrix, 0, len(v))
for _, s := range v {
matrix = append(matrix, promql.Series{Metric: s.Metric, Floats: []promql.FPoint{{T: s.T, F: s.F}}})
}
return matrix, nil
case promql.Scalar:
return promql.Matrix{{Metric: labels.EmptyLabels(), Floats: []promql.FPoint{{T: v.T, F: v.V}}}}, nil
default:
return nil, errors.NewInternalf(errors.CodeInternal, "unexpected hybrid result type %T", res.Value)
}
}
// hybridQueryable serves synthetic (compiled) selectors from memory and
// everything else from the live storage.
type hybridQueryable struct {
client *client
synthetic map[string][]*series
}
func (h *hybridQueryable) Querier(mint, maxt int64) (storage.Querier, error) {
return &hybridQuerier{
querier: querier{mint: mint, maxt: maxt, client: h.client},
synthetic: h.synthetic,
}, nil
}
type hybridQuerier struct {
querier
synthetic map[string][]*series
}
func (h *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]
if sortSeries {
sorted := make([]*series, len(list))
copy(sorted, list)
sort.Slice(sorted, func(i, j int) bool { return labels.Compare(sorted[i].lset, sorted[j].lset) < 0 })
list = sorted
}
return newSeriesSet(list)
}
return h.querier.Select(ctx, sortSeries, hints, matchers...)
}

View File

@@ -1,414 +0,0 @@
package clickhouseprometheusv2
import (
"fmt"
"strings"
"github.com/SigNoz/signoz/pkg/telemetryschema/metricstelemetryschema"
"github.com/huandu/go-sqlbuilder"
)
// experimental gate for the timeSeries*ToGrid aggregate functions; attached
// as a SETTINGS clause so telemetrystore hooks cannot clobber it.
const gridFunctionsSetting = "SETTINGS allow_experimental_ts_to_grid_aggregate_function = 1"
var aggForEach = map[string]string{
"sum": "sumForEach",
"min": "minForEach",
"max": "maxForEach",
"avg": "avgForEach",
"count": "countForEach",
}
// buildUnitSQL renders the single ClickHouse query evaluating a core unit
// over the [startMs, endMs] / stepMs evaluation grid: per-series grids via a
// timeSeries*ToGrid aggregate (or a windowed aggregation for *_over_time),
// then spatial aggregation with -ForEach combinators grouped by a canonical
// JSON key of the projected label pairs.
//
// The heavy level is shaped to run on the shards: the top-level FROM is the
// distributed samples table and the group-key join partner is a subquery on
// the shard-local time series table, so the shard rewrite executes the join
// and the per-(fingerprint, group key) aggregation next to the data —
// complete by fingerprint co-locality (see localTimeSeriesTable) — and the
// initiator only merges the per-series states and applies the spatial
// -ForEach step. Same layout as the telemetrymetrics statement builder.
// The windowed *_over_time form shares the frame but aggregates per
// (series, group key, step bucket) instead of straight to grids
// (see windowedInner).
//
// The selector's data window is offset-shifted; the resulting grid indices
// map 1:1 onto the query grid (output ts = startMs + i*stepMs). Grid
// parameters are rendered as literals — they are aggregate-function
// parameters, not bindable values.
//
// Statements nest builder-rendered SQL as text, so the returned args must be
// ordered by where each fragment lands in the final statement: ClickHouse
// binds ? placeholders by position. A JOIN renders before WHERE, so a joined
// subquery's args precede the outer query's own condition args.
//
// Row shape: the group-key columns (see groupKeyColumns) followed by
// grid Array(Nullable(Float64)); NULL grid points are absent points (the
// engine's "no value here"), which the -ForEach combinators preserve: an
// index where every series is NULL aggregates to NULL, and countForEach's 0
// is mapped back to NULL.
func buildUnitSQL(unit *coreUnit, metricNames []string, dataStart, dataEnd int64, startMs, endMs, stepMs, lookbackMs int64) (string, []any, error) {
selStart := startMs - unit.offsetMs
selEnd := endMs - unit.offsetMs
stepSec := stepMs / 1000
if stepSec == 0 {
// Instant query: start == end, so the grid has one point for any
// positive step.
stepSec = 1
}
windowMs := unit.rangeMs
if unit.kind == unitInstant {
windowMs = lookbackMs
}
windowSec := windowMs / 1000
adjustedTsStartU, _, _, localTsTable := metricstelemetryschema.WhichTSTableToUse(uint64(dataStart), uint64(dataEnd), false, nil)
adjustedTsStart := int64(adjustedTsStartU)
keyNames := groupKeyColumns(unit)
// seriesSub computes fingerprint -> group key columns. It reads the
// local series table when it rides inside the shard-rewritten samples
// query, and the distributed one when it joins at the initiator
// (windowed form).
seriesSub := func(table string) (string, []any, error) {
sub := sqlbuilder.NewSelectBuilder()
selects := []string{"fingerprint"}
if keyNames == nil {
selects = append(selects, groupKeyExpr(unit)+" AS gkey")
} else {
// by (...) grouping extracts exactly the listed labels as plain
// columns: no reason to build, sort and stringify every label
// pair per row when the projection is a known short list and
// the label names live in Go anyway.
for i, name := range keyNames {
selects = append(selects, fmt.Sprintf("JSONExtractString(labels, %s) AS g%d", sub.Var(name), i))
}
}
sub.Select(selects...)
sub.From(fmt.Sprintf("%s.%s", metricstelemetryschema.DBName, table))
if err := applySeriesConditions(sub, adjustedTsStart, dataEnd, unit.matchers); err != nil {
return "", nil, err
}
sub.GroupBy(append([]string{"fingerprint"}, keyColumnAliases(keyNames)...)...)
q, args := sub.BuildWithFlavor(sqlbuilder.ClickHouse)
return q, args, nil
}
// samplesConditions adds the samples-side WHERE. The group-key join
// restricts to the matched series; no fingerprint condition is added
// here.
samplesConditions := func(sb *sqlbuilder.SelectBuilder, excludeStale bool) {
switch len(metricNames) {
case 0:
// No name constraint derivable; correct but unable to use the
// metric_name primary-key prefix.
case 1:
sb.Where(sb.EQ("metric_name", metricNames[0]))
default:
sb.Where(sb.In("metric_name", sqlbuilder.List(metricNames)))
}
// temporality precedes metric_name in the samples primary key; the
// fingerprints already come from these temporalities, so this only
// helps granule pruning.
sb.Where("temporality IN ['Cumulative', 'Unspecified']")
// When the window is narrower than the step, the grid windows
// (t_k window, t_k] cover only window/step of the selector's
// timeline; a sample in a gap belongs to no window and cannot move
// any grid point, but the grid aggregate buffers every row it is
// fed. Keeping only in-window rows cut a 36k-series 1w rate from
// 74s/28GiB to 16s/4.3GiB on fleet data — the read stays the same,
// the aggregate input shrinks by the coverage ratio. The lattice
// anchors at selStart (end may sit off-lattice on unaligned grids),
// positiveModulo because samples above selStart make the dividend
// negative, and the upper bound tightens to the last grid point —
// rows past it are equally windowless. window >= step tiles the
// timeline and keeps today's plain bounds.
sliver := stepMs > 0 && windowMs > 0 && windowMs < stepMs
upper := selEnd
if sliver {
upper = selStart + (selEnd-selStart)/stepMs*stepMs
}
// Left-open window: a sample exactly at the window's lower boundary
// is never used (range selectors and lookback are both left-open).
sb.Where(sb.GT("unix_milli", selStart-windowMs), sb.LTE("unix_milli", upper))
if sliver {
sb.Where(fmt.Sprintf("positiveModulo(%s - unix_milli, %s) < %s",
sb.Var(selStart), sb.Var(stepMs), sb.Var(windowMs)))
}
if excludeStale {
// PromQL excludes stale markers from range vectors. Instant
// selectors need the stale rows for shadowing instead.
sb.Where("bitAnd(flags, 1) = 0")
}
}
keyCols := keyColumnAliases(keyNames)
// joinedInner builds the shard-side SELECT for the single-pass kinds:
// grid expression per (fingerprint, group key), group-key join against
// the local series table.
joinedInner := func(gridExpr string, excludeStale bool) (string, []any, error) {
seriesSQL, seriesArgs, err := seriesSub(localTsTable)
if err != nil {
return "", nil, err
}
sb := sqlbuilder.NewSelectBuilder()
selects := make([]string, 0, len(keyCols)+1)
// A fingerprint is the hash of one labelset, so every group-key
// column is functionally dependent on it: any() is exact, and
// grouping by the fingerprint alone spares hashing the joined
// string per sample row — measured -10-13% on a 1.9B-row rate.
for _, col := range keyCols {
selects = append(selects, fmt.Sprintf("any(series.%s) AS %s", col, col))
}
sb.Select(append(selects, gridExpr+" AS grid")...)
sb.From(fmt.Sprintf("%s.%s AS points", metricstelemetryschema.DBName, metricstelemetryschema.SamplesV4TableName))
sb.JoinWithOption(sqlbuilder.InnerJoin, fmt.Sprintf("(%s) AS series", seriesSQL), "points.fingerprint = series.fingerprint")
samplesConditions(sb, excludeStale)
sb.GroupBy("points.fingerprint")
q, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
// The join text renders before WHERE: its args come first.
return q, append(seriesArgs, args...), nil
}
var inner string
var innerArgs []any
var err error
switch unit.kind {
case unitInstant:
// Instant selection with stale shadowing: the grid value is the last
// non-stale sample in (t-lookback, t], absent when the overall last
// sample in that window is a stale marker (verified semantics: the
// -If combinator applies to the grid aggregates, and NULL comparisons
// make a stale-latest point absent).
gridParams := fmt.Sprintf("(fromUnixTimestamp64Milli(%d), fromUnixTimestamp64Milli(%d), %d, %d)", selStart, selEnd, stepSec, windowSec)
gridExpr := fmt.Sprintf(
"arrayMap((tall, tok, vok) -> if(tall IS NULL OR tok IS NULL OR tall != tok, NULL, vok), timeSeriesLastToGrid%s(fromUnixTimestamp64Milli(unix_milli), toFloat64(unix_milli)), timeSeriesLastToGridIf%s(fromUnixTimestamp64Milli(unix_milli), toFloat64(unix_milli), bitAnd(flags, 1) = 0), timeSeriesLastToGridIf%s(fromUnixTimestamp64Milli(unix_milli), value, bitAnd(flags, 1) = 0))",
gridParams, gridParams, gridParams,
)
inner, innerArgs, err = joinedInner(gridExpr, false)
case unitOverTime:
if unit.overFn == "last" {
// last_over_time == last non-stale sample in the window: the
// stale rows are already excluded in WHERE.
gridExpr := fmt.Sprintf(
"timeSeriesLastToGrid(fromUnixTimestamp64Milli(%d), fromUnixTimestamp64Milli(%d), %d, %d)(fromUnixTimestamp64Milli(unix_milli), value)",
selStart, selEnd, stepSec, windowSec,
)
inner, innerArgs, err = joinedInner(gridExpr, true)
break
}
inner, innerArgs, err = windowedInner(unit, samplesConditions, seriesSub, keyCols, localTsTable, selStart, selEnd, stepMs, windowMs)
default: // unitRange
gridExpr := fmt.Sprintf(
"%s(fromUnixTimestamp64Milli(%d), fromUnixTimestamp64Milli(%d), %d, %d)(fromUnixTimestamp64Milli(unix_milli), value)",
gridFunction[unit.fn], selStart, selEnd, stepSec, windowSec,
)
if unit.fn == fnIncrease {
// increase == rate * range-seconds, exactly: extrapolatedRate
// divides by the range only when isRate.
gridExpr = fmt.Sprintf("arrayMap(x -> x * %d, %s)", windowSec, gridExpr)
}
inner, innerArgs, err = joinedInner(gridExpr, true)
}
if err != nil {
return "", nil, err
}
spatial := "maxForEach(grid)"
switch {
case !unit.hasAgg:
// Per-series output: one row per (labels-minus-__name__) group.
// Distinct fingerprints can collapse onto the same projected label
// set only via a regex __name__ selector over metrics with identical
// other labels; maxForEach is a deterministic NULL-skipping merge and
// the identity for the overwhelmingly common one-fingerprint group.
case unit.aggOp.String() == "count":
// count over an all-absent index is an absent point, not 0.
spatial = "arrayMap(c -> if(c = 0, NULL, toFloat64(c)), countForEach(grid))"
default:
spatial = fmt.Sprintf("%s(grid)", aggForEach[unit.aggOp.String()])
}
keyList := strings.Join(keyCols, ", ")
query := fmt.Sprintf("SELECT %s, %s AS grid FROM (%s) GROUP BY %s %s", keyList, spatial, inner, keyList, gridFunctionsSetting)
return query, innerArgs, nil
}
// groupKeyColumns returns the label names to extract as plain group-key
// columns, or nil when the unit needs the canonical JSON key instead. Only
// by (...) grouping qualifies: its projection is a known short list, so
// extracting each label directly beats building, sorting and stringifying
// every label pair per row. without and no-aggregation project a label SET
// that varies per series — there the sorted-JSON key is load-bearing: the
// sort is what makes two fingerprints with different stored JSON key order
// land in one group, and the string carries the labels back out.
func groupKeyColumns(unit *coreUnit) []string {
if unit.hasAgg && unit.by && len(unit.grouping) > 0 {
return unit.grouping
}
return nil
}
// keyColumnAliases names the group-key columns in every SELECT level: g0..gN
// for direct extraction, the single canonical gkey otherwise.
func keyColumnAliases(keyNames []string) []string {
if keyNames == nil {
return []string{"gkey"}
}
cols := make([]string, len(keyNames))
for i := range keyNames {
cols[i] = fmt.Sprintf("g%d", i)
}
return cols
}
// windowedInner builds the avg/min/max/sum/count _over_time form without
// fanning samples out. It runs only when the range is a whole multiple of
// the step (see the transpile gate), because then the window
// (t_k - range, t_k] is exactly the union of W = range/step step buckets —
// both are left-open on the same boundaries — so bucket membership fully
// determines window membership. Fanning each sample into all W windows it
// covers (ARRAY JOIN) multiplies rows by W, which at long ranges over short
// steps is a row explosion measured in billions.
//
// The bucketing itself is the -Resample combinator: one group per (series,
// group key) whose state is a fixed array of per-bucket aggregates, updated
// in place per sample. Grouping by (series, bucket) instead — measured on a
// 100k-series x 371-bucket workload — creates a 37M-entry hash aggregation
// whose per-thread partial tables scale memory WITH max_threads (12 -> 48
// GiB from 2 to 8 threads, dead at 16) and ships one row per group to the
// initiator; the Resample form carries the same numbers in 100k compact
// array states, like every other unit kind.
//
// The wrapper level slides the window: slot k combines buckets k..k+W-1 by
// direct aggregation over at most W partials — no prefix-sum tricks, so no
// large-minus-large cancellation against the engine's directly-summed
// windows. A slot with zero window count is absent, which also keeps
// min/max honest: their slices filter on the bucket counts, so an empty
// bucket's zero-fill can never be mistaken for a value (a real sample can
// legitimately be 0 or +Inf).
func windowedInner(unit *coreUnit, samplesConditions func(*sqlbuilder.SelectBuilder, bool), seriesSub func(string) (string, []any, error), keyCols []string, localSeriesTable string, selStart, selEnd, stepMs, windowMs int64) (string, []any, error) {
effStepMs := stepMs
if effStepMs == 0 {
effStepMs = 1000
}
lastIdx := (selEnd - selStart) / effStepMs
gridLen := lastIdx + 1
w := windowMs / effStepMs
bucketLen := gridLen + w
// A window narrower than the step makes the windows (t_k - range, t_k]
// pairwise disjoint: there is nothing to slide, each slot reads exactly
// its own window's aggregate. This is exact ONLY over sliver-filtered
// rows (samplesConditions adds the window<step predicate): the index
// below assigns every gap sample to the window above it, and the filter
// is what removes them. Requires a real step — instant queries carry no
// sliver filter, so they keep the tiled form and its gates.
disjoint := stepMs > 0 && windowMs < stepMs
if disjoint {
w = 1
bucketLen = gridLen
}
seriesSQL, seriesArgs, err := seriesSub(localSeriesTable)
if err != nil {
return "", nil, err
}
// Bucket index, shifted so the earliest in-window sample lands at 0:
// jj = ceil((ts - selStart)/step) + W - 1, folded into one intDiv. Slot
// k's window is then buckets jj in [k, k+W-1]. In the disjoint form the
// same ceil lands each in-window sample directly on its slot (W = 1),
// and the numerator stays positive: the fetch floor is
// selStart - range > selStart - step.
jjShift := windowMs
if disjoint {
jjShift = effStepMs
}
jj := fmt.Sprintf("intDiv(unix_milli - %d + %d - 1, %d)", selStart, jjShift, effStepMs)
buckets := sqlbuilder.NewSelectBuilder()
selects := make([]string, 0, len(keyCols)+2)
// any() over the group key: exact because the key is functionally
// dependent on the fingerprint (see joinedInner).
for _, col := range keyCols {
selects = append(selects, fmt.Sprintf("any(series.%s) AS %s", col, col))
}
selects = append(selects, fmt.Sprintf("countResample(0, %d, 1)(value, %s) AS cnts", bucketLen, jj))
if unit.overFn != "count" {
selects = append(selects, fmt.Sprintf("%sResample(0, %d, 1)(value, %s) AS vals", map[string]string{
"avg": "sum",
"sum": "sum",
"min": "min",
"max": "max",
}[unit.overFn], bucketLen, jj))
}
buckets.Select(selects...)
buckets.From(fmt.Sprintf("%s.%s AS points", metricstelemetryschema.DBName, metricstelemetryschema.SamplesV4TableName))
buckets.JoinWithOption(sqlbuilder.InnerJoin, fmt.Sprintf("(%s) AS series", seriesSQL), "points.fingerprint = series.fingerprint")
samplesConditions(buckets, true)
buckets.GroupBy("points.fingerprint")
bucketsSQL, bucketsArgs := buckets.BuildWithFlavor(sqlbuilder.ClickHouse)
windowCnt := fmt.Sprintf("arraySum(arraySlice(cnts, k + 1, %d))", w)
var slot string
switch unit.overFn {
case "count":
slot = fmt.Sprintf("if(%s = 0, NULL, toFloat64(%s))", windowCnt, windowCnt)
case "sum":
slot = fmt.Sprintf("if(%s = 0, NULL, arraySum(arraySlice(vals, k + 1, %d)))", windowCnt, w)
case "avg":
slot = fmt.Sprintf("if(%s = 0, NULL, arraySum(arraySlice(vals, k + 1, %d)) / %s)", windowCnt, w, windowCnt)
case "min":
slot = fmt.Sprintf("if(%s = 0, NULL, arrayMin(arrayFilter((v, c) -> c > 0, arraySlice(vals, k + 1, %d), arraySlice(cnts, k + 1, %d))))", windowCnt, w, w)
case "max":
slot = fmt.Sprintf("if(%s = 0, NULL, arrayMax(arrayFilter((v, c) -> c > 0, arraySlice(vals, k + 1, %d), arraySlice(cnts, k + 1, %d))))", windowCnt, w, w)
}
keyList := strings.Join(keyCols, ", ")
inner := fmt.Sprintf(
"SELECT %s, arrayMap(k -> %s, range(toUInt64(%d))) AS grid FROM (%s)",
keyList, slot, gridLen, bucketsSQL,
)
return inner, append(seriesArgs, bucketsArgs...), nil
}
// groupKeyExpr renders the canonical JSON group key for the units whose
// projected label SET varies per series (see groupKeyColumns): the sorted
// [key, value] pairs of the projected labels, JSON-encoded.
// - by () with no labels: one constant group;
// - without (a, b): keep everything except the listed labels and __name__;
// - no aggregation: keep everything including __name__ — even when the
// unit drops the name from its OUTPUT, the key must keep it so distinct
// metrics never merge in SQL; executeUnit strips the name afterwards and
// turns a post-strip collision into the engine's duplicate-labelset
// error instead of a silently invented merge.
func groupKeyExpr(unit *coreUnit) string {
// An empty label value means "label absent" in Prometheus; the stored
// labels JSON can carry empty attribute values, which must not become
// output labels or group keys.
pairs := "arraySort(JSONExtractKeysAndValues(labels, 'String'))"
if !unit.hasAgg {
return fmt.Sprintf("toJSONString(arrayFilter(p -> p.2 != '', %s))", pairs)
}
if unit.by {
// Non-empty by (...) never reaches here; groupKeyColumns extracts
// those labels as plain columns instead.
return "'[]'"
}
excluded := append([]string{metricNameLabel}, unit.grouping...)
return fmt.Sprintf("toJSONString(arrayFilter(p -> p.2 != '' AND p.1 NOT IN (%s), %s))", quotedList(excluded), pairs)
}
func quotedList(items []string) string {
quoted := make([]string, len(items))
for i, s := range items {
quoted[i] = "'" + strings.ReplaceAll(s, "'", "\\'") + "'"
}
return strings.Join(quoted, ", ")
}

View File

@@ -1,751 +0,0 @@
package clickhouseprometheusv2
import (
"context"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
cmock "github.com/SigNoz/clickhouse-go-mock"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/prometheus"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest"
"github.com/prometheus/prometheus/model/labels"
"github.com/prometheus/prometheus/promql"
"github.com/prometheus/prometheus/promql/parser"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func newTestClient(t *testing.T) (*client, *telemetrystoretest.Provider) {
t.Helper()
store := telemetrystoretest.New(telemetrystore.Config{Provider: "clickhouse"}, sqlmock.QueryMatcherRegexp)
settings := factory.NewScopedProviderSettings(instrumentationtest.New().ToProviderSettings(), "clickhouseprometheusv2_test")
return newClient(settings, store, prometheus.Config{}), store
}
var seriesCols = []cmock.ColumnType{
{Name: "fingerprint", Type: "UInt64"},
{Name: "labels", Type: "String"},
}
func parse(t *testing.T, q string) parser.Expr {
t.Helper()
expr, err := parser.NewParser(parser.Options{}).ParseExpr(q)
require.NoError(t, err)
return expr
}
func TestClassifyFullShapes(t *testing.T) {
tests := []struct {
name string
query string
check func(t *testing.T, u *coreUnit)
}{
{
name: "sum by rate",
query: `sum by (pod) (rate(http_requests_total{job="api"}[5m]))`,
check: func(t *testing.T, u *coreUnit) {
assert.Equal(t, fnRate, u.fn)
assert.Equal(t, int64(300_000), u.rangeMs)
assert.True(t, u.hasAgg)
assert.True(t, u.by)
assert.Equal(t, []string{"pod"}, u.grouping)
},
},
{
name: "bare increase with offset",
query: `increase(errors_total[10m] offset 30m)`,
check: func(t *testing.T, u *coreUnit) {
assert.Equal(t, fnIncrease, u.fn)
assert.Equal(t, int64(1_800_000), u.offsetMs)
assert.False(t, u.hasAgg)
},
},
{
name: "avg without over delta",
query: `avg without (instance) (delta(gauge_metric[15m]))`,
check: func(t *testing.T, u *coreUnit) {
assert.Equal(t, fnDelta, u.fn)
assert.True(t, u.hasAgg)
assert.False(t, u.by)
},
},
{
name: "scalar pipeline with comparison",
query: `sum(rate(x[5m])) * 100 > 5`,
check: func(t *testing.T, u *coreUnit) {
require.Len(t, u.ops, 2)
assert.Equal(t, parser.ItemType(parser.MUL), u.ops[0].op)
assert.Equal(t, 100.0, u.ops[0].scalar)
assert.Equal(t, parser.ItemType(parser.GTR), u.ops[1].op)
},
},
{
name: "scalar on left with unary minus",
query: `-1 * sum(rate(x[5m]))`,
check: func(t *testing.T, u *coreUnit) {
require.Len(t, u.ops, 1)
assert.True(t, u.ops[0].scalarOnLeft)
assert.Equal(t, -1.0, u.ops[0].scalar)
},
},
{
name: "bool comparison",
query: `sum(rate(x[5m])) >= bool 0.5`,
check: func(t *testing.T, u *coreUnit) {
require.Len(t, u.ops, 1)
assert.True(t, u.ops[0].returnBool)
},
},
{
name: "irate utf8 name",
query: `sum by ("k8s.pod.name") (irate({"k8s.container.cpu.time"}[2m]))`,
check: func(t *testing.T, u *coreUnit) {
assert.Equal(t, fnIRate, u.fn)
assert.Equal(t, []string{"k8s.pod.name"}, u.grouping)
},
},
{
name: "bare instant selector keeps name",
query: `up{job="api"}`,
check: func(t *testing.T, u *coreUnit) {
assert.Equal(t, unitInstant, u.kind)
assert.True(t, u.keepsName())
},
},
{
name: "gauge aggregation",
query: `sum by (pod) (container_memory offset 5m)`,
check: func(t *testing.T, u *coreUnit) {
assert.Equal(t, unitInstant, u.kind)
assert.Equal(t, int64(300_000), u.offsetMs)
assert.True(t, u.hasAgg)
assert.False(t, u.keepsName())
},
},
{
name: "gauge comparison keeps name",
query: `container_memory > 100`,
check: func(t *testing.T, u *coreUnit) {
assert.Equal(t, unitInstant, u.kind)
assert.True(t, u.keepsName())
},
},
{
name: "gauge arithmetic drops name",
query: `container_memory / 1024`,
check: func(t *testing.T, u *coreUnit) {
assert.Equal(t, unitInstant, u.kind)
assert.False(t, u.keepsName())
},
},
{
name: "avg_over_time",
query: `max by (node) (avg_over_time(load1[10m]))`,
check: func(t *testing.T, u *coreUnit) {
assert.Equal(t, unitOverTime, u.kind)
assert.Equal(t, "avg", u.overFn)
assert.Equal(t, int64(600_000), u.rangeMs)
},
},
{
name: "last_over_time keeps name",
query: `last_over_time(load1[10m])`,
check: func(t *testing.T, u *coreUnit) {
assert.Equal(t, unitOverTime, u.kind)
assert.Equal(t, "last", u.overFn)
assert.True(t, u.keepsName())
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
plan, ok := classify(parse(t, tt.query), testGrid(60_000))
require.True(t, ok, "expected transpilable")
require.True(t, plan.full, "expected full compilation")
require.Len(t, plan.units, 1)
tt.check(t, &plan.units[0].core)
})
}
}
func TestClassifyFallbackShapes(t *testing.T) {
queries := []struct {
name string
query string
step int64
}{
{"default-resolution subquery", `max_over_time(rate(x[5m])[30m:])`, 60_000},
{"at modifier", `sum(rate(x[5m] @ 1609746000))`, 60_000},
{"at modifier on gauge", `sum(container_memory @ 1609746000)`, 60_000},
{"sub-second step", `sum(rate(x[5m]))`, 500},
{"sub-second range", `sum(rate(x[1500ms]))`, 60_000},
{"by __name__ full", `sum by (__name__) (rate({__name__=~"a|b"}[5m]))`, 60_000},
{"quantile_over_time unsupported", `quantile_over_time(0.9, load1[10m])`, 60_000},
// Duration expressions resolve into the selectors' static fields only
// at evaluation time; classification reads those fields as zero, so
// transpiling would silently use the wrong offset (caught by the
// conformance corpus' duration_expression.test cases). Offset
// expressions parse without the experimental-parser flag, so they do
// reach the transpiler; range-position expressions are rejected at
// parse (the RangeExpr/StepExpr guards are defense-in-depth).
{"duration expression offset on instant", `x offset step()`, 60_000},
{"duration expression offset arithmetic", `x offset -step()*2`, 60_000},
{"duration expression offset on range", `sum(rate(x[5m] offset max(3s, step())))`, 60_000},
{"duration expression subquery step", `max_over_time(rate(x[5m])[30m:step()])`, 60_000},
}
for _, tt := range queries {
t.Run(tt.name, func(t *testing.T) {
_, ok := classify(parse(t, tt.query), testGrid(tt.step))
assert.False(t, ok, "expected fallback for %s", tt.query)
})
}
}
func TestClassifyHybridShapes(t *testing.T) {
tests := []struct {
name string
query string
wantUnits int
wantRewritten string
}{
{
name: "histogram quantile",
query: `histogram_quantile(0.95, sum by (le) (rate(http_bucket[5m])))`,
wantUnits: 1,
wantRewritten: `histogram_quantile(0.95, __signoz_transpiled_0__)`,
},
{
name: "topk over compiled",
query: `topk(5, sum by (pod) (rate(x[5m])))`,
wantUnits: 1,
wantRewritten: `topk(5, __signoz_transpiled_0__)`,
},
{
name: "ratio of compiled units",
query: `sum(rate(a[5m])) / sum(rate(b[5m]))`,
wantUnits: 2,
wantRewritten: `__signoz_transpiled_0__ / __signoz_transpiled_1__`,
},
{
name: "or vector zero",
query: `sum(rate(a[5m])) or vector(0)`,
wantUnits: 1,
wantRewritten: `__signoz_transpiled_0__ or vector(0)`,
},
{
name: "quantile agg over compiled rate",
query: `quantile(0.9, rate(x[5m]))`,
wantUnits: 1,
wantRewritten: `quantile(0.9, __signoz_transpiled_0__)`,
},
{
name: "non-literal scalar side stays engine-side",
query: `sum(rate(x[5m])) * scalar(y)`,
wantUnits: 1,
wantRewritten: `__signoz_transpiled_0__ * scalar(y)`,
},
{
name: "compiled mixed with raw selector",
query: `sum by (pod) (rate(a[5m])) / on (pod) group_left () b`,
wantUnits: 1,
wantRewritten: `__signoz_transpiled_0__ / on (pod) group_left () b`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
plan, ok := classify(parse(t, tt.query), testGrid(60_000))
require.True(t, ok)
assert.False(t, plan.full)
assert.Len(t, plan.units, tt.wantUnits)
assert.Equal(t, tt.wantRewritten, plan.rewritten)
})
}
}
func TestClassifyHybridGuards(t *testing.T) {
t.Run("no substitution under on(__name__)", func(t *testing.T) {
plan, ok := classify(parse(t, `sum(rate(a[5m])) * on (__name__) b`), testGrid(60_000))
_ = plan
assert.False(t, ok, "matching on __name__ must not see synthetic names")
})
t.Run("no substitution inside @-pinned subquery", func(t *testing.T) {
_, ok := classify(parse(t, `max_over_time(rate(x[5m])[30m:1m] @ 1609746000)`), testGrid(60_000))
assert.False(t, ok)
})
}
// The alert-smoothing idiom: units inside a fixed-resolution subquery
// evaluate on the subquery grid — epoch-aligned multiples of the resolution,
// starting strictly after (outer start - range), exactly as the engine
// derives it.
func TestClassifySubqueryUnits(t *testing.T) {
grid := gridContext{startMs: 1_700_000_030_000, endMs: 1_700_007_200_000, stepMs: 60_000}
plan, ok := classify(parse(t, `min_over_time((sum by (ns) (increase(x[5m])))[10m:5m]) > 0`), grid)
require.True(t, ok)
require.False(t, plan.full)
require.Len(t, plan.units, 1)
assert.Equal(t, `min_over_time(__signoz_transpiled_0__[10m:5m]) > 0`, plan.rewritten)
unit := plan.units[0]
// lower bound = outer start - range = 1_699_999_430_000; first multiple
// of 300_000 strictly greater is 1_699_999_500_000.
assert.Equal(t, int64(1_699_999_500_000), unit.grid.startMs)
assert.Equal(t, grid.endMs, unit.grid.endMs)
assert.Equal(t, int64(300_000), unit.grid.stepMs)
assert.Equal(t, fnIncrease, unit.core.fn)
t.Run("subquery offset shifts the grid", func(t *testing.T) {
plan, ok := classify(parse(t, `max_over_time((sum(rate(x[5m])))[10m:5m] offset 30m)`), grid)
require.True(t, ok)
require.Len(t, plan.units, 1)
// lower = start - offset - range = 1_699_997_630_000 -> first
// multiple of 300_000 above = 1_699_997_700_000; end shifts too.
assert.Equal(t, int64(1_699_997_700_000), plan.units[0].grid.startMs)
assert.Equal(t, grid.endMs-1_800_000, plan.units[0].grid.endMs)
})
t.Run("mollusk ratio-inside-subquery idiom", func(t *testing.T) {
q := `min_over_time(((sum by (a) (rate(m1[5m]))) / (avg by (a) (m2)))[5m:1m])`
plan, ok := classify(parse(t, q), grid)
require.True(t, ok)
// Both sides compile on the subquery grid: the rate side and the
// gauge aggregation side; the engine joins them and smooths.
require.Len(t, plan.units, 2)
assert.Equal(t, int64(60_000), plan.units[0].grid.stepMs)
assert.Equal(t, unitInstant, plan.units[1].core.kind)
assert.Contains(t, plan.rewritten, `__signoz_transpiled_0__ / __signoz_transpiled_1__`)
})
}
func TestBuildUnitSQL(t *testing.T) {
unit := &coreUnit{
fn: fnRate,
rangeMs: 300_000,
hasAgg: true,
aggOp: parser.SUM,
by: true,
grouping: []string{"pod"},
matchers: []*labels.Matcher{mustMatcher(t, labels.MatchEqual, "__name__", "http_requests_total")},
}
sql, args, err := buildUnitSQL(unit, []string{"http_requests_total"}, 1_699_999_700_000, 1_700_003_600_000, 1_700_000_000_000, 1_700_003_600_000, 60_000, 300_000)
require.NoError(t, err)
assert.Contains(t, sql, "timeSeriesRateToGrid(fromUnixTimestamp64Milli(1700000000000), fromUnixTimestamp64Milli(1700003600000), 60, 300)(fromUnixTimestamp64Milli(unix_milli), value)")
assert.Contains(t, sql, "unix_milli > ? AND unix_milli <= ?")
assert.Contains(t, sql, "bitAnd(flags, 1) = 0")
assert.Contains(t, sql, "sumForEach(grid)")
// The group-key join rides inside the shard query: distributed samples
// at the top level, the local series table in the join subquery, the
// grid aggregation grouped per (fingerprint, group key) shard-side.
assert.Contains(t, sql, "FROM signoz_metrics.distributed_samples_v4 AS points INNER JOIN (SELECT fingerprint,")
assert.Contains(t, sql, "FROM signoz_metrics.time_series_v4 WHERE")
// The group key is functionally dependent on the fingerprint (one
// labelset per fingerprint): any() is exact and the per-row hash key
// shrinks to the fingerprint alone.
assert.Contains(t, sql, "any(series.g0) AS g0")
assert.Contains(t, sql, "GROUP BY points.fingerprint)")
// No samples-side fingerprint condition: the group-key join restricts.
assert.NotContains(t, sql, "points.fingerprint IN (")
// by (pod) extracts the grouped label directly — no per-row JSON
// build/sort/stringify for a known projection.
assert.Contains(t, sql, "JSONExtractString(labels, ?) AS g0")
assert.NotContains(t, sql, "toJSONString")
assert.Contains(t, sql, "SETTINGS allow_experimental_ts_to_grid_aggregate_function = 1")
// Args follow placeholder order: the joined series subquery renders
// before the samples WHERE, and its select list ('pod') renders before
// its own conditions.
assert.Equal(t, []any{"pod", "http_requests_total", int64(1_699_999_200_000), int64(1_700_003_600_000), "http_requests_total", int64(1_699_999_700_000), int64(1_700_003_600_000)}, args)
}
func TestBuildUnitSQLIncreaseAndOffset(t *testing.T) {
unit := &coreUnit{
fn: fnIncrease,
rangeMs: 600_000,
offsetMs: 1_800_000,
matchers: []*labels.Matcher{mustMatcher(t, labels.MatchEqual, "__name__", "errors_total")},
}
sql, _, err := buildUnitSQL(unit, nil, 1_699_997_600_000, 1_700_001_800_000, 1_700_000_000_000, 1_700_003_600_000, 60_000, 300_000)
require.NoError(t, err)
// Grid and window shift by the offset; increase multiplies rate by the
// range in seconds.
assert.Contains(t, sql, "fromUnixTimestamp64Milli(1699998200000), fromUnixTimestamp64Milli(1700001800000)")
assert.Contains(t, sql, "arrayMap(x -> x * 600, timeSeriesRateToGrid")
assert.Contains(t, sql, "maxForEach(grid)")
}
func TestBuildUnitSQLOverLimitJoinOnly(t *testing.T) {
// Past the inline limit no fingerprint filter is rendered: the series
// join restricts to the matched fingerprints on its own.
unit := &coreUnit{
fn: fnRate,
rangeMs: 300_000,
hasAgg: true,
aggOp: parser.SUM,
by: true,
matchers: []*labels.Matcher{mustMatcher(t, labels.MatchEqual, "__name__", "http_requests_total")},
}
sql, _, err := buildUnitSQL(unit, []string{"http_requests_total"}, 1_699_999_700_000, 1_700_003_600_000, 1_700_000_000_000, 1_700_003_600_000, 60_000, 300_000)
require.NoError(t, err)
assert.NotContains(t, sql, "points.fingerprint IN")
assert.Contains(t, sql, "INNER JOIN (SELECT fingerprint,")
assert.Contains(t, sql, "FROM signoz_metrics.time_series_v4 WHERE")
}
func TestBuildUnitSQLWindowSliver(t *testing.T) {
// rate[5m] on a 30m grid evaluates only a 5m sliver before each grid
// point — samples in the gaps belong to no window and would only be
// buffered by the grid aggregate. The WHERE must keep exactly the
// in-window rows: positiveModulo anchored at the selector start (end
// can sit off-lattice on unaligned grids, and samples above the start
// make the plain modulo dividend negative), and the scan capped at the
// last grid point — rows past it are equally windowless.
unit := &coreUnit{
fn: fnRate,
rangeMs: 300_000,
hasAgg: true,
aggOp: parser.SUM,
by: true,
grouping: []string{"pod"},
matchers: []*labels.Matcher{mustMatcher(t, labels.MatchEqual, "__name__", "http_requests_total")},
}
sql, args, err := buildUnitSQL(unit, []string{"http_requests_total"}, 1_699_999_700_000, 1_700_003_600_000, 1_700_000_000_000, 1_700_003_600_000, 1_800_000, 300_000)
require.NoError(t, err)
assert.Contains(t, sql, "positiveModulo(? - unix_milli, ?) < ?")
assert.Equal(t, []any{"pod", "http_requests_total", int64(1_699_999_200_000), int64(1_700_003_600_000), "http_requests_total", int64(1_699_999_700_000), int64(1_700_003_600_000), int64(1_700_000_000_000), int64(1_800_000), int64(300_000)}, args)
t.Run("off-lattice end caps the scan at the last grid point", func(t *testing.T) {
// end - start = 50m at a 30m step: the only grid points are start
// and start+30m; samples in the trailing 20m serve no window.
_, args, err := buildUnitSQL(unit, []string{"http_requests_total"}, 1_699_999_700_000, 1_700_003_000_000, 1_700_000_000_000, 1_700_003_000_000, 1_800_000, 300_000)
require.NoError(t, err)
assert.Contains(t, args, int64(1_700_001_800_000))
})
t.Run("window covering the step keeps plain bounds", func(t *testing.T) {
sql, _, err := buildUnitSQL(unit, []string{"http_requests_total"}, 1_699_999_700_000, 1_700_003_600_000, 1_700_000_000_000, 1_700_003_600_000, 60_000, 300_000)
require.NoError(t, err)
assert.NotContains(t, sql, "positiveModulo")
})
}
func TestBuildUnitSQLWindowedBucketsWithoutFanOut(t *testing.T) {
// The window is W = range/step whole buckets, so each sample lands in
// exactly one bucket via GROUP BY and the window slides over bucket
// partials — fanning samples into every covered window (ARRAY JOIN)
// multiplies rows by W, a row explosion at long ranges.
unit := &coreUnit{
kind: unitOverTime,
overFn: "avg",
rangeMs: 600_000,
matchers: []*labels.Matcher{mustMatcher(t, labels.MatchEqual, "__name__", "node_load1")},
}
sql, _, err := buildUnitSQL(unit, []string{"node_load1"}, 1_699_999_400_000, 1_700_003_600_000, 1_700_000_000_000, 1_700_003_600_000, 60_000, 600_000)
require.NoError(t, err)
assert.NotContains(t, sql, "ARRAY JOIN")
// One group per series with fixed per-bucket arrays (-Resample); the
// bucket index jj = ceil((ts - start)/step) + W - 1 folded into a single
// intDiv. Grouping by (series, bucket) instead measured 37M hash groups
// whose per-thread partials scale memory with max_threads.
assert.Contains(t, sql, "countResample(0, 71, 1)(value, intDiv(unix_milli - 1700000000000 + 600000 - 1, 60000)) AS cnts")
assert.Contains(t, sql, "sumResample(0, 71, 1)(value, intDiv(unix_milli - 1700000000000 + 600000 - 1, 60000)) AS vals")
assert.Contains(t, sql, "any(series.gkey) AS gkey")
assert.Contains(t, sql, "GROUP BY points.fingerprint)")
assert.NotContains(t, sql, "jj) AS jj")
assert.Contains(t, sql, "INNER JOIN (SELECT fingerprint,")
assert.Contains(t, sql, "FROM signoz_metrics.time_series_v4 WHERE")
// Slide: W = 10 buckets per slot, absent when the window count is 0.
assert.Contains(t, sql, "arraySum(arraySlice(cnts, k + 1, 10))")
assert.Contains(t, sql, "arraySum(arraySlice(vals, k + 1, 10))")
}
func TestBuildUnitSQLDisjointOverTime(t *testing.T) {
// avg_over_time[5m] on a 30m grid: the windows are pairwise disjoint,
// so there is no slide — one Resample bucket per grid slot, read
// directly. Exact only together with the window-sliver predicate, which
// removes the gap samples the ceil index would otherwise assign to the
// window above them.
unit := &coreUnit{
kind: unitOverTime,
overFn: "avg",
rangeMs: 300_000,
matchers: []*labels.Matcher{mustMatcher(t, labels.MatchEqual, "__name__", "node_load1")},
}
sql, _, err := buildUnitSQL(unit, []string{"node_load1"}, 1_699_999_700_000, 1_700_003_600_000, 1_700_000_000_000, 1_700_003_600_000, 1_800_000, 300_000)
require.NoError(t, err)
assert.NotContains(t, sql, "ARRAY JOIN")
// gridLen = 3 slots, bucket array the same length — no W tail.
assert.Contains(t, sql, "countResample(0, 3, 1)(value, intDiv(unix_milli - 1700000000000 + 1800000 - 1, 1800000)) AS cnts")
assert.Contains(t, sql, "sumResample(0, 3, 1)(value, intDiv(unix_milli - 1700000000000 + 1800000 - 1, 1800000)) AS vals")
// Single-bucket window: the slide degenerates to reading one slot.
assert.Contains(t, sql, "arraySum(arraySlice(cnts, k + 1, 1))")
// The sliver predicate is the correctness precondition of this form.
assert.Contains(t, sql, "positiveModulo(? - unix_milli, ?) < ?")
}
// TestDisjointWindowLattice brute-forces the disjoint-form arithmetic: a
// sample survives the sliver predicate exactly when some grid window
// contains it, and the ceil bucket index then lands it on that window's
// slot. This is the pure-Go mirror of the SQL expressions — the predicate
// in samplesConditions and jj in windowedInner — over random lattices,
// including off-lattice ends and samples beyond the last grid point.
func TestDisjointWindowLattice(t *testing.T) {
rng := func(seed *uint64) int64 {
*seed = *seed*6364136223846793005 + 1442695040888963407
return int64(*seed >> 33)
}
seed := uint64(42)
for trial := 0; trial < 2000; trial++ {
stepMs := 1_000 * (1 + rng(&seed)%3600)
windowMs := 1 + rng(&seed)%(stepMs-1) // strictly below the step
selStart := 1_700_000_000_000 + rng(&seed)%1_000_000
selEnd := selStart + rng(&seed)%(50*stepMs) // end may sit off-lattice
lastIdx := (selEnd - selStart) / stepMs
upper := selStart + lastIdx*stepMs
for i := 0; i < 50; i++ {
u := selStart - windowMs - stepMs + rng(&seed)%(selEnd-selStart+3*stepMs)
// Oracle: is u inside any window (t_k - window, t_k]?
inWindow := false
var slot int64 = -1
for k := int64(0); k <= lastIdx; k++ {
tk := selStart + k*stepMs
if u > tk-windowMs && u <= tk {
inWindow = true
slot = k
break
}
}
// The SQL: fetch bounds, then the sliver predicate
// positiveModulo(selStart - u, step) < window.
kept := u > selStart-windowMs && u <= upper
if kept {
pmod := (selStart - u) % stepMs
if pmod < 0 {
pmod += stepMs
}
kept = pmod < windowMs
}
require.Equal(t, inWindow, kept,
"sliver keep mismatch: u=%d selStart=%d step=%d window=%d", u, selStart, stepMs, windowMs)
if !kept {
continue
}
// jj = ceil((u - selStart)/step) via one intDiv; numerator is
// positive because u > selStart - window > selStart - step.
jj := (u - selStart + stepMs - 1) / stepMs
require.Equal(t, slot, jj,
"slot mismatch: u=%d selStart=%d step=%d window=%d", u, selStart, stepMs, windowMs)
}
}
}
func TestTryExecuteRange_WindowedGateFallsBack(t *testing.T) {
c, store := newTestClient(t)
e := &executor{client: c, parser: prometheus.NewParser()}
start := time.UnixMilli(1_700_000_000_000)
end := time.UnixMilli(1_700_003_600_000)
// 10m range at 90s step: the window is not a whole number of buckets.
_, ok, err := e.TryExecuteRange(context.Background(), `avg_over_time(up[10m])`, start, end, 90*time.Second)
require.NoError(t, err)
assert.False(t, ok, "range not divisible by step must not transpile")
// 1d range at 60s step: 1440 bucket combines per slot, over the cap.
_, ok, err = e.TryExecuteRange(context.Background(), `avg_over_time(up[1d])`, start, end, time.Minute)
require.NoError(t, err)
assert.False(t, ok, "range/step above maxWindowBuckets must not transpile")
// 1m range at 5m step: the windows are disjoint slivers — no
// divisibility or width requirement, so this transpiles.
store.Mock().ExpectQuery("SELECT fingerprint, any\\(labels\\)").WithArgs("up", int64(1_699_999_200_000), int64(1_700_003_600_000)).WillReturnRows(cmock.NewRows(seriesCols, [][]any{}))
_, ok, err = e.TryExecuteRange(context.Background(), `avg_over_time(up[1m])`, start, end, 5*time.Minute)
require.NoError(t, err)
assert.True(t, ok, "range below step is the disjoint form and must transpile")
}
func TestApplyScalarOps(t *testing.T) {
f := func(v float64) *float64 { return &v }
t.Run("arithmetic chain", func(t *testing.T) {
values := []*float64{f(2), nil, f(4)}
applyScalarOps([]scalarOp{{op: parser.MUL, scalar: 100}, {op: parser.ADD, scalar: 1}}, values)
require.NotNil(t, values[0])
assert.Equal(t, 201.0, *values[0])
assert.Nil(t, values[1])
assert.Equal(t, 401.0, *values[2])
})
t.Run("comparison filters points", func(t *testing.T) {
values := []*float64{f(1), f(10)}
applyScalarOps([]scalarOp{{op: parser.GTR, scalar: 5}}, values)
assert.Nil(t, values[0])
require.NotNil(t, values[1])
assert.Equal(t, 10.0, *values[1], "filter comparisons keep the original value")
})
t.Run("bool comparison emits 0/1", func(t *testing.T) {
values := []*float64{f(1), f(10)}
applyScalarOps([]scalarOp{{op: parser.GTR, scalar: 5, returnBool: true}}, values)
assert.Equal(t, 0.0, *values[0])
assert.Equal(t, 1.0, *values[1])
})
t.Run("scalar on left division", func(t *testing.T) {
values := []*float64{f(4)}
applyScalarOps([]scalarOp{{op: parser.DIV, scalar: 100, scalarOnLeft: true}}, values)
assert.Equal(t, 25.0, *values[0])
})
}
func TestLabelsFromGroupKey(t *testing.T) {
lset, err := labelsFromGroupKey(`[["pod","api-0"],["ns","prod"]]`)
require.NoError(t, err)
assert.Equal(t, "api-0", lset.Get("pod"))
assert.Equal(t, "prod", lset.Get("ns"))
empty, err := labelsFromGroupKey(`[]`)
require.NoError(t, err)
assert.True(t, empty.IsEmpty())
}
// testGrid is a 2h query grid ending on a round timestamp.
func testGrid(stepMs int64) gridContext {
return gridContext{startMs: 1_700_000_000_000, endMs: 1_700_007_200_000, stepMs: stepMs}
}
// A bool comparison returns 0/1, not the sample, so the engine drops
// __name__; keeping it would change downstream vector matching.
func TestKeepsName_BoolComparisonDropsName(t *testing.T) {
plan, ok := classify(parse(t, `up > bool 0`), testGrid(60_000))
require.True(t, ok)
assert.False(t, plan.units[0].core.keepsName())
plan, ok = classify(parse(t, `up > 0`), testGrid(60_000))
require.True(t, ok)
assert.True(t, plan.units[0].core.keepsName())
}
// timeSeriesLastToGrid widens its window to max(window, step) — probed on
// 25.12 — so Last-style units at window < step must fall back or they would
// resurrect samples the engine's lookback already dropped.
func TestTryExecuteRange_LastStyleWindowBelowStepTranspiles(t *testing.T) {
// These used to fall back because timeSeriesLastToGrid widens its window
// to max(window, step). Over sliver-filtered rows the widening is
// harmless — the widened window intersected with the data IS the
// lookback window — so the gate is gone and both shapes transpile. The
// mock returns no series: the point here is the routing, the value
// semantics are the parity suite's job.
c, store := newTestClient(t)
e := &executor{client: c, parser: prometheus.NewParser()}
start := time.UnixMilli(1_700_000_000_000)
end := time.UnixMilli(1_700_003_600_000)
store.Mock().ExpectQuery("SELECT fingerprint, any\\(labels\\)").WithArgs("up", int64(1_699_999_200_000), int64(1_700_003_600_000)).WillReturnRows(cmock.NewRows(seriesCols, [][]any{}))
_, ok, err := e.TryExecuteRange(context.Background(), `sum by (pod) (up)`, start, end, time.Hour)
require.NoError(t, err)
assert.True(t, ok, "instant selection at step > lookback must transpile")
store.Mock().ExpectQuery("SELECT fingerprint, any\\(labels\\)").WithArgs("up", int64(1_699_999_200_000), int64(1_700_003_600_000)).WillReturnRows(cmock.NewRows(seriesCols, [][]any{}))
_, ok, err = e.TryExecuteRange(context.Background(), `last_over_time(up[10m])`, start, end, time.Hour)
require.NoError(t, err)
assert.True(t, ok, "last_over_time at range < step must transpile")
}
// Two metrics collapsing onto one labelset after the name drop, with values
// on the same grid slot, is the engine's duplicate-labelset error; merging
// them would invent a series no engine would produce. (Temporally disjoint
// twins merge instead — see TestMergeSameLabelsetSeries.)
func TestExecuteUnit_NameCollisionErrors(t *testing.T) {
c, store := newTestClient(t)
e := &executor{client: c, parser: prometheus.NewParser()}
store.Mock().ExpectQuery("SELECT fingerprint, any\\(labels\\)").WithArgs("^(?:a|b)$", int64(1_699_999_200_000), int64(1_700_003_600_000)).WillReturnRows(cmock.NewRows(seriesCols, [][]any{
{uint64(1), `{"__name__":"a","job":"x"}`},
{uint64(2), `{"__name__":"b","job":"x"}`},
}))
store.Mock().ExpectQuery("SELECT gkey").
WithArgs("^(?:a|b)$", int64(1_699_999_200_000), int64(1_700_003_600_000), "a", "b", int64(1_699_999_700_000), int64(1_700_003_600_000)).
WillReturnRows(cmock.NewRows(gkeyCols, [][]any{
{`[["__name__","a"],["job","x"]]`, []*float64{f64(1)}},
{`[["__name__","b"],["job","x"]]`, []*float64{f64(2)}},
}))
plan, ok := classify(parse(t, `rate({__name__=~"a|b"}[5m])`), gridContext{startMs: 1_700_000_000_000, endMs: 1_700_003_600_000, stepMs: 60_000})
require.True(t, ok)
_, err := e.executeUnit(context.Background(), &plan.units[0].core, plan.units[0].grid)
require.Error(t, err)
assert.Contains(t, err.Error(), "vector cannot contain metrics with the same labelset")
}
var gkeyCols = []cmock.ColumnType{
{Name: "gkey", Type: "String"},
{Name: "grid", Type: "Array(Nullable(Float64))"},
}
func f64(v float64) *float64 { return &v }
// A nameless selector can span metrics whose series alternate in time (one
// dies inside the lookback before the other appears); after the name drop
// the engine merges them into ONE series and errors only when two samples
// share an evaluation timestamp. Pinned by conformance cases
// operators.test:994/997 (-{job="api"} over http_requests/http_errors).
func TestMergeSameLabelsetSeries(t *testing.T) {
f := func(v float64) *float64 { return &v }
api := labels.FromStrings("job", "api")
out, err := mergeSameLabelsetSeries([]transpiledSeries{
{lset: api, values: []*float64{f(-2), nil}},
{lset: api, values: []*float64{nil, f(-4)}},
{lset: labels.FromStrings("job", "web"), values: []*float64{f(7), nil}},
})
require.NoError(t, err)
require.Len(t, out, 2)
assert.Equal(t, []*float64{f(-2), f(-4)}, out[0].values, "temporally disjoint twins must merge into one series")
_, err = mergeSameLabelsetSeries([]transpiledSeries{
{lset: api, values: []*float64{f(1), nil}},
{lset: api, values: []*float64{f(2), nil}},
})
require.Error(t, err, "two values on one evaluation timestamp is the engine's duplicate error")
assert.True(t, errors.Ast(err, errors.TypeInvalidInput))
}
// Hybrid twin case: stripping the synthetic __name__ can leave two engine
// output series distinguishable only by those names (-metric_a or -metric_b:
// both {} once real names are dropped). Pinned by conformance cases
// name_label_dropping.test:137 and operators.test:1016.
func TestMergeMatrixByLabelset(t *testing.T) {
empty := labels.EmptyLabels()
out, err := mergeMatrixByLabelset(promql.Matrix{
{Metric: empty, Floats: []promql.FPoint{{T: 0, F: -1}}},
{Metric: empty, Floats: []promql.FPoint{{T: 600_000, F: -4}}},
})
require.NoError(t, err)
require.Len(t, out, 1)
assert.Equal(t, []promql.FPoint{{T: 0, F: -1}, {T: 600_000, F: -4}}, out[0].Floats)
_, err = mergeMatrixByLabelset(promql.Matrix{
{Metric: empty, Floats: []promql.FPoint{{T: 0, F: -1}}},
{Metric: empty, Floats: []promql.FPoint{{T: 0, F: -3}}},
})
require.Error(t, err)
assert.True(t, errors.Ast(err, errors.TypeInvalidInput))
}

View File

@@ -24,10 +24,6 @@ type Config struct {
// Timeout is the maximum time a query is allowed to run before being aborted.
Timeout time.Duration `mapstructure:"timeout"`
// ProviderName selects the storage provider: "clickhouse" (default) or
// "clickhousev2".
ProviderName string `mapstructure:"provider"`
}
func NewConfigFactory() factory.ConfigFactory {
@@ -41,8 +37,7 @@ func newConfig() factory.Config {
Path: "",
MaxConcurrent: 20,
},
Timeout: 2 * time.Minute,
ProviderName: "clickhouse",
Timeout: 2 * time.Minute,
}
}
@@ -50,15 +45,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.ProviderName != "" && c.ProviderName != "clickhouse" && c.ProviderName != "clickhousev2" {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "prometheus::provider must be one of [clickhouse, clickhousev2], got %q", c.ProviderName)
}
return nil
}
func (c Config) Provider() string {
if c.ProviderName == "" {
return "clickhouse"
}
return c.ProviderName
return "clickhouse"
}

View File

@@ -1,233 +0,0 @@
// Package promapi serves the Prometheus HTTP query API over a
// prometheus.Prometheus provider: /query and /query_range in the shape of
// Prometheus' /api/v1 endpoints (https://prometheus.io/docs/prometheus/latest/querying/api/),
// intended to be mounted under a distinguishing prefix (/prometheus/api/v1)
// so PromQL-only endpoints are separate from the SigNoz query APIs. The
// request and response contracts follow Prometheus: form-encoded GET/POST
// params, {"status":"success","data":{resultType,result}} on success and
// {"status":"error","errorType","error"} with Prometheus' status codes on
// failure — so Prometheus-compatible clients can point at the prefix.
package promapi
import (
"context"
"encoding/json"
"log/slog"
"math"
"net/http"
"strconv"
"time"
promModel "github.com/prometheus/common/model"
"github.com/prometheus/prometheus/promql"
"github.com/prometheus/prometheus/promql/parser"
"github.com/prometheus/prometheus/util/stats"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/prometheus"
)
// Handler serves the Prometheus query API over the configured provider.
type Handler struct {
logger *slog.Logger
prom prometheus.Prometheus
}
func NewHandler(logger *slog.Logger, prom prometheus.Prometheus) *Handler {
return &Handler{logger: logger, prom: prom}
}
type errorType string
const (
errBadData errorType = "bad_data"
errExec errorType = "execution"
errCanceled errorType = "canceled"
errTimeout errorType = "timeout"
errInternal errorType = "internal"
)
type queryData struct {
ResultType parser.ValueType `json:"resultType"`
Result parser.Value `json:"result"`
Stats stats.QueryStats `json:"stats,omitempty"`
}
type response struct {
Status string `json:"status"`
Data *queryData `json:"data,omitempty"`
ErrorType errorType `json:"errorType,omitempty"`
Error string `json:"error,omitempty"`
}
// QueryRange evaluates an expression over a grid: query, start, end, step,
// and optional timeout/stats params, all in Prometheus' formats.
func (h *Handler) QueryRange(w http.ResponseWriter, r *http.Request) {
start, err := parseTime(r.FormValue("start"))
if err != nil {
h.respondError(r.Context(), w, errBadData, err)
return
}
end, err := parseTime(r.FormValue("end"))
if err != nil {
h.respondError(r.Context(), w, errBadData, err)
return
}
if end.Before(start) {
h.respondError(r.Context(), w, errBadData, errors.NewInvalidInputf(errors.CodeInvalidInput, "end timestamp must not be before start time"))
return
}
step, err := parseDuration(r.FormValue("step"))
if err != nil {
h.respondError(r.Context(), w, errBadData, err)
return
}
if step <= 0 {
h.respondError(r.Context(), w, errBadData, errors.NewInvalidInputf(errors.CodeInvalidInput, "zero or negative query resolution step widths are not accepted. Try a positive integer"))
return
}
// The engine materializes every point of every series; an unbounded
// grid is an unbounded allocation. 11,000 points covers 60s resolution
// for a week or 1h resolution for a year.
if end.Sub(start)/step > 11000 {
h.respondError(r.Context(), w, errBadData, errors.NewInvalidInputf(errors.CodeInvalidInput, "exceeded maximum resolution of 11,000 points per timeseries. Try decreasing the query resolution (?step=XX)"))
return
}
ctx, cancel, err := h.contextWithTimeout(r)
if err != nil {
h.respondError(r.Context(), w, errBadData, err)
return
}
defer cancel()
qry, err := h.prom.Engine().NewRangeQuery(ctx, h.prom.Storage(), nil, r.FormValue("query"), start, end, step)
if err != nil {
h.respondError(r.Context(), w, errBadData, err)
return
}
h.exec(ctx, w, r, qry)
}
// Query evaluates an expression at a single instant: query and optional
// time/timeout/stats params. A missing time evaluates at the server's now,
// as in Prometheus.
func (h *Handler) Query(w http.ResponseWriter, r *http.Request) {
ts := time.Now()
if t := r.FormValue("time"); t != "" {
var err error
ts, err = parseTime(t)
if err != nil {
h.respondError(r.Context(), w, errBadData, err)
return
}
}
ctx, cancel, err := h.contextWithTimeout(r)
if err != nil {
h.respondError(r.Context(), w, errBadData, err)
return
}
defer cancel()
qry, err := h.prom.Engine().NewInstantQuery(ctx, h.prom.Storage(), nil, r.FormValue("query"), ts)
if err != nil {
h.respondError(r.Context(), w, errBadData, err)
return
}
h.exec(ctx, w, r, qry)
}
func (h *Handler) exec(ctx context.Context, w http.ResponseWriter, r *http.Request, qry promql.Query) {
defer qry.Close()
res := qry.Exec(ctx)
if res.Err != nil {
h.logger.ErrorContext(ctx, "error evaluating promql query", errors.Attr(res.Err))
switch res.Err.(type) {
case promql.ErrQueryCanceled:
h.respondError(ctx, w, errCanceled, res.Err)
case promql.ErrQueryTimeout:
h.respondError(ctx, w, errTimeout, res.Err)
case promql.ErrStorage:
h.respondError(ctx, w, errInternal, res.Err)
default:
h.respondError(ctx, w, errExec, res.Err)
}
return
}
data := &queryData{ResultType: res.Value.Type(), Result: res.Value}
if r.FormValue("stats") != "" {
data.Stats = stats.NewQueryStats(qry.Stats())
}
h.respond(ctx, w, data)
}
func (h *Handler) contextWithTimeout(r *http.Request) (context.Context, context.CancelFunc, error) {
ctx := r.Context()
if to := r.FormValue("timeout"); to != "" {
timeout, err := parseDuration(to)
if err != nil {
return nil, nil, err
}
ctx, cancel := context.WithTimeout(ctx, timeout)
return ctx, cancel, nil
}
ctx, cancel := context.WithCancel(ctx)
return ctx, cancel, nil
}
func (h *Handler) respond(ctx context.Context, w http.ResponseWriter, data *queryData) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(&response{Status: "success", Data: data}); err != nil {
h.logger.ErrorContext(ctx, "error writing prometheus api response", errors.Attr(err))
}
}
// respondError follows Prometheus' status-code mapping: bad_data 400,
// execution 422, canceled/timeout 503, internal 500.
func (h *Handler) respondError(ctx context.Context, w http.ResponseWriter, typ errorType, err error) {
code := http.StatusInternalServerError
switch typ {
case errBadData:
code = http.StatusBadRequest
case errExec:
code = http.StatusUnprocessableEntity
case errCanceled, errTimeout:
code = http.StatusServiceUnavailable
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
if encErr := json.NewEncoder(w).Encode(&response{Status: "error", ErrorType: typ, Error: err.Error()}); encErr != nil {
h.logger.ErrorContext(ctx, "error writing prometheus api error response", errors.Attr(encErr))
}
}
// parseTime accepts Prometheus' time formats: float unix seconds or RFC3339.
func parseTime(s string) (time.Time, error) {
if t, err := strconv.ParseFloat(s, 64); err == nil {
sec, ns := math.Modf(t)
return time.Unix(int64(sec), int64(ns*float64(time.Second))), nil
}
if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
return t, nil
}
return time.Time{}, errors.NewInvalidInputf(errors.CodeInvalidInput, "cannot parse %q to a valid timestamp", s)
}
// parseDuration accepts Prometheus' duration formats: float seconds or a
// duration string like 5m.
func parseDuration(s string) (time.Duration, error) {
if d, err := strconv.ParseFloat(s, 64); err == nil {
ts := d * float64(time.Second)
if ts > float64(math.MaxInt64) || ts < float64(math.MinInt64) {
return 0, errors.NewInvalidInputf(errors.CodeInvalidInput, "cannot parse %q to a valid duration. It overflows int64", s)
}
return time.Duration(ts), nil
}
if d, err := promModel.ParseDuration(s); err == nil {
return time.Duration(d), nil
}
return 0, errors.NewInvalidInputf(errors.CodeInvalidInput, "cannot parse %q to a valid duration", s)
}

View File

@@ -1,9 +1,6 @@
package prometheus
import (
"context"
"time"
"github.com/prometheus/prometheus/promql"
"github.com/prometheus/prometheus/promql/parser"
"github.com/prometheus/prometheus/storage"
@@ -38,20 +35,3 @@ type StatementRecorder interface {
type StatementCapturer interface {
CapturingStorage() (storage.Queryable, StatementRecorder)
}
// ProviderClickhouseV2 is the clickhousev2 provider name: the factory
// registration, the prometheus::provider config value and the
// X-SigNoz-PromQL-Provider request header all use it, so they cannot drift
// apart.
const ProviderClickhouseV2 = "clickhousev2"
// RangeExecutor is the optional capability of a provider that can evaluate
// some range queries entirely inside the datastore. ok=false means the query
// is not evaluable that way and the caller should run the engine over the
// provider's Storage instead — which is always exact. Only the clickhousev2
// provider implements it; once that provider is the only one, the capability
// folds into Prometheus itself and the engine-vs-datastore decision becomes
// internal.
type RangeExecutor interface {
TryExecuteRange(ctx context.Context, query string, start, end time.Time, step time.Duration) (promql.Matrix, bool, error)
}

View File

@@ -57,7 +57,6 @@ func (handler *handler) QueryRange(rw http.ResponseWriter, req *http.Request) {
render.Error(rw, err)
return
}
queryRangeRequest.PromQLProvider = req.Header.Get("X-SigNoz-PromQL-Provider")
// Validate the query request
if err := queryRangeRequest.Validate(); err != nil {

View File

@@ -231,7 +231,7 @@ func (q *querier) buildPreviewProviders(
sub.CompositeQuery = qbtypes.CompositeQuery{Queries: []qbtypes.QueryEnvelope{query}}
}
built, _, bErr := q.buildQueries(orgID, &sub, deps, missingMetricQuerySet, event, promqlOptions{})
built, _, bErr := q.buildQueries(orgID, &sub, deps, missingMetricQuerySet, event)
if bErr != nil {
errs[name] = bErr
continue

View File

@@ -8,12 +8,9 @@ import (
"regexp"
"sort"
"strings"
"sync"
"text/template"
"time"
"github.com/ClickHouse/clickhouse-go/v2"
"github.com/prometheus/prometheus/model/labels"
"github.com/prometheus/prometheus/promql"
"github.com/prometheus/prometheus/promql/parser"
@@ -101,24 +98,6 @@ type promqlQuery struct {
tr qbv5.TimeRange
requestType qbv5.RequestType
vars map[string]qbv5.VariableItem
opts promqlOptions
}
// promqlOptions is how a PromQL query relates to the clickhousev2 provider
// (see querier.promqlOptions for where the fields come from and why they are
// flag-gated). Both providers are nil for a plain request, so a plain
// request costs nothing extra.
type promqlOptions struct {
// shadow, when set, runs the query on this provider after serving and
// logs any result difference; the response is never affected.
shadow prometheus.Prometheus
// shadowSlots is the querier-wide admission for shadow runs, shared by
// every query so the bound holds per process.
shadowSlots chan struct{}
// serve, when set, serves the response from this provider instead of the
// default path. Comparison callers fetch the default and the pinned
// result as two API calls and diff them.
serve prometheus.Prometheus
}
var _ qbv5.Query = (*promqlQuery)(nil)
@@ -131,7 +110,6 @@ func newPromqlQuery(
tr qbv5.TimeRange,
requestType qbv5.RequestType,
variables map[string]qbv5.VariableItem,
opts promqlOptions,
) *promqlQuery {
return &promqlQuery{
logger: logger,
@@ -141,19 +119,10 @@ func newPromqlQuery(
tr: tr,
requestType: requestType,
vars: variables,
opts: opts,
}
}
func (q *promqlQuery) Fingerprint() string {
// A pinned request must not share cache entries with default serving: a
// cached default result would satisfy the pin without running the pinned
// provider, and a pinned result would poison normal serving. No
// fingerprint means no caching at all — the pin exists to observe a
// provider, so a cache in front of it defeats the point.
if q.opts.serve != nil {
return ""
}
if q.requestType != qbv5.RequestTypeTimeSeries {
return ""
}
@@ -283,16 +252,7 @@ func (q *promqlQuery) PreviewStatements(ctx context.Context) ([]prometheus.Captu
start := int64(querybuilder.ToNanoSecs(q.tr.From))
end := int64(querybuilder.ToNanoSecs(q.tr.To))
// Attach the same query traits as Execute so the captured statements
// match what the live path would run.
if expr, parseErr := q.parser.ParseExpr(rendered); parseErr == nil {
ctx = prometheus.NewContextWithQueryTraits(ctx, prometheus.DetectQueryTraits(expr))
}
capStorage, recorder := storer.CapturingStorage()
if capStorage == nil {
return nil, nil
}
qry, err := q.promEngine.Engine().NewRangeQuery(
ctx,
capStorage,
@@ -336,58 +296,6 @@ func (q *promqlQuery) Execute(ctx context.Context) (*qbv5.Result, error) {
return nil, err
}
// Attach query traits so the storage can prove step-aligned optimizations
// safe (see prometheus.QueryTraits). A parse failure surfaces below via
// the engine with the enhanced error message.
if expr, parseErr := q.parser.ParseExpr(query); parseErr == nil {
ctx = prometheus.NewContextWithQueryTraits(ctx, prometheus.DetectQueryTraits(expr))
}
// Accumulate ClickHouse-side scan stats across every storage query this
// evaluation issues (engine selectors or the compiled executor): progress
// options propagate to each ClickHouse query through the context.
var statsMu sync.Mutex
var rowsScanned, bytesScanned uint64
ctx = clickhouse.Context(ctx, clickhouse.WithProgress(func(p *clickhouse.Progress) {
statsMu.Lock()
rowsScanned += p.Rows
bytesScanned += p.Bytes
statsMu.Unlock()
}))
began := time.Now()
// A pinned provider serves directly from it: comparison callers fetch
// the default result and the pinned result as two API calls and diff
// them.
if q.opts.serve != nil {
matrix, err := q.serveFromProvider(ctx, query, start, end)
if err != nil {
if enhanced := tryEnhancePromQLExecError(err); enhanced != nil {
return nil, enhanced
}
return nil, err
}
return q.toResult(matrix, nil, began, &statsMu, &rowsScanned, &bytesScanned), nil
}
// When the serving provider has the RangeExecutor capability
// (prometheus::provider: clickhousev2), serve the way the provider is
// designed to serve: transpiled when the shape allows. Without this the
// override would silently run the engine path only.
if re, ok := q.promEngine.(prometheus.RangeExecutor); ok {
matrix, served, err := re.TryExecuteRange(ctx, query, time.Unix(0, start), time.Unix(0, end), q.query.Step.Duration)
if err != nil {
if enhanced := tryEnhancePromQLExecError(err); enhanced != nil {
return nil, enhanced
}
return nil, err
}
if served {
return q.toResult(matrix, nil, began, &statsMu, &rowsScanned, &bytesScanned), nil
}
}
qry, err := q.promEngine.Engine().NewRangeQuery(
ctx,
q.promEngine.Storage(),
@@ -423,34 +331,6 @@ func (q *promqlQuery) Execute(ctx context.Context) (*qbv5.Result, error) {
return nil, errors.WrapInternalf(promErr, errors.CodeInternal, "error getting matrix from promql query %q", query)
}
if q.opts.shadow != nil {
// Shadows detach from the request, so without admission a dashboard
// burst would stack unbounded ClickHouse work for up to the shadow
// timeout — the concurrency pattern behind the original outages.
// Non-blocking: at the cap the comparison is skipped, not queued;
// a sampled shadow stream is exactly as useful for rollout evidence.
select {
case q.opts.shadowSlots <- struct{}{}:
// The engine pools the result's sample slices on Close; the
// shadow comparison needs a stable copy of what was served.
served := copyMatrix(matrix)
servedIn := time.Since(began)
go func() {
defer func() { <-q.opts.shadowSlots }()
q.runShadowCompare(context.WithoutCancel(ctx), query, start, end, served, servedIn)
}()
default:
q.logger.DebugContext(ctx, "promql shadow skipped: at concurrency cap", slog.String("query", query))
}
}
warnings, _ := res.Warnings.AsStrings(query, 10, 0)
return q.toResult(matrix, warnings, began, &statsMu, &rowsScanned, &bytesScanned), nil
}
// toResult converts an evaluated matrix into the v5 result shape, attaching
// the ClickHouse scan stats accumulated during evaluation.
func (q *promqlQuery) toResult(matrix promql.Matrix, warnings []string, began time.Time, statsMu *sync.Mutex, rowsScanned, bytesScanned *uint64) *qbv5.Result {
// Hide only known SigNoz storage keys: label names are user data and may
// legitimately start with "__" (e.g. __address__), so a blanket dunder
// strip mangles user labelsets. The __scope./__resource. prefixes cover
@@ -486,13 +366,7 @@ func (q *promqlQuery) toResult(matrix promql.Matrix, warnings []string, began ti
series = append(series, &s)
}
statsMu.Lock()
stats := qbv5.ExecStats{
RowsScanned: *rowsScanned,
BytesScanned: *bytesScanned,
DurationMS: uint64(time.Since(began).Milliseconds()),
}
statsMu.Unlock()
warnings, _ := res.Warnings.AsStrings(query, 10, 0)
tsData := &qbv5.TimeSeriesData{
QueryName: q.query.Name,
@@ -526,6 +400,6 @@ func (q *promqlQuery) toResult(matrix promql.Matrix, warnings []string, began ti
Type: q.requestType,
Value: payload,
Warnings: warnings,
Stats: stats,
}
// TODO: map promql stats?
}, nil
}

View File

@@ -7,7 +7,6 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/prometheus"
"github.com/SigNoz/signoz/pkg/prometheus/prometheustest"
qbv5 "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/stretchr/testify/assert"
)
@@ -441,15 +440,3 @@ func TestQuotedMetricOutsideBracesPattern(t *testing.T) {
})
}
}
// A pinned request must not share cache entries with default serving: a
// cached default result would satisfy the pin without running the pinned
// provider.
func TestFingerprint_PinnedProviderBypassesCache(t *testing.T) {
q := &promqlQuery{
logger: slog.Default(),
query: qbv5.PromQuery{Query: "up"},
opts: promqlOptions{serve: &prometheustest.Provider{}},
}
assert.Empty(t, q.Fingerprint())
}

View File

@@ -1,188 +0,0 @@
package querier
import (
"context"
"fmt"
"log/slog"
"math"
"sort"
"time"
"github.com/ClickHouse/clickhouse-go/v2"
"github.com/SigNoz/signoz/pkg/prometheus"
"github.com/prometheus/prometheus/model/labels"
"github.com/prometheus/prometheus/promql"
)
// shadowTimeout bounds a shadow evaluation; a shadow run must never outlive
// the request by much or pile up.
const shadowTimeout = 2 * time.Minute
// runShadowCompare executes the query on the clickhousev2 provider exactly
// as it would serve (transpiled when the shape allows, engine over the v2
// querier otherwise), compares against the served result and logs the
// outcome. Serving is never affected: this runs after the response, off the
// request context, and only logs. The mismatch and failure logs are the
// rollout evidence — serving cuts over to v2 only after they stay clean.
func (q *promqlQuery) runShadowCompare(ctx context.Context, query string, startNs, endNs int64, served promql.Matrix, servedIn time.Duration) {
defer func() {
if r := recover(); r != nil {
q.logger.ErrorContext(ctx, "promql shadow comparison panicked", slog.Any("panic", r), slog.String("query", query))
}
}()
ctx, cancel := context.WithTimeout(ctx, shadowTimeout)
defer cancel()
// The request context carries the served response's scan-stats progress
// callback; without replacing it the shadow's ClickHouse progress would
// race into the served stats. The response itself was already sent.
ctx = clickhouse.Context(ctx, clickhouse.WithProgress(func(*clickhouse.Progress) {}))
if expr, parseErr := q.parser.ParseExpr(query); parseErr == nil {
ctx = prometheus.NewContextWithQueryTraits(ctx, prometheus.DetectQueryTraits(expr))
}
start, end := time.Unix(0, startNs), time.Unix(0, endNs)
began := time.Now()
shadow, transpiled, err := executeOnProvider(ctx, q.opts.shadow, query, start, end, q.query.Step.Duration)
shadowIn := time.Since(began)
logAttrs := []any{
slog.String("query", query),
slog.Int64("start_ms", startNs/int64(time.Millisecond)),
slog.Int64("end_ms", endNs/int64(time.Millisecond)),
slog.Duration("step", q.query.Step.Duration),
slog.Bool("transpiled", transpiled),
slog.Duration("served_in", servedIn),
slog.Duration("shadow_in", shadowIn),
}
if err != nil {
// A shadow failure would be a serving failure after rollout; surface
// it at the same level as a result mismatch.
q.logger.WarnContext(ctx, "promql shadow execution failed", append(logAttrs, slog.Any("error", err))...)
return
}
servedNorm := normalizeShadowMatrix(served)
shadowNorm := normalizeShadowMatrix(shadow)
if diff := diffShadowMatrices(servedNorm, shadowNorm); diff != "" {
q.logger.WarnContext(ctx, "promql shadow comparison mismatch", append(logAttrs,
slog.String("diff", diff),
slog.Int("served_series", len(servedNorm)),
slog.Int("shadow_series", len(shadowNorm)),
)...)
return
}
// Matches log the timings: served_in vs shadow_in across the fleet is
// the perf evidence for the cutover, gathered for free.
q.logger.DebugContext(ctx, "promql shadow comparison matched", logAttrs...)
}
// serveFromProvider evaluates the query the way the pinned provider would
// serve it.
func (q *promqlQuery) serveFromProvider(ctx context.Context, query string, startNs, endNs int64) (promql.Matrix, error) {
matrix, _, err := executeOnProvider(ctx, q.opts.serve, query, time.Unix(0, startNs), time.Unix(0, endNs), q.query.Step.Duration)
return matrix, err
}
// executeOnProvider evaluates the query the way the provider would serve it:
// transpiled in the datastore when the provider has the RangeExecutor
// capability and the shape allows, the engine over the provider's storage
// otherwise. The returned matrix is an owned copy.
func executeOnProvider(ctx context.Context, prov prometheus.Prometheus, query string, start, end time.Time, step time.Duration) (promql.Matrix, bool, error) {
if re, ok := prov.(prometheus.RangeExecutor); ok {
matrix, served, err := re.TryExecuteRange(ctx, query, start, end, step)
if err != nil {
return nil, true, err
}
if served {
return matrix, true, nil
}
}
qry, err := prov.Engine().NewRangeQuery(ctx, prov.Storage(), nil, query, start, end, step)
if err != nil {
return nil, false, err
}
defer qry.Close()
res := qry.Exec(ctx)
if res.Err != nil {
return nil, false, res.Err
}
matrix, err := res.Matrix()
if err != nil {
return nil, false, err
}
// Close returns the result's sample slices to the engine pool.
return copyMatrix(matrix), false, nil
}
func copyMatrix(matrix promql.Matrix) promql.Matrix {
out := make(promql.Matrix, 0, len(matrix))
for _, s := range matrix {
floats := make([]promql.FPoint, len(s.Floats))
copy(floats, s.Floats)
out = append(out, promql.Series{Metric: s.Metric.Copy(), Floats: floats})
}
return out
}
// normalizeShadowMatrix sorts by label set for order-independent
// comparison. Both providers now resolve series identity the same way
// (empty-valued labels dropped at read, no synthetic fingerprint label
// since the v1 series-identity fix), so labels need no normalization.
func normalizeShadowMatrix(matrix promql.Matrix) promql.Matrix {
out := make(promql.Matrix, 0, len(matrix))
out = append(out, matrix...)
sort.Slice(out, func(i, j int) bool { return labels.Compare(out[i].Metric, out[j].Metric) < 0 })
return out
}
// diffShadowMatrices returns a description of the first difference, or "".
// Values compare with relative tolerance: spatial aggregations accumulate
// floats in storage order, which differs between the providers in the last
// ULP.
func diffShadowMatrices(served, shadow promql.Matrix) string {
const relTol = 1e-9
if len(served) != len(shadow) {
return fmt.Sprintf("series count: served=%d shadow=%d", len(served), len(shadow))
}
for i := range served {
if labels.Compare(served[i].Metric, shadow[i].Metric) != 0 {
return fmt.Sprintf("series %d labels: served=%s shadow=%s", i, served[i].Metric, shadow[i].Metric)
}
if len(served[i].Floats) != len(shadow[i].Floats) {
return fmt.Sprintf("series %s points: served=%d shadow=%d", served[i].Metric, len(served[i].Floats), len(shadow[i].Floats))
}
for j := range served[i].Floats {
a, b := served[i].Floats[j], shadow[i].Floats[j]
if a.T != b.T {
return fmt.Sprintf("series %s point %d ts: served=%d shadow=%d", served[i].Metric, j, a.T, b.T)
}
// NaN and infinities first: NaN != NaN and Inf-Inf arithmetic
// would otherwise make one-sided NaN and Inf-vs-finite compare
// as equal (NaN > x and Inf > Inf are both false).
if math.IsNaN(a.F) || math.IsNaN(b.F) {
if math.IsNaN(a.F) != math.IsNaN(b.F) {
return fmt.Sprintf("series %s @%d value: served=%v shadow=%v", served[i].Metric, a.T, a.F, b.F)
}
continue
}
if math.IsInf(a.F, 0) || math.IsInf(b.F, 0) {
if a.F != b.F {
return fmt.Sprintf("series %s @%d value: served=%v shadow=%v", served[i].Metric, a.T, a.F, b.F)
}
continue
}
diff := math.Abs(a.F - b.F)
scale := math.Max(math.Abs(a.F), math.Abs(b.F))
if diff > relTol*math.Max(scale, 1e-300) && diff > 1e-12 {
return fmt.Sprintf("series %s @%d value: served=%v shadow=%v", served[i].Metric, a.T, a.F, b.F)
}
}
}
return ""
}

View File

@@ -1,67 +0,0 @@
package querier
import (
"math"
"testing"
"github.com/prometheus/prometheus/model/labels"
"github.com/prometheus/prometheus/promql"
"github.com/stretchr/testify/assert"
)
func TestNormalizeShadowMatrix(t *testing.T) {
matrix := promql.Matrix{
{
Metric: labels.FromStrings("__name__", "up", "job", "api"),
Floats: []promql.FPoint{{T: 1000, F: 1}},
},
{
Metric: labels.FromStrings("a", "1"),
Floats: []promql.FPoint{{T: 1000, F: 2}},
},
}
norm := normalizeShadowMatrix(matrix)
// sorted by label set; labels pass through untouched — both providers
// resolve series identity identically since the v1 series-identity fix
assert.Equal(t, labels.FromStrings("__name__", "up", "job", "api"), norm[0].Metric)
assert.Equal(t, labels.FromStrings("a", "1"), norm[1].Metric)
}
func TestDiffShadowMatrices(t *testing.T) {
series := func(v float64) promql.Matrix {
return promql.Matrix{{Metric: labels.FromStrings("a", "1"), Floats: []promql.FPoint{{T: 1000, F: v}}}}
}
assert.Empty(t, diffShadowMatrices(series(1.5), series(1.5)))
// last-ULP differences from storage-order float accumulation are expected
assert.Empty(t, diffShadowMatrices(series(0.08888888888888889), series(0.08888888888888888)))
assert.Empty(t, diffShadowMatrices(series(math.NaN()), series(math.NaN())))
assert.Contains(t, diffShadowMatrices(series(1.5), series(1.6)), "value")
assert.Contains(t, diffShadowMatrices(series(1.5), promql.Matrix{}), "series count")
assert.Contains(t, diffShadowMatrices(
series(1.5),
promql.Matrix{{Metric: labels.FromStrings("a", "2"), Floats: []promql.FPoint{{T: 1000, F: 1.5}}}},
), "labels")
assert.Contains(t, diffShadowMatrices(
series(1.5),
promql.Matrix{{Metric: labels.FromStrings("a", "1"), Floats: []promql.FPoint{{T: 2000, F: 1.5}}}},
), "ts")
}
// One-sided NaN makes every float comparison false, and Inf-Inf arithmetic
// yields Inf > Inf == false; without explicit handling both divergences log
// as matched — a shadow comparator that cannot see them would green-light a
// broken rollout.
func TestDiffShadowMatrices_SpecialFloats(t *testing.T) {
point := func(v float64) promql.Matrix {
return promql.Matrix{{Metric: labels.FromStrings("a", "1"), Floats: []promql.FPoint{{T: 1000, F: v}}}}
}
assert.NotEmpty(t, diffShadowMatrices(point(math.NaN()), point(1.5)), "one-sided NaN must diff")
assert.NotEmpty(t, diffShadowMatrices(point(1.5), point(math.NaN())), "one-sided NaN must diff either way")
assert.NotEmpty(t, diffShadowMatrices(point(math.Inf(1)), point(1.5)), "Inf vs finite must diff")
assert.NotEmpty(t, diffShadowMatrices(point(math.Inf(1)), point(math.Inf(-1))), "opposite infinities must diff")
assert.Empty(t, diffShadowMatrices(point(math.Inf(1)), point(math.Inf(1))), "equal infinities match")
assert.Empty(t, diffShadowMatrices(point(math.NaN()), point(math.NaN())), "both NaN match")
}

View File

@@ -24,7 +24,6 @@ import (
"github.com/SigNoz/signoz/pkg/statsreporter"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
"github.com/SigNoz/signoz/pkg/types/featuretypes"
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
"github.com/SigNoz/signoz/pkg/types/metrictypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
@@ -47,19 +46,11 @@ type Querier interface {
}
type querier struct {
logger *slog.Logger
fl flagger.Flagger
telemetryStore telemetrystore.TelemetryStore
metadataStore telemetrytypes.MetadataStore
promEngine prometheus.Prometheus
// promV2 is the clickhousev2 prometheus provider, wired only when the
// serving provider is the default one (nil otherwise). It reads the same
// ClickHouse data through a different implementation; PromQL queries
// shadow-compare against it behind the use_prometheus_clickhouse_v2 flag
// and can be pinned to it for a response (see promqlOptions). It never
// serves by default — that cutover happens only after the shadow logs
// stay clean.
promV2 prometheus.Prometheus
logger *slog.Logger
fl flagger.Flagger
telemetryStore telemetrystore.TelemetryStore
metadataStore telemetrytypes.MetadataStore
promEngine prometheus.Prometheus
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation]
logStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation]
auditStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation]
@@ -70,16 +61,8 @@ type querier struct {
liveDataRefresh time.Duration
builderConfig builderConfig
maxConcurrentQueries int
// shadowSlots bounds concurrent shadow comparisons per process; shadows
// detach from their requests, so nothing else limits how many pile up.
shadowSlots chan struct{}
}
// maxConcurrentShadows is deliberately small: a shadow is a full extra
// ClickHouse evaluation, and a sampled stream of comparisons is exactly as
// useful for rollout evidence as an exhaustive one under load.
const maxConcurrentShadows = 8
var _ Querier = (*querier)(nil)
func New(
@@ -87,7 +70,6 @@ func New(
telemetryStore telemetrystore.TelemetryStore,
metadataStore telemetrytypes.MetadataStore,
promEngine prometheus.Prometheus,
promV2 prometheus.Prometheus,
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation],
logStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation],
auditStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation],
@@ -109,7 +91,6 @@ func New(
telemetryStore: telemetryStore,
metadataStore: metadataStore,
promEngine: promEngine,
promV2: promV2,
traceStmtBuilder: traceStmtBuilder,
logStmtBuilder: logStmtBuilder,
auditStmtBuilder: auditStmtBuilder,
@@ -122,7 +103,6 @@ func New(
logTraceIDWindowPaddingMS: uint64(logTraceIDWindowPadding.Milliseconds()),
},
maxConcurrentQueries: maxConcurrentQueries,
shadowSlots: make(chan struct{}, maxConcurrentShadows),
}
}
@@ -162,11 +142,7 @@ func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtype
missingMetricQuerySet[name] = true
}
promqlOpts, err := q.promqlOptions(ctx, orgID, req)
if err != nil {
return nil, err
}
queries, steps, err := q.buildQueries(orgID, req, dependencyQueries, missingMetricQuerySet, event, promqlOpts)
queries, steps, err := q.buildQueries(orgID, req, dependencyQueries, missingMetricQuerySet, event)
if err != nil {
return nil, err
}
@@ -209,41 +185,12 @@ func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtype
return qbResp, qbErr
}
// promqlOptions derives the PromQL execution options for a request. With the
// org's use_prometheus_clickhouse_v2 flag on, queries are shadow-compared
// against the clickhousev2 provider (serving unaffected, diffs logged; see
// promql_shadow.go). The X-SigNoz-PromQL-Provider header may instead pin the
// response to that provider — integration tests and support fetch both
// results for comparison — so it is deliberately flag-gated too: without the
// gate the header would be an unaudited switch onto a provider still under
// validation.
func (q *querier) promqlOptions(ctx context.Context, orgID valuer.UUID, req *qbtypes.QueryRangeRequest) (promqlOptions, error) {
enabled := q.fl.BooleanOrEmpty(ctx, flagger.FeatureUsePrometheusClickhouseV2, featuretypes.NewFlaggerEvaluationContext(orgID))
if req.PromQLProvider == "" {
if enabled && q.promV2 != nil {
return promqlOptions{shadow: q.promV2, shadowSlots: q.shadowSlots}, nil
}
return promqlOptions{}, nil
}
if req.PromQLProvider != prometheus.ProviderClickhouseV2 {
return promqlOptions{}, errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown promql provider %q", req.PromQLProvider)
}
if !enabled {
return promqlOptions{}, errors.NewInvalidInputf(errors.CodeInvalidInput, "promql provider %q requires the use_prometheus_clickhouse_v2 flag", req.PromQLProvider)
}
if q.promV2 == nil {
return promqlOptions{}, errors.NewInvalidInputf(errors.CodeInvalidInput, "promql provider %q is not available", req.PromQLProvider)
}
return promqlOptions{serve: q.promV2}, nil
}
func (q *querier) buildQueries(
orgID valuer.UUID,
req *qbtypes.QueryRangeRequest,
dependencyQueries map[string]bool,
missingMetricQuerySet map[string]bool,
event *qbtypes.QBEvent,
promqlOpts promqlOptions,
) (map[string]qbtypes.Query, map[string]qbtypes.Step, error) {
tmplVars := req.Variables
@@ -268,7 +215,7 @@ func (q *querier) buildQueries(
if !ok {
return nil, nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid promql query spec %T", query.Spec)
}
promqlQuery := newPromqlQuery(q.logger, q.promEngine, promQuery, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType, tmplVars, promqlOpts)
promqlQuery := newPromqlQuery(q.logger, q.promEngine, promQuery, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType, tmplVars)
queries[promQuery.Name] = promqlQuery
steps[promQuery.Name] = promQuery.Step
case qbtypes.QueryTypeClickHouseSQL:
@@ -911,7 +858,7 @@ func (q *querier) createRangedQuery(_ valuer.UUID, originalQuery qbtypes.Query,
switch qt := originalQuery.(type) {
case *promqlQuery:
queryCopy := qt.query.Copy()
return newPromqlQuery(q.logger, qt.promEngine, queryCopy, timeRange, qt.requestType, qt.vars, qt.opts)
return newPromqlQuery(q.logger, q.promEngine, queryCopy, timeRange, qt.requestType, qt.vars)
case *chSQLQuery:
queryCopy := qt.query.Copy()

View File

@@ -48,7 +48,6 @@ func TestQueryRange_MetricTypeMissing(t *testing.T) {
nil, // telemetryStore
metadataStore,
nil, // prometheus
nil, // promV2
nil, // traceStmtBuilder
nil, // logStmtBuilder
nil, // auditStmtBuilder
@@ -121,7 +120,6 @@ func TestQueryRange_MetricTypeFromStore(t *testing.T) {
telemetryStore,
metadataStore,
nil, // prometheus
nil, // promV2
nil, // traceStmtBuilder
nil, // logStmtBuilder
nil, // auditStmtBuilder

View File

@@ -18,7 +18,6 @@ import (
func NewFactory(
telemetryStore telemetrystore.TelemetryStore,
prometheus prometheus.Prometheus,
promV2 prometheus.Prometheus,
metadataStore telemetrytypes.MetadataStore,
traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation],
logStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation],
@@ -41,7 +40,6 @@ func NewFactory(
telemetryStore,
metadataStore,
prometheus,
promV2,
traceStmtBuilder,
logStmtBuilder,
auditStmtBuilder,

View File

@@ -232,6 +232,25 @@ func NewReader(
}
}
func (r *ClickHouseReader) GetInstantQueryMetricsResult(ctx context.Context, queryParams *model.InstantQueryMetricsParams) (*promql.Result, *stats.QueryStats, *model.ApiError) {
qry, err := r.prometheus.Engine().NewInstantQuery(ctx, r.prometheus.Storage(), nil, queryParams.Query, queryParams.Time)
if err != nil {
return nil, nil, &model.ApiError{Typ: model.ErrorBadData, Err: err}
}
res := qry.Exec(ctx)
// Optional stats field in response if parameter "stats" is not empty.
var qs stats.QueryStats
if queryParams.Stats != "" {
qs = stats.NewQueryStats(qry.Stats())
}
qry.Close()
return res, &qs, nil
}
func (r *ClickHouseReader) GetQueryRangeResult(ctx context.Context, query *model.QueryRangeParams) (*promql.Result, *stats.QueryStats, *model.ApiError) {
qry, err := r.prometheus.Engine().NewRangeQuery(ctx, r.prometheus.Storage(), nil, query.Query, query.Start, query.End, query.Step)

View File

@@ -26,10 +26,11 @@ import (
"text/template"
"time"
"github.com/prometheus/prometheus/promql"
"github.com/SigNoz/signoz/pkg/http/middleware"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/prometheus/promapi"
"github.com/SigNoz/signoz/pkg/query-service/app/integrations"
"github.com/SigNoz/signoz/pkg/signoz"
"github.com/SigNoz/signoz/pkg/types/retentiontypes"
@@ -482,12 +483,8 @@ func (aH *APIHandler) Respond(w http.ResponseWriter, data interface{}) {
// RegisterRoutes registers routes for this handler on the given router
func (aH *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
// PromQL-only endpoints, in Prometheus' own API shape, live under a
// /prometheus prefix so they are distinguishable from the SigNoz query
// APIs; Prometheus-compatible clients can be pointed at the prefix.
promAPI := promapi.NewHandler(aH.logger, aH.Signoz.Prometheus)
router.HandleFunc("/prometheus/api/v1/query_range", am.ViewAccess(promAPI.QueryRange)).Methods(http.MethodGet, http.MethodPost)
router.HandleFunc("/prometheus/api/v1/query", am.ViewAccess(promAPI.Query)).Methods(http.MethodGet, http.MethodPost)
router.HandleFunc("/api/v1/query_range", am.ViewAccess(aH.queryRangeMetrics)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/query", am.ViewAccess(aH.queryMetrics)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/rules", am.ViewAccess(aH.listRules)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/rules/{id}", am.ViewAccess(aH.getRule)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/rules", am.EditAccess(aH.createRule)).Methods(http.MethodPost)
@@ -1106,6 +1103,115 @@ func (aH *APIHandler) queryDashboardVarsV2(w http.ResponseWriter, r *http.Reques
aH.Respond(w, dashboardVars)
}
func (aH *APIHandler) queryRangeMetrics(w http.ResponseWriter, r *http.Request) {
query, apiErrorObj := parseQueryRangeRequest(r)
if apiErrorObj != nil {
RespondError(w, apiErrorObj, nil)
return
}
// TODO: add structured logging for query and apiError if needed
ctx := r.Context()
if to := r.FormValue("timeout"); to != "" {
var cancel context.CancelFunc
timeout, err := parseMetricsDuration(to)
if aH.HandleError(w, err, http.StatusBadRequest) {
return
}
ctx, cancel = context.WithTimeout(ctx, timeout)
defer cancel()
}
res, qs, apiError := aH.reader.GetQueryRangeResult(ctx, query)
if apiError != nil {
RespondError(w, apiError, nil)
return
}
if res.Err != nil {
aH.logger.ErrorContext(r.Context(), "error in query range metrics", errors.Attr(res.Err))
}
if res.Err != nil {
switch res.Err.(type) {
case promql.ErrQueryCanceled:
RespondError(w, &model.ApiError{Typ: model.ErrorCanceled, Err: res.Err}, nil)
case promql.ErrQueryTimeout:
RespondError(w, &model.ApiError{Typ: model.ErrorTimeout, Err: res.Err}, nil)
}
RespondError(w, &model.ApiError{Typ: model.ErrorExec, Err: res.Err}, nil)
return
}
response_data := &model.QueryData{
ResultType: res.Value.Type(),
Result: res.Value,
Stats: qs,
}
aH.Respond(w, response_data)
}
func (aH *APIHandler) queryMetrics(w http.ResponseWriter, r *http.Request) {
queryParams, apiErrorObj := parseInstantQueryMetricsRequest(r)
if apiErrorObj != nil {
RespondError(w, apiErrorObj, nil)
return
}
// TODO: add structured logging for query and apiError if needed
ctx := r.Context()
if to := r.FormValue("timeout"); to != "" {
var cancel context.CancelFunc
timeout, err := parseMetricsDuration(to)
if aH.HandleError(w, err, http.StatusBadRequest) {
return
}
ctx, cancel = context.WithTimeout(ctx, timeout)
defer cancel()
}
res, qs, apiError := aH.reader.GetInstantQueryMetricsResult(ctx, queryParams)
if apiError != nil {
RespondError(w, apiError, nil)
return
}
if res.Err != nil {
aH.logger.ErrorContext(r.Context(), "error in query range metrics", errors.Attr(res.Err))
}
if res.Err != nil {
switch res.Err.(type) {
case promql.ErrQueryCanceled:
RespondError(w, &model.ApiError{Typ: model.ErrorCanceled, Err: res.Err}, nil)
case promql.ErrQueryTimeout:
RespondError(w, &model.ApiError{Typ: model.ErrorTimeout, Err: res.Err}, nil)
}
RespondError(w, &model.ApiError{Typ: model.ErrorExec, Err: res.Err}, nil)
}
responseData := &model.QueryData{
ResultType: res.Value.Type(),
Result: res.Value,
Stats: qs,
}
aH.Respond(w, responseData)
}
func (aH *APIHandler) registerEvent(w http.ResponseWriter, r *http.Request) {
request, err := parseRegisterEventRequest(r)
if aH.HandleError(w, err, http.StatusBadRequest) {

View File

@@ -27,6 +27,7 @@ import (
queues2 "github.com/SigNoz/signoz/pkg/query-service/app/integrations/messagingQueues/queues"
"github.com/gorilla/mux"
promModel "github.com/prometheus/common/model"
"go.uber.org/multierr"
errorsV2 "github.com/SigNoz/signoz/pkg/errors"
@@ -87,6 +88,95 @@ func parseRegisterEventRequest(r *http.Request) (*model.RegisterEventParams, err
return postData, nil
}
func parseMetricsTime(s string) (time.Time, error) {
if t, err := strconv.ParseFloat(s, 64); err == nil {
s, ns := math.Modf(t)
return time.Unix(int64(s), int64(ns*float64(time.Second))), nil
// return time.Unix(0, t), nil
}
if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
return t, nil
}
return time.Time{}, fmt.Errorf("cannot parse %q to a valid timestamp", s)
}
func parseMetricsDuration(s string) (time.Duration, error) {
if d, err := strconv.ParseFloat(s, 64); err == nil {
ts := d * float64(time.Second)
if ts > float64(math.MaxInt64) || ts < float64(math.MinInt64) {
return 0, fmt.Errorf("cannot parse %q to a valid duration. It overflows int64", s)
}
return time.Duration(ts), nil
}
if d, err := promModel.ParseDuration(s); err == nil {
return time.Duration(d), nil
}
return 0, fmt.Errorf("cannot parse %q to a valid duration", s)
}
func parseInstantQueryMetricsRequest(r *http.Request) (*model.InstantQueryMetricsParams, *model.ApiError) {
var ts time.Time
if t := r.FormValue("time"); t != "" {
var err error
ts, err = parseMetricsTime(t)
if err != nil {
return nil, &model.ApiError{Typ: model.ErrorBadData, Err: err}
}
} else {
ts = time.Now()
}
return &model.InstantQueryMetricsParams{
Time: ts,
Query: r.FormValue("query"),
Stats: r.FormValue("stats"),
}, nil
}
func parseQueryRangeRequest(r *http.Request) (*model.QueryRangeParams, *model.ApiError) {
start, err := parseMetricsTime(r.FormValue("start"))
if err != nil {
return nil, &model.ApiError{Typ: model.ErrorBadData, Err: err}
}
end, err := parseMetricsTime(r.FormValue("end"))
if err != nil {
return nil, &model.ApiError{Typ: model.ErrorBadData, Err: err}
}
if end.Before(start) {
err := errors.New("end timestamp must not be before start time")
return nil, &model.ApiError{Typ: model.ErrorBadData, Err: err}
}
step, err := parseMetricsDuration(r.FormValue("step"))
if err != nil {
return nil, &model.ApiError{Typ: model.ErrorBadData, Err: err}
}
if step <= 0 {
err := errors.New("zero or negative query resolution step widths are not accepted. Try a positive integer")
return nil, &model.ApiError{Typ: model.ErrorBadData, Err: err}
}
// For safety, limit the number of returned points per timeseries.
// This is sufficient for 60s resolution for a week or 1h resolution for a year.
if end.Sub(start)/step > 11000 {
err := errors.New("exceeded maximum resolution of 11,000 points per timeseries. Try decreasing the query resolution (?step=XX)")
return nil, &model.ApiError{Typ: model.ErrorBadData, Err: err}
}
queryRangeParams := model.QueryRangeParams{
Start: start,
End: end,
Step: step,
Query: r.FormValue("query"),
Stats: r.FormValue("stats"),
}
return &queryRangeParams, nil
}
func parseGetUsageRequest(r *http.Request) (*model.GetUsageParams, error) {
startTime, err := parseTime("start", r)
if err != nil {

View File

@@ -14,6 +14,7 @@ import (
)
type Reader interface {
GetInstantQueryMetricsResult(ctx context.Context, query *model.InstantQueryMetricsParams) (*promql.Result, *stats.QueryStats, *model.ApiError)
GetQueryRangeResult(ctx context.Context, query *model.QueryRangeParams) (*promql.Result, *stats.QueryStats, *model.ApiError)
GetTopLevelOperations(ctx context.Context, start, end time.Time, services []string) (*map[string][]string, *model.ApiError)
GetEntryPointOperations(ctx context.Context, query *model.GetTopOperationsParams) (*[]model.TopOperationsItem, error)

View File

@@ -4,6 +4,12 @@ import (
"time"
)
type InstantQueryMetricsParams struct {
Time time.Time
Query string
Stats string
}
type QueryRangeParams struct {
Start time.Time
End time.Time

View File

@@ -128,7 +128,7 @@ func NewTestManager(t *testing.T, testOpts *TestManagerOptions) *Manager {
meterStmtBuilder, err := meterstatementbuilder.NewFactory(metadataStore, flagger).New(ctx, providerSettings, cfg)
require.NoError(t, err)
bucketCache := querier.NewBucketCache(providerSettings, cache, 0, 0)
providerFactory := signozquerier.NewFactory(telemetryStore, prometheus, nil, metadataStore, traceStmtBuilder, logStmtBuilder, auditStmtBuilder, metricStmtBuilder, meterStmtBuilder, traceOperatorStmtBuilder, bucketCache, flagger)
providerFactory := signozquerier.NewFactory(telemetryStore, prometheus, metadataStore, traceStmtBuilder, logStmtBuilder, auditStmtBuilder, metricStmtBuilder, meterStmtBuilder, traceOperatorStmtBuilder, bucketCache, flagger)
mockQuerier, err := providerFactory.New(context.Background(), providerSettings, querier.Config{})
require.NoError(t, err)

View File

@@ -40,7 +40,6 @@ func prepareQuerierForMetrics(t *testing.T, telemetryStore telemetrystore.Teleme
telemetryStore,
metadataStore,
nil, // prometheus
nil, // promV2
nil, // traceStmtBuilder
nil, // logStmtBuilder
nil, // auditStmtBuilder
@@ -75,7 +74,6 @@ func prepareQuerierForLogs(t *testing.T, telemetryStore telemetrystore.Telemetry
telemetryStore,
metadataStore,
nil, // prometheus
nil, // promV2
nil, // traceStmtBuilder
logStmtBuilder,
nil, // auditStmtBuilder
@@ -111,7 +109,6 @@ func prepareQuerierForTraces(t *testing.T, telemetryStore telemetrystore.Telemet
telemetryStore,
metadataStore,
nil, // prometheus
nil, // promV2
traceStmtBuilder,
nil, // logStmtBuilder
nil, // auditStmtBuilder

View File

@@ -45,7 +45,6 @@ import (
"github.com/SigNoz/signoz/pkg/pprof/nooppprof"
"github.com/SigNoz/signoz/pkg/prometheus"
"github.com/SigNoz/signoz/pkg/prometheus/clickhouseprometheus"
"github.com/SigNoz/signoz/pkg/prometheus/clickhouseprometheusv2"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/querier/signozquerier"
"github.com/SigNoz/signoz/pkg/sharder"
@@ -249,7 +248,6 @@ func NewTelemetryStoreProviderFactories() factory.NamedMap[factory.ProviderFacto
func NewPrometheusProviderFactories(telemetryStore telemetrystore.TelemetryStore) factory.NamedMap[factory.ProviderFactory[prometheus.Prometheus, prometheus.Config]] {
return factory.MustNewNamedMap(
clickhouseprometheus.NewFactory(telemetryStore),
clickhouseprometheusv2.NewFactory(telemetryStore),
)
}
@@ -291,9 +289,9 @@ func NewStatsReporterProviderFactories(aggregator statsreporter.Aggregator, orgG
)
}
func NewQuerierProviderFactories(telemetryStore telemetrystore.TelemetryStore, prometheus prometheus.Prometheus, promV2 prometheus.Prometheus, metadataStore telemetrytypes.MetadataStore, traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation], logStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation], auditStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation], metricStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation], meterStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation], traceOperatorStmtBuilder qbtypes.TraceOperatorStatementBuilder, bucketCache querier.BucketCache, flagger flagger.Flagger) factory.NamedMap[factory.ProviderFactory[querier.Querier, querier.Config]] {
func NewQuerierProviderFactories(telemetryStore telemetrystore.TelemetryStore, prometheus prometheus.Prometheus, metadataStore telemetrytypes.MetadataStore, traceStmtBuilder qbtypes.StatementBuilder[qbtypes.TraceAggregation], logStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation], auditStmtBuilder qbtypes.StatementBuilder[qbtypes.LogAggregation], metricStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation], meterStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation], traceOperatorStmtBuilder qbtypes.TraceOperatorStatementBuilder, bucketCache querier.BucketCache, flagger flagger.Flagger) factory.NamedMap[factory.ProviderFactory[querier.Querier, querier.Config]] {
return factory.MustNewNamedMap(
signozquerier.NewFactory(telemetryStore, prometheus, promV2, metadataStore, traceStmtBuilder, logStmtBuilder, auditStmtBuilder, metricStmtBuilder, meterStmtBuilder, traceOperatorStmtBuilder, bucketCache, flagger),
signozquerier.NewFactory(telemetryStore, prometheus, metadataStore, traceStmtBuilder, logStmtBuilder, auditStmtBuilder, metricStmtBuilder, meterStmtBuilder, traceOperatorStmtBuilder, bucketCache, flagger),
)
}

View File

@@ -40,7 +40,6 @@ import (
"github.com/SigNoz/signoz/pkg/modules/tag/impltag"
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
"github.com/SigNoz/signoz/pkg/prometheus"
"github.com/SigNoz/signoz/pkg/prometheus/clickhouseprometheusv2"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/ruler"
@@ -300,11 +299,6 @@ func New(
retentionGetter := implretention.NewGetter(implretention.NewStore(sqlstore))
// promV2 is the clickhousev2 provider handed to the querier for shadow
// comparison and pinned serving (declared before the serving provider,
// whose variable shadows the package name below).
var promV2 prometheus.Prometheus
// Initialize prometheus from the available prometheus provider factories
prometheus, err := factory.NewProviderFromNamedMap(
ctx,
@@ -317,23 +311,6 @@ func New(
return nil, err
}
// With the default provider, also stand up the clickhousev2 provider for
// the querier: PromQL queries shadow-compare against it behind the
// use_prometheus_clickhouse_v2 flag (see pkg/querier/promql_shadow.go).
// It never serves by default. An explicit
// prometheus::provider: clickhousev2 makes v2 the serving provider
// outright, so there is nothing to compare against.
if config.Prometheus.Provider() == "clickhouse" {
v2Config := config.Prometheus
// The v2 engine only evaluates shadow and pinned queries; disable its
// active query tracker so two trackers never share a file.
v2Config.ActiveQueryTrackerConfig.Enabled = false
promV2, err = clickhouseprometheusv2.New(ctx, providerSettings, v2Config, telemetrystore)
if err != nil {
return nil, err
}
}
// Assemble the query stack (metadata store, statement builders, bucket cache) once,
// and reuse the single metadata store everywhere downstream.
telemetryMetadataStore, traceStmtBuilder, logStmtBuilder, auditStmtBuilder, metricStmtBuilder, meterStmtBuilder, traceOperatorStmtBuilder, bucketCache, err := newQueryStack(ctx, providerSettings, config, telemetrystore, cache, flagger)
@@ -346,7 +323,7 @@ func New(
ctx,
providerSettings,
config.Querier,
NewQuerierProviderFactories(telemetrystore, prometheus, promV2, telemetryMetadataStore, traceStmtBuilder, logStmtBuilder, auditStmtBuilder, metricStmtBuilder, meterStmtBuilder, traceOperatorStmtBuilder, bucketCache, flagger),
NewQuerierProviderFactories(telemetrystore, prometheus, telemetryMetadataStore, traceStmtBuilder, logStmtBuilder, auditStmtBuilder, metricStmtBuilder, meterStmtBuilder, traceOperatorStmtBuilder, bucketCache, flagger),
config.Querier.Provider(),
)
if err != nil {

View File

@@ -370,14 +370,6 @@ type QueryRangeRequest struct {
// NoCache is a flag to disable caching for the request.
NoCache bool `json:"noCache,omitempty"`
// PromQLProvider serves this request's PromQL queries via the named
// prometheus provider ("clickhousev2") instead of the default — the same
// data read through a different implementation. It is set from the
// X-SigNoz-PromQL-Provider header by the API handler, never from the
// body: a rollout-scoped comparison hook for integration tests and
// support should not become part of the public request schema.
PromQLProvider string `json:"-"`
FormatOptions *FormatOptions `json:"formatOptions,omitempty"`
}

View File

@@ -1,3 +1,5 @@
import subprocess
import pytest
pytest_plugins = [
@@ -32,6 +34,22 @@ pytest_plugins = [
]
def pytest_configure(config: pytest.Config):
if config.getoption("--rebuild"):
if not config.getoption("--reuse"):
raise pytest.UsageError("--rebuild requires --reuse: it replaces the signoz container within an environment that is being reused.")
if config.getoption("--teardown"):
raise pytest.UsageError("--rebuild cannot be combined with --teardown.")
if config.getoption("--clean"):
raise pytest.UsageError("--rebuild cannot be combined with --clean: --clean forces a cold build, which defeats the purpose of --rebuild.")
def pytest_sessionstart(session: pytest.Session):
if session.config.getoption("--clean"):
# The type filter removes only cache mounts, leaving images and layer cache intact.
subprocess.run(["docker", "builder", "prune", "--force", "--filter", "type=exec.cachemount"], check=True)
def pytest_addoption(parser: pytest.Parser):
parser.addoption(
"--reuse",
@@ -45,6 +63,18 @@ def pytest_addoption(parser: pytest.Parser):
default=False,
help="Teardown environment. Run pytest --basetemp=./tmp/ -vv --teardown src/bootstrap/setup::test_teardown to teardown your local dev environment.",
)
parser.addoption(
"--rebuild",
action="store_true",
default=False,
help="Rebuild the signoz container from the current sources while reusing the rest of the stack (databases, mocks, migrations). Only meaningful together with --reuse: pytest --basetemp=./tmp/ -vv --reuse --rebuild integration/bootstrap/setup.py::test_setup.",
)
parser.addoption(
"--clean",
action="store_true",
default=False,
help="Prune the BuildKit cache mounts (go build and module caches) used by the signoz image build, forcing the next build to start cold. Combine with --teardown to reset everything: pytest --basetemp=./tmp/ -vv --teardown --clean integration/bootstrap/setup.py::test_teardown.",
)
parser.addoption(
"--with-web",
action="store_true",

View File

@@ -168,7 +168,6 @@ def make_query_request(
variables: dict | None = None,
no_cache: bool = True,
timeout: int = QUERY_TIMEOUT,
headers: dict | None = None,
) -> requests.Response:
if format_options is None:
format_options = {"formatTableResultForUI": False, "fillGaps": False}
@@ -188,7 +187,7 @@ def make_query_request(
return requests.post(
signoz.self.host_configs["8080"].get("/api/v5/query_range"),
timeout=timeout,
headers={"authorization": f"Bearer {token}", **(headers or {})},
headers={"authorization": f"Bearer {token}"},
json=payload,
)

View File

@@ -41,6 +41,7 @@ def wrap( # pylint: disable=too-many-arguments,too-many-positional-arguments
create: Callable[[], T],
delete: Callable[[T], None],
restore: Callable[[dict], T],
rebuild: bool = False,
) -> T:
"""
Wraps a resource creation and cleanup process with reuse and teardown options.
@@ -51,6 +52,7 @@ def wrap( # pylint: disable=too-many-arguments,too-many-positional-arguments
- create: function to create the resource
- delete: function to delete the resource
- restore: function to restore resource from cache
- rebuild: under --reuse, delete the cached resource and recreate it instead of restoring it
"""
resource = empty()
@@ -58,8 +60,13 @@ def wrap( # pylint: disable=too-many-arguments,too-many-positional-arguments
existing_resource = pytestconfig.cache.get(key, None)
if existing_resource:
assert isinstance(existing_resource, dict)
logger.info("Reusing existing %s(%s)", key, existing_resource)
return restore(existing_resource)
if rebuild:
logger.info("Rebuilding %s(%s), removing the existing one", key, existing_resource)
delete(restore(existing_resource))
pytestconfig.cache.set(key, None)
else:
logger.info("Reusing existing %s(%s)", key, existing_resource)
return restore(existing_resource)
if not teardown(request):
resource = create()

View File

@@ -1,4 +1,6 @@
import os
import platform
import subprocess
import time
from http import HTTPStatus
from os import path
@@ -8,7 +10,6 @@ import docker.errors
import pytest
import requests
from testcontainers.core.container import DockerContainer, Network
from testcontainers.core.image import DockerImage
from fixtures import reuse, types
from fixtures.logger import setup_logger
@@ -50,17 +51,27 @@ def create_signoz(
# Docker build context is the repo root — one up from pytest's
# rootdir (tests/).
self = DockerImage(
path=str(pytestconfig.rootpath.parent),
dockerfile_path=dockerfile_path,
tag="signoz:integration",
buildargs={
"TARGETARCH": arch,
"ZEUSURL": zeus.container_configs["8080"].base(),
},
)
context = pytestconfig.rootpath.parent
self.build()
# The docker CLI is required: the Dockerfiles use BuildKit cache
# mounts, which docker-py does not support.
subprocess.run(
[
"docker",
"build",
"--file",
str(context / dockerfile_path),
"--tag",
"signoz:integration",
"--build-arg",
f"TARGETARCH={arch}",
"--build-arg",
f"ZEUSURL={zeus.container_configs['8080'].base()}",
str(context),
],
check=True,
env=os.environ | {"DOCKER_BUILDKIT": "1"},
)
env = (
{
@@ -200,6 +211,7 @@ def create_signoz(
create=create,
delete=delete,
restore=restore,
rebuild=pytestconfig.getoption("--rebuild"),
)

View File

@@ -1,17 +0,0 @@
{
"note": "Divergences of the clickhousev2 provider (pinned via X-SigNoz-PromQL-Provider) from the upstream reference engine, enforced exactly by 01_upstream_corpus.py in both directions. This ledger is the rollout scorecard for the provider swap: the default provider cannot be replaced by clickhousev2 while anything is listed here. Entries must carry the defect's cause and be REMOVED as the provider is fixed. Current class: the engine aggregates floats with Kahan compensated summation (sum, sum_over_time) and an overflow-free incremental mean (avg); ClickHouse's sumForEach/avgForEach/arraySum are naive, so extreme-magnitude corpus data (±1e100 cancellation, ±1.8e308 overflow) diverges on transpiled plans. Burn-down candidates: sumKahanForEach for the cancellation class; the overflow class needs an incremental-mean aggregate ClickHouse does not have.",
"divergences": {
"aggregators.test:651[base]": "avg over near-max-float64 values: engine's incremental mean never forms the overflowing sum; avgForEach sums then divides, overflowing to +Inf",
"aggregators.test:651[instant-coarse]": "same as aggregators.test:651[base] on the coarse-step grid variant",
"aggregators.test:654[base]": "avg over near-min-float64 values: engine's incremental mean never forms the overflowing sum; avgForEach overflows to -Inf",
"aggregators.test:654[instant-coarse]": "same as aggregators.test:654[base] on the coarse-step grid variant",
"aggregators.test:687[base]": "sum over {1e100, -1e100, small}: engine uses Kahan compensated summation; sumForEach's naive summation loses the small terms to cancellation and returns 0",
"aggregators.test:687[instant-coarse]": "same as aggregators.test:687[base] on the coarse-step grid variant",
"aggregators.test:695[base]": "avg over {1e100, -1e100, small}: same Kahan-vs-naive cancellation as aggregators.test:687, divided by count",
"aggregators.test:695[instant-coarse]": "same as aggregators.test:695[base] on the coarse-step grid variant",
"functions.test:1084[instant-coarse]": "sum_over_time over a window containing ±1e100: the disjoint coarse-step form's arraySum slide is naive summation, cancelling to 0 (the base variant's W>64 shape falls back to the engine and is exact)",
"functions.test:1087[instant-coarse]": "avg_over_time, same window and cancellation as functions.test:1084[instant-coarse]",
"functions.test:1149[base]": "avg_over_time over ±2.258e220-magnitude samples: engine's Kahan-compensated incremental mean cancels exactly to 0; the bucketed form's naive slide summation leaves a ~1e202 residue",
"functions.test:1149[instant-coarse]": "same as functions.test:1149[base] through the disjoint coarse-step form"
}
}

View File

@@ -3,26 +3,13 @@ Upstream promqltest conformance: replay the frozen corpus extracted from
Prometheus' own promql/promqltest testdata and assert our API returns the
reference engine's answers.
Unlike live-vs-live parity suites, the oracle here is a committed file
Unlike the parity suites, the oracle here is a committed file
(tests/integration/testdata/promqltestcorpus/corpus.json), generated by
scripts/promqltestcorpus from upstream's load scripts and the vendored
reference engine. It therefore keeps working when the serving path itself is
the thing being changed — the one situation where comparing two live paths
against each other is blind.
Every case replays on both serving paths — the default provider, and the
clickhousev2 provider pinned via the flag-gated X-SigNoz-PromQL-Provider
header (see conftest.py) — and each leg is asserted against the same frozen
expectations, each leg against its own known-divergences ledger. The legs
are deliberately never asserted against each other: both can sit within one
rounding quantum of the expected value yet differ from each other by up to
two quanta when a true value straddles a rounding boundary, so a leg-vs-leg
equality check would reintroduce exactly the boundary noise the quantum
tolerance exists to absorb. Because both legs anchor to the same oracle over
the same ingested bytes, a case failing on one leg while passing on the
other already localizes the defect to that provider — and the printed
DIVERGED lines for both legs are the side-by-side view for triage.
Datasets are placed on disjoint time windows (2h isolation gaps, far beyond
the 5m lookback) so one bulk ingest serves every case without cross-talk.
Expected values carry the API's 3-significant-decimal rounding, mirrored by
@@ -44,30 +31,12 @@ from fixtures.querier import get_all_series, make_query_request
TESTDATA_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "testdata")
CORPUS_FILE = os.path.join(TESTDATA_DIR, "promqltestcorpus", "corpus.json")
# One ledger per leg, enforced exactly in both directions. The default leg's
# ledger is empty and pinned there; the clickhousev2 ledger is the rollout
# scorecard — the provider swap is measured by burning it down to empty.
LEDGER_FILES = {
"default": os.path.join(TESTDATA_DIR, "promqltestcorpus", "known_divergences.json"),
"clickhousev2": os.path.join(TESTDATA_DIR, "promqltestcorpus", "known_divergences_v2.json"),
}
LEGS: list[tuple[str, dict | None]] = [
("default", None),
("clickhousev2", {"X-SigNoz-PromQL-Provider": "clickhousev2"}),
]
KNOWN_DIVERGENCES_FILE = os.path.join(TESTDATA_DIR, "promqltestcorpus", "known_divergences.json")
ISOLATION_GAP_MS = 2 * 3600 * 1000
SPECIALS = {"NaN": math.nan, "Inf": math.inf, "-Inf": -math.inf}
def _decode(v: float | str) -> float:
if isinstance(v, str):
return SPECIALS[v]
return float(v)
def _values_close(a: float, b: float) -> bool:
if math.isnan(a) or math.isnan(b):
return math.isnan(a) and math.isnan(b)
@@ -90,10 +59,6 @@ def _values_close(a: float, b: float) -> bool:
return abs(a - b) <= quantum + 1e-12
def _labelset(labels: dict[str, str]) -> tuple:
return tuple(sorted(labels.items()))
def _response_series(data: dict) -> tuple[dict[tuple, dict[int, float]], list[tuple]]:
"""Returns (series map, duplicate labelsets). A response carrying several
series with identical visible labels is itself a defect signal (e.g. a
@@ -104,70 +69,14 @@ def _response_series(data: dict) -> tuple[dict[tuple, dict[int, float]], list[tu
# Empty results serialize with null aggregations/series/values fields.
for series in get_all_series(data, "A") or []:
lbls = {l["key"]["name"]: str(l["value"]) for l in series.get("labels") or []}
points = {int(v["timestamp"]): _decode(v["value"]) for v in series.get("values") or []}
key = _labelset(lbls)
points = {int(v["timestamp"]): SPECIALS[v["value"]] if isinstance(v["value"], str) else float(v["value"]) for v in series.get("values") or []}
key = tuple(sorted(lbls.items()))
if key in out:
duplicates.append(key)
out[key] = points
return out, duplicates
def _case_failure(
signoz: types.SigNoz,
token: str,
case: dict,
base: int,
headers: dict | None,
) -> str | None:
"""Replays one corpus case on one leg; returns a failure line or None."""
start_ms = base + case["start_ms"]
end_ms = base + case["end_ms"]
step_s = max(1, case["step_ms"] // 1000)
req_start_ms = start_ms
if case["instant"]:
# The API rejects start == end; ask for one extra step backward
# and compare only at the instant timestamp. Nudging the start
# earlier instead of the end later keeps every window that the
# expected values were computed from untouched.
req_start_ms = start_ms - step_s * 1000
query = {
"type": "promql",
"spec": {"name": "A", "query": case["expr"], "step": step_s},
}
case_id = f"{case['source']}[{case['variant']}]"
response = make_query_request(signoz, token, req_start_ms, end_ms, [query], headers=headers)
if response.status_code != HTTPStatus.OK:
return f"{case_id}: HTTP {response.status_code} for {case['expr']!r}: {response.text[:200]}"
actual, duplicates = _response_series(response.json())
if duplicates:
return f"{case_id}: response carries multiple series with identical labels for {case['expr']!r}: {[dict(d) for d in duplicates[:3]]}"
if case["instant"]:
# Keep only the instant point; the extra grid step is a request
# encoding byproduct, not part of the assertion.
actual = {lset: {ts: v for ts, v in pts.items() if ts == end_ms} for lset, pts in actual.items()}
actual = {lset: pts for lset, pts in actual.items() if pts}
expected: dict[tuple, dict[int, float]] = {}
for res in case["expected"]:
points = {base + off_ms: _decode(v) for off_ms, v in res["points"]}
expected[_labelset(res["labels"])] = points
if set(actual) != set(expected):
missing = set(expected) - set(actual)
extra = set(actual) - set(expected)
return f"{case_id}: series mismatch for {case['expr']!r} (missing={sorted(missing)[:3]} extra={sorted(extra)[:3]}) actual={[(dict(k), {t - base: v for t, v in pts.items()}) for k, pts in actual.items()]}"
for lset, exp_points in expected.items():
act_points = actual[lset]
if set(act_points) != set(exp_points):
return f"{case_id}: timestamp mismatch for {case['expr']!r} series {dict(lset)} (expected {len(exp_points)} points, got {len(act_points)})"
for ts, exp_v in exp_points.items():
if not _values_close(act_points[ts], exp_v):
return f"{case_id}: value mismatch for {case['expr']!r} series {dict(lset)} at {ts}: expected {exp_v}, got {act_points[ts]}"
return None
def test_upstream_promqltest_corpus(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
@@ -212,7 +121,7 @@ def test_upstream_promqltest_corpus(
metric_name=metric_name,
labels=labels,
timestamp=datetime.fromtimestamp((cursor + off_ms) / 1000, tz=UTC),
value=0.0 if stale else _decode(raw),
value=0.0 if stale else (SPECIALS[raw] if isinstance(raw, str) else float(raw)),
flags=1 if stale else 0,
)
)
@@ -221,36 +130,79 @@ def test_upstream_promqltest_corpus(
insert_metrics(metrics)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
failures: dict[str, list[str]] = {leg: [] for leg, _ in LEGS}
failures: list[str] = []
for case in corpus["cases"]:
for leg, headers in LEGS:
f_line = _case_failure(signoz, token, case, bases[case["dataset"]], headers)
if f_line:
failures[leg].append(f_line)
base = bases[case["dataset"]]
start_ms = base + case["start_ms"]
end_ms = base + case["end_ms"]
step_s = max(1, case["step_ms"] // 1000)
req_start_ms = start_ms
if case["instant"]:
# The API rejects start == end; ask for one extra step backward
# and compare only at the instant timestamp. Nudging the start
# earlier instead of the end later keeps every window that the
# expected values were computed from untouched.
req_start_ms = start_ms - step_s * 1000
query = {
"type": "promql",
"spec": {"name": "A", "query": case["expr"], "step": step_s},
}
for leg, _ in LEGS:
for f_line in failures[leg]:
print("DIVERGED", f"[{leg}]", f_line)
case_id = f"{case['source']}[{case['variant']}]"
response = make_query_request(signoz, token, req_start_ms, end_ms, [query])
if response.status_code != HTTPStatus.OK:
failures.append(f"{case_id}: HTTP {response.status_code} for {case['expr']!r}: {response.text[:200]}")
continue
# Known divergences are defects of that leg's serving path, frozen with
# reasons. Each set is enforced exactly in both directions: a NEW
actual, duplicates = _response_series(response.json())
if duplicates:
failures.append(f"{case_id}: response carries multiple series with identical labels for {case['expr']!r}: {[dict(d) for d in duplicates[:3]]}")
continue
if case["instant"]:
# Keep only the instant point; the extra grid step is a request
# encoding byproduct, not part of the assertion.
actual = {lset: {ts: v for ts, v in pts.items() if ts == end_ms} for lset, pts in actual.items()}
actual = {lset: pts for lset, pts in actual.items() if pts}
expected: dict[tuple, dict[int, float]] = {}
for res in case["expected"]:
points = {base + off_ms: SPECIALS[v] if isinstance(v, str) else float(v) for off_ms, v in res["points"]}
expected[tuple(sorted(res["labels"].items()))] = points
if set(actual) != set(expected):
missing = set(expected) - set(actual)
extra = set(actual) - set(expected)
failures.append(f"{case_id}: series mismatch for {case['expr']!r} (missing={sorted(missing)[:3]} extra={sorted(extra)[:3]}) actual={[(dict(k), {t - base: v for t, v in pts.items()}) for k, pts in actual.items()]}")
continue
for lset, exp_points in expected.items():
act_points = actual[lset]
if set(act_points) != set(exp_points):
failures.append(f"{case_id}: timestamp mismatch for {case['expr']!r} series {dict(lset)} (expected {len(exp_points)} points, got {len(act_points)})")
break
for ts, exp_v in exp_points.items():
if not _values_close(act_points[ts], exp_v):
failures.append(f"{case_id}: value mismatch for {case['expr']!r} series {dict(lset)} at {ts}: expected {exp_v}, got {act_points[ts]}")
break
else:
continue
break
for f_line in failures:
print("DIVERGED", f_line)
# Known divergences are defects of the current serving path, frozen with
# reasons. The set is enforced exactly in both directions: a NEW
# divergence is a regression, and a known divergence that starts passing
# must be removed from the file. Problems across both legs are collected
# before asserting so one leg's failure never hides the other's.
problems: list[str] = []
for leg, _ in LEGS:
known: dict[str, str] = {}
if os.path.exists(LEDGER_FILES[leg]):
with open(LEDGER_FILES[leg], encoding="utf-8") as f:
known = json.load(f)["divergences"]
# must be removed from the file — that is the ledger the serving-path
# swap is measured against.
known: dict[str, str] = {}
if os.path.exists(KNOWN_DIVERGENCES_FILE):
with open(KNOWN_DIVERGENCES_FILE, encoding="utf-8") as f:
known = json.load(f)["divergences"]
failed_ids = {f_line.split(": ", 1)[0] for f_line in failures[leg]}
unexpected = [f_line for f_line in failures[leg] if f_line.split(": ", 1)[0] not in known]
now_passing = sorted(set(known) - failed_ids)
failed_ids = {f_line.split(": ", 1)[0] for f_line in failures}
unexpected = [f_line for f_line in failures if f_line.split(": ", 1)[0] not in known]
now_passing = sorted(set(known) - failed_ids)
if unexpected:
problems.append(f"[{leg}] {len(unexpected)} corpus cases diverged beyond the known set:\n" + "\n".join(unexpected[:25]))
if now_passing:
problems.append(f"[{leg}] {len(now_passing)} known divergences now pass — remove them from {os.path.basename(LEDGER_FILES[leg])}: {now_passing[:25]}")
assert not problems, "\n\n".join(problems)
assert not unexpected, f"{len(unexpected)} corpus cases diverged beyond the known set:\n" + "\n".join(unexpected[:25])
assert not now_passing, f"{len(now_passing)} known divergences now pass — remove them from known_divergences.json: {now_passing[:25]}"

View File

@@ -1,39 +0,0 @@
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_conformance(
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:
"""
Package-scoped SigNoz with use_prometheus_clickhouse_v2 on, so the corpus
can replay every case twice: once against the default provider and once
pinned to the clickhousev2 provider via the X-SigNoz-PromQL-Provider
header (which the flag gates). Each leg is asserted against the same
frozen expectations — see 01_upstream_corpus.py for why the legs are
never asserted against each other.
"""
return create_signoz(
network=network,
zeus=zeus,
gateway=gateway,
sqlstore=sqlstore,
clickhouse=clickhouse,
request=request,
pytestconfig=pytestconfig,
cache_key="signoz-promql-conformance",
env_overrides={
"SIGNOZ_FLAGGER_CONFIG_BOOLEAN_USE__PROMETHEUS__CLICKHOUSE__V2": True,
},
)