Skip to content

Implement PromQL functions sum_over_time(), avg_over_time() and count_over_time() - #112353

Open
valerypetrov wants to merge 82 commits into
ClickHouse:masterfrom
valerypetrov:promql/sum-avg-count-over-time
Open

Implement PromQL functions sum_over_time(), avg_over_time() and count_over_time()#112353
valerypetrov wants to merge 82 commits into
ClickHouse:masterfrom
valerypetrov:promql/sum-avg-count-over-time

Conversation

@valerypetrov

@valerypetrov valerypetrov commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Related: #57545

Changelog category (leave one):

  • Experimental Feature

Changelog entry (a user-readable short description of the changes that goes into CHANGELOG.md):

PromQL: implemented functions sum_over_time(), avg_over_time() and count_over_time().

Description

Implements three PromQL range-vector functions previously reported as not implemented. sum_over_time, avg_over_time and count_over_time are three different projections of the same (sum, count) accumulator, so they share one new header (AggregateFunctionTimeseriesSumCountAvg.h) with a single invertible Summary{sum, count} monoid (merge adds both fields, unmerge subtracts them), plugged into the existing generic AggregateFunctionTimeseriesSlidingSum sliding-window fast path — the same infrastructure already proven by rate/increase/delta. No changes were needed to the generic grid/bucket machinery (AggregateFunctionTimeseriesBase.h) or to checkArgumentTypes (all three take a single range-vector argument, the existing default).

Registered timeSeriesSumToGrid/timeSeriesAvgToGrid/timeSeriesCountToGrid in AggregateFunctionTimeseriesHelpers.cpp and added the three impl_map entries in applyFunctionOverRange.cpp with drop_metric_name = true, matching rate/increase/delta's convention.

Verified against Prometheus 3.5.0.

Testing: new SQL-level test (04628_timeseries_sum_avg_count_over_time.sql) plus cases in tests/integration/test_prometheus_protocols/test_evaluation.py covering all three functions, an empty-window case, a window narrower than the step (grid points with no sample must be absent/NULL, not zero), and a nested avg_over_time(rate(...)[2m:10s]) case. This flips 19 queries of the PromQL compliance suite from unsupported to passed.


Workflow [PR]
Sync PR [sync-upstream/pr/112353]

@nikitamikhaylov nikitamikhaylov added the comp-promql PromQL / time-series subsystem: TimeSeries storage engine, PromQL parser, PromQL-to-SQL converter... label Jul 28, 2026
@alexey-milovidov alexey-milovidov added the can be tested Allows running workflows for external contributors label Jul 28, 2026
@clickhouse-gh

clickhouse-gh Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Workflow [PR], commit [d76d224]


AI Review

Summary

This PR adds PromQL sum_over_time, avg_over_time, and count_over_time, updates the timeSeries*ToGrid implementations and docs behind them, and carries the new count_over_time() Float64 override through several PromQL result carriers. I still see one remaining correctness gap on the empty-result path, so the PR is not ready to merge as-is.

Findings

⚠️ Majors

  • [src/Storages/TimeSeries/PrometheusQueryToSQL/applyOneArgumentAggregationOperator.cpp:177-185; src/Storages/TimeSeries/PrometheusQueryToSQL/applyAggregationOperatorQuantile.cpp:103-105; src/Storages/TimeSeries/PrometheusQueryToSQL/applyLimitAggregationOperator.cpp:175-177] [dismissed by author -- https://github.com/Implement PromQL functions sum_over_time(), avg_over_time() and count_over_time() #112353#discussion_r3725727536] Empty-result aggregation wrappers still rebuild a fresh StoreMethod::EMPTY piece without forwarding the input value_data_type, so sum(count_over_time(vector(1)[19:20])), quantile(0.5, count_over_time(vector(1)[19:20])), or topk(1, count_over_time(vector(1)[19:20])) on a Float32 TimeSeries table narrows back to Float32 exactly when the input range is compile-time empty. The same wrappers preserve Float64 as soon as the input is non-empty, so the new count_over_time() contract is still inconsistent across carriers.
    Suggested fix: preserve the child vector's value_data_type on those early StoreMethod::EMPTY returns, then add one focused DESCRIBE regression on a Float32 table for an aggregated empty query.
Tests
  • ⚠️ Add an end-to-end Float32 regression for at least one aggregated empty query, for example sum(count_over_time(vector(1)[19:20])), and ideally one parameterized wrapper such as quantile(0.5, ...) or topk(1, ...), asserting that DESCRIBE still reports Float64.
Final Verdict

⚠️ Needs changes before merge: count_over_time()'s fixed Float64 result type is still dropped by some empty-result aggregation wrappers.

@clickhouse-gh clickhouse-gh Bot added the pr-experimental Experimental Feature label Jul 28, 2026
Comment thread src/Storages/TimeSeries/PrometheusQueryToSQL/applyDateTimeFunction.cpp Outdated
Comment thread src/AggregateFunctions/TimeSeries/AggregateFunctionTimeseriesSumCountAvg.h Outdated
…er_time()

Add a new AggregateFunctionTimeseriesSumCountAvg aggregate function that
computes a shared (sum, count) accumulator over the sliding grid window
and projects it to sum, avg or count depending on a compile-time Kind
selector, mirroring the existing bool-template idiom used by
AggregateFunctionTimeseriesExtrapolatedValue. The (sum, count) Summary is
a trivially invertible monoid, so it reuses
AggregateFunctionTimeseriesSlidingSum's fast running-sum path instead of
the two-stacks/recompute paths needed by non-invertible aggregates.

Register timeSeriesSumToGrid, timeSeriesAvgToGrid and timeSeriesCountToGrid
in AggregateFunctionTimeseriesHelpers.cpp, and wire sum_over_time,
avg_over_time and count_over_time into applyFunctionOverRange.cpp's
impl_map with drop_metric_name=true (these are value-transforming
aggregations, like rate/increase/delta, not last_over_time's passthrough).

Add SQL-level test 04628_timeseries_sum_avg_count_over_time and PromQL
regression tests in test_evaluation.py covering all three functions plus
the empty-window/no-samples case.
@valerypetrov
valerypetrov force-pushed the promql/sum-avg-count-over-time branch from 00cc2e9 to 0beac3a Compare July 29, 2026 00:03
…input value type

timeSeriesCountToGrid projected its result as the input value column's
ValueType, so a Float32 `value` column made count_over_time return
Array(Nullable(Float32)): counts above 16777216 rounded, and even small
counts were constrained to Float32 precision. This contradicted the
function's documented Array(Nullable(Float64)) result.

AggregateFunctionTimeseriesBase now lets Traits optionally declare a
ResultType distinct from ValueType (defaulting to ValueType for every
other timeseries aggregate, so their behavior is unchanged), and the
Count specialization of AggregateFunctionTimeseriesSumCountAvgTraits
uses it to always project to Float64. Sum and Avg keep projecting to
ValueType as before.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Comment thread src/AggregateFunctions/TimeSeries/AggregateFunctionTimeseriesSumCountAvg.h Outdated
…on and fix Float32 date-function precision

Two Float32 TimeSeries-table precision gaps remained after the previous
commit, both flagged by review on this PR:

- count_over_time() was only fixed at the aggregate-function layer.
  applyFunctionOverRange() still handed its result to a VECTOR_GRID, and
  finalizeSQL()/getResultColumns() unconditionally cast/declared every
  VECTOR_GRID's value column as context.scalar_data_type (the table's
  sample type), rounding the new exact Float64 count back down to Float32
  before it reached the client. SQLQueryPiece gains an optional
  value_data_type override (nullptr by default, so every other function is
  unaffected); applyFunctionOverRange()'s ImplInfo sets it to Float64 only
  for count_over_time(), and finalizeSQL()'s two VECTOR_GRID branches now
  consult it. getResultColumns() had no SQLQueryPiece to consult at all, so
  Converter::getResultColumns() now builds one via the same tree walk
  getSQL() uses, but only trusts the override when the top-level piece's
  own value column is actually produced with StoreMethod::VECTOR_GRID -
  matching exactly the condition finalizeSQL() now checks, so the declared
  and actual result types can never disagree for any composition.

- Date/time functions (minute(), hour(), day_of_week(), etc.) still
  computed their result from an evaluation-time argument that flowed
  through context.scalar_data_type, which can be Float32 and only has
  ~128 seconds of precision at today's epoch magnitude - enough to flip
  the returned calendar component near a boundary (e.g. minute(vector(time()))
  at 1770582700, which rounds to 1770582656). This is the same bug already
  fixed on the unrelated promql/date-funcs-0arg branch, but that fix relied
  on a 0-argument date-function feature and a makeTimeQueryPiece()/Native()
  split this branch doesn't have. Ported the same idea instead:
  fromFunctionTimeNative() is a sibling of fromFunctionTime() that keeps
  the evaluation time typed context.timestamp_data_type; applyDateTimeFunction()
  detects the vector(time()) argument shape at the AST level and swaps in
  the native-precision piece before delegating to applySimpleFunction().
  time() itself is untouched and still legitimately returns a scalar/float
  value.

Added Float32-table PromQL regressions for both: an end-to-end
count_over_time() check (16777217 raw samples, verified via both the SQL
table-function path and the standalone dynamic-table HTTP API path) and a
minute(vector(time())) check at a non-128-aligned timestamp, both via
instant and range queries.

Verified against the built binary: count_over_time's Float64 precision and
the date-function fix work end-to-end; sum_over_time/avg_over_time/rate/
increase/delta/last_over_time remain unaffected (spot-checked and via the
unchanged 04628_timeseries_sum_avg_count_over_time.sql reference); the
standalone compliance harness still reports the unchanged baseline of
402 pass / 11 fail / 124 unsupported / 2 ref_mismatch (539 total).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Comment thread src/Storages/TimeSeries/PrometheusQueryToSQL/Converter.cpp Outdated
…ppers

The previous fix only preserved count_over_time()'s exact Float64 result
through the direct top-level VECTOR_GRID finalization path. Once the result
flowed through a wrapper, the override was silently dropped and the value
was cast back down to the table's sample type:

- applySimpleFunction() (abs(), sin(), date/time functions, etc.) built its
  result as a fresh SQLQueryPiece and never copied value_data_type from its
  VECTOR_GRID argument, so abs(count_over_time(...)) lost the override
  entirely.
- finalizeSQL()'s SINGLE_SCALAR/SCALAR_GRID branches (used by scalar() and
  by vector()-wrapped/subqueried scalars) unconditionally cast to
  context.scalar_data_type, ignoring value_data_type even when set.
- Converter::getResultColumns() only honored value_data_type for
  INSTANT_VECTOR/RANGE_VECTOR results produced via VECTOR_GRID, so a
  top-level scalar(count_over_time(...)) query would still advertise the
  table's sample type as its column type.

Fix all three: applySimpleFunction() now copies value_data_type from its
(at most one) VECTOR_GRID argument; every SINGLE_SCALAR/SCALAR_GRID
finalization branch now applies the same value_data_type-or-fallback
pattern already used by the VECTOR_GRID branches; and the getResultColumns()
override condition now matches store method (VECTOR_GRID/SINGLE_SCALAR/
SCALAR_GRID) instead of gating on ResultType, keeping it in sync with
finalizeSQL().

Verified end-to-end on a Float32 TimeSeries table that scalar(), abs(), and
vector(scalar(...)) wrapping (including through a subquery) all now return
the exact Float64 count instead of rounding it back to Float32, while
sum_over_time()/avg_over_time() and unrelated applySimpleFunction() users
(rate(), bool-modifier comparisons) are unaffected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Comment thread src/Storages/TimeSeries/PrometheusQueryToSQL/applyFunctionOverRange.cpp Outdated
Comment thread src/Storages/TimeSeries/PrometheusQueryToSQL/applySimpleFunction.cpp Outdated
Comment thread tests/integration/test_prometheus_protocols/test_evaluation.py Outdated
Three more places silently dropped count_over_time()'s Float64 override
back to the table's sample type:

- applyFunctionOverRange() gated the override on `has_group`, but `has_group`
  only reflects whether the *argument* was grouped, not the type of the
  aggregate function actually being built. count_over_time() over a
  scalar/subquery-sourced range vector (e.g. count_over_time(vector(1)[...]))
  produced a SCALAR_GRID/ungrouped piece and lost the override entirely.

- applySimpleFunction() only copied value_data_type in its VECTOR_GRID
  branch. A scalar()-wrapped count_over_time() result reaching this function
  as a SINGLE_SCALAR/SCALAR_GRID/CONST_SCALAR argument (e.g. as an operand of
  a math/comparison operator) lost the override again.

- The binary-operator files that build a fresh VECTOR_GRID SQLQueryPiece
  (applySimpleBinaryOperator, applyBinaryOperatorOr/And/Unless) never copied
  value_data_type from either operand, so combining two count_over_time()
  results (or mixing one with an ordinary metric) via +, or, and, or unless
  lost the override too.

Fixed each based on which operand(s) actually determine the output value:
arithmetic operators and `or` may draw from either operand, so they merge
value_data_type via a new mergeValueDataType() helper (falling back to the
default type on a genuine conflict, which can't happen today since Float64
is the only override in use); `and`/`unless` only ever forward the left
operand's own values, so they take left's value_data_type unconditionally.

Comparison operators without the bool modifier needed special handling:
they act as a filter that keeps exactly one operand's value unmodified
(whichever side is/contains the instant vector), so applyComparisonOperator()
now captures that side's value_data_type before calling
applySimpleBinaryOperator() and applies it to the result afterwards -
otherwise a plain metric compared against a count_over_time() result would
wrongly inherit Float64 from the *discarded* operand.

Audited the remaining files that reference these store methods
(applyAggregationOperatorQuantile, applyHistogramQuantile,
applyLabelManipulationFunction, applyLimitAggregationOperator, applyOffset,
applyOneArgumentAggregationOperator, applyUnaryOperator, dropMetricName,
fromFunctionTime, toVectorGrid): all but applyHistogramQuantile and
fromFunctionTime already preserve value_data_type correctly because they
mutate their argument/result in place or copy it wholesale, and doing so is
the right call - e.g. sum/min/max/quantile/topk of count_over_time() results
should stay exact Float64. applyHistogramQuantile() computes a genuinely new
interpolated estimate (not a copy of its input), so it correctly keeps
defaulting to the table's sample type; fromFunctionTime() takes no arguments
and can never receive a count_over_time()-derived value at all.

Verified end-to-end on a Float32 TimeSeries table: all newly-fixed cases now
return exact Float64, all four previously-fixed cases (bare count_over_time,
scalar(), abs(), vector(scalar())) still work, ordinary (non-count_over_time)
uses of every touched file are unaffected (regular +, comparisons with and
without bool, or/and/unless, unary minus, topk, sum/quantile by(...),
label_replace, offset/@), and quantile/sum aggregation of count_over_time()
correctly stays exact Float64 through aggregation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Comment thread src/AggregateFunctions/TimeSeries/AggregateFunctionTimeseriesHelpers.cpp Outdated
Comment thread tests/integration/test_prometheus_protocols/test_evaluation.py Outdated
…e docs

Two issues found by review, both confirmed against current code:

1. test_date_time_functions_float32_precision()'s instant- and range-query
   assertions used execute_query_in_prometheus()/execute_range_query_in_prometheus(),
   which hit the real Prometheus reader/receiver services. Per
   configs/prometheus.xml, those services' /api/v1/query and /api/v1/query_range
   handlers (my_rule_5/my_rule_6) are hardcoded to `default.prometheus`, never
   `prometheus_f32` - so neither assertion ever touched the Float32 table this
   test exists to cover. Worse, since the query is vector(time())-based (no
   series selector), real Prometheus can evaluate it entirely client-side
   without even issuing a remote-read, so these assertions may not have
   exercised ClickHouse's PromQL engine at all. The test would have stayed
   green even if the Float32 date-function fix were completely reverted.

   Fixed both assertions using the same dynamic-table HTTP API pattern
   test_count_over_time_float32_precision() already uses: hit ClickHouse's
   own /dynamic_table/api/v1/query handler (my_rule_10) directly, with the
   table supplied via the `table` query parameter so it can actually target
   `prometheus_f32`. No equivalent range-query handler existed, so added
   my_rule_12 (/dynamic_table/api/v1/query_range) mirroring my_rule_10. This
   is a low-risk, purely additive config change: PrometheusRequestHandler.cpp
   dispatches Instant vs. Range by the trailing path segment of the actual
   request URL (uri_path.ends_with("/query_range") vs "/query"), not by
   anything baked into the handler's configured <url>, so the new rule can't
   affect any existing handler's behavior. Chose this over dropping the
   range-query HTTP-API assertion as redundant with the SQL-level check below
   it, since covering both the HTTP API and SQL surfaces (like the
   count_over_time precision test already does) is more thorough for the
   same low cost.

   Audited every other execute_query_in_prometheus()/execute_range_query_in_prometheus()
   call in the file for the same table-mismatch bug: only this test function
   had it. test_count_over_time_float32_precision() already used the correct
   dynamic-table pattern throughout.

2. AggregateFunctionTimeseriesHelpers.cpp's ReturnedValue doc strings for
   timeSeriesSumToGrid/timeSeriesAvgToGrid claimed a fixed
   `Array(Nullable(Float64))` result. That's only true for
   timeSeriesCountToGrid - per AggregateFunctionTimeseriesSumCountAvg.h's
   ResultType, Sum/Avg deliberately keep projecting to the input `value`
   column's own type (confirmed correct and already covered by
   04628_timeseries_sum_avg_count_over_time.sql, which explicitly checks
   Float32 input yielding Array(Nullable(Float32)) for both). Fixed the docs,
   not the behavior: reworded both descriptions to "of the same type as
   `value`" (matching the existing "of the same type as `x`" convention used
   in FunctionsRound.cpp/changeDate.cpp) and changed their `types` list to
   `Array(Nullable(Float*))` (matching the `Float*` wildcard convention
   AggregateFunctionQuantileExactWeighted.cpp etc. use for the same
   type-preserving pattern).

Verification:
- Built standalone via ninja; `SELECT returned_value FROM system.functions`
  confirms the new Sum/Avg doc text renders correctly at runtime, and
  timeSeriesCountToGrid's doc is untouched.
- Ran 04628_timeseries_sum_avg_count_over_time.sql directly and diffed
  against its .reference file: zero differences, confirming the doc fix is
  behavior-neutral.
- Docker isn't available in this sandbox (as in prior rounds on this
  branch), so replicated the fixed integration-test assertions by hand
  against a standalone clickhouse-server using the exact same
  prometheus_test_utils helpers the test calls: both the instant- and
  range-query assertions pass against a live `prometheus_f32` table
  (minute=31 via both endpoints, matching the date-function fix), and
  pointing the same query at a nonexistent table returns an error rather
  than silently succeeding - confirming the assertions are genuinely keyed
  off the `table` parameter and therefore do exercise `prometheus_f32`,
  unlike the assertions they replace.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Comment thread src/Storages/TimeSeries/PrometheusQueryToSQL/applyDateTimeFunction.cpp Outdated
…pers around time()

The Float32-precision fast path for date/time functions (minute(), hour(), etc.)
previously only fired for the exact vector(time()) AST shape. But applyFunctionScalar()
and applyFunctionVector() are value-preserving no-ops for their argument's underlying
value, and applyUnaryOperator()'s '+' case is too, so wrapper forms like
scalar(vector(time())) or vector(scalar(vector(time()))) bypassed the fast path and
fell back to the lossy context.scalar_data_type cast, which can be Float32 on a
TimeSeries table with Float32 samples.

Generalize asVectorOfTime() into findTimeCallThroughScalarVectorWrappers(), which
recursively peels off any nesting of scalar(...), vector(...), and unary '+' around a
bare time() call before falling back to the generic (possibly lossy) conversion path.
Unary '-' is intentionally not peeled, since it actually negates the value rather than
passing it through unchanged.
…ppers

findTimeCallThroughScalarVectorWrappers() already peeled scalar()/vector()/
unary '+' wrappers around time() before falling back to the lossy
context.scalar_data_type cast. Extend it to also peel an Offset node
(the "@ <timestamp>" / "offset <duration>" AST wrapper): applyOffset.cpp's
offsetEvaluationTime()/setEvaluationTime() are value-preserving no-ops for
CONST_SCALAR/SINGLE_SCALAR/SCALAR_GRID pieces (only start_time/end_time/step
bookkeeping changes, never scalar_value/select_query), and
NodeEvaluationRangeGetter pre-computes each node's evaluation range in a
separate upfront pass, already folding the @/offset adjustment into the
range it assigns to the wrapped inner expression - so looking up the range
for the innermost time() node directly still yields the correctly shifted
start_time/end_time.

Verified empirically that, per the current PromQL grammar/parser, an Offset
node is only ever constructed directly around an InstantSelector,
RangeSelector, or Subquery - never directly around a Function or
UnaryOperator - so this branch is presently unreachable via any valid
PromQL syntax (queries like `vector(time()) @ 123` fail to parse). It's
kept as a defensive, provably non-regressing generalization consistent
with the existing scalar()/vector()/unary-'+' peeling, documented inline.
No new regression test was added for this specific path since no valid
query exercises it; all pre-existing regression tests (Float32 date/time
precision, count_over_time/sum_over_time/avg_over_time, value_data_type
propagation) and SQL-level PromQL parser tests were re-verified to still
pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@clickhouse-gh

clickhouse-gh Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

LLVM Coverage Report

Metric Baseline Current Δ
Lines 86.40% 86.40% +0.00%
Functions 91.90% 91.90% +0.00%
Branches 78.60% 78.50% -0.10%

Changed lines: Changed C/C++ lines covered: 429/448 (95.76%) · Uncovered code

Full report · Diff report

@nikitamikhaylov

Copy link
Copy Markdown
Member

Please condense all the comments in the code to a single (max two) lines.

@valerypetrov

Copy link
Copy Markdown
Contributor Author

Please condense all the comments in the code to a single (max two) lines.

No worries, let me fix that. Thank you for the review!

@clickhouse-gh

clickhouse-gh Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Build profile diff (arm_release)

Comparing d76d22482 with master 9adeb11c7 (stripped binary size, per-symbol sizes and ThinLTO time; compile times per translation unit against the most recent warmup build that recompiled it).

⚠️ Significant changes: object file sizes.

Binary sizes
Binary Master PR Δ
programs/clickhouse-stripped 710.14 MiB 707.74 MiB -2.40 MiB (-0.34%)

Only the stripped binary is compared: the official master build keeps debug symbols while PR builds strip them, so the other binaries differ by construction.

Object file sizes ⚠️

41 object files changed (+1.46 MiB total), 0 added.

Object file Master PR Δ
src/AggregateFunctions/CMakeFiles/clickhouse_aggregate_functions.dir/TimeSeries/AggregateFunctionTi… 4.90 MiB 6.31 MiB +1.41 MiB (+28.72%)

716 more object files are built by the master warmup baseline only (it builds every object-file target, a pull request build only clickhouse-bundle) and not compared.

Compile time of recompiled translation units

48 translation units recompiled, 114 s compile time in total, 48 of them have a recent master baseline.

Job report

@vitlibar vitlibar self-assigned this Aug 4, 2026
Resolves a conflict in getResultColumns.cpp where master renamed the
PQT alias to PrometheusQueryTree while this branch added the
value_data_type_override parameter for sum_over_time/avg_over_time/
count_over_time result typing; kept both changes.

Also fixes two spots left over by git's automatic (non-conflicting)
merge in fromFunctionTime.{h,cpp} and applyDateTimeFunction.cpp that
still referenced the now-removed PQT alias after master dropped it,
replacing them with PrometheusQueryTree to match the rest of the
codebase.
Comment thread src/Storages/TimeSeries/PrometheusQueryToSQL/applyFunctionOverRange.cpp Outdated
valerypetrov and others added 2 commits August 25, 2026 05:32
These functions return Array(Nullable(Float64)), one value per grid point, so
the added assertions must not apply tupleElement to an element: the tuples in
this test's other expectations come from a query that zips the timestamps in
itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcH1uX3QLcLes3XfidQqsC
The nanosecond-precision test asserted that a carrier inside `topk`'s first
argument leaves the result unrestricted, which is the assumption the review
refuted: `k` decides how many series survive. That query's `k` is in fact far
larger than the series count, so its own answer was right, but the walk cannot
prove that and fails closed on what it cannot prove - the same treatment unary
`-` already gets here.

Also fixes the added nested-empty regression: a range selector applies only to
a vector selector, so the inner argument has to be a subquery.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcH1uX3QLcLes3XfidQqsC
Comment thread src/Storages/TimeSeries/PrometheusQueryToSQL/applyDateTimeFunction.cpp Outdated
Review feedback from vitlibar: keep only what sum_over_time / avg_over_time /
count_over_time need.

Dropped the evaluation-time carrier feature entirely - applyDateTimeFunction's
carrier walk, fromFunctionTime, the round_evaluation_time_down context flag and
the Converter refactor that exported convertNode for it, along with its tests
and the query_range handler they used. Those four source files are back to their
master contents.

Kept the value type override plumbing: it exists because count_over_time returns
Float64 on a table whose samples are Float32, so it has to survive every
operator, the empty-result short circuits and the result-column schema. That is
this PR's own contract, not the removed feature's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcH1uX3QLcLes3XfidQqsC
Comment thread src/AggregateFunctions/TimeSeries/AggregateFunctionTimeseriesSumCountAvg.h Outdated
valerypetrov and others added 3 commits August 25, 2026 16:45
Review feedback from vitlibar: split the shared implementation and its traits
into three, one per file, and use Kahan summation for the average.

Each function now owns its summary, which is what the three actually differ in:

- count keeps only a UInt64, exact under subtraction, so it alone stays
  invertible and its window is an O(1) running sum.
- sum keeps a plain Float64 and has no unmerge, so its window is recomputed.
- avg sums with Kahan-Babuska-Neumaier compensation. Each addition's rounding
  error is carried in a second accumulator and folded back in at the end, so
  samples far smaller than the running sum survive: beside 1e16, whose ulp is 2,
  a plain sum drops every following 1.

The kind enum, the conditional ResultType, the constrained unmerge and the
if-constexpr projection in getResult all disappear with the split.

Test: a window holding 1e16 followed by four 1s must average (1e16 + 4)/5,
which a plain Float64 sum cannot produce.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcH1uX3QLcLes3XfidQqsC
More of vitlibar's "remove anything else": these operators never receive a
sum/avg/count_over_time result in any test, so carrying the Float64 override
through them was speculative rather than required. Reverted to master:
offset, and/or/unless, clamp, the limit and quantile aggregation operators,
and the one-argument aggregation operator.

What stays is the path the Float64 contract is actually tested on:
count_over_time itself, `+`, `<`, abs(), scalar(), the empty-range short
circuits, and the result schema.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcH1uX3QLcLes3XfidQqsC
The previous commit removed the value type propagation from offset, and/or/
unless, clamp, and the limit/quantile/one-argument aggregation operators, but
left the assertions that exercised it. Six of them expect Float64 where the
reverted code now yields the table's Float32, and the group() case is inverted
the other way: applyOneArgumentAggregationOperator copies the whole piece, so
group() now inherits its operand's override instead of clearing it.

Removed the and/unless, sum(), clamp, or, group() and count() assertions. What
stays is the coverage for the paths that kept their propagation: count_over_time
itself, abs(), scalar(), `+`, the comparison operators, and `> bool`, which
still clears the override in applyComparisonOperator.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
valerypetrov added a commit to valerypetrov/ClickHouse that referenced this pull request Aug 26, 2026
Same cleanup as ClickHouse#112353. timestamp() is the only producer of a value type
override, and no test in this PR, in 0_stateless, in test_evaluation.py or in
the compliance suite composes timestamp() with and/or/unless, an aggregation,
quantile, limitk, histogram_quantile, label_replace, offset or a range function
-- so carrying the override through those operators was speculative.

Reverted to master: the and/or/unless operators, clamp, offset, the limit,
quantile and one-argument aggregation operators, label manipulation, histogram
quantile, and applyFunctionOverRange's empty and CONST_SCALAR branches.
applySimpleBinaryOperator keeps the vector/vector site the tests cover and loses
the empty one they do not.

What stays is the path the tests exercise: applyFunctionTimestamp itself,
applySimpleFunction, applyFunctionScalar, toVectorGrid, finalizeSQL and the
result schema.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
valerypetrov and others added 4 commits August 25, 2026 22:04
…) revert

tryEvaluateOneArgumentMathFunction() had its only caller in
applyDateTimeFunction.cpp's constant-folding carrier walk, which the previous
commit removed entirely. Reverts applyOneArgumentMathFunction.cpp/.h to their
master contents; isOneArgumentMathFunction() still dispatches these functions
in applyFunction.cpp and is unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`AggregateFunctionTimeseriesSamples` collapsed equal-timestamp duplicates into
one sample keeping the largest value, so `timeSeriesCountToGrid` counted
distinct timestamps instead of rows and `timeSeriesSumToGrid`/
`timeSeriesAvgToGrid` folded only the per-timestamp maximum. Aggregate
functions should consider duplicates.

The samples storage gains a `keep_duplicates` policy (default off, so changes,
extrapolated-value and linear-regression aggregates are unaffected): `add`
appends equal timestamps instead of collapsing them, the sorted invariant
becomes non-decreasing, and `deduplicateSorted` is compiled out. The sum/avg/
count traits opt in, and their `FORMAT_VERSION` goes 1 -> 2 because
post-change states can hold duplicate timestamps that an old binary would
silently re-collapse on deserialize.

The stateless test now pins the multiplicity: for the 7-sample input with two
repeated timestamps on a single-point grid, count is 7, sum is 16.5 and avg is
16.5/7.
`timeSeriesSumToGrid` and `timeSeriesAvgToGrid` always recomputed the window
per grid point, which gets expensive once a window holds many populated
buckets. Their summaries are not invertible (a Float64 sum cannot be
un-merged), so `SlidingSum` offers the two-stack monoid queue as the
alternative removal strategy.

Mirror the `AggregateFunctionTimeseriesLinearRegression` wiring: the traits
declare the same `AVG_POPULATED_BPW_TO_ENABLE_TWO_STACKS` = 10 and
`BPW_TO_FORCE_TWO_STACKS` = 20 thresholds (their `Summary::merge` is no
pricier than regression's, so the same crossover applies), and
`createAggregator` applies the same density estimate - enable two-stacks when
the average populated buckets per window reaches the threshold or the window
capacity hits the hard cap, reserving `min(buckets_per_window,
num_populated_buckets)`.

`timeSeriesCountToGrid` is unchanged: its summary is invertible, so
`SlidingSum` keeps the O(1) running sum and never uses the queue.
AggregateFunctionTimeseriesBase reads FunctionImpl::FORMAT_VERSION, so
the constant must live on the function class like every sibling declares
it, not on the traits. Newer clang rejects the traits placement as a
non-constant-expression read during Base's own initialization.
@valerypetrov

Copy link
Copy Markdown
Contributor Author

@vitlibar The two remaining aggregate items are in: buckets keep duplicate timestamps for sum/avg/count (bcd26ce, with FORMAT_VERSION bumped and the 04628 test pinning multiplicity), and sum/avg use the two-stack queue with the same thresholds as LinearRegression (ed3b82c). Could you take another look?

valerypetrov and others added 2 commits August 26, 2026 20:52
Conflict in AggregateFunctionTimeseriesSamples.h: master extracted the
duplicate-timestamp rule into timeseriesMaxValueForDuplicateTimestamp(),
this branch made the collapse conditional on `keep_duplicates`. Kept both -
the policy switch now calls the shared helper.

Master also reworked the two-stacks API the sum/avg traits use, so the three
new headers are adapted to it: FORMAT_VERSION moves into the traits (the base
now reads Traits::FORMAT_VERSION), createAggregator() takes the stack size the
base computes via getStackSizeForTwoStacks() instead of redoing the density
math, and the redundant DateTime64Supported declarations are dropped along
with the siblings'.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Per nikitamikhaylov's review request on this PR. The three blocks left over
two lines were added after the earlier sweep: the Samples class summary, the
inherited-override note in applyFunctionOverRange, and three comments in the
value-type regressions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread src/AggregateFunctions/TimeSeries/AggregateFunctionTimeseriesAvgToGrid.h Outdated
valerypetrov and others added 2 commits August 27, 2026 12:22
The Kahan-Babuska-Neumaier summary's merge is not commutative: which of the
two sums is larger decides where the rounding error lands, and the added
compensation folds into a different running total. The two-stacks queue
combines bucket summaries in queue order, so on dense windows the reported
average depended on the queue state. Drop the two-stacks thresholds from the
avg traits so getStackSizeForTwoStacks always returns 0 and the window is
recomputed in time order; timeSeriesSumToGrid keeps the shortcut, its merge
is commutative.

The regression pins the last grid point of a 25-bucket-per-window grid (the
old forcing threshold) against the single-point grid over the same window,
which combines its samples sequentially: values whose compensation is
order-sensitive make the two orders differ by an ulp, and the dense grid now
equals the sequential reference exactly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The scope cut in 8495079 reverted the value type propagation from the
one-argument aggregation operators, clamp and or, but count()'s own fixed
type is required, like count_over_time()'s: a series count is exact only in
Float64, which Float32 stops representing above 16777216. Recover just that
part - the override replaces the argument's inherited override on the
non-empty path and is reported on the compile-time-empty path too - and the
two empty fast paths that drop an override the operand carried: clamp with
constant max < min, and or with both sides compile-time empty, where the
early return handed back one operand verbatim and the operand order decided
the type. The general propagation through sum/quantile/topk stays reverted
per vitlibar's scope cut.

Restores the count(), clamp() and or assertions of test_evaluation.py that
exercised exactly these paths, on the Float32-backed table.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@valerypetrov

Copy link
Copy Markdown
Contributor Author

Update on the open items:

  • avg_over_time stays on the recompute path — the two-stack thresholds are removed for avg only (sum keeps them), making the merge order-independent (e12ed60).
  • count()'s Float64 result type is restored on both the non-empty and EMPTY fast paths, and the clamp/or empty fast paths forward the override (397b038). Float32 regressions are restored in test_evaluation.py.
  • The general value_data_type propagation through aggregation wrappers remains reverted per vitlibar's scope cut.

On the older applyOffset.cpp thread (vitlibar's "keep only changes related to sum/avg/count_over_time"): the scope cut is complete at the current head.

@clickhouse-gh

clickhouse-gh Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

PromQL Compliance Report

Baseline: S3 master 617af45cc

Metric Baseline Current Δ
Score 77.74% 81.26% +3.53%
Passed 419 438 +19
Failed 3 3 +0
Unsupported 117 98 -19

Uploaded compliance JSON for this PR commit

Baseline update

This run reports 81.26% vs master baseline 77.74% (617af45cc). Merging to master will publish a new S3 object from the integration job that runs test_promql_compliance, which becomes the baseline for later PRs.

Both operators pass the left operand's values through, but neither copied
its value_data_type: the empty fast paths and the VECTOR_GRID results
dropped it, so a Float64 override reverted to the table's Float32 and
exact counts above 2^24 rounded again. The override now follows the
values, independent of whether the result contains rows.  already
carried it (it moves the surviving side), as does the arithmetic
operator's empty path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcH1uX3QLcLes3XfidQqsC
Comment thread src/AggregateFunctions/TimeSeries/AggregateFunctionTimeseriesSumToGrid.h Outdated
Comment thread src/AggregateFunctions/TimeSeries/AggregateFunctionTimeseriesAvgToGrid.h Outdated
valerypetrov and others added 3 commits August 28, 2026 19:07
The non-empty or path built its result without copying either operand's
value_data_type, so a Float64 grid narrowed back to the table type before
reaching the client. My previous reply on this thread said or carried the
override everywhere; that was only true of its empty fast paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcH1uX3QLcLes3XfidQqsC
The two-stacks queue regroups bucket summaries, which needs an
associative merge; Float64 addition is commutative but not associative,
so opting sum_over_time in made the result depend on the queue's
front/back split. With samples 1, 1, 1e16 in one window, a grid dense
enough to force the queue reports 1e16 while the recompute path reports
1e16 + 2. Drop the thresholds and correct the comments that credited
commutativity as sufficient. The new case in 04628 fails on the current
build and passes with the queue disabled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcH1uX3QLcLes3XfidQqsC
Merging rounded per-bucket totals made the average depend on where the
grid put the bucket boundaries, which is an internal detail and not part
of avg_over_time. The window now keeps its buckets -- they outlive the
aggregator, so they are referenced, not copied -- and getResult runs one
Kahan-Babuska-Neumaier pass over their samples in timestamp order. The
serialized state is unchanged: only buckets are written, and the
aggregator lives and dies inside doInsertResultInto.

The new 04628 case pins the property: 25 one-second buckets and 5
five-second buckets over the same 25 samples report ...99.4 and ...99.5
on the current build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcH1uX3QLcLes3XfidQqsC
Comment thread src/AggregateFunctions/TimeSeries/AggregateFunctionTimeseriesHelpers.cpp Outdated
The three new registrations copied an older doc block that advertises
only UInt32/DateTime timestamps and integer step/staleness, while the
factory accepts the DateTime64 / fractional / decimal / string surface
that timeSeriesRateToGrid in the same file already documents; 04628
exercises these functions with DateTime64(3) timestamps. Mirror that
wording, and state that these three keep duplicate timestamps rather
than collapsing them to the greatest value like their neighbours.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcH1uX3QLcLes3XfidQqsC
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

can be tested Allows running workflows for external contributors comp-promql PromQL / time-series subsystem: TimeSeries storage engine, PromQL parser, PromQL-to-SQL converter... manual approve Manual approve required to run CI pr-experimental Experimental Feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants