From 5ab3f899b0f36734931f19e6106e4ff9bdee7f8e Mon Sep 17 00:00:00 2001 From: Dennis Zhuang Date: Wed, 21 Oct 2026 16:36:12 +0800 Subject: [PATCH 01/18] feat(promql): partial state and merge for native float aggregates Signed-off-by: Dennis Zhuang --- src/promql/src/aggregate.rs | 73 ++++--- src/promql/src/aggregate/state.rs | 339 ++++++++++++++++++++++++++++++ src/promql/src/aggregate/tests.rs | 259 +++++++++++++++++++++++ 3 files changed, 645 insertions(+), 26 deletions(-) create mode 100644 src/promql/src/aggregate/state.rs diff --git a/src/promql/src/aggregate.rs b/src/promql/src/aggregate.rs index 14cd6f95e8d..7b0c225ec8c 100644 --- a/src/promql/src/aggregate.rs +++ b/src/promql/src/aggregate.rs @@ -16,9 +16,14 @@ //! is keyed by absolute grid index, never by local row number; sparse state and trimmed output //! do not expand the full grid. +mod state; #[cfg(test)] mod tests; +pub use state::{ + AccumulatorState, FloatMerger, PartialAggregate, PartialGroup, StateField, merge_count_values, +}; + use std::cmp::Reverse; use std::collections::hash_map::Entry; use std::collections::{BTreeMap, BinaryHeap, HashMap}; @@ -33,7 +38,7 @@ use crate::map::{drop_empty_labels, validate_float_series, validate_scalar}; use crate::range_eval::quantile; /// Float aggregations without a parameter. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum AggregateOp { Sum, Count, @@ -139,30 +144,39 @@ impl FloatAggregator { /// The result of every group, in the order the groups first appeared. pub fn finish(self) -> Result> { - let width = self.schema.fields.len(); - let mut builder = GridSeriesBuilder::try_new(self.schema)?; - let mut output = Vec::with_capacity(self.groups.len()); - let mut row = Vec::with_capacity(width); - for group in self.groups { - // One builder serves every group, so each result series starts explicitly. - builder.begin_series(); - for (&block, states) in &group.blocks { - // The last block can extend past the grid; only points with a sample are pushed. - for (offset, point) in states.chunks_exact(width).enumerate() { - if !point.iter().any(|state| state.seen) { - continue; - } - row.clear(); - row.extend(point.iter().map(|state| state.finish(self.op))); - builder.push(block * BLOCK_POINTS + offset, &row)?; - } - } - output.extend(builder.finish(group.metric)?); - } - Ok(output) + finish_groups(self.schema, self.op, self.groups) } } +/// Turns the state of `groups` into result series, in group order. +fn finish_groups( + schema: Arc, + op: AggregateOp, + groups: Vec, +) -> Result> { + let width = schema.fields.len(); + let mut builder = GridSeriesBuilder::try_new(schema)?; + let mut output = Vec::with_capacity(groups.len()); + let mut row = Vec::with_capacity(width); + for group in groups { + // One builder serves every group, so each result series starts explicitly. + builder.begin_series(); + for (&block, states) in &group.blocks { + // The last block can extend past the grid; only points with a sample are pushed. + for (offset, point) in states.chunks_exact(width).enumerate() { + if !point.iter().any(|state| state.seen) { + continue; + } + row.clear(); + row.extend(point.iter().map(|state| state.finish(op))); + builder.push(block * BLOCK_POINTS + offset, &row)?; + } + } + output.extend(builder.finish(group.metric)?); + } + Ok(output) +} + /// `quantile(q, v)`, with `q` read at each grid point from an unlabelled single-field scalar on /// the same grid, as Prometheus evaluates the parameter at every step. A point where `q` has no /// value has no result; `q` outside `[0, 1]` or NaN gives what Prometheus' `quantile` gives. @@ -301,7 +315,7 @@ pub fn count_values( let mut group_index = HashMap::new(); // Counts are kept per point that has one, not in blocks: a gauge with distinct values makes // about one group per sample. - let mut groups: Vec<(MetricName, BTreeMap>)> = Vec::new(); + let mut groups: Counts = Vec::new(); for series in input { // Equal bits format to the same label value, so each distinct value of a series // resolves its group once. @@ -333,9 +347,16 @@ pub fn count_values( } } } + count_series(schema, groups) +} + +/// Counts per point and field: one series per label set, a count of 0 being no sample. +type Counts = Vec<(MetricName, BTreeMap>)>; + +fn count_series(schema: &Arc, groups: Counts) -> Result> { let mut builder = GridSeriesBuilder::try_new(schema.clone())?; let mut output = Vec::with_capacity(groups.len()); - let mut row = Vec::with_capacity(width); + let mut row = Vec::with_capacity(schema.fields.len()); for (metric, counts) in groups { builder.begin_series(); for (point, counts) in counts { @@ -523,7 +544,6 @@ impl FloatState { *self = Self { seen: true, value: match op { - AggregateOp::Group => 1.0, // The running sum of squared deviations; Prometheus starts it at NaN for a // non-finite first sample. AggregateOp::Stddev | AggregateOp::Stdvar if !sample.is_finite() => f64::NAN, @@ -590,7 +610,8 @@ impl FloatState { AggregateOp::Avg if self.incremental_mean => self.mean + self.compensation, AggregateOp::Avg => self.value / self.count + self.compensation / self.count, AggregateOp::Count => self.count, - AggregateOp::Min | AggregateOp::Max | AggregateOp::Group => self.value, + AggregateOp::Min | AggregateOp::Max => self.value, + AggregateOp::Group => 1.0, AggregateOp::Stdvar => self.value / self.count, AggregateOp::Stddev => (self.value / self.count).sqrt(), }) diff --git a/src/promql/src/aggregate/state.rs b/src/promql/src/aggregate/state.rs new file mode 100644 index 00000000000..df4e3055b67 --- /dev/null +++ b/src/promql/src/aggregate/state.rs @@ -0,0 +1,339 @@ +// Copyright 2023 Greptime Team +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Partial aggregation: the state one execution partition folds its series into, and the merge +//! of such states into the final result. +//! +//! A merge regroups the operations of a serial fold, so floating-point results differ from +//! [`aggregate_float`](crate::aggregate::aggregate_float) by rounding. The same reassociation can +//! also move an overflow of finite values: a serial `sum` of `[1e308, 1e308, -1e308]` is `+Inf`, +//! while the merge of `[1e308]` and `[1e308, -1e308]` is finite. Explicit NaN and infinite inputs +//! follow the same rules as the serial fold. Where the serial fold keeps the first of two equal +//! values, as `min` and `max` do for `0` and `-0`, a merge keeps the one merged first. + +use std::collections::hash_map::Entry; +use std::collections::{BTreeMap, HashMap}; +use std::sync::Arc; + +use datafusion::error::{DataFusionError, Result}; +use datatypes::series::MetricName; +use datatypes::series::samples::{SampleRef, Series, SeriesSchema}; + +use crate::aggregate::{ + AggregateOp, BLOCK_POINTS, Counts, FloatAggregator, FloatState, Group, LabelKey, count_series, + finish_groups, kahan_inc, label_key, +}; +use crate::map::validate_float_series; + +/// A component of [`AccumulatorState`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StateField { + /// The running sum (`sum`, and `avg` until it switches to a mean), the extreme (`min`, + /// `max`), or the sum of squared deviations (`stddev`, `stdvar`). + Value, + /// The running mean (`avg` once switched, `stddev`, `stdvar`). + Mean, + /// The Kahan compensation of `Value`, or of `Mean` once `avg` has switched. + Compensation, + /// The number of samples folded in. + Count, + /// Whether `avg` has switched from a sum to a mean, which it does when the sum overflows. + IncrementalMean, +} + +impl AggregateOp { + /// The components the state of this aggregation carries; the others are unused and need not + /// be stored. `group` has none: a state that exists has a value of 1. + pub fn state_fields(self) -> &'static [StateField] { + use StateField::*; + match self { + Self::Sum => &[Value, Compensation], + Self::Count => &[Count], + Self::Avg => &[Value, Mean, Compensation, Count, IncrementalMean], + Self::Min | Self::Max => &[Value], + Self::Group => &[], + Self::Stddev | Self::Stdvar => &[Value, Mean, Count], + } + } +} + +/// The state of one field of one group at one grid point. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct AccumulatorState { + pub value: f64, + pub mean: f64, + pub compensation: f64, + pub count: f64, + pub incremental_mean: bool, +} + +/// The state of one group: its points in ascending order, each with one state per field, `None` +/// where no sample of that field was folded in. +#[derive(Clone, Debug)] +pub struct PartialGroup { + pub metric: MetricName, + pub points: Vec<(usize, Vec>)>, +} + +/// The state of one execution partition, with its groups in the order they first appeared. +#[derive(Clone, Debug)] +pub struct PartialAggregate { + pub op: AggregateOp, + pub groups: Vec, +} + +impl FloatAggregator { + /// The state folded so far, for a merge with the state of other partitions. + pub fn finish_partial(self) -> PartialAggregate { + let width = self.schema.fields.len(); + let groups = self + .groups + .into_iter() + .map(|group| { + let mut points = Vec::new(); + for (&block, states) in &group.blocks { + for (offset, point) in states.chunks_exact(width).enumerate() { + if point.iter().any(|state| state.seen) { + points.push(( + block * BLOCK_POINTS + offset, + point.iter().map(|state| state.partial()).collect(), + )); + } + } + } + PartialGroup { + metric: group.metric, + points, + } + }) + .collect(); + PartialAggregate { + op: self.op, + groups, + } + } +} + +/// Merges the partial states of one aggregation into its result. Groups are identified by their +/// full label set, already grouped by the partitions, and keep the order they first arrive in. +pub struct FloatMerger { + schema: Arc, + op: AggregateOp, + group_index: HashMap, + groups: Vec, +} + +impl FloatMerger { + pub fn try_new(schema: &Arc, op: AggregateOp) -> Result { + if schema.eval_grid.is_none() { + return Err(DataFusionError::Execution( + "Aggregate requires an evaluated grid".into(), + )); + } + Ok(Self { + schema: schema.clone(), + op, + group_index: HashMap::new(), + groups: Vec::new(), + }) + } + + /// Merges the state of one partition. A partial that does not fit this merge, by its + /// aggregation, field count or grid positions, fails and leaves the merge unchanged. + pub fn merge(&mut self, partial: PartialAggregate) -> Result<()> { + let width = self.schema.fields.len(); + let points = self + .schema + .eval_grid + .as_ref() + .expect("checked at construction") + .timestamps() + .len(); + if partial.op != self.op { + return Err(DataFusionError::Execution(format!( + "cannot merge {:?} state into {:?}", + partial.op, self.op + ))); + } + for group in &partial.groups { + for (point, states) in &group.points { + if *point >= points || states.len() != width { + return Err(DataFusionError::Execution( + "aggregate state does not match the grid or fields of the merge".into(), + )); + } + } + } + for group in partial.groups { + let index = match self.group_index.entry(label_key(&group.metric)) { + Entry::Occupied(entry) => *entry.get(), + Entry::Vacant(entry) => { + self.groups.push(Group { + metric: group.metric, + blocks: BTreeMap::new(), + }); + *entry.insert(self.groups.len() - 1) + } + }; + let blocks = &mut self.groups[index].blocks; + for (point, states) in group.points { + let block = blocks.entry(point / BLOCK_POINTS).or_insert_with(|| { + vec![FloatState::default(); BLOCK_POINTS * width].into_boxed_slice() + }); + let offset = (point % BLOCK_POINTS) * width; + for (slot, state) in block[offset..offset + width].iter_mut().zip(states) { + if let Some(state) = state { + slot.merge(self.op, FloatState::from_partial(state)); + } + } + } + } + Ok(()) + } + + /// The result of every group, in the order the groups first arrived. + pub fn finish(self) -> Result> { + finish_groups(self.schema, self.op, self.groups) + } +} + +impl FloatState { + fn partial(&self) -> Option { + self.seen.then_some(AccumulatorState { + value: self.value, + mean: self.mean, + compensation: self.compensation, + count: self.count, + incremental_mean: self.incremental_mean, + }) + } + + fn from_partial(state: AccumulatorState) -> Self { + Self { + seen: true, + value: state.value, + mean: state.mean, + compensation: state.compensation, + count: state.count, + incremental_mean: state.incremental_mean, + } + } + + /// Folds the state of another partition into this one. + fn merge(&mut self, op: AggregateOp, other: Self) { + if !self.seen { + *self = other; + return; + } + match op { + AggregateOp::Sum => { + // The error of adding the two sums goes into the compensation with theirs. + let (value, compensation) = kahan_inc(other.value, self.value, self.compensation); + self.value = value; + self.compensation = compensation + other.compensation; + } + AggregateOp::Count => self.count += other.count, + AggregateOp::Group => {} + AggregateOp::Max => { + if self.value < other.value || self.value.is_nan() { + self.value = other.value; + } + } + AggregateOp::Min => { + if self.value > other.value || self.value.is_nan() { + self.value = other.value; + } + } + AggregateOp::Avg => self.merge_avg(other), + AggregateOp::Stddev | AggregateOp::Stdvar => { + // Chan et al.'s pairwise update. `delta * (delta * weight)` keeps the product + // finite where it is: `delta²` alone overflows for `[0]` and `[1.5e154]`, whose + // merged sum of squares is finite. + let count = self.count + other.count; + let delta = other.mean - self.mean; + let weight = self.count * other.count / count; + self.mean += delta * (other.count / count); + self.value = self.value + other.value + delta * (delta * weight); + self.count = count; + } + } + } + + /// Adds the sums while they stay finite, and otherwise continues with the count-weighted + /// mean, as the serial update switches to an incremental mean once its sum overflows. + fn merge_avg(&mut self, other: Self) { + let count = self.count + other.count; + if !self.incremental_mean && !other.incremental_mean { + let (value, compensation) = kahan_inc(other.value, self.value, self.compensation); + if !value.is_infinite() { + self.value = value; + self.compensation = compensation + other.compensation; + self.count = count; + return; + } + } + let (mean, compensation) = self.mean_form(); + let (other_mean, other_compensation) = other.mean_form(); + let (weight, other_weight) = (self.count / count, other.count / count); + (self.mean, self.compensation) = kahan_inc( + other_mean * other_weight, + mean * weight, + compensation * weight + other_compensation * other_weight, + ); + self.incremental_mean = true; + self.count = count; + } + + /// The mean and its compensation, converting a sum as the serial update does on switching. + fn mean_form(&self) -> (f64, f64) { + if self.incremental_mean { + (self.mean, self.compensation) + } else { + (self.value / self.count, self.compensation / self.count) + } + } +} + +/// Merges `count_values` results of several partitions. Each partial already carries its full +/// output label set, value label included, so series with one label set add their counts point +/// by point and field by field. +pub fn merge_count_values( + schema: &Arc, + partials: impl IntoIterator, +) -> Result> { + let width = schema.fields.len(); + let mut group_index: HashMap = HashMap::new(); + let mut groups: Counts = Vec::new(); + for series in partials { + validate_float_series(schema, &series)?; + let index = match group_index.entry(label_key(&series.metric)) { + Entry::Occupied(entry) => *entry.get(), + Entry::Vacant(entry) => { + groups.push((series.metric.clone(), BTreeMap::new())); + *entry.insert(groups.len() - 1) + } + }; + for (field_index, field) in series.fields.iter().enumerate() { + for ((point, _), sample) in series.time.positions().zip(field.iter()) { + if let SampleRef::Float(count) = sample { + groups[index] + .1 + .entry(point) + .or_insert_with(|| vec![0.0; width])[field_index] += count; + } + } + } + } + count_series(schema, groups) +} diff --git a/src/promql/src/aggregate/tests.rs b/src/promql/src/aggregate/tests.rs index 31f5a2217ed..1661a205817 100644 --- a/src/promql/src/aggregate/tests.rs +++ b/src/promql/src/aggregate/tests.rs @@ -20,6 +20,10 @@ use datatypes::series::histogram::HistogramBatchBuilder; use datatypes::series::samples::{FieldSpec, Samples, SeriesFields}; use datatypes::series::time::{EvalGrid, GridRange, GridSpec, TimeAxis}; +use rand::rngs::StdRng; +use rand::seq::SliceRandom; +use rand::{Rng, SeedableRng}; + use super::*; use crate::golden::{assert_result, cases}; use crate::grid_series::reference::{Labels, piece_ranges}; @@ -1021,3 +1025,258 @@ fn quantile_over_one_point_members_reads_each_member_at_its_point() { let expected: Vec> = (0..128).map(|point| Some(point as f64)).collect(); assert_eq!(values_of(&output, 0, 128), (0, expected)); } + +/// A float sample for the partial tests: ordinary values, repeats, signed zeros, NaN and +/// infinities, but no finite values large enough to overflow when regrouped. +fn partial_sample(rng: &mut StdRng) -> f64 { + match rng.random_range(0..20) { + 0 => f64::NAN, + 1 => f64::INFINITY, + 2 => f64::NEG_INFINITY, + 3 => 0.0, + 4 => -0.0, + 5..=8 => rng.random_range(0..4) as f64, + _ => (rng.random::() - 0.5) * 2e6, + } +} + +/// Every valued (point, field) of `output` by rendered label set. +fn flatten(output: &[Series]) -> BTreeMap> { + let mut flat: BTreeMap> = BTreeMap::new(); + for series in output { + let points = flat.entry(format!("{:?}", series.metric)).or_default(); + for (field, samples) in series.fields.iter().enumerate() { + for ((point, _), sample) in series.time.positions().zip(samples.iter()) { + if let SampleRef::Float(value) = sample { + assert!(points.insert((point, field), value).is_none()); + } + } + } + } + flat +} + +fn assert_flat_close( + actual: &[Series], + expected: &[Series], + epsilon: f64, + context: &dyn std::fmt::Display, +) { + let (actual, expected) = (flatten(actual), flatten(expected)); + assert_eq!( + actual.keys().collect::>(), + expected.keys().collect::>(), + "{context}" + ); + for (labels, expected) in &expected { + let actual = &actual[labels]; + assert_eq!( + actual.keys().collect::>(), + expected.keys().collect::>(), + "{context} {labels}" + ); + for (key, &value) in expected { + assert!( + crate::grid_series::reference::close(actual[key], value, epsilon), + "{context} {labels} {key:?}: merged {} serial {value}", + actual[key] + ); + } + } +} + +/// Partial states merged in any partition and merge order give the serial result, within the +/// rounding a regrouped fold may change: the same groups, points and fields, the same NaN and +/// infinities. Each partial keeps only the components its aggregation declares, so the layout +/// in `state_fields` is all a merge needs. +#[test] +fn partials_merge_to_the_serial_result() { + const OPS: [AggregateOp; 8] = [ + AggregateOp::Sum, + AggregateOp::Count, + AggregateOp::Avg, + AggregateOp::Min, + AggregateOp::Max, + AggregateOp::Group, + AggregateOp::Stddev, + AggregateOp::Stdvar, + ]; + let strip = |op: AggregateOp, state: &mut AccumulatorState| { + let kept = op.state_fields(); + let mut stripped = AccumulatorState::default(); + for field in kept { + match field { + StateField::Value => stripped.value = state.value, + StateField::Mean => stripped.mean = state.mean, + StateField::Compensation => stripped.compensation = state.compensation, + StateField::Count => stripped.count = state.count, + StateField::IncrementalMean => stripped.incremental_mean = state.incremental_mean, + } + } + *state = stripped; + }; + for seed in 0..300 { + let mut rng = StdRng::seed_from_u64(seed); + let fields: Vec = (0..rng.random_range(1..=3)) + .map(|field| format!("f{field}")) + .collect(); + let names: Vec<&str> = fields.iter().map(String::as_str).collect(); + let schema = schema_with_grid_len(&names, rng.random_range(1..80)); + let points = schema.eval_grid.as_ref().unwrap().timestamps().len(); + let mut builder = GridSeriesBuilder::try_new(schema.clone()).unwrap(); + let mut series = Vec::new(); + for index in 0..rng.random_range(1..12) { + builder.begin_series(); + for point in 0..points { + let row: Vec> = (0..names.len()) + .map(|_| rng.random_bool(0.6).then(|| partial_sample(&mut rng))) + .collect(); + builder.push(point, &row).unwrap(); + } + let job = ["x", "y"][rng.random_range(0..2)]; + let index = index.to_string(); + series.extend( + builder + .finish(metric("m", &[("job", job), ("i", &index)])) + .unwrap(), + ); + } + let parts: Vec = series.iter().map(|_| rng.random_range(0..4)).collect(); + let mut order: Vec = (0..4).collect(); + order.shuffle(&mut rng); + let grouping = if rng.random_bool(0.5) { + Grouping::By(&["job"]) + } else { + Grouping::Without(&["i"]) + }; + + for op in OPS { + let serial = aggregate_float(&schema, &series, op, grouping).unwrap(); + let mut merger = FloatMerger::try_new(&schema, op).unwrap(); + for &part in &order { + let mut aggregator = FloatAggregator::try_new(&schema, op, grouping).unwrap(); + for (series, _) in series.iter().zip(&parts).filter(|(_, p)| **p == part) { + aggregator.update(series).unwrap(); + } + let mut partial = aggregator.finish_partial(); + for group in &mut partial.groups { + for (_, states) in &mut group.points { + states + .iter_mut() + .flatten() + .for_each(|state| strip(op, state)); + } + } + merger.merge(partial).unwrap(); + } + let merged = merger.finish().unwrap(); + assert_flat_close(&merged, &serial, 1e-10, &format!("seed {seed} {op:?}")); + } + + let serial = count_values(&schema, &series, "value", grouping).unwrap(); + let partials = order.iter().flat_map(|&part| { + let members: Vec = series + .iter() + .zip(&parts) + .filter(|(_, p)| **p == part) + .map(|(series, _)| series.clone()) + .collect(); + count_values(&schema, &members, "value", grouping).unwrap() + }); + let merged = merge_count_values(&schema, partials).unwrap(); + assert_flat_close(&merged, &serial, 0.0, &format!("seed {seed} count_values")); + } +} + +/// Adding two partial sums can lose low bits, which go into the compensation: `1` survives +/// between `1e100` and `-1e100` in three partials as it does in the serial fold. +#[test] +fn a_sum_merge_keeps_the_error_of_adding_partial_sums() { + let schema = schema(&["value"]); + let mut merger = FloatMerger::try_new(&schema, AggregateOp::Sum).unwrap(); + for value in [1e100, 1.0, -1e100] { + let mut aggregator = + FloatAggregator::try_new(&schema, AggregateOp::Sum, Grouping::By(&[])).unwrap(); + aggregator + .update(&input(&schema, metric("m", &[]), 0, &[vec![Some(value)]])) + .unwrap(); + merger.merge(aggregator.finish_partial()).unwrap(); + } + assert_eq!( + values_of(&merger.finish().unwrap(), 0, 128), + (0, vec![Some(1.0)]) + ); +} + +/// Merging finite partials does not overflow where the result is finite: `avg` switches to a +/// mean when the summed partials overflow, and `stdvar` does not square the mean difference on +/// its own. +#[test] +fn merges_of_large_finite_partials_stay_finite() { + let schema = schema(&["value"]); + let merged = |op, values: [f64; 2]| { + let mut merger = FloatMerger::try_new(&schema, op).unwrap(); + for value in values { + let mut aggregator = FloatAggregator::try_new(&schema, op, Grouping::By(&[])).unwrap(); + aggregator + .update(&input(&schema, metric("m", &[]), 0, &[vec![Some(value)]])) + .unwrap(); + merger.merge(aggregator.finish_partial()).unwrap(); + } + values_of(&merger.finish().unwrap(), 0, 128).1[0].unwrap() + }; + assert_eq!(merged(AggregateOp::Avg, [1e308, 1e308]), 1e308); + let stdvar = merged(AggregateOp::Stdvar, [0.0, 1.5e154]); + assert!(stdvar.is_finite(), "{stdvar}"); + assert!(crate::grid_series::reference::close( + stdvar, 5.625e307, 1e-10 + )); +} + +/// `min` and `max` keep the first of two equal values, so with `0` and `-0` the sign is that of +/// the partial merged first. +#[test] +fn equal_zero_extremes_follow_the_merge_order() { + let schema = schema(&["value"]); + for op in [AggregateOp::Min, AggregateOp::Max] { + for (first, second) in [(0.0, -0.0), (-0.0, 0.0)] { + let mut merger = FloatMerger::try_new(&schema, op).unwrap(); + for value in [first, second] { + let mut aggregator = + FloatAggregator::try_new(&schema, op, Grouping::By(&[])).unwrap(); + aggregator + .update(&input(&schema, metric("m", &[]), 0, &[vec![Some(value)]])) + .unwrap(); + merger.merge(aggregator.finish_partial()).unwrap(); + } + let value = values_of(&merger.finish().unwrap(), 0, 128).1[0].unwrap(); + assert_eq!( + value.to_bits(), + f64::to_bits(first), + "{op:?} {first} {second}" + ); + } + } +} + +/// A partial of another aggregation, field count or grid fails the merge. +#[test] +fn a_merge_rejects_state_that_does_not_fit() { + let schema = schema(&["value"]); + let partial = |op| { + let mut aggregator = FloatAggregator::try_new(&schema, op, Grouping::By(&[])).unwrap(); + aggregator + .update(&input(&schema, metric("m", &[]), 5, &[vec![Some(1.0)]])) + .unwrap(); + aggregator.finish_partial() + }; + let mut merger = FloatMerger::try_new(&schema, AggregateOp::Sum).unwrap(); + assert!(merger.merge(partial(AggregateOp::Avg)).is_err()); + let mut wide = partial(AggregateOp::Sum); + wide.groups[0].points[0].1.push(None); + assert!(merger.merge(wide).is_err()); + let mut late = partial(AggregateOp::Sum); + late.groups[0].points[0].0 = 128; + assert!(merger.merge(late).is_err()); + assert!(merger.finish().unwrap().is_empty()); +} From 7c10a4743742515d7e39d1e67ef27db04e607c70 Mon Sep 17 00:00:00 2001 From: Dennis Zhuang Date: Wed, 21 Oct 2026 16:55:25 +0800 Subject: [PATCH 02/18] test(promql): pin compensation and weights in partial aggregate merges Signed-off-by: Dennis Zhuang --- src/promql/src/aggregate/tests.rs | 175 ++++++++++++++++++++---------- 1 file changed, 116 insertions(+), 59 deletions(-) diff --git a/src/promql/src/aggregate/tests.rs b/src/promql/src/aggregate/tests.rs index 1661a205817..376d8e6e7e4 100644 --- a/src/promql/src/aggregate/tests.rs +++ b/src/promql/src/aggregate/tests.rs @@ -1076,8 +1076,14 @@ fn assert_flat_close( "{context} {labels}" ); for (key, &value) in expected { + // An epsilon of 0 asks for the same bits, NaN payloads aside. + let same = if epsilon == 0.0 { + actual[key].to_bits() == value.to_bits() || (actual[key].is_nan() && value.is_nan()) + } else { + crate::grid_series::reference::close(actual[key], value, epsilon) + }; assert!( - crate::grid_series::reference::close(actual[key], value, epsilon), + same, "{context} {labels} {key:?}: merged {} serial {value}", actual[key] ); @@ -1085,6 +1091,63 @@ fn assert_flat_close( } } +/// `partial` with only the components its aggregation declares in `state_fields`, as an encoder +/// that stores the layout and nothing else would hand it over. +fn to_layout(mut partial: PartialAggregate) -> PartialAggregate { + let kept = partial.op.state_fields(); + for group in &mut partial.groups { + for (_, states) in &mut group.points { + for state in states.iter_mut().flatten() { + let mut stripped = AccumulatorState::default(); + for field in kept { + match field { + StateField::Value => stripped.value = state.value, + StateField::Mean => stripped.mean = state.mean, + StateField::Compensation => stripped.compensation = state.compensation, + StateField::Count => stripped.count = state.count, + StateField::IncrementalMean => { + stripped.incremental_mean = state.incremental_mean + } + } + } + *state = stripped; + } + } + } + partial +} + +/// One group's value at point 0, folding each list of samples in its own partition and merging +/// the partitions in order, next to the serial fold of all samples. +fn merged_and_serial(op: AggregateOp, partitions: &[&[f64]]) -> (f64, f64) { + let schema = schema(&["value"]); + let series = |samples: &[f64]| -> Vec { + samples + .iter() + .map(|&value| input(&schema, metric("m", &[]), 0, &[vec![Some(value)]])) + .collect() + }; + let mut merger = FloatMerger::try_new(&schema, op).unwrap(); + for samples in partitions { + let mut aggregator = FloatAggregator::try_new(&schema, op, Grouping::By(&[])).unwrap(); + for series in series(samples) { + aggregator.update(&series).unwrap(); + } + merger + .merge(to_layout(aggregator.finish_partial())) + .unwrap(); + } + let all: Vec = partitions + .iter() + .flat_map(|samples| series(samples)) + .collect(); + let value = |output: Vec| values_of(&output, 0, 128).1[0].unwrap(); + ( + value(merger.finish().unwrap()), + value(aggregate_float(&schema, &all, op, Grouping::By(&[])).unwrap()), + ) +} + /// Partial states merged in any partition and merge order give the serial result, within the /// rounding a regrouped fold may change: the same groups, points and fields, the same NaN and /// infinities. Each partial keeps only the components its aggregation declares, so the layout @@ -1101,20 +1164,6 @@ fn partials_merge_to_the_serial_result() { AggregateOp::Stddev, AggregateOp::Stdvar, ]; - let strip = |op: AggregateOp, state: &mut AccumulatorState| { - let kept = op.state_fields(); - let mut stripped = AccumulatorState::default(); - for field in kept { - match field { - StateField::Value => stripped.value = state.value, - StateField::Mean => stripped.mean = state.mean, - StateField::Compensation => stripped.compensation = state.compensation, - StateField::Count => stripped.count = state.count, - StateField::IncrementalMean => stripped.incremental_mean = state.incremental_mean, - } - } - *state = stripped; - }; for seed in 0..300 { let mut rng = StdRng::seed_from_u64(seed); let fields: Vec = (0..rng.random_range(1..=3)) @@ -1158,19 +1207,28 @@ fn partials_merge_to_the_serial_result() { for (series, _) in series.iter().zip(&parts).filter(|(_, p)| **p == part) { aggregator.update(series).unwrap(); } - let mut partial = aggregator.finish_partial(); - for group in &mut partial.groups { - for (_, states) in &mut group.points { - states - .iter_mut() - .flatten() - .for_each(|state| strip(op, state)); - } - } - merger.merge(partial).unwrap(); + merger + .merge(to_layout(aggregator.finish_partial())) + .unwrap(); } let merged = merger.finish().unwrap(); assert_flat_close(&merged, &serial, 1e-10, &format!("seed {seed} {op:?}")); + + // One partition holding every series is the serial fold, bit for bit. + let mut aggregator = FloatAggregator::try_new(&schema, op, grouping).unwrap(); + for series in &series { + aggregator.update(series).unwrap(); + } + let mut merger = FloatMerger::try_new(&schema, op).unwrap(); + merger + .merge(to_layout(aggregator.finish_partial())) + .unwrap(); + assert_flat_close( + &merger.finish().unwrap(), + &serial, + 0.0, + &format!("seed {seed} {op:?} one partition"), + ); } let serial = count_values(&schema, &series, "value", grouping).unwrap(); @@ -1188,49 +1246,48 @@ fn partials_merge_to_the_serial_result() { } } -/// Adding two partial sums can lose low bits, which go into the compensation: `1` survives -/// between `1e100` and `-1e100` in three partials as it does in the serial fold. +/// The compensation travels with a partial and absorbs what adding two partial sums loses: `1` +/// survives next to `1e100` and `-1e100` whether it is a partition's own compensation or the +/// error of a merge, and `avg` divides it like the sum. #[test] -fn a_sum_merge_keeps_the_error_of_adding_partial_sums() { - let schema = schema(&["value"]); - let mut merger = FloatMerger::try_new(&schema, AggregateOp::Sum).unwrap(); - for value in [1e100, 1.0, -1e100] { - let mut aggregator = - FloatAggregator::try_new(&schema, AggregateOp::Sum, Grouping::By(&[])).unwrap(); - aggregator - .update(&input(&schema, metric("m", &[]), 0, &[vec![Some(value)]])) - .unwrap(); - merger.merge(aggregator.finish_partial()).unwrap(); - } +fn compensation_survives_partials_and_merges() { assert_eq!( - values_of(&merger.finish().unwrap(), 0, 128), - (0, vec![Some(1.0)]) + merged_and_serial(AggregateOp::Sum, &[&[-1e100], &[1e100, 1.0]]), + (1.0, 1.0) ); + assert_eq!( + merged_and_serial(AggregateOp::Sum, &[&[1e100], &[1.0], &[-1e100]]), + (1.0, 1.0) + ); + let (merged, serial) = merged_and_serial(AggregateOp::Avg, &[&[-1e100], &[1e100, 1.0]]); + assert_eq!(merged, serial); + assert!(crate::grid_series::reference::close( + merged, + 1.0 / 3.0, + 1e-10 + )); } /// Merging finite partials does not overflow where the result is finite: `avg` switches to a -/// mean when the summed partials overflow, and `stdvar` does not square the mean difference on -/// its own. +/// count-weighted mean when the summed partials overflow, and `stdvar` does not square the mean +/// difference on its own. #[test] fn merges_of_large_finite_partials_stay_finite() { - let schema = schema(&["value"]); - let merged = |op, values: [f64; 2]| { - let mut merger = FloatMerger::try_new(&schema, op).unwrap(); - for value in values { - let mut aggregator = FloatAggregator::try_new(&schema, op, Grouping::By(&[])).unwrap(); - aggregator - .update(&input(&schema, metric("m", &[]), 0, &[vec![Some(value)]])) - .unwrap(); - merger.merge(aggregator.finish_partial()).unwrap(); - } - values_of(&merger.finish().unwrap(), 0, 128).1[0].unwrap() - }; - assert_eq!(merged(AggregateOp::Avg, [1e308, 1e308]), 1e308); - let stdvar = merged(AggregateOp::Stdvar, [0.0, 1.5e154]); + for partitions in [ + &[&[1e308][..], &[1e308][..]][..], + // Unequal counts, one partition already a mean: the weights decide the result. + &[&[1e308, 1e308][..], &[0.0][..]][..], + &[&[0.0][..], &[1e308, 1e308][..]][..], + ] { + let (merged, serial) = merged_and_serial(AggregateOp::Avg, partitions); + assert!( + merged.is_finite() && crate::grid_series::reference::close(merged, serial, 1e-10), + "{partitions:?}: merged {merged} serial {serial}" + ); + } + let (stdvar, serial) = merged_and_serial(AggregateOp::Stdvar, &[&[0.0], &[1.5e154]]); assert!(stdvar.is_finite(), "{stdvar}"); - assert!(crate::grid_series::reference::close( - stdvar, 5.625e307, 1e-10 - )); + assert!(crate::grid_series::reference::close(stdvar, serial, 1e-10)); } /// `min` and `max` keep the first of two equal values, so with `0` and `-0` the sign is that of From e1da852c46e746862157f368b342f07d3ddbfc2b Mon Sep 17 00:00:00 2001 From: Dennis Zhuang Date: Wed, 21 Oct 2026 19:59:48 +0800 Subject: [PATCH 03/18] fix(promql): add a partial's compensation as a Kahan addend when merging sums Signed-off-by: Dennis Zhuang --- src/promql/src/aggregate/state.rs | 18 ++++++++++++------ src/promql/src/aggregate/tests.rs | 17 +++++++++++++++++ 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/promql/src/aggregate/state.rs b/src/promql/src/aggregate/state.rs index df4e3055b67..7f51a7c4b6f 100644 --- a/src/promql/src/aggregate/state.rs +++ b/src/promql/src/aggregate/state.rs @@ -238,10 +238,7 @@ impl FloatState { } match op { AggregateOp::Sum => { - // The error of adding the two sums goes into the compensation with theirs. - let (value, compensation) = kahan_inc(other.value, self.value, self.compensation); - self.value = value; - self.compensation = compensation + other.compensation; + (self.value, self.compensation) = add_compensated(*self, other); } AggregateOp::Count => self.count += other.count, AggregateOp::Group => {} @@ -275,10 +272,10 @@ impl FloatState { fn merge_avg(&mut self, other: Self) { let count = self.count + other.count; if !self.incremental_mean && !other.incremental_mean { - let (value, compensation) = kahan_inc(other.value, self.value, self.compensation); + let (value, compensation) = add_compensated(*self, other); if !value.is_infinite() { self.value = value; - self.compensation = compensation + other.compensation; + self.compensation = compensation; self.count = count; return; } @@ -305,6 +302,15 @@ impl FloatState { } } +/// The compensated sum of two partial sums. Both the other sum and its compensation are added as +/// Kahan addends: adding the compensation plainly would drop what the compensation already +/// holds once later partials cancel the sums, as merging `[1e100, 1]`, `[-1e100, 1e84]` and +/// `[-1e84]` would lose the `1`. +fn add_compensated(this: FloatState, other: FloatState) -> (f64, f64) { + let (value, compensation) = kahan_inc(other.value, this.value, this.compensation); + kahan_inc(other.compensation, value, compensation) +} + /// Merges `count_values` results of several partitions. Each partial already carries its full /// output label set, value label included, so series with one label set add their counts point /// by point and field by field. diff --git a/src/promql/src/aggregate/tests.rs b/src/promql/src/aggregate/tests.rs index c28ffa5d6ef..ba38385f4fe 100644 --- a/src/promql/src/aggregate/tests.rs +++ b/src/promql/src/aggregate/tests.rs @@ -1266,6 +1266,23 @@ fn compensation_survives_partials_and_merges() { 1.0 / 3.0, 1e-10 )); + // A partial's own compensation must survive a later cancellation of the sums: `1` rides in + // the first partial's compensation while `1e84` and `-1e84` cancel across partials. + let cancelling: &[&[f64]] = &[&[1e100, 1.0], &[-1e100, 1e84], &[-1e84]]; + assert_eq!(merged_and_serial(AggregateOp::Sum, cancelling), (1.0, 1.0)); + let (merged, serial) = merged_and_serial(AggregateOp::Avg, cancelling); + assert_eq!(serial, 0.2); + assert!( + crate::grid_series::reference::close(merged, serial, 1e-10), + "{merged}" + ); + let (merged, serial) = + merged_and_serial(AggregateOp::Avg, &[&[1e32, 1.0], &[-1e32, 1e16], &[-1e16]]); + assert_eq!(serial, 0.2); + assert!( + crate::grid_series::reference::close(merged, serial, 1e-10), + "{merged}" + ); } /// Merging finite partials does not overflow where the result is finite: `avg` switches to a From db26fce1f741da10d71b6e82140b677c197731b8 Mon Sep 17 00:00:00 2001 From: Dennis Zhuang Date: Wed, 21 Oct 2026 20:03:04 +0800 Subject: [PATCH 04/18] docs(promql): state where merged variance departs from Prometheus' serial rounding Signed-off-by: Dennis Zhuang --- src/promql/src/aggregate/state.rs | 7 +++++-- src/promql/src/aggregate/tests.rs | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/promql/src/aggregate/state.rs b/src/promql/src/aggregate/state.rs index 7f51a7c4b6f..d609334aa10 100644 --- a/src/promql/src/aggregate/state.rs +++ b/src/promql/src/aggregate/state.rs @@ -18,8 +18,11 @@ //! A merge regroups the operations of a serial fold, so floating-point results differ from //! [`aggregate_float`](crate::aggregate::aggregate_float) by rounding. The same reassociation can //! also move an overflow of finite values: a serial `sum` of `[1e308, 1e308, -1e308]` is `+Inf`, -//! while the merge of `[1e308]` and `[1e308, -1e308]` is finite. Explicit NaN and infinite inputs -//! follow the same rules as the serial fold. Where the serial fold keeps the first of two equal +//! while the merge of `[1e308]` and `[1e308, -1e308]` is finite. `stddev` and `stdvar` differ +//! more where the samples differ by little more than the rounding of their mean: Prometheus' +//! uncompensated Welford update rounds the mean on every sample, and no pairwise merge reproduces +//! those roundings. For `[1e16]` and `[1e16 + 2]` Prometheus gives a variance of 2 and the merge +//! gives the exact 1. Explicit NaN and infinite inputs follow the same rules as the serial fold. Where the serial fold keeps the first of two equal //! values, as `min` and `max` do for `0` and `-0`, a merge keeps the one merged first. use std::collections::hash_map::Entry; diff --git a/src/promql/src/aggregate/tests.rs b/src/promql/src/aggregate/tests.rs index ba38385f4fe..d692e3e5389 100644 --- a/src/promql/src/aggregate/tests.rs +++ b/src/promql/src/aggregate/tests.rs @@ -1307,6 +1307,22 @@ fn merges_of_large_finite_partials_stay_finite() { assert!(crate::grid_series::reference::close(stdvar, serial, 1e-10)); } +/// Samples that differ by about the rounding of their mean are outside the merge's agreement with +/// Prometheus: its serial Welford update rounds the mean back and doubles the variance, while the +/// pairwise merge keeps the exact one. +#[test] +fn ill_conditioned_variance_merges_to_the_exact_value() { + let partitions: &[&[f64]] = &[&[1e16], &[1e16 + 2.0]]; + assert_eq!( + merged_and_serial(AggregateOp::Stdvar, partitions), + (1.0, 2.0) + ); + assert_eq!( + merged_and_serial(AggregateOp::Stddev, partitions), + (1.0, std::f64::consts::SQRT_2) + ); +} + /// `min` and `max` keep the first of two equal values, so with `0` and `-0` the sign is that of /// the partial merged first. #[test] From 3264f560faa85dbfbbdde1d12beb8fddc5484fd7 Mon Sep 17 00:00:00 2001 From: Dennis Zhuang Date: Wed, 21 Oct 2026 20:28:07 +0800 Subject: [PATCH 05/18] feat(promql): project selected sample times for timestamp() and info() Signed-off-by: Dennis Zhuang --- src/promql/src/instant_eval.rs | 179 +++++++++- src/promql/src/instant_eval/tests.rs | 212 ++++++++++- .../src/instant_eval/timestamp_cases.txt | 329 ++++++++++++++++++ 3 files changed, 717 insertions(+), 3 deletions(-) create mode 100644 src/promql/src/instant_eval/timestamp_cases.txt diff --git a/src/promql/src/instant_eval.rs b/src/promql/src/instant_eval.rs index 7c15d26ed69..887a0816b88 100644 --- a/src/promql/src/instant_eval.rs +++ b/src/promql/src/instant_eval.rs @@ -13,11 +13,16 @@ // limitations under the License. use std::ops::RangeInclusive; +use std::sync::Arc; use common_query::prometheus::is_prometheus_stale_nan; use datafusion::error::{DataFusionError, Result}; +use datatypes::series::MetricName; use datatypes::series::raw::RawTimestamps; -use datatypes::series::samples::{SampleRef, SeriesFields}; +use datatypes::series::samples::{SampleRef, Series, SeriesFields, SeriesSchema}; + +use crate::grid_series::GridSeriesBuilder; +use crate::map::drop_metric_name; #[cfg(test)] mod tests; @@ -142,3 +147,175 @@ pub fn select_instants( } Ok((indices, evaluation_timestamps)) } + +/// `timestamp(v)` over an instant selector: at each point the selection holds, the original time of +/// the selected sample in seconds, not the time it was looked up at, so an offset or `@` moves +/// which sample is selected but not the time reported. The metric name, `__type__` and `__unit__` +/// are dropped. +/// +/// `indices` and `evaluation_timestamps` are what [`select_instants`] returns for `time` and +/// `fields`; `schema` holds the evaluation grid the timestamps lie on and one field per field of +/// `fields`. A field without a sample in the selected row has no result there. +pub fn timestamp_of_selection( + schema: &Arc, + metric: &MetricName, + time: &RawTimestamps, + fields: &SeriesFields, + indices: &[u64], + evaluation_timestamps: &[i64], +) -> Result> { + let selection = Selection::try_new(schema, time, fields, indices, evaluation_timestamps)?; + selection.project( + drop_metric_name(metric)?, + |_, time| Ok(time as f64 / 1000.0), + ) +} + +/// The series `info()` reads its data labels from: at each point the selection holds, the original +/// time of the selected sample in milliseconds, as Prometheus' `evalSeries` records it for `info`. +/// The labels are kept. Info series hold float samples, so a selected native histogram fails. +/// +/// Takes the same selection as [`timestamp_of_selection`]. +pub fn info_sample_times( + schema: &Arc, + metric: &MetricName, + time: &RawTimestamps, + fields: &SeriesFields, + indices: &[u64], + evaluation_timestamps: &[i64], +) -> Result> { + let selection = Selection::try_new(schema, time, fields, indices, evaluation_timestamps)?; + selection.project(metric.clone(), |sample, time| match sample { + SampleRef::Histogram(_) => Err(DataFusionError::Execution(format!( + "this should be an info metric, with float samples: {metric:?}" + ))), + _ => Ok(time as f64), + }) +} + +/// `timestamp(v)` over an evaluated `v`, such as `timestamp(abs(x))`: the evaluation time in +/// seconds at each point a field of `series` holds a value. The metric name, `__type__` and +/// `__unit__` are dropped. +pub fn timestamp_of_evaluated( + schema: &Arc, + series: &Series, +) -> Result> { + let grid = schema.eval_grid.as_ref().ok_or_else(|| { + DataFusionError::Execution("timestamp requires an evaluation grid".into()) + })?; + if series.time.grid().map(|grid| grid.spec()) != Some(grid.spec()) + || series.fields.len() != schema.fields.len() + { + return Err(DataFusionError::Execution( + "timestamp input does not match its schema".into(), + )); + } + let timestamps = grid.timestamps(); + let mut builder = GridSeriesBuilder::try_new(schema.clone())?; + let mut cursors: Vec<_> = series.fields.iter().map(|field| field.cursor()).collect(); + let mut row = Vec::with_capacity(cursors.len()); + for (point, index) in series.time.positions() { + let seconds = timestamps[point] as f64 / 1000.0; + row.clear(); + for cursor in &mut cursors { + let sample = cursor + .at(index) + .expect("the axis and the fields are aligned"); + row.push((!matches!(sample, SampleRef::Missing)).then_some(seconds)); + } + builder.push(point, &row)?; + } + builder.finish(drop_metric_name(&series.metric)?) +} + +/// A checked instant selection: the grid position and source row of each selected point. +struct Selection<'a> { + schema: &'a Arc, + time: &'a RawTimestamps, + fields: &'a SeriesFields, + /// `(grid position, source row)` per selected point, positions increasing. + points: Vec<(usize, usize)>, +} + +impl<'a> Selection<'a> { + fn try_new( + schema: &'a Arc, + time: &'a RawTimestamps, + fields: &'a SeriesFields, + indices: &[u64], + evaluation_timestamps: &[i64], + ) -> Result { + let grid = schema.eval_grid.as_ref().ok_or_else(|| { + DataFusionError::Execution("an instant selection requires an evaluation grid".into()) + })?; + if indices.len() != evaluation_timestamps.len() || fields.len() != schema.fields.len() { + return Err(DataFusionError::Execution( + "instant selection does not match its series or schema".into(), + )); + } + let spec = grid.spec(); + let mut points = Vec::with_capacity(indices.len()); + for (&row, ×tamp) in indices.iter().zip(evaluation_timestamps) { + let offset = timestamp as i128 - spec.start_ms as i128; + let position = offset / spec.step_ms as i128; + let row = usize::try_from(row).ok().filter(|row| *row < time.len()); + match row { + Some(row) + if offset % spec.step_ms as i128 == 0 + && (0..spec.len as i128).contains(&position) + && points.last().is_none_or(|&(last, last_row)| { + (last as i128) < position && last_row <= row + }) => + { + points.push((position as usize, row)); + } + _ => { + return Err(DataFusionError::Execution( + "instant selection points off the grid or out of the series".into(), + )); + } + } + } + Ok(Self { + schema, + time, + fields, + points, + }) + } + + /// One series over the selected points, each field holding `value(sample, original time)` + /// where its selected sample is present. + fn project( + &self, + metric: MetricName, + value: impl Fn(SampleRef<'_>, i64) -> Result, + ) -> Result> { + let mut builder = GridSeriesBuilder::try_new(self.schema.clone())?; + let mut cursors: Vec<_> = self.fields.iter().map(|field| field.cursor()).collect(); + // Consecutive points often select the same row, which a cursor reads only once. + let mut row: Vec> = Vec::with_capacity(self.fields.len()); + let mut row_index = None; + for &(position, index) in &self.points { + if row_index != Some(index) { + let time = self + .time + .timestamp(index) + .expect("checked against the axis"); + row.clear(); + for cursor in &mut cursors { + let sample = cursor + .at(index) + .expect("selected rows only increase and lie in the fields"); + row.push(match sample { + SampleRef::Missing => None, + sample => Some(value(sample, time)?), + }); + } + row_index = Some(index); + } + builder.push(position, &row)?; + } + builder.finish(metric) + } +} diff --git a/src/promql/src/instant_eval/tests.rs b/src/promql/src/instant_eval/tests.rs index 9df63e91f52..a85b8b48022 100644 --- a/src/promql/src/instant_eval/tests.rs +++ b/src/promql/src/instant_eval/tests.rs @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +use std::collections::BTreeMap; use std::ops::RangeInclusive; use std::sync::Arc; @@ -21,9 +22,14 @@ use datatypes::series::histogram::{ BucketSideView, CounterResetHint, HistogramBatch, HistogramBatchBuilder, HistogramView, }; use datatypes::series::raw::RawTimestamps; -use datatypes::series::samples::{DenseSamplesRef, SampleRef, Samples, SeriesFields}; +use datatypes::series::samples::{ + DenseSamplesRef, FieldSpec, SampleRef, Samples, Series, SeriesFields, SeriesSchema, +}; +use datatypes::series::time::{EvalGrid, GridSpec}; -use super::select_instants; +use super::{info_sample_times, select_instants, timestamp_of_evaluated, timestamp_of_selection}; +use crate::grid_series::GridSeriesBuilder; +use crate::grid_series::reference::{Labels, labels_of, metric_of}; fn axis(times: &[i64]) -> RawTimestamps { RawTimestamps::try_new(ScalarBuffer::from(times.to_vec())).unwrap() @@ -250,3 +256,205 @@ fn empty_axis_returns_no_selections() { (vec![], vec![]) ); } + +fn grid_schema(fields: usize, start: i64, step: i64, points: u64) -> Arc { + Arc::new(SeriesSchema { + fields: (0..fields) + .map(|field| FieldSpec { + name: Arc::from(format!("f{field}")), + }) + .collect::>() + .into(), + eval_grid: Some(Arc::new( + EvalGrid::try_new(GridSpec::try_new(start, step, points).unwrap()).unwrap(), + )), + }) +} + +/// Each field's value per position, `None` where it has none. +fn values_by_position(series: &Series) -> Vec<(usize, Vec>)> { + series + .time + .positions() + .map(|(position, row)| { + let values = series + .fields + .iter() + .map(|field| match field.sample(row) { + SampleRef::Float(value) => Some(value.to_bits()), + _ => None, + }) + .collect(); + (position, values) + }) + .collect() +} + +/// `timestamp()` evaluated by the Prometheus v3.15.0 engine, over a selector and over an +/// evaluated expression; see the header of `timestamp_cases.txt`. +#[test] +fn timestamp_matches_prometheus() { + const LOOKBACK_MS: i64 = 25_000; + let mut checked = 0; + for case in include_str!("timestamp_cases.txt") + .split("end\n") + .filter(|case| case.contains("case ")) + { + let lines: Vec<&str> = case.lines().filter(|line| !line.starts_with('#')).collect(); + let spec: Vec<&str> = lines[0].split_whitespace().skip(1).collect(); + let offset: i64 = spec[1].parse().unwrap(); + let grid: Vec = lines[2] + .split_whitespace() + .skip(1) + .map(|value| value.parse().unwrap()) + .collect(); + let (start, step, points) = (grid[0], grid[1], grid[2] as u64); + let schema = grid_schema(1, start, step, points); + let end = start + (points as i64 - 1) * step; + let mut actual = BTreeMap::new(); + let mut expected = BTreeMap::new(); + for line in &lines[3..] { + let (kind, rest) = line.split_once(' ').unwrap(); + let (labels, values) = rest.split_once(" ;").unwrap(); + let labels: Labels = labels + .split(',') + .filter(|pair| *pair != "-") + .map(|pair| { + let (name, value) = pair.split_once('=').unwrap(); + (name.to_string(), value.to_string()) + }) + .collect(); + let points: Vec<(i64, u64)> = values + .split_whitespace() + .map(|point| { + let (at, bits) = point.split_once(':').unwrap(); + (at.parse().unwrap(), u64::from_str_radix(bits, 16).unwrap()) + }) + .collect(); + match kind { + "raw" => { + let times: Vec = points.iter().map(|(at, _)| *at).collect(); + let fields = SeriesFields::one(Samples::from_float(Float64Array::from( + points + .iter() + .map(|(_, bits)| f64::from_bits(*bits)) + .collect::>(), + ))); + let time = axis(×); + let (indices, timestamps) = + select_instants(&time, &fields, start..=end, step, LOOKBACK_MS, offset) + .unwrap(); + let metric = metric_of(&labels); + let result = if spec[0] == "selector" { + timestamp_of_selection( + &schema, + &metric, + &time, + &fields, + &indices, + ×tamps, + ) + } else { + // Any evaluated series holding the selected points: only where it holds + // values matters to `timestamp`. + let mut builder = GridSeriesBuilder::try_new(schema.clone()).unwrap(); + for at in ×tamps { + builder + .push(((at - start) / step) as usize, &[Some(1.0)]) + .unwrap(); + } + match builder.finish(metric).unwrap() { + Some(evaluated) => timestamp_of_evaluated(&schema, &evaluated), + None => Ok(None), + } + } + .unwrap(); + if let Some(result) = result { + actual.insert(labels_of(&result.metric), values_by_position(&result)); + } + } + "result" => { + let values = points + .into_iter() + .map(|(position, bits)| (position as usize, vec![Some(bits)])) + .collect::>(); + expected.insert(labels, values); + } + other => panic!("unexpected line {other}"), + } + } + assert_eq!(actual, expected, "{case}"); + checked += 1; + } + assert!(checked > 40, "{checked}"); +} + +/// The series `info()` reads holds the original time in milliseconds, keeps its labels, and +/// leaves a field without a sample in the selected row empty; a native histogram is not an info +/// sample. +#[test] +fn info_sample_times_keep_milliseconds_labels_and_missing_fields() { + let schema = grid_schema(2, 100, 10, 3); + let time = axis(&[95, 107]); + let fields = SeriesFields::try_from_vec(vec![ + Samples::from_float(Float64Array::from(vec![Some(1.0), Some(1.0)])), + Samples::from_float(Float64Array::from(vec![Some(1.0), None])), + ]) + .unwrap(); + let metric = metric_of(&Labels::from([ + ("__name__".to_string(), "target_info".to_string()), + ("job".to_string(), "x".to_string()), + ])); + let (indices, timestamps) = select_instants(&time, &fields, 100..=120, 10, 20, 0).unwrap(); + let result = info_sample_times(&schema, &metric, &time, &fields, &indices, ×tamps) + .unwrap() + .unwrap(); + assert_eq!(result.metric, metric); + assert_eq!( + values_by_position(&result), + [ + (0, vec![Some(95f64.to_bits()), Some(95f64.to_bits())]), + (1, vec![Some(107f64.to_bits()), None]), + (2, vec![Some(107f64.to_bits()), None]), + ] + ); + + let histograms = SeriesFields::one( + Samples::try_from_histogram(UInt32Array::from(vec![Some(0)]), histogram_batch(&[1.0])) + .unwrap(), + ); + let schema = grid_schema(1, 100, 10, 1); + let time = axis(&[95]); + let (indices, timestamps) = select_instants(&time, &histograms, 100..=100, 10, 20, 0).unwrap(); + assert!( + info_sample_times(&schema, &metric, &time, &histograms, &indices, ×tamps).is_err() + ); + assert!( + timestamp_of_selection(&schema, &metric, &time, &histograms, &indices, ×tamps) + .unwrap() + .is_some(), + "timestamp() reads the time of a histogram sample too" + ); +} + +/// A selection must lie on the grid in increasing points and rows. +#[test] +fn selections_off_the_grid_or_going_back_are_rejected() { + let schema = grid_schema(1, 100, 10, 3); + let time = axis(&[95, 107]); + let fields = SeriesFields::one(Samples::from_float(Float64Array::from(vec![1.0, 2.0]))); + let metric = metric_of(&Labels::new()); + for (indices, timestamps) in [ + (vec![0], vec![105]), + (vec![0], vec![130]), + (vec![2], vec![100]), + (vec![1, 0], vec![100, 110]), + (vec![0, 0], vec![110, 100]), + ] { + assert!( + timestamp_of_selection(&schema, &metric, &time, &fields, &indices, ×tamps) + .is_err(), + "{indices:?} {timestamps:?}" + ); + } +} diff --git a/src/promql/src/instant_eval/timestamp_cases.txt b/src/promql/src/instant_eval/timestamp_cases.txt new file mode 100644 index 00000000000..d880caaa8c8 --- /dev/null +++ b/src/promql/src/instant_eval/timestamp_cases.txt @@ -0,0 +1,329 @@ +# Generated by promql-compat cmd/narthex-golden-timestamp built for amd64 (Prometheus v3.15.0 +# engine, seed 1, lookback 25000ms). `case `, `raw` series as +# `ms:hex value bits` samples, then the result series as `grid index:hex value bits`. +case evaluated 0 +expr timestamp(abs(a)) +grid 100000 10000 2 +raw __name__=a,i=0,job=y ; 79059:c062d80000000000 96484:7ff0000000000002 102784:403e600000000000 108946:7ff0000000000002 +end +case selector 7000 +expr timestamp(a offset 7000ms) +grid 100000 10000 3 +raw __name__=a,i=0,job=x ; 81528:4086a80000000000 99475:7ff0000000000002 105265:4078980000000000 108673:c0855b0000000000 112102:c08a490000000000 115733:4078000000000000 +raw __name__=a,i=1,job=x ; 80563:c063380000000000 87641:c07b3e0000000000 91994:4087c90000000000 107183:7ff0000000000002 +raw __name__=a,i=2,job=x ; 79703:c077900000000000 86213:4082d10000000000 97479:c061e80000000000 107681:408def0000000000 +result i=0,job=x ; 0:405461cac083126f 2:405c06872b020c4a +result i=1,job=x ; 0:4056ff9db22d0e56 1:4056ff9db22d0e56 +result i=2,job=x ; 0:40558da1cac08312 1:40585ea7ef9db22d 2:405aeb95810624dd +end +case evaluated 0 +expr timestamp(abs(a)) +grid 100000 10000 8 +raw __name__=a,i=0,job=y ; 82463:4072b00000000000 99086:c0680c0000000000 105219:4088c50000000000 116252:4072120000000000 121254:7ff0000000000002 136800:4051a80000000000 151303:4070040000000000 166508:c0666c0000000000 +result i=0,job=y ; 0:4059000000000000 1:405b800000000000 2:405e000000000000 4:4061800000000000 5:4062c00000000000 6:4064000000000000 7:4065400000000000 +end +case evaluated 0 +expr timestamp(abs(a)) +grid 100000 10000 3 +raw __name__=a,i=0,job=y ; 83590:c011000000000000 90143:c08d650000000000 93527:4089fd0000000000 107664:4083b60000000000 113390:40690c0000000000 118469:c07f4a0000000000 +raw __name__=a,i=1,job=y ; 73981:40783a0000000000 81866:408dbb0000000000 93615:407fa60000000000 105999:7ff0000000000002 113546:c00a000000000000 +raw __name__=a,i=2,job=x ; 75786:4062040000000000 82426:405a480000000000 85790:4082930000000000 98591:c06ab00000000000 103849:407dfa0000000000 109427:4044600000000000 +result i=0,job=y ; 0:4059000000000000 1:405b800000000000 2:405e000000000000 +result i=1,job=y ; 0:4059000000000000 2:405e000000000000 +result i=2,job=x ; 0:4059000000000000 1:405b800000000000 2:405e000000000000 +end +case selector 7000 +expr timestamp(a offset 7000ms) +grid 100000 10000 1 +raw __name__=a,i=0,job=x ; 74535:7ff0000000000002 90697:4048800000000000 98068:4071140000000000 +raw __name__=a,i=1,job=y ; 81720:c0730a0000000000 97704:c081b80000000000 +raw __name__=a,i=2,job=y ; 77920:40847f0000000000 81615:7ff0000000000002 99071:40818c0000000000 +result i=0,job=x ; 0:4056ac9ba5e353f8 +result i=1,job=y ; 0:40546e147ae147ae +end +case evaluated 7000 +expr timestamp(abs(a offset 7000ms)) +grid 100000 10000 8 +raw __name__=a,i=0,job=y ; 75292:c089700000000000 82395:c059c80000000000 89151:40757a0000000000 98308:408c1e0000000000 113489:c068e40000000000 121906:c061d40000000000 138902:7ff0000000000002 154422:407c720000000000 164451:c08ab90000000000 +raw __name__=a,i=1,job=x ; 71464:c07f840000000000 85645:4084f20000000000 94245:4070800000000000 108778:4064f40000000000 118463:4072a60000000000 128182:7ff0000000000002 134844:7ff0000000000002 147128:4084d40000000000 151571:c064ac0000000000 165145:4088bd0000000000 169481:7ff0000000000002 +result i=0,job=y ; 0:4059000000000000 1:405b800000000000 2:405e000000000000 3:4060400000000000 4:4061800000000000 7:4065400000000000 +result i=1,job=x ; 0:4059000000000000 1:405b800000000000 2:405e000000000000 3:4060400000000000 6:4064000000000000 7:4065400000000000 +end +case evaluated 7000 +expr timestamp(abs(a offset 7000ms)) +grid 100000 10000 5 +raw __name__=a,i=0,job=x ; 84648:40853f0000000000 96270:c07dda0000000000 102421:c08ec50000000000 113900:c088fd0000000000 126131:4082320000000000 +raw __name__=a,i=1,job=y ; 75166:4068cc0000000000 86574:7ff0000000000002 93227:c080f10000000000 103602:c072d60000000000 108139:4075700000000000 117253:c078ba0000000000 123799:408bfc0000000000 139386:40835e0000000000 +result i=0,job=x ; 0:4059000000000000 1:405b800000000000 2:405e000000000000 3:4060400000000000 4:4061800000000000 +result i=1,job=y ; 1:405b800000000000 2:405e000000000000 3:4060400000000000 4:4061800000000000 +end +case evaluated 0 +expr timestamp(abs(a)) +grid 100000 10000 1 +raw __name__=a,i=0,job=x ; 77477:408bc60000000000 81528:408dbe0000000000 86317:c077140000000000 +raw __name__=a,i=1,job=x ; 74159:c054900000000000 84577:4059500000000000 90230:7ff0000000000002 +result i=0,job=x ; 0:4059000000000000 +end +case selector 20000 +expr timestamp(a offset 20000ms) +grid 100000 10000 3 +raw __name__=a,i=0,job=y ; 70580:c08ef90000000000 87847:4086370000000000 100840:7ff0000000000002 106471:4050b80000000000 112355:4079a40000000000 118517:c0749c0000000000 +result i=0,job=y ; 0:4051a51eb851eb85 1:4055f6353f7ced91 2:4055f6353f7ced91 +end +case selector 0 +expr timestamp(a) +grid 100000 10000 1 +raw __name__=a,i=0,job=y ; 72574:407a960000000000 84105:c048500000000000 97507:4068640000000000 +raw __name__=a,i=1,job=y ; 81462:407b8a0000000000 92655:c074200000000000 +result i=0,job=y ; 0:40586072b020c49c +result i=1,job=y ; 0:405729eb851eb852 +end +case selector 20000 +expr timestamp(a offset 20000ms) +grid 100000 10000 7 +raw __name__=a,i=0,job=x ; 76420:4073b00000000000 88427:c085e50000000000 105280:c07f7a0000000000 119020:c08a200000000000 132565:406e800000000000 143696:c0854d0000000000 157325:c07fc60000000000 +raw __name__=a,i=1,job=y ; 74377:407d3c0000000000 90394:c052780000000000 102174:c056680000000000 117648:c063140000000000 121542:4076d00000000000 132116:c06c480000000000 135511:c07aac0000000000 141161:408eea0000000000 144806:7ff0000000000002 +result i=0,job=x ; 0:40531ae147ae147b 1:40561b53f7ced917 2:40561b53f7ced917 3:405a51eb851eb852 4:405dc147ae147ae1 5:405dc147ae147ae1 6:406092147ae147ae +result i=1,job=y ; 0:40529820c49ba5e3 1:40529820c49ba5e3 2:405699374bc6a7f0 3:40598b22d0e56042 4:405d6978d4fdf3b6 5:405e62b020c49ba6 6:4060f05a1cac0831 +end +case selector 7000 +expr timestamp(a offset 7000ms) +grid 100000 10000 2 +raw __name__=a,i=0,job=x ; 75010:c06a840000000000 87311:7ff0000000000002 94231:4071da0000000000 105371:c08b210000000000 +result i=0,job=x ; 1:40578ec8b4395810 +end +case evaluated 7000 +expr timestamp(abs(a offset 7000ms)) +grid 100000 10000 2 +raw __name__=a,i=0,job=y ; 80325:4077720000000000 89569:c081b00000000000 94056:c080f70000000000 103981:4089050000000000 +raw __name__=a,i=1,job=x ; 78176:c08ed60000000000 90610:4080a70000000000 104523:406b7c0000000000 +result i=0,job=y ; 0:4059000000000000 1:405b800000000000 +result i=1,job=x ; 0:4059000000000000 1:405b800000000000 +end +case selector 7000 +expr timestamp(a offset 7000ms) +grid 100000 10000 7 +raw __name__=a,i=0,job=x ; 79485:7ff0000000000002 97045:c05df00000000000 111627:4046100000000000 122143:c085020000000000 129698:c07c140000000000 143915:7ff0000000000002 154094:c04b900000000000 +result i=0,job=x ; 1:405842e147ae147b 2:405be820c49ba5e3 3:405e8926e978d4fe 4:4060365604189375 5:4060365604189375 +end +case selector 20000 +expr timestamp(a offset 20000ms) +grid 100000 10000 1 +raw __name__=a,i=0,job=y ; 79969:c083fc0000000000 88978:c085590000000000 +raw __name__=a,i=1,job=y ; 72442:4071ac0000000000 87172:c07bb20000000000 98532:c061f00000000000 +raw __name__=a,i=2,job=y ; 77328:7ff0000000000002 86590:c0836a0000000000 90940:c08d980000000000 98432:c083c40000000000 +result i=0,job=y ; 0:4053fe04189374bc +result i=1,job=y ; 0:40521c49ba5e353f +end +case selector 0 +expr timestamp(a) +grid 100000 10000 8 +raw __name__=a,i=0,job=x ; 82283:c07b600000000000 95023:c082690000000000 104613:408b9b0000000000 121027:c08e1a0000000000 137295:c08b4c0000000000 149491:7ff0000000000002 155253:c076ba0000000000 167374:408d0b0000000000 +result i=0,job=x ; 0:4057c178d4fdf3b6 1:405a273b645a1cac 2:405a273b645a1cac 3:405e41ba5e353f7d 4:40612970a3d70a3d 6:406368189374bc6a 7:4064ebf7ced91687 +end +case evaluated 20000 +expr timestamp(abs(a offset 20000ms)) +grid 100000 10000 2 +raw __name__=a,i=0,job=x ; 73585:406cd80000000000 87303:c08b230000000000 96118:7ff0000000000002 106474:4056c00000000000 +result i=0,job=x ; 0:4059000000000000 1:405b800000000000 +end +case selector 20000 +expr timestamp(a offset 20000ms) +grid 100000 10000 5 +raw __name__=a,i=0,job=x ; 84214:406b000000000000 91349:406ec80000000000 97389:4077f80000000000 110055:7ff0000000000002 116849:c08d8e0000000000 125515:c06d7c0000000000 139575:c06fbc0000000000 +raw __name__=a,i=1,job=y ; 70093:c07a7e0000000000 84311:c0869d0000000000 99499:c08bce0000000000 107741:4054680000000000 124025:c07f1a0000000000 129247:c065680000000000 139495:7ff0000000000002 +result i=0,job=x ; 1:40550db22d0e5604 2:405858e560418937 3:405858e560418937 4:405d365604189375 +result i=1,job=y ; 0:405185f3b645a1cb 1:405513e76c8b4396 2:4058dfef9db22d0e 3:405aef6c8b439581 4:405aef6c8b439581 +end +case evaluated 20000 +expr timestamp(abs(a offset 20000ms)) +grid 100000 10000 8 +raw __name__=a,i=0,job=y ; 77173:c0653c0000000000 83623:c088c70000000000 91922:c085140000000000 109537:4081d20000000000 112877:c07eba0000000000 126470:7ff0000000000002 136248:c051380000000000 147427:4084dd0000000000 160938:c079b80000000000 169031:4059380000000000 +raw __name__=a,i=1,job=x ; 82483:4073c60000000000 99095:c0872d0000000000 110238:40801f0000000000 114761:c083a40000000000 128951:408dd00000000000 143452:7ff0000000000002 148859:c04f400000000000 157955:4054500000000000 162543:7ff0000000000002 +raw __name__=a,i=2,job=y ; 80197:407a2a0000000000 89805:4072080000000000 96259:407bda0000000000 107660:7ff0000000000002 120748:c084510000000000 129419:c08e860000000000 135946:c0899b0000000000 152730:c08b0e0000000000 161728:c085580000000000 +result i=0,job=y ; 0:4059000000000000 1:405b800000000000 2:405e000000000000 3:4060400000000000 4:4061800000000000 6:4064000000000000 7:4065400000000000 +result i=1,job=x ; 1:405b800000000000 2:405e000000000000 3:4060400000000000 4:4061800000000000 5:4062c00000000000 6:4064000000000000 7:4065400000000000 +result i=2,job=y ; 1:405b800000000000 2:405e000000000000 5:4062c00000000000 6:4064000000000000 7:4065400000000000 +end +case selector 20000 +expr timestamp(a offset 20000ms) +grid 100000 10000 4 +raw __name__=a,i=0,job=x ; 73495:4088fe0000000000 82484:407b9c0000000000 98399:c071c20000000000 102319:c07d2e0000000000 109123:4081310000000000 114002:40607c0000000000 123387:406ab00000000000 +result i=0,job=x ; 0:40525fae147ae148 1:40549ef9db22d0e5 2:40589989374bc6a8 3:405b47df3b645a1d +end +case evaluated 0 +expr timestamp(abs(a)) +grid 100000 10000 5 +raw __name__=a,i=0,job=x ; 76458:4015000000000000 88707:c077ea0000000000 100123:c087690000000000 117095:c04db00000000000 129728:c047100000000000 +result i=0,job=x ; 0:4059000000000000 1:405b800000000000 2:405e000000000000 3:4060400000000000 4:4061800000000000 +end +case evaluated 7000 +expr timestamp(abs(a offset 7000ms)) +grid 100000 10000 1 +raw __name__=a,i=0,job=y ; 72663:4053980000000000 77224:7ff0000000000002 84502:7ff0000000000002 88814:c0848b0000000000 +raw __name__=a,i=1,job=y ; 78158:c081500000000000 85741:c089da0000000000 94037:407c900000000000 +result i=0,job=y ; 0:4059000000000000 +result i=1,job=y ; 0:4059000000000000 +end +case evaluated 0 +expr timestamp(abs(a)) +grid 100000 10000 4 +raw __name__=a,i=0,job=y ; 73784:407f5e0000000000 79895:7ff0000000000002 90545:40838d0000000000 97792:c0878c0000000000 107256:4089960000000000 112659:4066640000000000 124276:c085360000000000 +result i=0,job=y ; 0:4059000000000000 1:405b800000000000 2:405e000000000000 3:4060400000000000 +end +case selector 0 +expr timestamp(a) +grid 100000 10000 2 +raw __name__=a,i=0,job=y ; 80763:4068cc0000000000 93710:407a140000000000 103920:40869c0000000000 +result i=0,job=y ; 0:40576d70a3d70a3d 1:4059fae147ae147b +end +case evaluated 20000 +expr timestamp(abs(a offset 20000ms)) +grid 100000 10000 3 +raw __name__=a,i=0,job=y ; 72337:4087940000000000 89966:407f120000000000 93255:c086d80000000000 108513:7ff0000000000002 +raw __name__=a,i=1,job=y ; 75213:40897d0000000000 90123:c06d340000000000 93395:7ff0000000000002 106382:4070040000000000 +raw __name__=a,i=2,job=x ; 70983:c0867d0000000000 75257:c088a40000000000 85105:c08dee0000000000 98459:7ff0000000000002 115950:c07a300000000000 +result i=0,job=y ; 0:4059000000000000 1:405b800000000000 2:405e000000000000 +result i=1,job=y ; 0:4059000000000000 1:405b800000000000 +result i=2,job=x ; 0:4059000000000000 1:405b800000000000 +end +case selector 0 +expr timestamp(a) +grid 100000 10000 6 +raw __name__=a,i=0,job=x ; 76830:c084890000000000 83432:c077c40000000000 97404:c081a00000000000 110009:400c000000000000 113602:4081e70000000000 122418:40793e0000000000 133494:c087e30000000000 142135:4072a60000000000 +raw __name__=a,i=1,job=x ; 83237:c0760e0000000000 94648:7ff0000000000002 108659:4088840000000000 122841:4082c00000000000 135311:4080e80000000000 144496:407d1a0000000000 +result i=0,job=x ; 0:405859db22d0e560 1:405859db22d0e560 2:405c66872b020c4a 3:405e9ac083126e98 4:4060afced916872b 5:4061c451eb851eb8 +result i=1,job=x ; 1:405b2a2d0e560419 2:405b2a2d0e560419 3:405eb5d2f1a9fbe7 4:4060e9f3b645a1cb 5:40620fdf3b645a1d +end +case evaluated 0 +expr timestamp(abs(a)) +grid 100000 10000 3 +raw __name__=a,i=0,job=x ; 73573:c083f80000000000 84358:c0797e0000000000 94517:7ff0000000000002 100117:4056f80000000000 113287:40744e0000000000 117896:4052000000000000 +raw __name__=a,i=1,job=y ; 74254:4080380000000000 84440:c08ea90000000000 96483:c086bb0000000000 101838:405f180000000000 116855:408a350000000000 +raw __name__=a,i=2,job=y ; 79882:c079de0000000000 86885:c0875e0000000000 102488:4083eb0000000000 108210:7ff0000000000002 +result i=0,job=x ; 1:405b800000000000 2:405e000000000000 +result i=1,job=y ; 0:4059000000000000 1:405b800000000000 2:405e000000000000 +result i=2,job=y ; 0:4059000000000000 +end +case evaluated 7000 +expr timestamp(abs(a offset 7000ms)) +grid 100000 10000 7 +raw __name__=a,i=0,job=x ; 81134:4056880000000000 85959:4076fa0000000000 91975:4041600000000000 101146:c070620000000000 118601:4050c00000000000 124531:7ff0000000000002 133191:c08b7b0000000000 138507:c07dc00000000000 145636:405ed00000000000 152418:c062d80000000000 +raw __name__=a,i=1,job=x ; 73371:c083d70000000000 82765:4089390000000000 90378:40843c0000000000 94675:c0830b0000000000 105880:c0698c0000000000 121661:40710e0000000000 128521:c086fd0000000000 133192:4087b80000000000 139057:7ff0000000000002 147190:408d310000000000 +result i=0,job=x ; 0:4059000000000000 1:405b800000000000 2:405e000000000000 3:4060400000000000 5:4062c00000000000 6:4064000000000000 +result i=1,job=x ; 0:4059000000000000 1:405b800000000000 2:405e000000000000 3:4060400000000000 4:4061800000000000 6:4064000000000000 +end +case selector 0 +expr timestamp(a) +grid 100000 10000 4 +raw __name__=a,i=0,job=y ; 80343:4067380000000000 95678:c0837b0000000000 98812:c0857f0000000000 115313:c060e40000000000 +result i=0,job=y ; 0:4058b3f7ced91687 1:4058b3f7ced91687 2:405cd4083126e979 3:405cd4083126e979 +end +case selector 0 +expr timestamp(a) +grid 100000 10000 5 +raw __name__=a,i=0,job=x ; 70359:c053e00000000000 87803:7ff0000000000002 96519:c084350000000000 100451:c08bca0000000000 106415:7ff0000000000002 111246:c0756c0000000000 122772:40861b0000000000 126738:408d5e0000000000 +raw __name__=a,i=1,job=y ; 70521:40700c0000000000 82930:40661c0000000000 87118:4075a20000000000 98137:40888f0000000000 102042:c088c80000000000 115583:4069280000000000 126036:c07ac00000000000 +raw __name__=a,i=2,job=x ; 78261:408c2b0000000000 86619:4076d80000000000 90915:4084270000000000 101761:4089a90000000000 117458:c088ef0000000000 121861:4080b10000000000 137796:406a200000000000 +result i=0,job=x ; 0:405821374bc6a7f0 2:405bcfbe76c8b439 3:405faf3b645a1cac 4:405faf3b645a1cac +result i=1,job=y ; 0:405888c49ba5e354 1:405982b020c49ba6 2:405ce54fdf3b645a 3:405f824dd2f1a9fc 4:405f824dd2f1a9fc +result i=2,job=x ; 0:4056ba8f5c28f5c3 1:405970b439581062 2:405d5d4fdf3b645a 3:405e771a9fbe76c9 4:40613978d4fdf3b6 +end +case selector 0 +expr timestamp(a) +grid 100000 10000 3 +raw __name__=a,i=0,job=x ; 70692:c08d1e0000000000 78141:c07f6e0000000000 93314:4082fc0000000000 97579:c079500000000000 102613:408c750000000000 112101:c083000000000000 +result i=0,job=x ; 0:4058650e56041893 1:4059a73b645a1cac 2:405c0676c8b43958 +end +case selector 0 +expr timestamp(a) +grid 100000 10000 8 +raw __name__=a,i=0,job=x ; 70309:c06a6c0000000000 76161:4074a20000000000 87945:c06f600000000000 98986:c065fc0000000000 108988:c06c840000000000 118961:7ff0000000000002 131871:4062680000000000 148655:40846d0000000000 152936:4089a80000000000 161492:4087ab0000000000 165161:4084300000000000 +raw __name__=a,i=1,job=y ; 72192:7ff0000000000002 85093:40647c0000000000 98209:4086680000000000 108630:c03ea00000000000 122677:7ff0000000000002 128819:4073de0000000000 143534:c0811b0000000000 146909:405a580000000000 164059:4073f80000000000 +raw __name__=a,i=2,job=y ; 83490:c085880000000000 94498:40762c0000000000 97566:c07ca00000000000 111237:c06f500000000000 119775:406b480000000000 131001:4077a20000000000 141755:c085f40000000000 152222:c0717a0000000000 160874:4068e40000000000 +result i=0,job=x ; 0:4058bf1a9fbe76c9 1:405b3f3b645a1cac 4:40607bdf3b645a1d 5:406294f5c28f5c29 6:40631df3b645a1cb 7:4064a526e978d4fe +result i=1,job=y ; 0:40588d604189374c 1:405b2851eb851eb8 2:405b2851eb851eb8 3:40601a353f7ced91 4:40601a353f7ced91 5:40625d16872b020c 6:40625d16872b020c 7:406481e353f7ced9 +result i=2,job=y ; 0:405864395810624e 1:405864395810624e 2:405df1999999999a 3:405df1999999999a 4:406060083126e979 5:4061b828f5c28f5c 6:4063071a9fbe76c9 7:40641bf7ced91687 +end +case selector 0 +expr timestamp(a) +grid 100000 10000 8 +raw __name__=a,i=0,job=y ; 82336:4086df0000000000 97193:4086b60000000000 107022:c061540000000000 110754:4075420000000000 127175:4085550000000000 133841:408ace0000000000 144856:4080100000000000 151854:c084cb0000000000 155543:c084240000000000 +raw __name__=a,i=1,job=y ; 82716:407e2a0000000000 97270:c082920000000000 105884:c043200000000000 111713:4059180000000000 118035:4071f20000000000 128994:c0838f0000000000 135164:408e9c0000000000 148259:408da70000000000 157945:c08d340000000000 166446:4072ce0000000000 +raw __name__=a,i=2,job=x ; 74467:40751a0000000000 78531:c03ca00000000000 82485:406d140000000000 96626:c07c280000000000 108500:40811f0000000000 124811:407e3a0000000000 140915:c06ac80000000000 148542:c068900000000000 166451:407e500000000000 +result i=0,job=y ; 0:40584c5a1cac0831 1:405ac16872b020c5 2:405bb04189374bc7 3:405fcb3333333333 4:4060bae978d4fdf4 5:40621b645a1cac08 6:406371604189374c 7:406371604189374c +result i=1,job=y ; 0:40585147ae147ae1 1:405a789374bc6a7f 2:405d823d70a3d70a 3:40601fced916872b 4:4060e53f7ced9168 5:40628849ba5e353f 6:4063be3d70a3d70a 7:4064ce45a1cac083 +result i=2,job=x ; 0:40582810624dd2f2 1:405b200000000000 2:405b200000000000 3:405f33e76c8b4396 4:405f33e76c8b4396 5:4062915810624dd3 6:4062915810624dd3 7:4064ce6e978d4fdf +end +case selector 7000 +expr timestamp(a offset 7000ms) +grid 100000 10000 2 +raw __name__=a,i=0,job=x ; 81983:408af30000000000 86135:c077800000000000 89808:4085920000000000 98138:4076e00000000000 +raw __name__=a,i=1,job=y ; 75638:c077680000000000 84618:c08d680000000000 101016:4082580000000000 +result i=0,job=x ; 0:405673b645a1cac1 1:405888d4fdf3b646 +result i=1,job=y ; 0:4055278d4fdf3b64 1:4059410624dd2f1b +end +case selector 0 +expr timestamp(a) +grid 100000 10000 7 +raw __name__=a,i=0,job=x ; 80262:4084870000000000 97781:c085160000000000 104530:c08e230000000000 112820:4088160000000000 119829:c0709c0000000000 136289:c08c990000000000 148554:4062400000000000 +raw __name__=a,i=1,job=y ; 84412:40856a0000000000 91735:7ff0000000000002 107846:406e0c0000000000 113332:40822a0000000000 126462:407b3c0000000000 140435:408e9f0000000000 147416:c086b40000000000 150997:4085260000000000 158188:c079da0000000000 +raw __name__=a,i=2,job=x ; 70377:405a280000000000 88006:c030a00000000000 96180:c081360000000000 110797:4015000000000000 118425:c087eb0000000000 122156:c08eae0000000000 139863:408df90000000000 151463:7ff0000000000002 157803:c085760000000000 +result i=0,job=x ; 0:405871fbe76c8b44 1:405a21eb851eb852 2:405df50e56041893 3:405df50e56041893 4:4061093f7ced9168 5:406291ba5e353f7d 6:406291ba5e353f7d +result i=1,job=y ; 1:405af624dd2f1aa0 2:405c553f7ced9168 3:405f9d916872b021 4:405f9d916872b021 5:40626d4fdf3b645a 6:4063c604189374bc +result i=2,job=x ; 0:40580b851eb851ec 1:40580b851eb851ec 2:405d9b3333333333 3:405e89fbe76c8b44 4:40617b9db22d0e56 5:40617b9db22d0e56 6:4063b9b22d0e5604 +end +case selector 7000 +expr timestamp(a offset 7000ms) +grid 100000 10000 4 +raw __name__=a,i=0,job=x ; 72060:c085290000000000 86966:c080740000000000 94720:c088140000000000 111684:c0886a0000000000 116860:c078300000000000 +result i=0,job=x ; 0:4055bdd2f1a9fbe7 1:4057ae147ae147ae 2:405bebc6a7ef9db2 3:405d370a3d70a3d7 +end +case selector 20000 +expr timestamp(a offset 20000ms) +grid 100000 10000 2 +raw __name__=a,i=0,job=x ; 84133:407d4e0000000000 94059:4081990000000000 103268:4085f40000000000 +result i=0,job=x ; 1:40550883126e978d +end +case selector 7000 +expr timestamp(a offset 7000ms) +grid 100000 10000 5 +raw __name__=a,i=0,job=x ; 72030:c082640000000000 84232:c073240000000000 89684:c014000000000000 98005:c0784e0000000000 104332:40789a0000000000 115856:c070e80000000000 121883:407a020000000000 131354:7ff0000000000002 +result i=0,job=x ; 0:40566bc6a7ef9db2 1:40588051eb851eb8 2:405a153f7ced9168 3:405e7883126e978d +end +case evaluated 0 +expr timestamp(abs(a)) +grid 100000 10000 2 +raw __name__=a,i=0,job=y ; 78383:4074ce0000000000 86252:40868f0000000000 92548:4080340000000000 105336:4080e40000000000 +result i=0,job=y ; 0:4059000000000000 1:405b800000000000 +end +case selector 0 +expr timestamp(a) +grid 100000 10000 2 +raw __name__=a,i=0,job=y ; 83007:c081980000000000 99231:c08a9d0000000000 106872:c07cf60000000000 +result i=0,job=y ; 0:4058cec8b4395810 1:405ab7ced916872b +end +case selector 0 +expr timestamp(a) +grid 100000 10000 3 +raw __name__=a,job=x ; 75000:3ff0000000000000 110000:4000000000000000 +result job=x ; 1:405b800000000000 2:405b800000000000 +end +case selector 0 +expr timestamp(a) +grid 100000 10000 4 +raw __name__=a,job=x ; 99000:3ff0000000000000 105000:7ff0000000000002 123000:4008000000000000 +result job=x ; 0:4058c00000000000 3:405ec00000000000 +end +case selector 7000 +expr timestamp(a offset 7000ms) +grid 100000 10000 3 +raw __name__=a,job=x ; 93500:3ff0000000000000 103500:4000000000000000 +result job=x ; 1:4057600000000000 2:4059e00000000000 +end +case evaluated 7000 +expr timestamp(abs(a offset 7000ms)) +grid 100000 10000 3 +raw __name__=a,job=x ; 93500:3ff0000000000000 103500:4000000000000000 +result job=x ; 1:405b800000000000 2:405e000000000000 +end From 799bcbfdd75eb48b739381b1dd0dbc4e2c667964 Mon Sep 17 00:00:00 2001 From: Dennis Zhuang Date: Wed, 21 Oct 2026 20:40:34 +0800 Subject: [PATCH 06/18] feat(promql): add histogram_quantiles and share Go float formatting Signed-off-by: Dennis Zhuang --- src/promql/src/aggregate.rs | 54 +-- src/promql/src/aggregate/tests.rs | 4 +- src/promql/src/go_format.rs | 129 ++++++ .../src/go_format/open_metrics_cases.txt | 358 +++++++++++++++ src/promql/src/go_format/tests.rs | 29 ++ src/promql/src/histogram_eval.rs | 27 +- .../src/histogram_eval/quantiles_cases.txt | 425 ++++++++++++++++++ src/promql/src/histogram_eval/tests.rs | 26 +- src/promql/src/lib.rs | 1 + 9 files changed, 997 insertions(+), 56 deletions(-) create mode 100644 src/promql/src/go_format.rs create mode 100644 src/promql/src/go_format/open_metrics_cases.txt create mode 100644 src/promql/src/go_format/tests.rs create mode 100644 src/promql/src/histogram_eval/quantiles_cases.txt diff --git a/src/promql/src/aggregate.rs b/src/promql/src/aggregate.rs index 115c5ac4030..d164cf3bac5 100644 --- a/src/promql/src/aggregate.rs +++ b/src/promql/src/aggregate.rs @@ -28,6 +28,7 @@ use datafusion::error::{DataFusionError, Result}; use datatypes::series::MetricName; use datatypes::series::samples::{SampleRef, Series, SeriesSchema}; +use crate::go_format::format_float_f; use crate::grid_series::{GridSeriesBuilder, external}; use crate::map::{drop_empty_labels, validate_float_series, validate_scalar}; use crate::range_eval::quantile; @@ -316,7 +317,7 @@ pub fn count_values( Entry::Vacant(entry) => { let mut metric = series.metric.clone(); metric - .set(label, Arc::from(format_value(value))) + .set(label, Arc::from(format_float_f(value))) .map_err(external)?; let metric = grouping.apply(&metric)?; let index = *group_index.entry(label_key(&metric)).or_insert_with(|| { @@ -348,57 +349,6 @@ pub fn count_values( Ok(output) } -/// Go's `strconv.FormatFloat(value, 'f', -1, 64)`: the fewest significant digits that round-trip, -/// in positional notation. When two candidates of that length are equally close, Go takes the -/// even one while Rust's shortest formatting rounds up, so the digits are recomputed with Rust's -/// exact formatting, which rounds half to even. The recomputed digits only count when they still -/// parse back to `value`: just above a power of two the lower neighbour is half as far away, so -/// the even candidate can name that neighbour, and only the other one round-trips. -fn format_value(value: f64) -> String { - match value { - f64::INFINITY => return "+Inf".to_string(), - f64::NEG_INFINITY => return "-Inf".to_string(), - _ if value.is_nan() => return "NaN".to_string(), - _ => {} - } - let shortest = format!("{value:e}"); - let mantissa = shortest.split_once('e').expect("exponent format").0; - let significant = mantissa.chars().filter(char::is_ascii_digit).count(); - let exact = format!("{value:.precision$e}", precision = significant - 1); - let digits = if exact.parse::() == Ok(value) { - &exact - } else { - &shortest - }; - let (mantissa, exponent) = digits.split_once('e').expect("exponent format"); - let exponent: i64 = exponent.parse().expect("integer exponent"); - let (sign, mantissa) = match mantissa.strip_prefix('-') { - Some(unsigned) => ("-", unsigned), - None => ("", mantissa), - }; - let digits: String = mantissa.chars().filter(char::is_ascii_digit).collect(); - // The decimal point goes after `exponent + 1` digits. - let point = exponent + 1; - let mut out = String::from(sign); - if point <= 0 { - out.push_str("0."); - out.extend(std::iter::repeat_n('0', (-point) as usize)); - out.push_str(digits.trim_end_matches('0')); - } else if point as usize >= digits.len() { - out.push_str(&digits); - out.extend(std::iter::repeat_n('0', point as usize - digits.len())); - } else { - let (whole, fraction) = digits.split_at(point as usize); - out.push_str(whole); - let fraction = fraction.trim_end_matches('0'); - if !fraction.is_empty() { - out.push('.'); - out.push_str(fraction); - } - } - out -} - /// Schema validation is repeated because Series exposes its fields publicly. fn validate_input(schema: &SeriesSchema, input: &[Series], grouping: Grouping<'_>) -> Result<()> { if schema.eval_grid.is_none() { diff --git a/src/promql/src/aggregate/tests.rs b/src/promql/src/aggregate/tests.rs index 6b20c4c6bf8..9d71cb0ca48 100644 --- a/src/promql/src/aggregate/tests.rs +++ b/src/promql/src/aggregate/tests.rs @@ -907,7 +907,7 @@ fn value_labels_format_like_go() { for line in include_str!("format_float_cases.txt").lines() { let (bits, expected) = line.split_once(' ').unwrap(); let value = f64::from_bits(u64::from_str_radix(bits, 16).unwrap()); - assert_eq!(format_value(value), expected, "{bits}"); + assert_eq!(crate::go_format::format_float_f(value), expected, "{bits}"); checked += 1; } assert!(checked > 2_400, "{checked}"); @@ -916,7 +916,7 @@ fn value_labels_format_like_go() { (f64::INFINITY, "+Inf"), (f64::NEG_INFINITY, "-Inf"), ] { - assert_eq!(format_value(value), expected); + assert_eq!(crate::go_format::format_float_f(value), expected); } } diff --git a/src/promql/src/go_format.rs b/src/promql/src/go_format.rs new file mode 100644 index 00000000000..bf19922df25 --- /dev/null +++ b/src/promql/src/go_format.rs @@ -0,0 +1,129 @@ +// Copyright 2023 Greptime Team +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! The shortest float formats of Go's `strconv`, which Prometheus writes label values in. + +#[cfg(test)] +mod tests; + +/// Go's `strconv.FormatFloat(value, 'f', -1, 64)`, the form of `count_values` labels. +pub(crate) fn format_float_f(value: f64) -> String { + match special(value) { + Some(text) => text.to_string(), + None => { + let (negative, digits, point) = shortest(value); + positional(negative, &digits, point) + } + } +} + +/// Prometheus `labels.FormatOpenMetricsFloat`, the form of `histogram_quantiles` labels: Go's +/// shortest `'g'` format, with `.0` appended to an integer and `0`, `1` and `-1` written as +/// `0.0`, `1.0` and `-1.0`. +pub(crate) fn format_open_metrics_float(value: f64) -> String { + if value == 1.0 { + return "1.0".to_string(); + } + if value == 0.0 { + return "0.0".to_string(); + } + if value == -1.0 { + return "-1.0".to_string(); + } + if let Some(text) = special(value) { + return text.to_string(); + } + let (negative, digits, point) = shortest(value); + // The shortest `'g'` format switches to an exponent below 1e-4 and from 1e6 on. + let exponent = point - 1; + let mut text = if !(-4..6).contains(&exponent) { + let mut text = String::from(if negative { "-" } else { "" }); + text.push_str(&digits[..1]); + if digits.len() > 1 { + text.push('.'); + text.push_str(&digits[1..]); + } + text.push_str(&format!( + "e{}{:02}", + if exponent < 0 { '-' } else { '+' }, + exponent.abs() + )); + text + } else { + positional(negative, &digits, point) + }; + if !text.contains(['e', '.']) { + text.push_str(".0"); + } + text +} + +fn special(value: f64) -> Option<&'static str> { + match value { + f64::INFINITY => Some("+Inf"), + f64::NEG_INFINITY => Some("-Inf"), + _ if value.is_nan() => Some("NaN"), + _ => None, + } +} + +/// The fewest significant digits of a finite `value` that round-trip, without trailing zeros, and +/// the position of the decimal point among them: `value = ±0.digits × 10^point`. Zero is `"0"` at +/// point 1. +/// +/// When two candidates of that length are equally close, Go takes the even one while Rust's +/// shortest formatting rounds up, so the digits are recomputed with Rust's exact formatting, +/// which rounds half to even. The recomputed digits only count when they still parse back to +/// `value`: just above a power of two the lower neighbour is half as far away, so the even +/// candidate can name that neighbour, and only the other one round-trips. +fn shortest(value: f64) -> (bool, String, i64) { + let shortest = format!("{value:e}"); + let mantissa = shortest.split_once('e').expect("exponent format").0; + let significant = mantissa.chars().filter(char::is_ascii_digit).count(); + let exact = format!("{value:.precision$e}", precision = significant - 1); + let chosen = if exact.parse::() == Ok(value) { + &exact + } else { + &shortest + }; + let (mantissa, exponent) = chosen.split_once('e').expect("exponent format"); + let exponent: i64 = exponent.parse().expect("integer exponent"); + let (negative, mantissa) = match mantissa.strip_prefix('-') { + Some(unsigned) => (true, unsigned), + None => (false, mantissa), + }; + let digits: String = mantissa.chars().filter(char::is_ascii_digit).collect(); + let trimmed = digits.trim_end_matches('0'); + let digits = if trimmed.is_empty() { "0" } else { trimmed }; + (negative, digits.to_string(), exponent + 1) +} + +/// `±0.digits × 10^point` in positional notation without trailing fractional zeros. +fn positional(negative: bool, digits: &str, point: i64) -> String { + let mut out = String::from(if negative { "-" } else { "" }); + if point <= 0 { + out.push_str("0."); + out.extend(std::iter::repeat_n('0', (-point) as usize)); + out.push_str(digits); + } else if point as usize >= digits.len() { + out.push_str(digits); + out.extend(std::iter::repeat_n('0', point as usize - digits.len())); + } else { + let (whole, fraction) = digits.split_at(point as usize); + out.push_str(whole); + out.push('.'); + out.push_str(fraction); + } + out +} diff --git a/src/promql/src/go_format/open_metrics_cases.txt b/src/promql/src/go_format/open_metrics_cases.txt new file mode 100644 index 00000000000..38bb7526cfe --- /dev/null +++ b/src/promql/src/go_format/open_metrics_cases.txt @@ -0,0 +1,358 @@ +0 0.0 +8000000000000000 0.0 +3ff0000000000000 1.0 +bff0000000000000 -1.0 +3fe0000000000000 0.5 +3feccccccccccccd 0.9 +3fefae147ae147ae 0.99 +3feff7ced916872b 0.999 +3f1a36e2eb1c432d 0.0001 +3ee4f8b588e368f1 1e-05 +3f201f31f46ed246 0.000123 +40f869f000000000 99999.0 +40f86a0000000000 100000.0 +412e847e00000000 999999.0 +412e848000000000 1e+06 +4132d68700000000 1.234567e+06 +444b1ae4d6e2ef50 1e+21 +3e7ad7f29abcaf48 1e-07 +4000000000000000 2.0 +c004000000000000 -2.5 +1 5e-324 +7fefffffffffffff 1.7976931348623157e+308 +7ff8000000000001 NaN +7ff0000000000000 +Inf +fff0000000000000 -Inf +3e0fffffffffffff 9.313225746154784e-10 +3e10000000000000 9.313225746154785e-10 +3e10000000000001 9.313225746154787e-10 +3e1fffffffffffff 1.8626451492309568e-09 +3e20000000000000 1.862645149230957e-09 +3e20000000000001 1.8626451492309574e-09 +3e2fffffffffffff 3.7252902984619136e-09 +3e30000000000000 3.725290298461914e-09 +3e30000000000001 3.725290298461915e-09 +3e3fffffffffffff 7.450580596923827e-09 +3e40000000000000 7.450580596923828e-09 +3e40000000000001 7.45058059692383e-09 +3e4fffffffffffff 1.4901161193847655e-08 +3e50000000000000 1.4901161193847656e-08 +3e50000000000001 1.490116119384766e-08 +3e5fffffffffffff 2.980232238769531e-08 +3e60000000000000 2.9802322387695312e-08 +3e60000000000001 2.980232238769532e-08 +3e6fffffffffffff 5.960464477539062e-08 +3e70000000000000 5.960464477539063e-08 +3e70000000000001 5.960464477539064e-08 +3e7fffffffffffff 1.1920928955078124e-07 +3e80000000000000 1.1920928955078125e-07 +3e80000000000001 1.1920928955078128e-07 +3e8fffffffffffff 2.3841857910156247e-07 +3e90000000000000 2.384185791015625e-07 +3e90000000000001 2.3841857910156255e-07 +3e9fffffffffffff 4.7683715820312495e-07 +3ea0000000000000 4.76837158203125e-07 +3ea0000000000001 4.768371582031251e-07 +3eafffffffffffff 9.536743164062499e-07 +3eb0000000000000 9.5367431640625e-07 +3eb0000000000001 9.536743164062502e-07 +3ebfffffffffffff 1.9073486328124998e-06 +3ec0000000000000 1.9073486328125e-06 +3ec0000000000001 1.9073486328125004e-06 +3ecfffffffffffff 3.8146972656249996e-06 +3ed0000000000000 3.814697265625e-06 +3ed0000000000001 3.814697265625001e-06 +3edfffffffffffff 7.629394531249999e-06 +3ee0000000000000 7.62939453125e-06 +3ee0000000000001 7.629394531250002e-06 +3eefffffffffffff 1.5258789062499998e-05 +3ef0000000000000 1.52587890625e-05 +3ef0000000000001 1.5258789062500003e-05 +3effffffffffffff 3.0517578124999997e-05 +3f00000000000000 3.0517578125e-05 +3f00000000000001 3.051757812500001e-05 +3f0fffffffffffff 6.103515624999999e-05 +3f10000000000000 6.103515625e-05 +3f10000000000001 6.103515625000001e-05 +3f1fffffffffffff 0.00012207031249999999 +3f20000000000000 0.0001220703125 +3f20000000000001 0.00012207031250000003 +3f2fffffffffffff 0.00024414062499999997 +3f30000000000000 0.000244140625 +3f30000000000001 0.00024414062500000005 +3f3fffffffffffff 0.00048828124999999995 +3f40000000000000 0.00048828125 +3f40000000000001 0.0004882812500000001 +3f4fffffffffffff 0.0009765624999999999 +3f50000000000000 0.0009765625 +3f50000000000001 0.0009765625000000002 +3f5fffffffffffff 0.0019531249999999998 +3f60000000000000 0.001953125 +3f60000000000001 0.0019531250000000004 +3f6fffffffffffff 0.0039062499999999996 +3f70000000000000 0.00390625 +3f70000000000001 0.003906250000000001 +3f7fffffffffffff 0.007812499999999999 +3f80000000000000 0.0078125 +3f80000000000001 0.007812500000000002 +3f8fffffffffffff 0.015624999999999998 +3f90000000000000 0.015625 +3f90000000000001 0.015625000000000003 +3f9fffffffffffff 0.031249999999999997 +3fa0000000000000 0.03125 +3fa0000000000001 0.03125000000000001 +3fafffffffffffff 0.06249999999999999 +3fb0000000000000 0.0625 +3fb0000000000001 0.06250000000000001 +3fbfffffffffffff 0.12499999999999999 +3fc0000000000000 0.125 +3fc0000000000001 0.12500000000000003 +3fcfffffffffffff 0.24999999999999997 +3fd0000000000000 0.25 +3fd0000000000001 0.25000000000000006 +3fdfffffffffffff 0.49999999999999994 +3fe0000000000000 0.5 +3fe0000000000001 0.5000000000000001 +3fefffffffffffff 0.9999999999999999 +3ff0000000000000 1.0 +3ff0000000000001 1.0000000000000002 +3fffffffffffffff 1.9999999999999998 +4000000000000000 2.0 +4000000000000001 2.0000000000000004 +400fffffffffffff 3.9999999999999996 +4010000000000000 4.0 +4010000000000001 4.000000000000001 +401fffffffffffff 7.999999999999999 +4020000000000000 8.0 +4020000000000001 8.000000000000002 +402fffffffffffff 15.999999999999998 +4030000000000000 16.0 +4030000000000001 16.000000000000004 +403fffffffffffff 31.999999999999996 +4040000000000000 32.0 +4040000000000001 32.00000000000001 +404fffffffffffff 63.99999999999999 +4050000000000000 64.0 +4050000000000001 64.00000000000001 +405fffffffffffff 127.99999999999999 +4060000000000000 128.0 +4060000000000001 128.00000000000003 +406fffffffffffff 255.99999999999997 +4070000000000000 256.0 +4070000000000001 256.00000000000006 +407fffffffffffff 511.99999999999994 +4080000000000000 512.0 +4080000000000001 512.0000000000001 +408fffffffffffff 1023.9999999999999 +4090000000000000 1024.0 +4090000000000001 1024.0000000000002 +409fffffffffffff 2047.9999999999998 +40a0000000000000 2048.0 +40a0000000000001 2048.0000000000005 +40afffffffffffff 4095.9999999999995 +40b0000000000000 4096.0 +40b0000000000001 4096.000000000001 +40bfffffffffffff 8191.999999999999 +40c0000000000000 8192.0 +40c0000000000001 8192.000000000002 +40cfffffffffffff 16383.999999999998 +40d0000000000000 16384.0 +40d0000000000001 16384.000000000004 +40dfffffffffffff 32767.999999999996 +40e0000000000000 32768.0 +40e0000000000001 32768.00000000001 +40efffffffffffff 65535.99999999999 +40f0000000000000 65536.0 +40f0000000000001 65536.00000000001 +40ffffffffffffff 131071.99999999999 +4100000000000000 131072.0 +4100000000000001 131072.00000000003 +410fffffffffffff 262143.99999999997 +4110000000000000 262144.0 +4110000000000001 262144.00000000006 +411fffffffffffff 524287.99999999994 +4120000000000000 524288.0 +4120000000000001 524288.0000000001 +412fffffffffffff 1.0485759999999999e+06 +4130000000000000 1.048576e+06 +4130000000000001 1.0485760000000002e+06 +413fffffffffffff 2.0971519999999998e+06 +4140000000000000 2.097152e+06 +4140000000000001 2.0971520000000005e+06 +414fffffffffffff 4.1943039999999995e+06 +4150000000000000 4.194304e+06 +4150000000000001 4.194304000000001e+06 +415fffffffffffff 8.388607999999999e+06 +4160000000000000 8.388608e+06 +4160000000000001 8.388608000000002e+06 +416fffffffffffff 1.6777215999999998e+07 +4170000000000000 1.6777216e+07 +4170000000000001 1.6777216000000004e+07 +417fffffffffffff 3.3554431999999996e+07 +4180000000000000 3.3554432e+07 +4180000000000001 3.355443200000001e+07 +418fffffffffffff 6.710886399999999e+07 +4190000000000000 6.7108864e+07 +4190000000000001 6.710886400000001e+07 +419fffffffffffff 1.3421772799999999e+08 +41a0000000000000 1.34217728e+08 +41a0000000000001 1.3421772800000003e+08 +41afffffffffffff 2.6843545599999997e+08 +41b0000000000000 2.68435456e+08 +41b0000000000001 2.6843545600000006e+08 +41bfffffffffffff 5.3687091199999994e+08 +41c0000000000000 5.36870912e+08 +41c0000000000001 5.368709120000001e+08 +41cfffffffffffff 1.0737418239999999e+09 +41d0000000000000 1.073741824e+09 +41d0000000000001 1.0737418240000002e+09 +3e65822107fcfd52 4.0062361426977984e-08 +bfd04dc76695721d -0.2547472478670743 +3dda858149c6e2d1 9.648466959782995e-11 +c276cb397916001e -1.5663811505600073e+12 +40197f48392907a0 6.374298947457561 +40b27cc6f3875d04 4732.777153454026 +be2cffa2ba517936 -3.3758786482808152e-09 +c27621587cb3ad0b -1.5207796437068152e+12 +c06a768b7c4e0b68 -211.70452704661443 +424c3f2525632186 2.426369665982619e+11 +41f2070f169c1121 4.839240041754182e+09 +bd8bd268b68e6a3f -3.162985000843313e-12 +c0b4c47f2cdf5b8a -5316.496778449868 +3ea4502fc5d6d268 6.053813980584764e-07 +c1034c367674cb74 -158086.80783995579 +3f8c8b35bb9457d8 0.013937396800013532 +c00419db794209ff -2.5126256440428274 +c1e4b7a55a25e0cb -2.7806379051836905e+09 +3e72b02936d4ff9b 6.961870520783569e-08 +40d442fd5ad145f4 20747.958668058694 +be4d968d3f71f8cb -1.3778029368671855e-08 +be06e3cafccae224 -6.661813267564451e-10 +c169d9f17d9e8fbc -1.3553547925605647e+07 +420403def63a5b6f 1.0745536199294645e+10 +4221017fbc897d06 3.651979629274419e+10 +c265a315724c7af1 -7.434432395238419e+11 +41ca71f52257bb7d 8.873518766854092e+08 +40bb9ca8be8e9981 7068.659157669522 +be33fac825416fed -4.651866964628022e-09 +41b9f7676678e7aa 4.356442624722849e+08 +3ef7830356aa6b86 2.2422570888562812e-05 +4228f8bd3e99b0a8 5.362618350084503e+10 +403a156ae58348b0 26.083662361671202 +bfa0a1e9406aec9d -0.03248528395985171 +bd9d8e9fa5ead0bd -6.720511814114512e-12 +40408e4b738b6829 33.11167759234406 +41b780ea967cd710 3.943246304876566e+08 +bfb9c16725fd3d7f -0.10060734441252882 +411f01c926fbf29b 508018.2880704791 +bf12787c2bf3394b -7.045991786755916e-05 +c16ed31f1cb9e6c6 -1.616101689769305e+07 +c0e92d0169a39144 -51560.04414537785 +c02b5389421657ff -13.663156571605212 +3f7e91e98b02c917 0.007463371541393002 +4052560383d17909 73.34396453338935 +c1a58fbeaf635d47 -1.8087099969407102e+08 +bfc03382be14186f -0.1265719822042146 +bdb7ae24e9174548 -2.1537062557132775e-11 +41f5855807b73658 5.7769657554507675e+09 +beb2a18da250bf34 -1.1104981549984584e-06 +bedb3641fe3dea79 -6.487833136388005e-06 +c1eb36dbc9b344e9 -3.652640333602162e+09 +40f5cb471941e52a 89268.44366635816 +c080d6326b40eabc -538.7746186324725 +bd82aafe336eacca -2.1222992129454815e-12 +c0ef278a67149b9c -63804.325083068776 +bf1c44bf5a5ffefb -0.0001078359406108085 +bf6d29286f00f02b -0.0035596646994502565 +bdbfafc3545836c4 -2.8818771389702537e-11 +bd7f325712f4128d -1.7733237396191494e-12 +c05e3e3e4a305605 -120.97255186767877 +bfeca80d72521a90 -0.8955142243219871 +3f85f26283efc6c6 0.01071621861590034 +411cfc5dc1540864 474903.4387971221 +bfbb185b70ab53ba -0.1058404111930277 +413fb024878d4063 2.076708529499077e+06 +c274d6353cb62f07 -1.4318905455069392e+12 +40b900fb4ffbf3a9 6400.981689211833 +3fd796b83f190476 0.36857420122038975 +3f89c820f5c353cf 0.012588746547786215 +bf1c9b582d230176 -0.00010912633595961348 +3f0b626c3f1d7427 5.223172037347622e-05 +c1b98e40b01aa47d -4.287530721040724e+08 +bf40e7ac7417bf38 -0.0005158989007991504 +c0fa23eb55ece0ea -107070.70847785813 +c144a3c813d346b5 -2.705296154885138e+06 +420cfdbc6db30e37 1.556450859838194e+10 +bffaedf1a6e222f5 -1.6830917853241634 +bdaa7d712e166bdf -1.2046261072190693e-11 +bd9166a221898f34 -3.956509186266304e-12 +41f29c7719af53ba 4.995903898957941e+09 +c257d70a133c9673 -4.0956803813035077e+11 +3e03f10e0f212551 5.803775500632443e-10 +bdd09a3e6f241c57 -6.039959833086004e-11 +3def70ff3be70cb0 2.2876625416160384e-10 +3e0b4e8e20f31127 7.947341135001321e-10 +c101aa87289cbd2c -144720.89483020583 +c1a30cf840a79c3b -1.5980854432736382e+08 +c1f2cee10365790c -5.048766518342052e+09 +4241f57b0bb131e8 1.542658517463899e+11 +41644786139efcca 1.0632240613157649e+07 +c10c4694a54af747 -231634.5807093924 +3fb93fb09f6c47ac 0.09862808124697037 +410283b654043a66 151670.7910236895 +3f955397b4926431 0.020826692959051655 +c2770eb58f0d2558 -1.584496439506334e+12 +c2574b9d7f803594 -4.0021144320083716e+11 +c1798c4033fe2656 -2.678886724954828e+07 +bdda20af6b41292d -9.505193404385634e-11 +bd9d5ac882093298 -6.6744682973325806e-12 +c10fbff5b2d5a113 -260094.71232152786 +3ea8e96f5b8e56a9 7.42431146882456e-07 +3d98fa6e04f9ce92 5.679394522162092e-12 +bfb82947a67e52b1 -0.09437988104611851 +3f819e7792417fc3 0.008603033209681038 +3ef67230b027b7e0 2.1406226560154065e-05 +410bdd8f44c9ab40 228273.908587778 +bdaddb0efd7a84af -1.3576799338601639e-11 +bfde7f03e3abff4a -0.476502392135434 +be3c374d736e41cc -6.569555391781771e-09 +bd8fd8d37b3ccebc -3.6205821004411243e-12 +bf015dbd315ae6af -3.312333698830599e-05 +bef0a23119ea0f2f -1.58630006077541e-05 +4147e80684a6bfdc 3.1334530363387894e+06 +418ffa8aa44a03a4 6.706414853613976e+07 +41b39bb8b580c8ba 3.2897246950306284e+08 +c13b6a22b648e604 -1.7966427120498428e+06 +c235fcdd893a310f -9.443669023419164e+10 +421ec18437f5198b 3.3023921661274944e+10 +bf8ae37ae39d02db -0.013129196234900877 +bef95e8cca599392 -2.4194083968604218e-05 +3dcad1bdc176163d 4.8784025588835895e-11 +3f1df0dcf9f95ede 0.00011421536191258015 +c1757d0f8a928585 -2.2532344660771865e+07 +c0a5dcfd887953e8 -2798.4951818385052 +c1ad00c4e533e9a3 -2.4329483460139188e+08 +be189b251dba77ae -1.432259747359515e-09 +c0faea4753b428d7 -110244.45793548539 +3f1888203391860a 9.358116666782087e-05 +c07eccfcba9075b0 -492.81170135907723 +c1a9bf542b517f2b -2.1598466165917334e+08 +be6f489bc56ba9c3 -5.827029585742761e-08 +c07aeee5fdde63b1 -430.9311503111922 +bdbff35e51e90a1f -2.905895413133007e-11 +c08585e696d511e1 -688.7375923772889 +3e1584aa5403510d 1.2525254861511635e-09 +425175c897a14c51 2.9996209728519244e+11 +bfefb52e3298237e -0.9908667553109891 +3ece74d4b80f63d4 3.630682329430478e-06 +be56a526ee438107 -2.108991638348143e-08 +3e7365a824bf7034 7.225981696286206e-08 +405b24ce99aa99ff 108.57510987911517 +bd8f8f5522164965 -3.5879444088569995e-12 +c11bb2a14ccdd7af -453800.3250039769 +be24ed37e9a7b972 -2.4361808796334138e-09 +bec4827163240d09 -2.444928008606154e-06 +c1c39851da458093 -6.574990605429863e+08 +3e46091f323f491a 1.0261140888290476e-08 +bdaa570dd2d99eb9 -1.197807008879946e-11 +40f265b33b61e4f4 75355.2019976562 diff --git a/src/promql/src/go_format/tests.rs b/src/promql/src/go_format/tests.rs new file mode 100644 index 00000000000..ef7a02591a4 --- /dev/null +++ b/src/promql/src/go_format/tests.rs @@ -0,0 +1,29 @@ +// Copyright 2023 Greptime Team +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use crate::go_format::format_open_metrics_float; + +/// Prometheus `labels.FormatOpenMetricsFloat`; `open_metrics_cases.txt` comes from promql-compat +/// `narthex-golden-formatfloat -openmetrics` as ` `. +#[test] +fn open_metrics_floats_format_like_prometheus() { + let mut checked = 0; + for line in include_str!("open_metrics_cases.txt").lines() { + let (bits, expected) = line.split_once(' ').unwrap(); + let value = f64::from_bits(u64::from_str_radix(bits, 16).unwrap()); + assert_eq!(format_open_metrics_float(value), expected, "{bits}"); + checked += 1; + } + assert!(checked > 300, "{checked}"); +} diff --git a/src/promql/src/histogram_eval.rs b/src/promql/src/histogram_eval.rs index 4b7fdba6c54..4aa577fc54a 100644 --- a/src/promql/src/histogram_eval.rs +++ b/src/promql/src/histogram_eval.rs @@ -40,9 +40,10 @@ use datatypes::series::time::{TimeAxis, union_positions}; pub(crate) use aggregate::sum_or_avg; pub use aggregate::{HistogramAggregate, aggregate_histograms}; +use crate::go_format::format_open_metrics_float; use crate::go_sort; use crate::grid_series::{ - GridSeriesBuilder, LabelSet, METADATA_LABELS, label_set, merge_same_labelset, + GridSeriesBuilder, LabelSet, METADATA_LABELS, external, label_set, merge_same_labelset, }; const BUCKET_LABEL: &str = "le"; @@ -56,6 +57,30 @@ pub enum HistogramFunction { Fraction { lower: f64, upper: f64 }, } +/// `histogram_quantiles(v, label, q...)`: [`evaluate_histograms`] with `Quantile(q)` for each +/// `q`, each result labelled with `label` set to `q` as Prometheus' `FormatOpenMetricsFloat` +/// writes it. Results that then share a label set merge as [`merge_same_labelset`] does, so a +/// repeated `q`, or a `label` that overwrites the label telling two histograms apart, fails at a +/// point both hold. +pub fn histogram_quantiles( + label: &str, + quantiles: &[f64], + schema: &Arc, + series: &[Series], +) -> Result> { + let mut output = Vec::new(); + for &quantile in quantiles { + let value: Arc = Arc::from(format_open_metrics_float(quantile)); + for mut result in + evaluate_histograms(HistogramFunction::Quantile(quantile), schema, series)? + { + result.metric.set(label, value.clone()).map_err(external)?; + output.push(result); + } + } + merge_same_labelset(schema, output) +} + /// Evaluates `function` over the classic and native histograms in `series`. /// /// Float series with the same labels other than `le` form a classic histogram; a float series diff --git a/src/promql/src/histogram_eval/quantiles_cases.txt b/src/promql/src/histogram_eval/quantiles_cases.txt new file mode 100644 index 00000000000..0e9e1501ff8 --- /dev/null +++ b/src/promql/src/histogram_eval/quantiles_cases.txt @@ -0,0 +1,425 @@ +# Generated by promql-compat cmd/narthex-golden-histogram built for amd64 (Prometheus +# v3.15.0 engine, seed 1, 30 cases per function). Each case lists its operation and +# tab-separated arguments, the grid, the input series and either the result series or +# the error. Points are `grid index:hex bits`. +case quantiles quantile 3fd0000000000000 +grid 100000 10000 2 +series __name__=a_bucket|job=x ; 0:4026000000000000 1:4018000000000000 +series __name__=a_bucket|job=x|le=abc ; 0:404f000000000000 1:4057c00000000000 +series __name__=a_bucket|job=x|le=+Inf ; 0:403c000000000000 1:403c000000000000 +series __name__=a_bucket|job=y|le=0.1 ; 0:4051500000000000 1:4053b00000000000 +series __name__=a_bucket|job=y|le=0.5 ; 0:4058e00000000000 +series __name__=a_bucket|job=y|le=1 ; 0:405d300000000000 1:4069600000000000 +series __name__=a_bucket|job=y|le=5 ; 0:4065580000000000 +series __name__=a_bucket|job=y|le=+Inf ; 0:406c280000000000 1:4074240000000000 +series __name__=b_bucket|job=z|le=0.25 ; 0:404ba00000000000 1:4040200000000000 +series __name__=b_bucket|job=z|le=2.5 ; 0:4060580000000000 1:405a100000000000 +series __name__=b_bucket|job=z|le=inf ; 0:4065680000000000 1:4065180000000000 +result job=x|quantile=0.25 ; 0:7ff8000000000001 1:7ff8000000000001 +result job=y|quantile=0.25 ; 0:3fb4d13c711ede2d 1:3fbcf601dab51b18 +result job=z|quantile=0.25 ; 0:3fc8cbdf90cbdf91 1:3fe1f00000000000 +end +case quantiles quantile bfe0000000000000,3fefae147ae147ae +grid 100000 10000 5 +series __name__=a_bucket|job=x|le=1 ; 0:4024000000000000 1:4035000000000000 2:4000000000000000 3:4028000000000000 4:7ff0000000000000 +series __name__=a_bucket|job=x|le=abc ; 1:4047800000000000 4:7ff0000000000000 +series __name__=a_bucket|job=x|le=1e400 ; 0:0 1:4053800000000000 2:4052800000000000 3:7ff8000000000001 4:7ff0000000000000 +series __name__=a_bucket|job=x|le=+Inf ; 0:4044800000000000 1:4057800000000000 3:7ff8000000000001 4:7ff0000000000000 +series __name__=a_bucket|job=y ; 0:404a400000000000 1:4041400000000000 2:4017000000000000 3:4058600000000000 4:4050d00000000000 +series __name__=a_bucket|job=y|le=0.5 ; 0:405d800000000000 1:404de00000000000 2:405a700000000000 3:405db00000000000 4:405ad00000000000 +series __name__=a_bucket|job=y|le=5 ; 2:4063c80000000000 3:406af00000000000 4:4072440000000000 +series __name__=a_bucket|job=y|le=+Inf ; 0:406fd80000000000 1:4066280000000000 3:406c180000000000 4:4074440000000000 +series __name__=b_bucket|job=y ; 0:404f800000000000 2:4058300000000000 +series __name__=b_bucket|job=y|le=0 ; 0:4061480000000000 1:4064700000000000 2:4066800000000000 3:4058600000000000 4:4051900000000000 +series __name__=b_bucket|job=y|le=1 ; 0:4069300000000000 1:406ee00000000000 2:406a600000000000 3:4066180000000000 4:405a400000000000 +series __name__=b_bucket|job=y|le=+Inf ; 0:406a680000000000 2:4072380000000000 3:406b600000000000 4:4063680000000000 +series __name__=b_bucket|job=z|le=0.25 ; 0:4014000000000000 1:4020000000000000 2:4031000000000000 3:4043800000000000 4:4028000000000000 +series __name__=b_bucket|job=z|le=2.5 ; 0:4048000000000000 1:40200000000001c2 2:4030ffffffff4515 3:4055400000000000 4:4028000000000000 +series __name__=b_bucket|job=z|le=inf ; 1:40200000000001c2 2:4030ffffffff4336 3:405c400000000000 4:4028000000000000 +error vector cannot contain metrics with the same labelset +end +case quantiles job 0,bfe0000000000000 +grid 100000 10000 3 +series __name__=a_bucket|job=x|le=0.1 ; 0:403e000000000000 1:4028000000000000 2:7ff0000000000000 +series __name__=a_bucket|job=x|le=0.5 ; 0:404c800000000000 1:4041000000000000 2:7ff0000000000000 +series __name__=a_bucket|job=x|le=1 ; 0:4055400000000000 1:4045000000000000 2:7ff0000000000000 +series __name__=a_bucket|job=x|le=5 ; 0:4060000000000000 1:4056000000000000 2:7ff0000000000000 +series __name__=a_bucket|job=x|le=+Inf ; 0:7ff8000000000001 1:7ff0000000000000 2:7ff0000000000000 +series __name__=b_bucket|job=y|le=0.1 ; 0:4041800000000000 1:4038000000000000 2:4048000000000000 +series __name__=b_bucket|job=y|le=0.5 ; 0:4043000000000000 1:4037fffffffffd5c +series __name__=b_bucket|job=y|le=1 ; 0:4054000000000000 1:4037ffffffffffff +series __name__=b_bucket|job=y|le=5 ; 0:4053ffffffff2419 1:40380000000107e1 2:40553fffffff165b +series __name__=b_bucket|job=y|le=+Inf ; 0:4054000000000000 2:40557fffffff165b +series __name__=b_bucket|job=z|le=1 ; 0:0 1:0 2:0 +series __name__=b_bucket|job=z|le=1.0 ; 0:0 2:0 +series __name__=b_bucket|job=z|le=2 ; 1:1 2:1a56e1fc2f8f359 +series __name__=b_bucket|job=z|le=+Inf ; 0:1 1:1 2:0 +error vector cannot contain metrics with the same labelset +end +case quantiles quantile bfe0000000000000,bfe0000000000000 +grid 100000 10000 6 +series __name__=a_bucket|job=y|le=1 ; 0:402a000000000000 2:4010000000000000 3:4033000000000000 4:401c000000000000 +series __name__=a_bucket|job=y|le=2 ; 0:4035000000000000 1:4040ffffffff4515 2:4010000000000000 3:403d000000000000 4:401c000000000000 5:4056800000000000 +series __name__=a_bucket|job=y|le=5 ; 0:4034ffffffff191a 1:404a7fffffff4515 2:4047000000000000 4:401c000000000000 5:4058400000000000 +series __name__=b_bucket|job=z|le=1 ; 0:4057c00000000000 1:4039800000000000 3:4028800000000000 4:404f600000000000 5:4044600000000000 +series __name__=b_bucket|job=z|le=2 ; 0:4065c00000000000 1:4055d00000000000 2:4060200000000000 3:4057500000000000 4:405f300000000000 5:4059600000000000 +series __name__=b_bucket|job=z|le=5 ; 0:4070e80000000000 1:405a900000000000 2:4069e80000000000 3:4062580000000000 4:4065500000000000 5:4062780000000000 +error vector cannot contain metrics with the same labelset +end +case quantiles quantile 3fd0000000000000,3ff8000000000000,3fe0000000000000 +grid 100000 10000 8 +series __name__=b_bucket|job=z ; 0:4044c00000000000 1:402e000000000000 2:4021000000000000 3:4048e00000000000 4:404ea00000000000 5:4056c00000000000 6:404ce00000000000 7:4047e00000000000 +series __name__=b_bucket|job=z|le=2.5 ; 1:403dc00000000000 2:4049000000000000 3:4050800000000000 4:4055b00000000000 5:4061300000000000 6:4055d00000000000 +series __name__=b_bucket|job=z|le=inf ; 0:4064180000000000 1:4056c00000000000 2:4060400000000000 3:4064100000000000 5:406bf00000000000 6:4057d00000000000 7:4051e00000000000 +result job=z|quantile=0.25 ; 0:7ff8000000000001 1:3ffe969696969696 2:3ffa000000000000 3:3ff851745d1745d1 4:7ff8000000000001 5:3ff04129e4129e41 6:3fe5d574a1601779 7:7ff8000000000001 +result job=z|quantile=0.5 ; 0:7ff8000000000001 1:4004000000000000 2:4004000000000000 3:4004000000000000 4:7ff8000000000001 5:40004129e4129e41 6:3ff5d574a1601779 7:7ff8000000000001 +result job=z|quantile=1.5 ; 0:7ff0000000000000 1:7ff0000000000000 2:7ff0000000000000 3:7ff0000000000000 4:7ff0000000000000 5:7ff0000000000000 6:7ff0000000000000 7:7ff0000000000000 +end +case quantiles quantile 3ff8000000000000,3ff0000000000000 +grid 100000 10000 5 +series __name__=a_bucket|job=x|le=+Inf ; 1:4052600000000000 3:404b000000000000 4:404fe00000000000 +series __name__=a_bucket|job=y|le=+Inf ; 1:0 2:0 3:0 +series __name__=b_bucket|job=z|le=1 ; 0:404e800000000000 1:4052000000000000 2:4029800000000000 3:4054b00000000000 4:4052900000000000 +series __name__=b_bucket|job=z|le=abc ; 2:4056900000000000 3:4062380000000000 4:405f200000000000 +series __name__=b_bucket|job=z|le=1e400 ; 0:4053900000000000 1:4062f00000000000 2:405e800000000000 3:4064380000000000 +series __name__=b_bucket|job=z|le=+Inf ; 0:4063080000000000 1:4067680000000000 2:4064480000000000 4:406ee80000000000 +result job=x|quantile=1.0 ; 1:7ff8000000000001 3:7ff8000000000001 4:7ff8000000000001 +result job=x|quantile=1.5 ; 1:7ff0000000000000 3:7ff0000000000000 4:7ff0000000000000 +result job=y|quantile=1.0 ; 1:7ff8000000000001 2:7ff8000000000001 3:7ff8000000000001 +result job=y|quantile=1.5 ; 1:7ff0000000000000 2:7ff0000000000000 3:7ff0000000000000 +result job=z|quantile=1.0 ; 0:3ff0000000000000 1:3ff0000000000000 2:3ff0000000000000 3:7ff8000000000001 4:3ff0000000000000 +result job=z|quantile=1.5 ; 0:7ff0000000000000 1:7ff0000000000000 2:7ff0000000000000 3:7ff0000000000000 4:7ff0000000000000 +end +case quantiles quantile 3ff0000000000000 +grid 100000 10000 2 +series __name__=a_bucket|job=x ; 0:4026000000000000 1:4040800000000000 +series __name__=a_bucket|job=x|le=0.5 ; 0:7ff8000000000001 1:7ff0000000000000 +series __name__=a_bucket|job=x|le=1 ; 0:7ff8000000000001 1:7ff8000000000001 +series __name__=a_bucket|job=x|le=5 ; 0:7ff8000000000001 1:7ff8000000000001 +series __name__=a_bucket|job=x|le=+Inf ; 0:7ff8000000000001 +result job=x|quantile=1.0 ; 0:4014000000000000 1:7ff8000000000001 +end +case quantiles quantile 3feccccccccccccd,3feccccccccccccd,3fe0000000000000 +grid 100000 10000 2 +series __name__=a_bucket|job=x|le=1 ; 0:4024000000000000 1:4010000000000000 +series __name__=a_bucket|job=x|le=2 ; 0:4008000000000000 1:4058800000000000 +series __name__=a_bucket|job=x|le=5 ; +error vector cannot contain metrics with the same labelset +end +case quantiles quantile 3fefae147ae147ae,3fd0000000000000,3ff0000000000000 +grid 100000 10000 7 +series __name__=a_bucket|job=x|le=+Inf ; 1:4047000000000000 2:4010000000000000 3:4024000000000000 4:4022000000000000 5:4047800000000000 6:4033000000000000 +series __name__=b_bucket|job=y|le=1 ; 0:4053000000000000 1:404d000000000000 2:4026000000000000 3:4054800000000000 4:4055400000000000 5:4050000000000000 6:403d000000000000 +series __name__=b_bucket|job=y|le=2 ; 0:4054400000000000 1:4045000000000000 2:402a000000000000 3:4032000000000000 4:403d000000000000 5:4054800000000000 6:4055400000000000 +series __name__=b_bucket|job=y|le=5 ; 0:4057800000000000 1:4056800000000000 2:4038000000000000 3:403a000000000000 4:404f800000000000 5:404a000000000000 6:4055000000000000 +result job=x|quantile=0.25 ; 1:7ff8000000000001 2:7ff8000000000001 3:7ff8000000000001 4:7ff8000000000001 5:7ff8000000000001 6:7ff8000000000001 +result job=x|quantile=0.99 ; 1:7ff8000000000001 2:7ff8000000000001 3:7ff8000000000001 4:7ff8000000000001 5:7ff8000000000001 6:7ff8000000000001 +result job=x|quantile=1.0 ; 1:7ff8000000000001 2:7ff8000000000001 3:7ff8000000000001 4:7ff8000000000001 5:7ff8000000000001 6:7ff8000000000001 +result job=y|quantile=0.25 ; 0:7ff8000000000001 1:7ff8000000000001 2:7ff8000000000001 3:7ff8000000000001 4:7ff8000000000001 5:7ff8000000000001 6:7ff8000000000001 +result job=y|quantile=0.99 ; 0:7ff8000000000001 1:7ff8000000000001 2:7ff8000000000001 3:7ff8000000000001 4:7ff8000000000001 5:7ff8000000000001 6:7ff8000000000001 +result job=y|quantile=1.0 ; 0:7ff8000000000001 1:7ff8000000000001 2:7ff8000000000001 3:7ff8000000000001 4:7ff8000000000001 5:7ff8000000000001 6:7ff8000000000001 +end +case quantiles quantile 0,3ff0000000000000,0 +grid 100000 10000 7 +series __name__=a_bucket|job=x|le=1 ; 0:4045000000000000 1:4034000000000000 2:403a000000000000 3:4056000000000000 5:401c000000000000 +series __name__=a_bucket|job=x|le=1.0 ; 0:4024000000000000 1:4044000000000000 2:4008000000000000 3:4054400000000000 4:4039000000000000 5:4055800000000000 6:4052c00000000000 +series __name__=a_bucket|job=x|le=2 ; 0:404b800000000000 1:4042800000000000 3:4055c00000000000 4:4018000000000000 5:404b800000000000 6:4054c00000000000 +series __name__=a_bucket|job=x|le=+Inf ; 0:4045800000000000 1:4054800000000000 2:4050400000000000 3:4043000000000000 4:4050800000000000 +series __name__=a_bucket|job=y|le=0.1 ; 0:4056900000000000 1:403bc00000000000 2:4035800000000000 3:403b400000000000 4:402f000000000000 5:4052600000000000 6:4047600000000000 +series __name__=a_bucket|job=y|le=0.5 ; 0:4063f00000000000 1:4045800000000000 2:405a100000000000 3:4057d00000000000 5:4061080000000000 +series __name__=a_bucket|job=y|le=1 ; 0:4069e00000000000 1:405ab00000000000 3:4066400000000000 6:4053200000000000 +series __name__=a_bucket|job=y|le=5 ; 0:4072180000000000 2:406b780000000000 3:406d000000000000 4:4067080000000000 5:4070700000000000 6:405a500000000000 +series __name__=a_bucket|job=y|le=+Inf ; 0:4074b00000000000 1:4067680000000000 2:4073c80000000000 3:4072340000000000 4:4069000000000000 5:4070e80000000000 6:4062c80000000000 +error vector cannot contain metrics with the same labelset +end +case quantiles job 0 +grid 100000 10000 2 +series __name__=b_bucket|job=z|le=1 ; 0:4055300000000000 1:4036400000000000 +series __name__=b_bucket|job=z|le=2 ; 0:4062280000000000 1:4047800000000000 +series __name__=b_bucket|job=z|le=5 ; 1:404e600000000000 +result job=0.0 ; 0:7ff8000000000001 1:7ff8000000000001 +end +case quantiles quantile 3feccccccccccccd,bfe0000000000000 +grid 100000 10000 6 +series __name__=a_bucket|job=x|le=+Inf ; 0:0 1:0 4:0 5:0 +series __name__=b_bucket|job=z|le=0.25 ; 0:4031000000000000 1:404e000000000000 3:4047800000000000 4:402c000000000000 5:4053400000000000 +series __name__=b_bucket|job=z|le=2.5 ; 0:404a800000000000 1:4058c00000000000 2:4033000000000000 3:4058000000000000 4:4054c00000000000 5:4048000000000000 +series __name__=b_bucket|job=z ; 0:4055000000000000 1:404c000000000000 3:3ff0000000000000 4:4035000000000000 5:4045000000000000 +result job=x|quantile=-0.5 ; 0:fff0000000000000 1:fff0000000000000 4:fff0000000000000 5:fff0000000000000 +result job=x|quantile=0.9 ; 0:7ff8000000000001 1:7ff8000000000001 4:7ff8000000000001 5:7ff8000000000001 +result job=z|quantile=-0.5 ; 0:fff0000000000000 1:fff0000000000000 2:fff0000000000000 3:fff0000000000000 4:fff0000000000000 5:fff0000000000000 +result job=z|quantile=0.9 ; 0:7ff8000000000001 1:7ff8000000000001 2:7ff8000000000001 3:7ff8000000000001 4:7ff8000000000001 5:7ff8000000000001 +end +case quantiles job 3fefae147ae147ae,3fefae147ae147ae +grid 100000 10000 8 +series __name__=a_bucket|job=x ; 0:4050f00000000000 1:4047000000000000 2:4057500000000000 3:404aa00000000000 4:4025000000000000 5:4050c00000000000 6:4055500000000000 7:402b800000000000 +series __name__=a_bucket|job=x|le=1.0 ; 0:4059b00000000000 1:405ac00000000000 3:405e200000000000 4:4056a00000000000 5:4064280000000000 6:4064200000000000 7:4048600000000000 +series __name__=a_bucket|job=x|le=2 ; 0:4061d80000000000 1:405c000000000000 2:4062180000000000 4:4066180000000000 5:406fe00000000000 6:406fa80000000000 7:404b000000000000 +series __name__=a_bucket|job=x|le=+Inf ; 0:4065380000000000 1:4061800000000000 2:4064080000000000 3:4066180000000000 4:4067080000000000 5:4075340000000000 6:4071f40000000000 7:4051b00000000000 +series __name__=b_bucket|job=y|le=-1 ; 0:3ff0000000000000 1:4010000000000000 2:4010000000000000 4:c014000000000000 5:c014000000000000 6:4028000000000000 7:403d000000000000 +series __name__=b_bucket|job=y|le=0 ; 0:0 1:403d000000000000 2:c014000000000000 3:4055000000000000 4:4032000000000000 5:403e000000000000 6:c014000000000000 7:4043800000000000 +series __name__=b_bucket|job=y|le=1 ; 1:4048000000000000 2:4008000000000000 3:405d400000000000 4:4040800000000000 5:4045000000000000 6:c014000000000000 7:4053c00000000000 +series __name__=b_bucket|job=y|le=+Inf ; 0:c014000000000000 1:4052000000000000 2:7ff0000000000000 3:4060800000000000 4:7ff0000000000000 5:404c000000000000 6:4044800000000000 7:4056000000000000 +series __name__=b_bucket|job=z|le=1 ; 0:4053c00000000000 2:403f400000000000 3:4048a00000000000 4:4008000000000000 5:4052400000000000 6:404ec00000000000 7:4055600000000000 +series __name__=b_bucket|job=z|le=abc ; 0:405be00000000000 1:4059a00000000000 2:4059d00000000000 3:4054c00000000000 4:402d800000000000 5:4055b00000000000 6:4060680000000000 7:4057a00000000000 +series __name__=b_bucket|job=z|le=1e400 ; 0:4063500000000000 1:4065580000000000 2:4061600000000000 3:405ae00000000000 4:4049200000000000 5:4059c00000000000 6:4067900000000000 7:405d900000000000 +series __name__=b_bucket|job=z|le=+Inf ; 0:406c480000000000 1:4070800000000000 2:406d200000000000 3:4060400000000000 4:404ba00000000000 6:406a580000000000 7:4062c00000000000 +error vector cannot contain metrics with the same labelset +end +case quantiles job 7ff8000000000001 +grid 100000 10000 6 +series __name__=a_bucket|job=x|le=0.1 ; 0:4053900000000000 1:402b000000000000 2:4045400000000000 4:4020000000000000 5:4053000000000000 +series __name__=a_bucket|job=x|le=0.5 ; 0:405a400000000000 1:4048e00000000000 2:404aa00000000000 3:405e700000000000 4:4041600000000000 5:405e100000000000 +series __name__=a_bucket|job=x|le=1 ; 0:405d400000000000 1:4053000000000000 2:4054f00000000000 3:4067800000000000 5:405e500000000000 +series __name__=a_bucket|job=x|le=5 ; 0:4069480000000000 1:4058200000000000 2:4064480000000000 3:4068d80000000000 4:4057f00000000000 5:4068c80000000000 +series __name__=a_bucket|job=x|le=+Inf ; 0:4070ec0000000000 2:4069000000000000 3:4071b40000000000 5:406a080000000000 +series __name__=b_bucket|job=z|le=0.25 ; 0:4046000000000000 1:403e000000000000 3:403c000000000000 4:403b000000000000 5:4045800000000000 +series __name__=b_bucket|job=z|le=2.5 ; 0:4053400000000000 1:4045000000000000 2:40390000000112e1 3:404e000000000000 4:403b000000000000 5:40457ffffffffda3 +series __name__=b_bucket|job=z|le=inf ; 0:40533fffffff2c58 1:4051000000000000 3:404dfffffffffcb3 5:40457ffffffffda3 +error vector cannot contain metrics with the same labelset +end +case quantiles quantile 3fd0000000000000 +grid 100000 10000 2 +series __name__=a_bucket|job=x|le=-1 ; 0:4048800000000000 +series __name__=a_bucket|job=x|le=0 ; 0:4055800000000000 1:4034000000000000 +series __name__=a_bucket|job=x|le=1 ; 0:4056800000000000 1:0 +series __name__=a_bucket|job=x|le=+Inf ; 0:c014000000000000 1:4036000000000000 +result job=x|quantile=0.25 ; 0:bff0000000000000 1:0 +end +case quantiles job 3fefae147ae147ae,3fd0000000000000 +grid 100000 10000 7 +series __name__=a_bucket|job=x|le=1 ; 0:4026000000000000 1:403a000000000000 5:4043000000000000 6:4000000000000000 +series __name__=a_bucket|job=x|le=2 ; 0:4046800000000000 1:4051800000000000 2:402e000000000000 3:4014000000000000 4:4040000000000000 5:403f000000000000 6:4041000000000000 +series __name__=a_bucket|job=x|le=5 ; 0:4030000000000000 1:4028000000000000 2:4052400000000000 3:4056400000000000 4:4041800000000000 5:4055800000000000 6:4050000000000000 +series __name__=a_bucket|job=y|le=1 ; 1:4041800000000000 2:4056e00000000000 3:4048000000000000 4:4052c00000000000 5:4042a00000000000 6:401d000000000000 +series __name__=a_bucket|job=y|le=2 ; 1:4046800000000000 2:4062180000000000 3:404e400000000000 4:4065800000000000 5:4050e00000000000 6:4056e00000000000 +series __name__=a_bucket|job=y ; 0:405de00000000000 2:4068180000000000 3:4050000000000000 4:4069700000000000 5:4063d00000000000 6:4066980000000000 +series __name__=b_bucket|job=z|le=-1 ; 0:4014000000000000 1:4000000000000000 2:402c000000000000 3:403d000000000000 4:7ff8000000000001 5:3ff0000000000000 6:4043800000000000 +series __name__=b_bucket|job=z|le=0 ; 0:4039000000000000 1:4028000000000000 2:403f000000000000 4:7ff8000000000001 6:c014000000000000 +series __name__=b_bucket|job=z|le=1 ; 0:0 1:7ff0000000000000 2:4041800000000000 3:0 4:7ff8000000000001 5:c014000000000000 +series __name__=b_bucket|job=z|le=+Inf ; 0:7ff0000000000000 1:7ff0000000000000 4:7ff8000000000001 5:7ff0000000000000 6:4046000000000000 +error vector cannot contain metrics with the same labelset +end +case quantiles quantile 3feccccccccccccd +grid 100000 10000 2 +series __name__=a_bucket|job=x ; 0:0 +series __name__=a_bucket|job=x|le=0.5 ; 0:0 1:0 +series __name__=a_bucket|job=x|le=1 ; 0:0 1:0 +series __name__=a_bucket|job=x|le=5 ; 1:0 +series __name__=a_bucket|job=x|le=+Inf ; 0:1a56e1fc2f8f359 1:0 +series __name__=a_bucket|job=y|le=+Inf ; 1:4058800000000000 +series __name__=b_bucket|job=z|le=1 ; 0:0 1:0 +series __name__=b_bucket|job=z|le=abc ; 1:0 +series __name__=b_bucket|job=z|le=1e400 ; 0:0 1:1a56e1fc2f8f359 +series __name__=b_bucket|job=z|le=+Inf ; 1:0 +result job=x|quantile=0.9 ; 0:3ff0000000000000 1:7ff8000000000001 +result job=y|quantile=0.9 ; 1:7ff8000000000001 +result job=z|quantile=0.9 ; 0:7ff8000000000001 1:7ff8000000000001 +end +case quantiles quantile 3ff0000000000000 +grid 100000 10000 4 +series __name__=a_bucket|job=x|le=-1 ; 0:4045000000000000 1:4048800000000000 2:4036000000000000 +series __name__=a_bucket|job=x ; 0:4046800000000000 1:4053400000000000 2:4058c00000000000 3:4057800000000000 +series __name__=a_bucket|job=x|le=1 ; 0:4051c00000000000 1:4044000000000000 2:4058c00000000000 3:403e000000000000 +series __name__=a_bucket|job=x|le=+Inf ; 0:4018000000000000 1:4048000000000000 2:4041800000000000 +series __name__=a_bucket|job=y|le=0.1 ; 0:4022000000000000 2:402e000000000000 3:4042800000000000 +series __name__=a_bucket|job=y|le=0.5 ; 0:4024000000000000 2:4043800000000000 3:4042800000000000 +series __name__=a_bucket|job=y|le=1 ; 0:4023ffffffff2419 1:404a000000000000 2:4043800000000000 3:40427fffffff3497 +series __name__=a_bucket|job=y ; 0:4032ffffffff920c 1:404a000000000000 2:404380000000d668 3:40427fffffff3497 +series __name__=a_bucket|job=y|le=+Inf ; 0:4032ffffffff920c 1:404a000000011de0 2:404380000000d443 3:40437fffffff3497 +series __name__=b_bucket|job=z|le=0.1 ; 0:7ff0000000000000 1:c014000000000000 2:4043000000000000 3:4008000000000000 +series __name__=b_bucket|job=z|le=0.5 ; 1:4030000000000000 2:4055800000000000 3:0 +series __name__=b_bucket|job=z|le=1 ; 0:7ff0000000000000 1:4050400000000000 2:0 3:4037000000000000 +series __name__=b_bucket|job=z|le=5 ; 1:405a400000000000 2:403f000000000000 3:404b000000000000 +series __name__=b_bucket|job=z|le=+Inf ; 0:7ff8000000000001 1:4060600000000000 2:4041800000000000 3:7ff8000000000001 +result job=x|quantile=1.0 ; 0:3ff0000000000000 1:bff0000000000000 2:3ff0000000000000 3:7ff8000000000001 +result job=y|quantile=1.0 ; 0:3ff0000000000000 1:3ff0000000000000 2:3ff0000000000000 3:3ff0000000000000 +result job=z|quantile=1.0 ; 0:3ff0000000000000 1:4014000000000000 2:3fe0000000000000 3:4014000000000000 +end +case quantiles quantile 3fefae147ae147ae +grid 100000 10000 6 +series __name__=a_bucket|job=y|le=-1 ; 0:4051100000000000 1:4043800000000000 2:4050000000000000 4:4054300000000000 5:4055000000000000 +series __name__=a_bucket|job=y|le=0 ; 1:4047600000000000 2:4059a00000000000 3:405e000000000000 4:4055f00000000000 5:405a200000000000 +series __name__=a_bucket|job=y ; 0:4068e80000000000 1:4060900000000000 2:405fd00000000000 3:4063c00000000000 4:4061000000000000 5:4067b80000000000 +series __name__=b_bucket|job=z|le=-1 ; 0:4055000000000000 2:404a800000000000 3:4052000000000000 4:4024000000000000 5:4051400000000000 +series __name__=b_bucket|job=z|le=0 ; 1:4010000000000000 2:4053000000000000 3:4044800000000000 5:4032000000000000 +series __name__=b_bucket|job=z|le=1 ; 0:4053c00000000000 1:4024000000000000 2:404a000000000000 3:4047000000000000 4:403e000000000000 5:4047000000000000 +series __name__=b_bucket|job=z|le=+Inf ; 0:404c000000000000 1:403d000000000000 2:3ff0000000000000 3:4054400000000000 4:404a000000000000 5:4024000000000000 +result job=y|quantile=0.99 ; 0:7ff8000000000001 1:7ff8000000000001 2:7ff8000000000001 3:7ff8000000000001 4:7ff8000000000001 5:7ff8000000000001 +result job=z|quantile=0.99 ; 0:bff0000000000000 1:3ff0000000000000 2:bfa0eb1324f3fad0 3:3ff0000000000000 4:3ff0000000000000 5:bff0000000000000 +end +case quantiles quantile 3fefae147ae147ae +grid 100000 10000 6 +series __name__=a_bucket|job=y|le=0.25 ; 0:4057800000000000 2:404f000000000000 3:403e000000000000 4:403d000000000000 5:4056c00000000000 +series __name__=a_bucket|job=y|le=2.5 ; 0:4055400000000000 1:4053400000000000 2:404a800000000000 3:404f000000000000 4:4055400000000000 5:4050c00000000000 +series __name__=a_bucket|job=y|le=inf ; 0:4044800000000000 1:4034000000000000 2:4041000000000000 3:404f000000000000 4:404f000000000000 5:402a000000000000 +series __name__=b_bucket|job=z|le=+Inf ; 0:4043800000000000 1:4022000000000000 3:4042800000000000 4:4026000000000000 5:403e000000000000 +result job=y|quantile=0.99 ; 0:3fcfae147ae147ae 1:4003cccccccccccd 2:3fcfae147ae147ae 3:4003a6b851eb851f 4:4003ba0ea0ea0ea1 5:3fcfae147ae147ae +result job=z|quantile=0.99 ; 0:7ff8000000000001 1:7ff8000000000001 3:7ff8000000000001 4:7ff8000000000001 5:7ff8000000000001 +end +case quantiles quantile 7ff8000000000001 +grid 100000 10000 7 +series __name__=a_bucket|job=x|le=1 ; 0:0 1:0 2:0 3:0 4:0 5:0 +series __name__=a_bucket|job=x|le=abc ; 0:0 1:0 2:1 3:1 4:0 5:0 +series __name__=a_bucket|job=x|le=1e400 ; 0:1a56e1fc2f8f359 1:0 2:0 3:0 4:1a56e1fc2f8f359 5:1 6:0 +series __name__=a_bucket|job=x ; 0:0 1:1a56e1fc2f8f359 2:0 3:0 4:1a56e1fc2f8f359 5:0 6:0 +series __name__=a_bucket|job=y ; 0:402a000000000000 1:4035000000000000 3:4044000000000000 4:403f000000000000 5:3ff0000000000000 6:4034000000000000 +series __name__=a_bucket|job=y|le=0.5 ; 0:404d000000000000 1:4046800000000000 2:4043000000000000 4:4042800000000000 5:4040000000000000 6:403a000000000000 +series __name__=a_bucket|job=y|le=1 ; 0:405a400000000000 1:4049800000000000 3:405e800000000000 4:404e000000000000 5:4042000000000000 6:4045800000000000 +series __name__=a_bucket|job=y|le=5 ; 0:7ff0000000000000 1:404c000000000000 2:0 3:4061000000000000 4:4059000000000000 5:0 6:4056400000000000 +series __name__=a_bucket|job=y|le=+Inf ; 1:4059000000000000 2:4020000000000000 3:4062a00000000000 4:405bc00000000000 5:c014000000000000 6:405e800000000000 +series __name__=b_bucket|job=z|le=+Inf ; 0:404b800000000000 2:4052400000000000 4:4044000000000000 5:4047800000000000 6:4050800000000000 +result job=x|quantile=NaN ; 0:7ff8000000000001 1:7ff8000000000001 2:7ff8000000000001 3:7ff8000000000001 4:7ff8000000000001 5:7ff8000000000001 +result job=y|quantile=NaN ; 0:7ff8000000000001 1:7ff8000000000001 2:7ff8000000000001 3:7ff8000000000001 4:7ff8000000000001 5:7ff8000000000001 6:7ff8000000000001 +result job=z|quantile=NaN ; 0:7ff8000000000001 2:7ff8000000000001 4:7ff8000000000001 5:7ff8000000000001 6:7ff8000000000001 +end +case quantiles quantile 3fefae147ae147ae,3fefae147ae147ae,7ff8000000000001 +grid 100000 10000 2 +series __name__=a_bucket|job=x|le=0.25 ; 0:403a000000000000 1:4030000000000000 +series __name__=a_bucket|job=x|le=2.5 ; 0:4039fffffffffd24 +series __name__=a_bucket|job=x|le=inf ; 0:404afffffffffe92 1:404d800000000000 +series __name__=a_bucket|job=y|le=1 ; 0:4046800000000000 +series __name__=a_bucket|job=y|le=1.0 ; 0:7ff0000000000000 1:4018000000000000 +series __name__=a_bucket|job=y|le=2 ; 0:7ff0000000000000 1:4046000000000000 +series __name__=a_bucket|job=y|le=+Inf ; 0:7ff0000000000000 1:404b800000000000 +series __name__=b_bucket|job=z|le=-1 ; 0:404b800000000000 1:4043e00000000000 +series __name__=b_bucket|job=z|le=0 ; 0:405ec00000000000 1:4060900000000000 +series __name__=b_bucket|job=z|le=1 ; 0:4062f80000000000 +series __name__=b_bucket|job=z ; 0:406e400000000000 1:406a380000000000 +error vector cannot contain metrics with the same labelset +end +case quantiles quantile 3ff0000000000000,7ff8000000000001,3ff8000000000000 +grid 100000 10000 1 +series __name__=a_bucket|job=x|le=0.1 ; 0:403d000000000000 +series __name__=a_bucket|job=x|le=0.5 ; 0:403d000000000330 +series __name__=a_bucket|job=x ; +series __name__=a_bucket|job=x|le=5 ; 0:404ffffffffffe13 +series __name__=a_bucket|job=x|le=+Inf ; 0:404ffffffffffe13 +series __name__=a_bucket|job=y|le=1 ; 0:4055000000000000 +series __name__=a_bucket|job=y|le=abc ; 0:404f800000000000 +series __name__=a_bucket|job=y|le=1e400 ; +series __name__=a_bucket|job=y|le=+Inf ; 0:4053000000000000 +series __name__=b_bucket|job=z|le=1 ; 0:404bc00000000000 +series __name__=b_bucket|job=z|le=1.0 ; 0:4058900000000000 +series __name__=b_bucket|job=z|le=2 ; +series __name__=b_bucket|job=z|le=+Inf ; 0:4065480000000000 +result job=x|quantile=1.0 ; 0:4014000000000000 +result job=x|quantile=1.5 ; 0:7ff0000000000000 +result job=x|quantile=NaN ; 0:7ff8000000000001 +result job=y|quantile=1.0 ; 0:3ff0000000000000 +result job=y|quantile=1.5 ; 0:7ff0000000000000 +result job=y|quantile=NaN ; 0:7ff8000000000001 +result job=z|quantile=1.0 ; 0:3ff0000000000000 +result job=z|quantile=1.5 ; 0:7ff0000000000000 +result job=z|quantile=NaN ; 0:7ff8000000000001 +end +case quantiles quantile 3fd0000000000000,bfe0000000000000,3fefae147ae147ae +grid 100000 10000 6 +series __name__=a_bucket|job=x|le=-1 ; 0:4045000000000000 1:4042000000000000 2:4046000000000000 3:403d000000000000 4:3ff0000000000000 +series __name__=a_bucket|job=x ; 1:404e800000000000 3:403d000000000330 5:4038000000000000 +series __name__=a_bucket|job=x|le=1 ; 0:405d400000000000 1:4057c00000000000 2:404fffffffff0e1c 3:403d000000000330 +series __name__=a_bucket|job=x|le=+Inf ; 1:4057c00000010522 2:4057ffffffff870e 3:4041000000000198 4:4030000000000000 5:4048000000000000 +series __name__=a_bucket|job=y|le=1 ; 0:0 1:0 2:0 3:0 4:0 +series __name__=a_bucket|job=y|le=2 ; 0:1 2:1a56e1fc2f8f359 3:1 4:0 5:0 +series __name__=a_bucket|job=y|le=5 ; 0:1a56e1fc2f8f359 1:0 2:0 3:1 4:0 +series __name__=b_bucket|job=y|le=-1 ; 0:402c000000000000 1:4018000000000000 2:4014000000000000 3:4047000000000000 4:4024000000000000 5:4031000000000000 +series __name__=b_bucket|job=y|le=0 ; 0:404a800000000000 2:4040800000000000 4:4031000000000000 5:404c800000000000 +series __name__=b_bucket|job=y|le=1 ; 0:404e800000000000 1:4055000000000000 2:4053400000000000 3:4046fffffffffd78 4:4040800000000000 5:404c800000000322 +series __name__=b_bucket|job=y|le=+Inf ; 1:4054ffffffff191a 2:4053400000000000 3:4057bffffffffebc 4:404080000000b56b 5:404c800000013c7e +series __name__=b_bucket|job=z|le=1 ; 0:4037000000000000 1:4048e00000000000 2:4050800000000000 3:404d400000000000 4:4057800000000000 +series __name__=b_bucket|job=z|le=abc ; 0:405a500000000000 1:405ea00000000000 2:4062a00000000000 4:405f800000000000 +series __name__=b_bucket|job=z|le=1e400 ; 0:405eb00000000000 1:4067b80000000000 2:406d300000000000 3:406ab00000000000 4:4064b80000000000 5:4062800000000000 +series __name__=b_bucket|job=z|le=+Inf ; 0:4061000000000000 1:406df00000000000 2:4070100000000000 3:4072040000000000 4:4065280000000000 5:4064880000000000 +error vector cannot contain metrics with the same labelset +end +case quantiles job 3fe0000000000000,3ff0000000000000,bfe0000000000000 +grid 100000 10000 1 +series __name__=a_bucket|job=x|le=1 ; 0:4012000000000000 +series __name__=a_bucket|job=x ; 0:403d000000000000 +series __name__=a_bucket|job=x|le=5 ; 0:4055f00000000000 +series __name__=b_bucket|job=z|le=1 ; +series __name__=b_bucket|job=z|le=1.0 ; 0:4042000000000000 +series __name__=b_bucket|job=z|le=2 ; 0:404b000000000000 +series __name__=b_bucket|job=z|le=+Inf ; 0:404b000000000000 +error vector cannot contain metrics with the same labelset +end +case quantiles job 3ff8000000000000,0 +grid 100000 10000 3 +series __name__=b_bucket|job=z|le=1 ; 0:4041800000000000 1:4024000000000000 2:4000000000000000 +series __name__=b_bucket|job=z|le=abc ; 0:40417fffffff3f96 1:402e000000000000 2:4000000000000000 +series __name__=b_bucket|job=z|le=1e400 ; 0:40527fffffff9fcb 1:4045000000000000 2:40000000000001c2 +series __name__=b_bucket|job=z|le=+Inf ; 0:405dbfffffff9fcb 1:4044ffffffff191a 2:400000000000b1ae +result job=0.0 ; 0:0 1:0 2:0 +result job=1.5 ; 0:7ff0000000000000 1:7ff0000000000000 2:7ff0000000000000 +end +case quantiles quantile 3ff0000000000000,3fe0000000000000,3fefae147ae147ae +grid 100000 10000 4 +series __name__=a_bucket|job=x|le=0.1 ; 0:c014000000000000 1:4026000000000000 2:4010000000000000 3:0 +series __name__=a_bucket|job=x|le=0.5 ; 0:4000000000000000 2:403e000000000000 3:403a000000000000 +series __name__=a_bucket|job=x|le=1 ; 0:4038000000000000 1:4054000000000000 2:404a000000000000 3:4044800000000000 +series __name__=a_bucket|job=x|le=5 ; 1:4057000000000000 2:404b000000000000 3:4056000000000000 +series __name__=a_bucket|job=x|le=+Inf ; 0:7ff8000000000001 1:4060400000000000 2:4055c00000000000 3:4061000000000000 +series __name__=a_bucket|job=y|le=0.1 ; 0:0 1:0 2:0 3:0 +series __name__=a_bucket|job=y|le=0.5 ; 0:1 1:0 2:0 3:1 +series __name__=a_bucket|job=y|le=1 ; 0:1 1:0 2:0 3:1 +series __name__=a_bucket|job=y|le=5 ; 0:0 2:0 3:1 +series __name__=a_bucket|job=y|le=+Inf ; 0:0 1:0 3:0 +result job=x|quantile=0.5 ; 0:3ff0000000000000 1:3fe9bd37a6f4de9c 2:3fe9d1745d1745d2 3:400a620ae4c415ca +result job=x|quantile=0.99 ; 0:3ff0000000000000 1:4014000000000000 2:4014000000000000 3:4014000000000000 +result job=x|quantile=1.0 ; 0:3ff0000000000000 1:4014000000000000 2:4014000000000000 3:4014000000000000 +result job=y|quantile=0.5 ; 0:7ff8000000000001 1:7ff8000000000001 2:7ff8000000000001 3:7ff8000000000001 +result job=y|quantile=0.99 ; 0:7ff8000000000001 1:7ff8000000000001 2:7ff8000000000001 3:7ff8000000000001 +result job=y|quantile=1.0 ; 0:7ff8000000000001 1:7ff8000000000001 2:7ff8000000000001 3:7ff8000000000001 +end +case quantiles job 3fefae147ae147ae +grid 100000 10000 1 +series __name__=a_bucket|job=x|le=0.25 ; 0:4055e00000000000 +series __name__=a_bucket|job=x|le=2.5 ; 0:4059200000000000 +series __name__=a_bucket|job=x|le=inf ; 0:405b200000000000 +series __name__=a_bucket|job=y|le=1 ; 0:4051300000000000 +series __name__=a_bucket|job=y|le=2 ; 0:4057800000000000 +series __name__=a_bucket|job=y|le=5 ; 0:4058b00000000000 +series __name__=b_bucket|job=z|le=-1 ; 0:4051c00000000000 +series __name__=b_bucket|job=z|le=0 ; 0:4060400000000000 +series __name__=b_bucket|job=z|le=1 ; 0:4065280000000000 +series __name__=b_bucket|job=z|le=+Inf ; 0:4066880000000000 +error vector cannot contain metrics with the same labelset +end +case quantiles job 3fe0000000000000,3fe0000000000000 +grid 100000 10000 5 +series __name__=a_bucket|job=x|le=0.1 ; 0:4008000000000000 3:4022000000000000 4:403a000000000000 +series __name__=a_bucket|job=x|le=0.5 ; 0:4032000000000000 1:4053400000000000 3:40220000000001fa 4:4040000000000000 +series __name__=a_bucket|job=x|le=1 ; 0:40320000000001fa 1:40533fffffff2c58 2:404200000000c5ea 3:404c80000000007e +series __name__=a_bucket|job=x|le=5 ; 0:40320000000003f4 2:4042000000018bd4 4:4054000000000000 +series __name__=a_bucket|job=x|le=+Inf ; 1:40533fffffff2c57 2:40420000000251be 3:404c7ffffffd8dc6 4:4053ffffffff2419 +series __name__=b_bucket|job=y|le=1 ; 0:4050c00000000000 2:4055500000000000 3:4044c00000000000 4:4047000000000000 +series __name__=b_bucket|job=y|le=1.0 ; 0:405b000000000000 1:405f400000000000 2:4066c00000000000 3:4054500000000000 4:404b800000000000 +series __name__=b_bucket|job=y|le=2 ; 0:405e400000000000 1:406a500000000000 2:406b980000000000 4:405d100000000000 +series __name__=b_bucket|job=y|le=+Inf ; 0:4060b80000000000 1:4072180000000000 2:406e400000000000 3:4067500000000000 4:4067580000000000 +series __name__=b_bucket|job=z|le=-1 ; 0:0 1:0 2:0 3:0 4:0 +series __name__=b_bucket|job=z|le=0 ; 0:1 1:0 3:1a56e1fc2f8f359 4:0 +series __name__=b_bucket|job=z ; 0:1 1:0 2:1a56e1fc2f8f359 3:0 4:0 +series __name__=b_bucket|job=z|le=+Inf ; 0:0 1:1 3:0 +error vector cannot contain metrics with the same labelset +end +case quantiles quantile bfe0000000000000,0 +grid 100000 10000 8 +series __name__=a_bucket|job=x|le=1 ; 0:0 1:0 5:0 6:0 +series __name__=a_bucket|job=x ; 0:1a56e1fc2f8f359 1:0 2:0 3:0 4:0 5:0 6:0 7:1 +series __name__=a_bucket|job=x|le=2 ; 0:1 1:1a56e1fc2f8f359 2:0 3:1 4:0 5:0 6:1a56e1fc2f8f359 7:0 +series __name__=a_bucket|job=x|le=+Inf ; 0:0 1:0 2:1 3:0 4:1a56e1fc2f8f359 5:0 6:0 7:0 +series __name__=a_bucket|job=y|le=0.1 ; 0:403b000000000000 1:4040000000000000 2:4018000000000000 3:4008000000000000 4:4042000000000000 5:403c000000000000 6:4045800000000000 7:4032000000000000 +series __name__=a_bucket|job=y|le=0.5 ; 0:4052000000000000 1:404b800000000000 2:4017fffffffffd5c 3:4008000000000000 5:403c000000000000 6:4045800000000000 7:4032000000000000 +series __name__=a_bucket|job=y ; 0:405bc00000000000 1:404b800000012e5e 2:4017fffffffef57a 3:4030000000000000 4:4051400000000000 7:4045000000000000 +series __name__=a_bucket|job=y|le=+Inf ; 0:4061c0000000c32a 1:404e000000012e5e 2:402ffffffffef6cc 3:402ffffffffea028 4:405ac00000000000 5:40587fffffffff3b 6:40458000000004ba +series __name__=b_bucket|job=z ; 0:4000000000000000 1:4043000000000000 2:402a000000000000 3:403c000000000000 4:4043800000000000 5:4043800000000000 6:4020000000000000 7:0 +series __name__=b_bucket|job=z|le=1.0 ; 0:4014000000000000 1:4050400000000000 2:402a000000000000 3:4046000000000000 4:4049800000000000 5:4055c00000000000 6:4039000000000000 7:403c000000000000 +series __name__=b_bucket|job=z|le=2 ; 0:4018000000000000 1:4056400000000000 2:402a0000000002db 3:4046000000000000 4:40497ffffffee7a0 5:405f800000000000 6:4038fffffffeed1f 7:403c0000000133dd +series __name__=b_bucket|job=z|le=+Inf ; 0:4047800000000000 1:405640000000f4a4 2:40418000000000b7 3:404600000000f1e4 4:40497ffffffee7a0 5:405f800000000376 6:4038fffffffeed1f 7:403c000000000000 +result job=x|quantile=-0.5 ; 0:fff0000000000000 1:fff0000000000000 2:fff0000000000000 3:fff0000000000000 4:fff0000000000000 5:fff0000000000000 6:fff0000000000000 7:fff0000000000000 +result job=x|quantile=0.0 ; 0:7ff8000000000001 1:fff8000000000000 2:7ff8000000000001 3:0 4:fff8000000000000 5:7ff8000000000001 6:fff8000000000000 7:7ff8000000000001 +result job=y|quantile=-0.5 ; 0:fff0000000000000 1:fff0000000000000 2:fff0000000000000 3:fff0000000000000 4:fff0000000000000 5:fff0000000000000 6:fff0000000000000 7:fff0000000000000 +result job=y|quantile=0.0 ; 0:0 1:0 2:0 3:0 4:0 5:0 6:0 7:7ff8000000000001 +result job=z|quantile=-0.5 ; 0:fff0000000000000 1:fff0000000000000 2:fff0000000000000 3:fff0000000000000 4:fff0000000000000 5:fff0000000000000 6:fff0000000000000 7:fff0000000000000 +result job=z|quantile=0.0 ; 0:0 1:0 2:0 3:0 4:0 5:0 6:0 7:0 +end diff --git a/src/promql/src/histogram_eval/tests.rs b/src/promql/src/histogram_eval/tests.rs index 8b815b0a49d..2452cffe5b7 100644 --- a/src/promql/src/histogram_eval/tests.rs +++ b/src/promql/src/histogram_eval/tests.rs @@ -22,7 +22,7 @@ use datatypes::series::time::{EvalGrid, GridRange, GridSpec, TimeAxis}; use super::float_histogram::{AddOutcome, FloatHistogram}; use super::{ HistogramAggregate, HistogramFunction, NativeHistogramFunction, aggregate_histograms, - evaluate_histograms, map_native_histogram, + evaluate_histograms, histogram_quantiles, map_native_histogram, }; use crate::grid_series::merge_same_labelset; use crate::grid_series::reference::{ @@ -127,6 +127,30 @@ fn classic_histograms_match_prometheus() { assert!(failed > 20, "{failed} failed"); } +/// `histogram_quantiles` over classic histograms, evaluated by the Prometheus v3.15.0 engine with +/// experimental functions enabled (promql-compat `narthex-golden-histogram -quantiles`). +#[test] +fn histogram_quantiles_match_prometheus() { + let cases = golden_cases(include_str!("quantiles_cases.txt")); + assert_eq!(cases.len(), 30); + let mut failed = 0; + for case in &cases { + let schema = schema(case); + let input: Vec<_> = case + .inputs + .iter() + .filter_map(|(labels, points)| mixed_series(&schema, labels, points, &BTreeMap::new())) + .collect(); + let quantiles: Vec = case.spec[2] + .split(',') + .map(|bits| f64::from_bits(u64::from_str_radix(bits, 16).unwrap())) + .collect(); + let actual = histogram_quantiles(&case.spec[1], &quantiles, &schema, &input); + failed += usize::from(assert_outcome(case, actual, &case.outcome)); + } + assert!(failed > 5, "{failed} failed"); +} + #[test] fn native_histograms_match_prometheus() { let cases = golden_cases(include_str!("native_cases.txt")); diff --git a/src/promql/src/lib.rs b/src/promql/src/lib.rs index c9ee89ff62c..433717d5502 100644 --- a/src/promql/src/lib.rs +++ b/src/promql/src/lib.rs @@ -17,6 +17,7 @@ pub mod error; pub mod evaluated; pub mod extension_plan; pub mod functions; +mod go_format; mod go_sort; #[cfg(test)] mod golden; From e4d1b79b76402569f0f14ed95a3cbd7d69fc2062 Mon Sep 17 00:00:00 2001 From: Dennis Zhuang Date: Thu, 22 Oct 2026 02:12:48 +0800 Subject: [PATCH 07/18] feat(promql): evaluate round and clamp with time-varying scalar parameters Signed-off-by: Dennis Zhuang --- src/promql/src/map.rs | 4 +- src/promql/src/map/broadcast.rs | 98 ++++- src/promql/src/map/scalar_parameter_cases.txt | 358 ++++++++++++++++++ src/promql/src/map/tests/broadcast.rs | 118 ++++++ 4 files changed, 575 insertions(+), 3 deletions(-) create mode 100644 src/promql/src/map/scalar_parameter_cases.txt diff --git a/src/promql/src/map.rs b/src/promql/src/map.rs index 122003e84bf..a23e1cbe62f 100644 --- a/src/promql/src/map.rs +++ b/src/promql/src/map.rs @@ -25,8 +25,8 @@ mod tests; pub(crate) use broadcast::validate_scalar; pub use broadcast::{ - ScalarFunction, ScalarSeriesOperand, map_arithmetic_series, map_compare_series, - map_scalar_function, + ParameterizedMath, ScalarFunction, ScalarSeriesOperand, map_arithmetic_series, + map_compare_series, map_math_with_parameters, map_scalar_function, }; pub use comparison::{Comparison, ComparisonOp, map_compare, scalar_arithmetic, scalar_compare}; diff --git a/src/promql/src/map/broadcast.rs b/src/promql/src/map/broadcast.rs index a9f46d3a471..d2f027852dc 100644 --- a/src/promql/src/map/broadcast.rs +++ b/src/promql/src/map/broadcast.rs @@ -20,7 +20,9 @@ use datatypes::series::samples::{FieldSpec, Series, SeriesSchema}; use datatypes::series::time::TimeAxis; use crate::grid_series::GridSeriesBuilder; -use crate::map::{ArithmeticOp, Comparison, drop_metric_name, float_sample, validate_float_series}; +use crate::map::{ + ArithmeticOp, Comparison, MathOp, drop_metric_name, float_sample, validate_float_series, +}; /// A time-varying, unlabelled single-field scalar, with explicit operand direction. #[derive(Clone, Copy, Debug)] @@ -126,6 +128,100 @@ pub fn map_scalar_function( ) } +/// A math function whose parameters are scalar expressions, which Prometheus evaluates at every +/// step: `round(v, to_nearest)`, `clamp(v, min, max)`, `clamp_min(v, min)` and `clamp_max(v, max)`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ParameterizedMath { + Round, + Clamp, + ClampMin, + ClampMax, +} + +impl ParameterizedMath { + /// The function with the parameters of one point, in argument order. + fn at(self, parameters: &[f64]) -> MathOp { + match self { + Self::Round => MathOp::Round { + nearest: parameters[0], + }, + Self::Clamp => MathOp::Clamp { + min: parameters[0], + max: parameters[1], + }, + Self::ClampMin => MathOp::ClampMin { min: parameters[0] }, + Self::ClampMax => MathOp::ClampMax { max: parameters[0] }, + } + } + + fn arity(self) -> usize { + match self { + Self::Clamp => 2, + Self::Round | Self::ClampMin | Self::ClampMax => 1, + } + } +} + +/// Applies `function` to every float field with its parameters read at each point from +/// time-varying scalars, and drops the metric name, as [`crate::map::map_math`] does with +/// constant parameters. +/// +/// A point where a parameter has no value has no result. `clamp` with `max < min` at a point has +/// no result there, as Prometheus returns an empty vector for that step. `None` means no point +/// holds a result. +pub fn map_math_with_parameters( + schema: &Arc, + series: &Series, + function: ParameterizedMath, + parameters: &[&Series], +) -> Result> { + if parameters.len() != function.arity() { + return Err(DataFusionError::Execution(format!( + "{function:?} takes {} scalar parameters, got {}", + function.arity(), + parameters.len() + ))); + } + let time = validate_float_series(schema, series)?; + let mut parameter_axes = Vec::with_capacity(parameters.len()); + for parameter in parameters { + parameter_axes.push(validate_scalar(schema, parameter)?); + } + // Points only increase, so each parameter's row does too and one cursor reads it. + let mut parameter_cursors: Vec<_> = parameters + .iter() + .map(|parameter| parameter.fields.as_slice()[0].cursor()) + .collect(); + let mut field_cursors: Vec<_> = series.fields.iter().map(|field| field.cursor()).collect(); + let mut values = Vec::with_capacity(parameters.len()); + let mut row = Vec::with_capacity(field_cursors.len()); + let mut builder = GridSeriesBuilder::try_new(schema.clone())?; + for (point, index) in time.positions() { + values.clear(); + for (axis, cursor) in parameter_axes.iter().zip(&mut parameter_cursors) { + let value = axis.row_of(point).and_then(|row| { + float_sample(cursor.at(row).expect("parameter rows only increase")) + }); + values.extend(value); + } + row.clear(); + let op = (values.len() == parameters.len()).then(|| function.at(&values)); + for cursor in &mut field_cursors { + let sample = float_sample( + cursor + .at(index) + .expect("the axis and the fields are aligned"), + ); + row.push(match op { + Some(op) if !op.is_empty_clamp() => sample.map(|value| op.calculate(value)), + _ => None, + }); + } + builder.push(point, &row)?; + } + builder.finish(drop_metric_name(&series.metric)?) +} + /// Calls `calculate(left, right, vector_sample)` at each index where both operands exist. fn map_grid_scalar( schema: &Arc, diff --git a/src/promql/src/map/scalar_parameter_cases.txt b/src/promql/src/map/scalar_parameter_cases.txt new file mode 100644 index 00000000000..9415f983921 --- /dev/null +++ b/src/promql/src/map/scalar_parameter_cases.txt @@ -0,0 +1,358 @@ +# Generated by promql-compat cmd/narthex-golden-mathparam built for amd64 (Prometheus v3.15.0 +# engine, seed 1). `case `, the input series, `param ` lines in argument order, +# then the result series. Points are `grid index:hex value bits`. +case round +expr round(a, scalar(p0)) +grid 100000 10000 6 +series __name__=a,i=0 ; 0:405edd2f1a9fbe77 1:c004000000000000 4:7ff8000000000001 5:3ff4000000000000 +param __name__=p0 ; 0:3fb999999999999a 1:4008000000000000 2:7ff0000000000000 3:3ff0000000000000 4:3fb999999999999a 5:3fb999999999999a +result i=0 ; 0:405ee00000000000 1:c008000000000000 4:7ff8000000000001 5:3ff4cccccccccccd +end +case clamp +expr clamp(a, scalar(p0), scalar(p1)) +grid 100000 10000 3 +series __name__=a,i=0 ; 0:401c000000000000 1:0 2:3ff4000000000000 +series __name__=a,i=1 ; 2:7ff8000000000001 +series __name__=a,i=2 ; 0:c02b800000000000 1:c02b800000000000 2:c02b800000000000 +param __name__=p0 ; 0:7ff8000000000001 1:3ff0000000000000 2:3ff0000000000000 +param __name__=p1 ; 0:4000000000000000 1:0 2:4000000000000000 +result i=0 ; 0:7ff8000000000001 2:3ff4000000000000 +result i=1 ; 2:7ff8000000000001 +result i=2 ; 0:7ff8000000000001 2:3ff0000000000000 +end +case clamp_min +expr clamp_min(a, scalar(p0)) +grid 100000 10000 5 +series __name__=a,i=0 ; 0:c02b800000000000 1:7ff0000000000000 2:0 +series __name__=a,i=1 ; 0:3ff4000000000000 2:c004000000000000 4:3e112e0be826d695 +series __name__=a,i=2 ; 0:fff0000000000000 1:fff0000000000000 2:7ff8000000000001 3:fff0000000000000 4:c004000000000000 +param __name__=p0 ; 0:fff0000000000000 1:4024000000000000 2:4024000000000000 3:fff0000000000000 4:0 +result i=0 ; 0:c02b800000000000 1:7ff0000000000000 2:4024000000000000 +result i=1 ; 0:3ff4000000000000 2:4024000000000000 4:3e112e0be826d695 +result i=2 ; 0:fff0000000000000 1:4024000000000000 2:7ff8000000000001 3:fff0000000000000 4:0 +end +case clamp_max +expr clamp_max(a, scalar(p0)) +grid 100000 10000 2 +series __name__=a,i=0 ; 0:3ff4000000000000 1:405edd2f1a9fbe77 +series __name__=a,i=1 ; 0:fff0000000000000 1:c004000000000000 +param __name__=p0 ; 0:4024000000000000 1:fff0000000000000 +result i=0 ; 0:3ff4000000000000 1:fff0000000000000 +result i=1 ; 0:fff0000000000000 1:fff0000000000000 +end +case round +expr round(a, scalar(p0)) +grid 100000 10000 1 +series __name__=a,i=0 ; +series __name__=a,i=1 ; +param __name__=p0 ; 0:4024000000000000 +end +case clamp +expr clamp(a, scalar(p0), scalar(p1)) +grid 100000 10000 6 +series __name__=a,i=0 ; 4:c004000000000000 5:3ff4000000000000 +series __name__=a,i=1 ; 0:7ff0000000000000 1:7ff0000000000000 2:c004000000000000 3:3e112e0be826d695 4:0 5:c004000000000000 +series __name__=a,i=2 ; 0:3ff4000000000000 1:7ff8000000000001 5:7ff0000000000000 +param __name__=p0 ; 0:3ff0000000000000 1:c014000000000000 2:fff0000000000000 3:c014000000000000 4:4000000000000000 5:4024000000000000 +param __name__=p1 ; 0:4024000000000000 1:c014000000000000 2:fff0000000000000 3:4024000000000000 4:3ff0000000000000 5:4000000000000000 +result i=1 ; 0:4024000000000000 1:c014000000000000 2:fff0000000000000 3:3e112e0be826d695 +result i=2 ; 0:3ff4000000000000 1:7ff8000000000001 +end +case clamp_min +expr clamp_min(a, scalar(p0)) +grid 100000 10000 3 +series __name__=a,i=0 ; 0:8000000000000000 1:4004000000000000 2:c02b800000000000 +param __name__=p0 ; 0:3ff0000000000000 1:3ff0000000000000 2:fff0000000000000 +result i=0 ; 0:3ff0000000000000 1:4004000000000000 2:c02b800000000000 +end +case clamp_max +expr clamp_max(a, scalar(p0)) +grid 100000 10000 4 +series __name__=a,i=0 ; 0:c004000000000000 1:c004000000000000 2:0 3:c004000000000000 +param __name__=p0 ; 0:fff0000000000000 1:c014000000000000 2:c014000000000000 3:3ff0000000000000 +result i=0 ; 0:fff0000000000000 1:c014000000000000 2:c014000000000000 3:c004000000000000 +end +case round +expr round(a, scalar(p0)) +grid 100000 10000 2 +series __name__=a,i=0 ; 0:405edd2f1a9fbe77 +param __name__=p0 ; 0:7ff8000000000001 1:4024000000000000 +result i=0 ; 0:7ff8000000000001 +end +case clamp +expr clamp(a, scalar(p0), scalar(p1)) +grid 100000 10000 3 +series __name__=a,i=0 ; 1:3ff4000000000000 +series __name__=a,i=1 ; 0:4004000000000000 2:8000000000000000 +series __name__=a,i=2 ; 0:401c000000000000 1:c02b800000000000 2:405edd2f1a9fbe77 +param __name__=p0 ; 0:7ff0000000000000 1:c014000000000000 2:fff0000000000000 +param __name__=p1 ; 0:3ff0000000000000 1:fff0000000000000 2:4024000000000000 +result i=1 ; 2:8000000000000000 +result i=2 ; 2:4024000000000000 +end +case clamp_min +expr clamp_min(a, scalar(p0)) +grid 100000 10000 5 +series __name__=a,i=0 ; 0:7ff8000000000001 1:7ff8000000000001 2:405edd2f1a9fbe77 3:7ff8000000000001 +series __name__=a,i=1 ; 0:3ff4000000000000 1:c02b800000000000 3:3e112e0be826d695 4:8000000000000000 +series __name__=a,i=2 ; 0:c02b800000000000 1:8000000000000000 3:c004000000000000 4:7ff8000000000001 +param __name__=p0 ; 0:7ff8000000000001 1:7ff8000000000001 2:4024000000000000 3:c014000000000000 4:fff0000000000000 +result i=0 ; 0:7ff8000000000001 1:7ff8000000000001 2:405edd2f1a9fbe77 3:7ff8000000000001 +result i=1 ; 0:7ff8000000000001 1:7ff8000000000001 3:3e112e0be826d695 4:8000000000000000 +result i=2 ; 0:7ff8000000000001 1:7ff8000000000001 3:c004000000000000 4:7ff8000000000001 +end +case clamp_max +expr clamp_max(a, scalar(p0)) +grid 100000 10000 2 +series __name__=a,i=0 ; 0:8000000000000000 +series __name__=a,i=1 ; 1:fff0000000000000 +param __name__=p0 ; 0:7ff0000000000000 1:4024000000000000 +result i=0 ; 0:8000000000000000 +result i=1 ; 1:fff0000000000000 +end +case round +expr round(a, scalar(p0)) +grid 100000 10000 1 +series __name__=a,i=0 ; 0:405edd2f1a9fbe77 +param __name__=p0 ; 0:c000000000000000 +result i=0 ; 0:405f000000000000 +end +case clamp +expr clamp(a, scalar(p0), scalar(p1)) +grid 100000 10000 1 +series __name__=a,i=0 ; 0:fff0000000000000 +series __name__=a,i=1 ; 0:8000000000000000 +param __name__=p0 ; 0:4000000000000000 +param __name__=p1 ; 0:7ff0000000000000 +result i=0 ; 0:4000000000000000 +result i=1 ; 0:4000000000000000 +end +case clamp_min +expr clamp_min(a, scalar(p0)) +grid 100000 10000 4 +series __name__=a,i=0 ; 0:c004000000000000 1:7ff8000000000001 3:3ff4000000000000 +series __name__=a,i=1 ; 0:4004000000000000 1:3ff4000000000000 2:7ff0000000000000 3:4004000000000000 +param __name__=p0 ; 0:fff0000000000000 1:fff0000000000000 2:7ff8000000000001 3:7ff0000000000000 +result i=0 ; 0:c004000000000000 1:7ff8000000000001 3:7ff0000000000000 +result i=1 ; 0:4004000000000000 1:3ff4000000000000 2:7ff0000000000000 3:7ff0000000000000 +end +case clamp_max +expr clamp_max(a, scalar(p0)) +grid 100000 10000 4 +series __name__=a,i=0 ; 0:7ff8000000000001 1:4004000000000000 2:8000000000000000 +series __name__=a,i=1 ; 0:7ff0000000000000 1:0 2:c004000000000000 3:c004000000000000 +param __name__=p0 ; 0:4024000000000000 1:4000000000000000 2:0 3:7ff0000000000000 +result i=0 ; 0:7ff8000000000001 1:4000000000000000 2:8000000000000000 +result i=1 ; 0:4024000000000000 1:0 2:c004000000000000 3:c004000000000000 +end +case round +expr round(a, scalar(p0)) +grid 100000 10000 4 +series __name__=a,i=0 ; 0:3ff4000000000000 1:7ff8000000000001 2:7ff0000000000000 3:7ff8000000000001 +series __name__=a,i=1 ; 1:7ff0000000000000 2:3ff4000000000000 +param __name__=p0 ; 0:4024000000000000 1:4008000000000000 2:0 3:4008000000000000 +result i=0 ; 0:0 1:7ff8000000000001 2:fff8000000000000 3:7ff8000000000001 +result i=1 ; 1:7ff0000000000000 2:fff8000000000000 +end +case clamp +expr clamp(a, scalar(p0), scalar(p1)) +grid 100000 10000 3 +series __name__=a,i=0 ; 0:7ff0000000000000 +param __name__=p0 ; 0:7ff8000000000001 1:4024000000000000 2:7ff8000000000001 +param __name__=p1 ; 0:3ff0000000000000 1:4024000000000000 2:fff0000000000000 +result i=0 ; 0:7ff8000000000001 +end +case clamp_min +expr clamp_min(a, scalar(p0)) +grid 100000 10000 5 +series __name__=a,i=0 ; 0:405edd2f1a9fbe77 1:405edd2f1a9fbe77 2:401c000000000000 3:0 +param __name__=p0 ; 0:4024000000000000 1:3ff0000000000000 2:fff0000000000000 3:3ff0000000000000 4:0 +result i=0 ; 0:405edd2f1a9fbe77 1:405edd2f1a9fbe77 2:401c000000000000 3:3ff0000000000000 +end +case clamp_max +expr clamp_max(a, scalar(p0)) +grid 100000 10000 3 +series __name__=a,i=0 ; 0:405edd2f1a9fbe77 1:401c000000000000 2:405edd2f1a9fbe77 +param __name__=p0 ; 0:3ff0000000000000 1:4024000000000000 2:4000000000000000 +result i=0 ; 0:3ff0000000000000 1:401c000000000000 2:4000000000000000 +end +case round +expr round(a, scalar(p0)) +grid 100000 10000 1 +series __name__=a,i=0 ; 0:3e112e0be826d695 +series __name__=a,i=1 ; +param __name__=p0 ; 0:7ff0000000000000 +result i=0 ; 0:fff8000000000000 +end +case clamp +expr clamp(a, scalar(p0), scalar(p1)) +grid 100000 10000 3 +series __name__=a,i=0 ; 0:c02b800000000000 1:8000000000000000 +param __name__=p0 ; 0:7ff8000000000001 1:7ff0000000000000 2:7ff0000000000000 +param __name__=p1 ; 0:fff0000000000000 1:c014000000000000 2:4024000000000000 +result i=0 ; 0:7ff8000000000001 +end +case clamp_min +expr clamp_min(a, scalar(p0)) +grid 100000 10000 5 +series __name__=a,i=0 ; 0:8000000000000000 1:4004000000000000 2:405edd2f1a9fbe77 4:fff0000000000000 +param __name__=p0 ; 0:4000000000000000 1:7ff8000000000001 2:4000000000000000 3:4000000000000000 4:0 +result i=0 ; 0:4000000000000000 1:7ff8000000000001 2:405edd2f1a9fbe77 4:0 +end +case clamp_max +expr clamp_max(a, scalar(p0)) +grid 100000 10000 4 +series __name__=a,i=0 ; 1:8000000000000000 3:c004000000000000 +series __name__=a,i=1 ; 0:3ff4000000000000 1:401c000000000000 3:c004000000000000 +param __name__=p0 ; 0:4000000000000000 1:7ff0000000000000 2:3ff0000000000000 3:0 +result i=0 ; 1:8000000000000000 3:c004000000000000 +result i=1 ; 0:3ff4000000000000 1:401c000000000000 3:c004000000000000 +end +case round +expr round(a, scalar(p0)) +grid 100000 10000 3 +series __name__=a,i=0 ; 2:0 +series __name__=a,i=1 ; 0:7ff0000000000000 2:401c000000000000 +param __name__=p0 ; 0:4024000000000000 1:7ff8000000000001 2:0 +result i=0 ; 2:fff8000000000000 +result i=1 ; 0:7ff0000000000000 2:fff8000000000000 +end +case clamp +expr clamp(a, scalar(p0), scalar(p1)) +grid 100000 10000 6 +series __name__=a,i=0 ; 0:c004000000000000 1:3ff4000000000000 2:c02b800000000000 3:401c000000000000 4:c02b800000000000 +param __name__=p0 ; 0:4024000000000000 1:3ff0000000000000 2:4000000000000000 3:7ff0000000000000 4:c014000000000000 5:0 +param __name__=p1 ; 0:7ff0000000000000 1:fff0000000000000 2:7ff8000000000001 3:7ff0000000000000 4:0 5:4024000000000000 +result i=0 ; 0:4024000000000000 2:7ff8000000000001 3:7ff0000000000000 4:c014000000000000 +end +case clamp_min +expr clamp_min(a, scalar(p0)) +grid 100000 10000 2 +series __name__=a,i=0 ; 0:7ff8000000000001 1:7ff0000000000000 +series __name__=a,i=1 ; 0:7ff8000000000001 1:3e112e0be826d695 +param __name__=p0 ; 0:4024000000000000 1:4000000000000000 +result i=0 ; 0:7ff8000000000001 1:7ff0000000000000 +result i=1 ; 0:7ff8000000000001 1:4000000000000000 +end +case clamp_max +expr clamp_max(a, scalar(p0)) +grid 100000 10000 3 +series __name__=a,i=0 ; 0:4004000000000000 2:fff0000000000000 +param __name__=p0 ; 0:7ff0000000000000 1:7ff8000000000001 2:7ff0000000000000 +result i=0 ; 0:4004000000000000 2:fff0000000000000 +end +case round +expr round(a, scalar(p0)) +grid 100000 10000 4 +series __name__=a,i=0 ; 1:8000000000000000 3:405edd2f1a9fbe77 +series __name__=a,i=1 ; 0:4004000000000000 1:fff0000000000000 2:3ff4000000000000 3:3ff4000000000000 +param __name__=p0 ; 0:7ff8000000000001 1:c000000000000000 2:3ff0000000000000 3:3fe0000000000000 +result i=0 ; 1:8000000000000000 3:405ee00000000000 +result i=1 ; 0:7ff8000000000001 1:fff0000000000000 2:3ff0000000000000 3:3ff8000000000000 +end +case clamp +expr clamp(a, scalar(p0), scalar(p1)) +grid 100000 10000 2 +series __name__=a,i=0 ; 0:3e112e0be826d695 1:7ff8000000000001 +series __name__=a,i=1 ; 0:c02b800000000000 1:c004000000000000 +param __name__=p0 ; 0:c014000000000000 1:4024000000000000 +param __name__=p1 ; 0:3ff0000000000000 1:7ff8000000000001 +result i=0 ; 0:3e112e0be826d695 1:7ff8000000000001 +result i=1 ; 0:c014000000000000 1:7ff8000000000001 +end +case clamp_min +expr clamp_min(a, scalar(p0)) +grid 100000 10000 2 +series __name__=a,i=0 ; 0:405edd2f1a9fbe77 +series __name__=a,i=1 ; 0:401c000000000000 1:c02b800000000000 +series __name__=a,i=2 ; 0:c02b800000000000 1:7ff0000000000000 +param __name__=p0 ; 0:7ff0000000000000 1:4000000000000000 +result i=0 ; 0:7ff0000000000000 +result i=1 ; 0:7ff0000000000000 1:4000000000000000 +result i=2 ; 0:7ff0000000000000 1:7ff0000000000000 +end +case clamp_max +expr clamp_max(a, scalar(p0)) +grid 100000 10000 6 +series __name__=a,i=0 ; 2:c004000000000000 3:fff0000000000000 4:0 5:3ff4000000000000 +param __name__=p0 ; 0:4024000000000000 1:4000000000000000 2:4000000000000000 3:0 4:4024000000000000 5:7ff0000000000000 +result i=0 ; 2:c004000000000000 3:fff0000000000000 4:0 5:3ff4000000000000 +end +case round +expr round(a, scalar(p0)) +grid 100000 10000 3 +series __name__=a,i=0 ; 0:fff0000000000000 1:3e112e0be826d695 2:401c000000000000 +series __name__=a,i=1 ; 0:8000000000000000 2:fff0000000000000 +param __name__=p0 ; 0:4008000000000000 1:7ff8000000000001 2:4024000000000000 +result i=0 ; 0:fff0000000000000 1:7ff8000000000001 2:4024000000000000 +result i=1 ; 0:0 2:fff0000000000000 +end +case clamp +expr clamp(a, scalar(p0), scalar(p1)) +grid 100000 10000 4 +series __name__=a,i=0 ; 1:3e112e0be826d695 2:fff0000000000000 3:c02b800000000000 +series __name__=a,i=1 ; 0:fff0000000000000 1:405edd2f1a9fbe77 3:8000000000000000 +param __name__=p0 ; 0:0 1:3ff0000000000000 2:4000000000000000 3:7ff8000000000001 +param __name__=p1 ; 0:4000000000000000 1:0 2:4024000000000000 3:4024000000000000 +result i=0 ; 2:4000000000000000 3:7ff8000000000001 +result i=1 ; 0:0 3:7ff8000000000001 +end +case clamp_min +expr clamp_min(a, scalar(p0)) +grid 100000 10000 3 +series __name__=a,i=0 ; 0:0 2:fff0000000000000 +series __name__=a,i=1 ; 0:c004000000000000 1:0 +series __name__=a,i=2 ; 1:fff0000000000000 2:3e112e0be826d695 +param __name__=p0 ; 0:3ff0000000000000 1:7ff8000000000001 2:3ff0000000000000 +result i=0 ; 0:3ff0000000000000 2:3ff0000000000000 +result i=1 ; 0:3ff0000000000000 1:7ff8000000000001 +result i=2 ; 1:7ff8000000000001 2:3ff0000000000000 +end +case clamp_max +expr clamp_max(a, scalar(p0)) +grid 100000 10000 5 +series __name__=a,i=0 ; 0:7ff8000000000001 1:3e112e0be826d695 2:c004000000000000 3:405edd2f1a9fbe77 4:3e112e0be826d695 +series __name__=a,i=1 ; 0:7ff8000000000001 2:fff0000000000000 +param __name__=p0 ; 0:7ff0000000000000 1:c014000000000000 2:3ff0000000000000 3:c014000000000000 4:fff0000000000000 +result i=0 ; 0:7ff8000000000001 1:c014000000000000 2:c004000000000000 3:c014000000000000 4:fff0000000000000 +result i=1 ; 0:7ff8000000000001 2:fff0000000000000 +end +case round +expr round(a, scalar(p0)) +grid 100000 10000 5 +series __name__=a,i=0 ; 0:4004000000000000 1:7ff0000000000000 +series __name__=a,i=1 ; 0:c004000000000000 1:405edd2f1a9fbe77 3:8000000000000000 +series __name__=a,i=2 ; 0:0 1:3e112e0be826d695 2:3e112e0be826d695 3:405edd2f1a9fbe77 +param __name__=p0 ; 0:4008000000000000 1:7ff0000000000000 2:3fb999999999999a 3:3ff0000000000000 4:7ff0000000000000 +result i=0 ; 0:4008000000000000 1:fff8000000000000 +result i=1 ; 0:c008000000000000 1:fff8000000000000 3:0 +result i=2 ; 0:0 1:fff8000000000000 2:0 3:405ec00000000000 +end +case clamp +expr clamp(a, scalar(p0), scalar(p1)) +grid 100000 10000 3 +series __name__=a,i=0 ; 0:0 1:4004000000000000 2:405edd2f1a9fbe77 +series __name__=a,i=1 ; 0:405edd2f1a9fbe77 1:405edd2f1a9fbe77 2:c02b800000000000 +param __name__=p0 ; 0:7ff8000000000001 1:0 2:7ff8000000000001 +param __name__=p1 ; 0:fff0000000000000 1:c014000000000000 2:fff0000000000000 +result i=0 ; 0:7ff8000000000001 2:7ff8000000000001 +result i=1 ; 0:7ff8000000000001 2:7ff8000000000001 +end +case clamp_min +expr clamp_min(a, scalar(p0)) +grid 100000 10000 4 +series __name__=a,i=0 ; 0:401c000000000000 1:8000000000000000 2:401c000000000000 3:fff0000000000000 +series __name__=a,i=1 ; 0:8000000000000000 1:401c000000000000 2:0 3:8000000000000000 +series __name__=a,i=2 ; 0:fff0000000000000 1:401c000000000000 3:3ff4000000000000 +param __name__=p0 ; 0:3ff0000000000000 1:fff0000000000000 2:4024000000000000 3:3ff0000000000000 +result i=0 ; 0:401c000000000000 1:8000000000000000 2:4024000000000000 3:3ff0000000000000 +result i=1 ; 0:3ff0000000000000 1:401c000000000000 2:4024000000000000 3:3ff0000000000000 +result i=2 ; 0:3ff0000000000000 1:401c000000000000 3:3ff4000000000000 +end +case clamp_max +expr clamp_max(a, scalar(p0)) +grid 100000 10000 3 +series __name__=a,i=0 ; 0:3e112e0be826d695 1:3e112e0be826d695 +param __name__=p0 ; 0:4000000000000000 1:fff0000000000000 2:4000000000000000 +result i=0 ; 0:3e112e0be826d695 1:fff0000000000000 +end diff --git a/src/promql/src/map/tests/broadcast.rs b/src/promql/src/map/tests/broadcast.rs index 68f57278392..62d6b674155 100644 --- a/src/promql/src/map/tests/broadcast.rs +++ b/src/promql/src/map/tests/broadcast.rs @@ -431,3 +431,121 @@ fn a_vector_scalar_operation_of_sparse_operands_keeps_the_shared_positions() { "nothing passes, so there is no result series" ); } + +/// `round` and the clamp functions with time-varying scalar parameters, evaluated by the +/// Prometheus v3.15.0 engine; see the header of `scalar_parameter_cases.txt`. +#[test] +fn parameterized_math_matches_prometheus() { + use std::collections::BTreeMap; + + use crate::grid_series::reference::{Labels, Points, labels_of, points_of}; + + let parse = |line: &str| -> (Labels, Points) { + let (labels, points) = line.split_once(" ;").unwrap(); + let labels = labels + .split(',') + .filter(|pair| *pair != "-") + .map(|pair| { + let (name, value) = pair.split_once('=').unwrap(); + (name.to_string(), value.to_string()) + }) + .collect(); + let points = points + .split_whitespace() + .map(|point| { + let (index, bits) = point.split_once(':').unwrap(); + ( + index.parse().unwrap(), + u64::from_str_radix(bits, 16).unwrap(), + ) + }) + .collect(); + (labels, points) + }; + let mut checked = 0; + for case in include_str!("../scalar_parameter_cases.txt") + .split("end\n") + .filter(|case| case.contains("case ")) + { + let lines: Vec<&str> = case.lines().filter(|line| !line.starts_with('#')).collect(); + let function = match lines[0].strip_prefix("case ").unwrap() { + "round" => ParameterizedMath::Round, + "clamp" => ParameterizedMath::Clamp, + "clamp_min" => ParameterizedMath::ClampMin, + "clamp_max" => ParameterizedMath::ClampMax, + other => panic!("unknown function {other}"), + }; + let points: u64 = lines[2].split_whitespace().nth(3).unwrap().parse().unwrap(); + let grid = Arc::new( + EvalGrid::try_new(GridSpec::try_new(100_000, 10_000, points).unwrap()).unwrap(), + ); + let input_schema = Arc::new(SeriesSchema { + fields: vec![FieldSpec { + name: "value".into(), + }] + .into(), + eval_grid: Some(grid.clone()), + }); + let dense = |schema: &Arc, labels: &Labels, values: &Points| { + crate::grid_series::reference::sparse_series(schema, labels, values) + }; + let (mut inputs, mut parameters, mut expected) = (Vec::new(), Vec::new(), BTreeMap::new()); + for line in &lines[3..] { + let (kind, rest) = line.split_once(' ').unwrap(); + let (labels, values) = parse(rest); + match kind { + "series" => inputs.extend(dense(&input_schema, &labels, &values)), + // A parameter is `scalar(p)`: unlabelled, with a value at every point. + "param" => parameters.push( + dense(&input_schema, &Labels::new(), &values).expect("a value at every point"), + ), + "result" => { + expected.insert(labels, values); + } + other => panic!("unexpected line {other}"), + } + } + let parameters: Vec<&Series> = parameters.iter().collect(); + let mut actual = BTreeMap::new(); + for input in &inputs { + if let Some(output) = + map_math_with_parameters(&input_schema, input, function, ¶meters).unwrap() + { + actual.insert(labels_of(&output.metric), points_of(&output)); + } + } + // Go's NaN and Rust's differ in their payload; a NaN is a NaN. + let canonical = |series: BTreeMap| -> BTreeMap { + series + .into_iter() + .map(|(labels, points)| { + let points = points + .into_iter() + .map(|(index, bits)| { + ( + index, + crate::grid_series::reference::canonical_bits(f64::from_bits(bits)), + ) + }) + .collect(); + (labels, points) + }) + .collect() + }; + assert_eq!(canonical(actual), canonical(expected), "{case}"); + checked += 1; + } + assert!(checked >= 40, "{checked}"); +} + +/// The parameter count must match the function. +#[test] +fn parameterized_math_checks_its_parameter_count() { + let schema = Arc::new(schema(&["value"])); + let series = series(&schema, 0, vec![floats(&[Some(1.0)])]); + let parameter = scalar(&schema, 0, floats(&[Some(1.0)])); + assert!( + map_math_with_parameters(&schema, &series, ParameterizedMath::Clamp, &[¶meter]) + .is_err() + ); +} From 7c139a2ccca20ba62960f6a97b305e033ee347a8 Mon Sep 17 00:00:00 2001 From: Dennis Zhuang Date: Thu, 22 Oct 2026 02:31:40 +0800 Subject: [PATCH 08/18] feat(promql): skip native histogram samples in math functions and cover review gaps Signed-off-by: Dennis Zhuang --- src/promql/src/map.rs | 71 +++++++++--- src/promql/src/map/broadcast.rs | 91 ++++++++------- src/promql/src/map/scalar_parameter_cases.txt | 55 +++++---- src/promql/src/map/tests.rs | 51 +++++--- src/promql/src/map/tests/broadcast.rs | 109 ++++++++++++++---- 5 files changed, 252 insertions(+), 125 deletions(-) diff --git a/src/promql/src/map.rs b/src/promql/src/map.rs index a23e1cbe62f..0e2ff67fe02 100644 --- a/src/promql/src/map.rs +++ b/src/promql/src/map.rs @@ -42,6 +42,7 @@ use datatypes::series::samples::{ use datatypes::series::time::{GridRange, TimeAxis}; use crate::functions::Round; +use crate::grid_series::GridSeriesBuilder; /// Float math supported by the evaluated-series kernel. #[derive(Clone, Copy, Debug)] @@ -117,9 +118,12 @@ pub enum ScalarOperand { /// /// Eval must resolve raw staleness before calling this kernel. Evaluated samples use validity, /// not NaN payloads, for missingness: arithmetic can produce the reserved stale bit pattern too. -/// Raw time axes and histogram/mixed segments are rejected. Unsupported query types must be -/// admitted to the ordinary path before execution; this function neither falls back nor returns -/// partial results. +/// Raw time axes are rejected. +/// +/// Native histogram samples are skipped, as Prometheus' math functions process only float +/// samples: a point holding a histogram has no result in that field, and a series with no float +/// sample left has none at all. Float-only fields keep their chunks; a field with histograms is +/// rebuilt point by point. /// /// Returns `None` for `clamp` with `max < min`, which yields no series in Prometheus. /// @@ -128,9 +132,31 @@ pub enum ScalarOperand { /// `mergeSeriesWithSameLabelset`). pub fn map_math(schema: &SeriesSchema, series: &Series, op: MathOp) -> Result> { if op.is_empty_clamp() { - validate_float_series(schema, series)?; + validate_grid_series(schema, series)?; return Ok(None); } + if !is_float_series(series) { + let time = validate_grid_series(schema, series)?; + let mut builder = GridSeriesBuilder::try_new(Arc::new(schema.clone()))?; + let mut cursors: Vec<_> = series.fields.iter().map(|field| field.cursor()).collect(); + let mut row = Vec::with_capacity(cursors.len()); + for (point, index) in time.positions() { + row.clear(); + for cursor in &mut cursors { + row.push( + match cursor + .at(index) + .expect("the axis and the fields are aligned") + { + SampleRef::Float(value) => Some(op.calculate(value)), + SampleRef::Missing | SampleRef::Histogram(_) => None, + }, + ); + } + builder.push(point, &row)?; + } + return builder.finish(drop_metric_name(&series.metric)?); + } let mut output = map_float_fields(schema, series, |floats| op.apply(floats))?; output.metric = drop_metric_name(&output.metric)?; Ok(Some(output)) @@ -372,6 +398,31 @@ fn series_error(error: datatypes::error::Error) -> DataFusionError { pub(crate) fn validate_float_series<'a>( schema: &SeriesSchema, series: &'a Series, +) -> Result<&'a TimeAxis> { + let time = validate_grid_series(schema, series)?; + if !is_float_series(series) { + return Err(DataFusionError::NotImplemented( + "Series operators support only float samples; histogram/mixed samples are not supported" + .into(), + )); + } + Ok(time) +} + +/// Whether every field of `series` holds float samples only. +fn is_float_series(series: &Series) -> bool { + series.fields.iter().all(|field| { + field + .segments() + .iter() + .all(|segment| matches!(segment.dense(), Some(DenseSamplesRef::Float(_)))) + }) +} + +/// Checks `series` against `schema` and its evaluation grid, whatever its sample types. +pub(crate) fn validate_grid_series<'a>( + schema: &SeriesSchema, + series: &'a Series, ) -> Result<&'a TimeAxis> { let time = &series.time; if time.grid().is_none() { @@ -386,18 +437,6 @@ pub(crate) fn validate_float_series<'a>( series.fields.clone(), ) .map_err(series_error)?; - for field in series.fields.iter() { - if field - .segments() - .iter() - .any(|segment| !matches!(segment.dense(), Some(DenseSamplesRef::Float(_)))) - { - return Err(DataFusionError::NotImplemented( - "Series operators support only float samples; histogram/mixed samples are not supported" - .into(), - )); - } - } Ok(time) } diff --git a/src/promql/src/map/broadcast.rs b/src/promql/src/map/broadcast.rs index d2f027852dc..3ddf82edba7 100644 --- a/src/promql/src/map/broadcast.rs +++ b/src/promql/src/map/broadcast.rs @@ -16,12 +16,13 @@ use std::sync::Arc; use datafusion::error::{DataFusionError, Result}; use datatypes::series::MetricName; -use datatypes::series::samples::{FieldSpec, Series, SeriesSchema}; +use datatypes::series::samples::{FieldSpec, SampleRef, Series, SeriesSchema}; use datatypes::series::time::TimeAxis; use crate::grid_series::GridSeriesBuilder; use crate::map::{ ArithmeticOp, Comparison, MathOp, drop_metric_name, float_sample, validate_float_series, + validate_grid_series, }; /// A time-varying, unlabelled single-field scalar, with explicit operand direction. @@ -129,62 +130,60 @@ pub fn map_scalar_function( } /// A math function whose parameters are scalar expressions, which Prometheus evaluates at every -/// step: `round(v, to_nearest)`, `clamp(v, min, max)`, `clamp_min(v, min)` and `clamp_max(v, max)`. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum ParameterizedMath { - Round, - Clamp, - ClampMin, - ClampMax, +/// step, with those parameters as time-varying scalars. +#[derive(Clone, Copy, Debug)] +pub enum ParameterizedMath<'a> { + /// `round(v, to_nearest)`. + Round { to_nearest: &'a Series }, + /// `clamp(v, min, max)`. + Clamp { min: &'a Series, max: &'a Series }, + /// `clamp_min(v, min)`. + ClampMin { min: &'a Series }, + /// `clamp_max(v, max)`. + ClampMax { max: &'a Series }, } -impl ParameterizedMath { - /// The function with the parameters of one point, in argument order. - fn at(self, parameters: &[f64]) -> MathOp { +impl<'a> ParameterizedMath<'a> { + /// The parameters in argument order. + fn parameters(self) -> Vec<&'a Series> { match self { - Self::Round => MathOp::Round { - nearest: parameters[0], - }, - Self::Clamp => MathOp::Clamp { - min: parameters[0], - max: parameters[1], - }, - Self::ClampMin => MathOp::ClampMin { min: parameters[0] }, - Self::ClampMax => MathOp::ClampMax { max: parameters[0] }, + Self::Round { to_nearest } => vec![to_nearest], + Self::Clamp { min, max } => vec![min, max], + Self::ClampMin { min } => vec![min], + Self::ClampMax { max } => vec![max], } } - fn arity(self) -> usize { + /// The function with the parameter values of one point, in argument order. + fn at(self, values: &[f64]) -> MathOp { match self { - Self::Clamp => 2, - Self::Round | Self::ClampMin | Self::ClampMax => 1, + Self::Round { .. } => MathOp::Round { nearest: values[0] }, + Self::Clamp { .. } => MathOp::Clamp { + min: values[0], + max: values[1], + }, + Self::ClampMin { .. } => MathOp::ClampMin { min: values[0] }, + Self::ClampMax { .. } => MathOp::ClampMax { max: values[0] }, } } } -/// Applies `function` to every float field with its parameters read at each point from -/// time-varying scalars, and drops the metric name, as [`crate::map::map_math`] does with -/// constant parameters. +/// Applies `function` to every field with its parameters read at each point from time-varying +/// scalars, and drops the metric name, as [`crate::map::map_math`] does with constant parameters; +/// native histogram samples are skipped the same way. /// /// A point where a parameter has no value has no result. `clamp` with `max < min` at a point has /// no result there, as Prometheus returns an empty vector for that step. `None` means no point -/// holds a result. +/// holds a result. Same-labelset merging is left to the caller. pub fn map_math_with_parameters( schema: &Arc, series: &Series, - function: ParameterizedMath, - parameters: &[&Series], + function: ParameterizedMath<'_>, ) -> Result> { - if parameters.len() != function.arity() { - return Err(DataFusionError::Execution(format!( - "{function:?} takes {} scalar parameters, got {}", - function.arity(), - parameters.len() - ))); - } - let time = validate_float_series(schema, series)?; + let time = validate_grid_series(schema, series)?; + let parameters = function.parameters(); let mut parameter_axes = Vec::with_capacity(parameters.len()); - for parameter in parameters { + for parameter in ¶meters { parameter_axes.push(validate_scalar(schema, parameter)?); } // Points only increase, so each parameter's row does too and one cursor reads it. @@ -204,16 +203,16 @@ pub fn map_math_with_parameters( }); values.extend(value); } + let op = (values.len() == parameters.len()) + .then(|| function.at(&values)) + .filter(|op| !op.is_empty_clamp()); row.clear(); - let op = (values.len() == parameters.len()).then(|| function.at(&values)); for cursor in &mut field_cursors { - let sample = float_sample( - cursor - .at(index) - .expect("the axis and the fields are aligned"), - ); - row.push(match op { - Some(op) if !op.is_empty_clamp() => sample.map(|value| op.calculate(value)), + let sample = cursor + .at(index) + .expect("the axis and the fields are aligned"); + row.push(match (op, sample) { + (Some(op), SampleRef::Float(value)) => Some(op.calculate(value)), _ => None, }); } diff --git a/src/promql/src/map/scalar_parameter_cases.txt b/src/promql/src/map/scalar_parameter_cases.txt index 9415f983921..5fe9df9dc82 100644 --- a/src/promql/src/map/scalar_parameter_cases.txt +++ b/src/promql/src/map/scalar_parameter_cases.txt @@ -5,8 +5,8 @@ case round expr round(a, scalar(p0)) grid 100000 10000 6 series __name__=a,i=0 ; 0:405edd2f1a9fbe77 1:c004000000000000 4:7ff8000000000001 5:3ff4000000000000 -param __name__=p0 ; 0:3fb999999999999a 1:4008000000000000 2:7ff0000000000000 3:3ff0000000000000 4:3fb999999999999a 5:3fb999999999999a -result i=0 ; 0:405ee00000000000 1:c008000000000000 4:7ff8000000000001 5:3ff4cccccccccccd +param __name__=p0 ; 0:fff0000000000000 1:7ff0000000000000 2:4008000000000000 3:3fe0000000000000 4:c000000000000000 5:7ff8000000000001 +result i=0 ; 0:fff8000000000000 1:fff8000000000000 4:7ff8000000000001 5:7ff8000000000001 end case clamp expr clamp(a, scalar(p0), scalar(p1)) @@ -45,7 +45,7 @@ expr round(a, scalar(p0)) grid 100000 10000 1 series __name__=a,i=0 ; series __name__=a,i=1 ; -param __name__=p0 ; 0:4024000000000000 +param __name__=p0 ; 0:c000000000000000 end case clamp expr clamp(a, scalar(p0), scalar(p1)) @@ -76,8 +76,8 @@ case round expr round(a, scalar(p0)) grid 100000 10000 2 series __name__=a,i=0 ; 0:405edd2f1a9fbe77 -param __name__=p0 ; 0:7ff8000000000001 1:4024000000000000 -result i=0 ; 0:7ff8000000000001 +param __name__=p0 ; 0:3ff0000000000000 1:fff0000000000000 +result i=0 ; 0:405ec00000000000 end case clamp expr clamp(a, scalar(p0), scalar(p1)) @@ -114,8 +114,8 @@ case round expr round(a, scalar(p0)) grid 100000 10000 1 series __name__=a,i=0 ; 0:405edd2f1a9fbe77 -param __name__=p0 ; 0:c000000000000000 -result i=0 ; 0:405f000000000000 +param __name__=p0 ; 0:3fe0000000000000 +result i=0 ; 0:405ee00000000000 end case clamp expr clamp(a, scalar(p0), scalar(p1)) @@ -150,9 +150,9 @@ expr round(a, scalar(p0)) grid 100000 10000 4 series __name__=a,i=0 ; 0:3ff4000000000000 1:7ff8000000000001 2:7ff0000000000000 3:7ff8000000000001 series __name__=a,i=1 ; 1:7ff0000000000000 2:3ff4000000000000 -param __name__=p0 ; 0:4024000000000000 1:4008000000000000 2:0 3:4008000000000000 -result i=0 ; 0:0 1:7ff8000000000001 2:fff8000000000000 3:7ff8000000000001 -result i=1 ; 1:7ff0000000000000 2:fff8000000000000 +param __name__=p0 ; 0:7ff0000000000000 1:fff0000000000000 2:3fe0000000000000 3:3fb999999999999a +result i=0 ; 0:fff8000000000000 1:7ff8000000000001 2:7ff0000000000000 3:7ff8000000000001 +result i=1 ; 1:fff8000000000000 2:3ff8000000000000 end case clamp expr clamp(a, scalar(p0), scalar(p1)) @@ -181,8 +181,8 @@ expr round(a, scalar(p0)) grid 100000 10000 1 series __name__=a,i=0 ; 0:3e112e0be826d695 series __name__=a,i=1 ; -param __name__=p0 ; 0:7ff0000000000000 -result i=0 ; 0:fff8000000000000 +param __name__=p0 ; 0:7ff8000000000001 +result i=0 ; 0:7ff8000000000001 end case clamp expr clamp(a, scalar(p0), scalar(p1)) @@ -213,9 +213,9 @@ expr round(a, scalar(p0)) grid 100000 10000 3 series __name__=a,i=0 ; 2:0 series __name__=a,i=1 ; 0:7ff0000000000000 2:401c000000000000 -param __name__=p0 ; 0:4024000000000000 1:7ff8000000000001 2:0 -result i=0 ; 2:fff8000000000000 -result i=1 ; 0:7ff0000000000000 2:fff8000000000000 +param __name__=p0 ; 0:4008000000000000 1:fff0000000000000 2:4008000000000000 +result i=0 ; 2:0 +result i=1 ; 0:7ff0000000000000 2:4018000000000000 end case clamp expr clamp(a, scalar(p0), scalar(p1)) @@ -246,9 +246,9 @@ expr round(a, scalar(p0)) grid 100000 10000 4 series __name__=a,i=0 ; 1:8000000000000000 3:405edd2f1a9fbe77 series __name__=a,i=1 ; 0:4004000000000000 1:fff0000000000000 2:3ff4000000000000 3:3ff4000000000000 -param __name__=p0 ; 0:7ff8000000000001 1:c000000000000000 2:3ff0000000000000 3:3fe0000000000000 -result i=0 ; 1:8000000000000000 3:405ee00000000000 -result i=1 ; 0:7ff8000000000001 1:fff0000000000000 2:3ff0000000000000 3:3ff8000000000000 +param __name__=p0 ; 0:7ff0000000000000 1:3fb999999999999a 2:fff0000000000000 3:c000000000000000 +result i=0 ; 1:0 3:405f000000000000 +result i=1 ; 0:fff8000000000000 1:fff0000000000000 2:fff8000000000000 3:4000000000000000 end case clamp expr clamp(a, scalar(p0), scalar(p1)) @@ -283,8 +283,8 @@ expr round(a, scalar(p0)) grid 100000 10000 3 series __name__=a,i=0 ; 0:fff0000000000000 1:3e112e0be826d695 2:401c000000000000 series __name__=a,i=1 ; 0:8000000000000000 2:fff0000000000000 -param __name__=p0 ; 0:4008000000000000 1:7ff8000000000001 2:4024000000000000 -result i=0 ; 0:fff0000000000000 1:7ff8000000000001 2:4024000000000000 +param __name__=p0 ; 0:3fe0000000000000 1:3fe0000000000000 2:4024000000000000 +result i=0 ; 0:fff0000000000000 1:0 2:4024000000000000 result i=1 ; 0:0 2:fff0000000000000 end case clamp @@ -323,10 +323,10 @@ grid 100000 10000 5 series __name__=a,i=0 ; 0:4004000000000000 1:7ff0000000000000 series __name__=a,i=1 ; 0:c004000000000000 1:405edd2f1a9fbe77 3:8000000000000000 series __name__=a,i=2 ; 0:0 1:3e112e0be826d695 2:3e112e0be826d695 3:405edd2f1a9fbe77 -param __name__=p0 ; 0:4008000000000000 1:7ff0000000000000 2:3fb999999999999a 3:3ff0000000000000 4:7ff0000000000000 -result i=0 ; 0:4008000000000000 1:fff8000000000000 -result i=1 ; 0:c008000000000000 1:fff8000000000000 3:0 -result i=2 ; 0:0 1:fff8000000000000 2:0 3:405ec00000000000 +param __name__=p0 ; 0:7ff0000000000000 1:4008000000000000 2:4024000000000000 3:0 4:c000000000000000 +result i=0 ; 0:fff8000000000000 1:7ff0000000000000 +result i=1 ; 0:fff8000000000000 1:405ec00000000000 3:fff8000000000000 +result i=2 ; 0:fff8000000000000 1:0 2:0 3:fff8000000000000 end case clamp expr clamp(a, scalar(p0), scalar(p1)) @@ -356,3 +356,10 @@ series __name__=a,i=0 ; 0:3e112e0be826d695 1:3e112e0be826d695 param __name__=p0 ; 0:4000000000000000 1:fff0000000000000 2:4000000000000000 result i=0 ; 0:3e112e0be826d695 1:fff0000000000000 end +case round +expr round(a, scalar(p0)) +grid 100000 10000 4 +series __name__=a,i=ties ; 0:4004000000000000 1:c004000000000000 2:3fe0000000000000 3:bfe0000000000000 +param __name__=p0 ; 0:3ff0000000000000 1:3ff0000000000000 2:3ff0000000000000 3:3ff0000000000000 +result i=ties ; 0:4008000000000000 1:c000000000000000 2:3ff0000000000000 3:0 +end diff --git a/src/promql/src/map/tests.rs b/src/promql/src/map/tests.rs index 1cc936287fd..66116e4d916 100644 --- a/src/promql/src/map/tests.rs +++ b/src/promql/src/map/tests.rs @@ -31,7 +31,7 @@ use datatypes::series::histogram::{ BucketSideView, CounterResetHint, HistogramBatch, HistogramBatchBuilder, HistogramView, }; use datatypes::series::samples::{FieldSpec, HistogramSamples, SampleRef}; -use datatypes::series::time::{EvalGrid, GridRange, GridSpec}; +use datatypes::series::time::{EvalGrid, GridRange, GridSpan, GridSpec}; use datatypes::series::{Label, MetricName}; use super::*; @@ -383,31 +383,50 @@ fn histogram_batch() -> Arc { } #[test] -fn histogram_and_mixed_fields_are_rejected_without_partial_success() { +fn histogram_samples_are_skipped_and_float_samples_mapped() { let batch = histogram_batch(); let histogram = Samples::try_from_histogram(UInt32Array::from(vec![0]), batch.clone()).unwrap(); let mixed = Samples::try_from_mixed( - Float64Array::from(vec![Some(1.0), None]), + Float64Array::from(vec![Some(-1.0), None]), HistogramSamples::try_new(UInt32Array::from(vec![None, Some(0)]), batch.clone()).unwrap(), ) .unwrap(); let null_histogram = Samples::try_from_histogram(UInt32Array::from(vec![None]), batch).unwrap(); let chunks = Samples::try_from_chunks(vec![floats(&[Some(-1.0)]), histogram.clone()]).unwrap(); - let schema = schema(&["float", "unsupported"]); - for unsupported in [histogram, mixed, null_histogram, chunks] { - let prefix = floats(&vec![Some(-2.0); unsupported.len()]); - let input = series(&schema, 2, vec![prefix, unsupported]); - for op in [MathOp::Abs, MathOp::Clamp { min: 2.0, max: 1.0 }] { - assert!(matches!( - map_math(&schema, &input, op), - Err(DataFusionError::NotImplemented(_)) - )); - } - assert_floats( - input.fields.get(0).unwrap(), - &vec![Some(-2.0); input.time.len()], + let two_fields = schema(&["float", "histograms"]); + // Prometheus' math functions process only float samples: each histogram is a point without a + // result, and the floats beside it, in its field or the other, are mapped as usual. + for (samples, expected) in [ + (histogram.clone(), vec![None]), + (mixed, vec![Some(1.0), None]), + (null_histogram, vec![None]), + (chunks, vec![Some(1.0), None]), + ] { + let len = samples.len(); + let input = series( + &two_fields, + 2, + vec![floats(&vec![Some(-2.0); len]), samples], + ); + let output = map_math(&two_fields, &input, MathOp::Abs).unwrap().unwrap(); + assert_eq!( + run_floats(&output, 0), + vec![((2, len), vec![Some(2.0); len])] + ); + assert_eq!(run_floats(&output, 1), vec![((2, len), expected)]); + assert!( + map_math(&two_fields, &input, MathOp::Clamp { min: 2.0, max: 1.0 }) + .unwrap() + .is_none() ); } + let only_histograms = schema(&["h"]); + let input = series(&only_histograms, 2, vec![histogram]); + assert!( + map_math(&only_histograms, &input, MathOp::Abs) + .unwrap() + .is_none() + ); } #[test] diff --git a/src/promql/src/map/tests/broadcast.rs b/src/promql/src/map/tests/broadcast.rs index 62d6b674155..7d8022ef967 100644 --- a/src/promql/src/map/tests/broadcast.rs +++ b/src/promql/src/map/tests/broadcast.rs @@ -468,13 +468,7 @@ fn parameterized_math_matches_prometheus() { .filter(|case| case.contains("case ")) { let lines: Vec<&str> = case.lines().filter(|line| !line.starts_with('#')).collect(); - let function = match lines[0].strip_prefix("case ").unwrap() { - "round" => ParameterizedMath::Round, - "clamp" => ParameterizedMath::Clamp, - "clamp_min" => ParameterizedMath::ClampMin, - "clamp_max" => ParameterizedMath::ClampMax, - other => panic!("unknown function {other}"), - }; + let name = lines[0].strip_prefix("case ").unwrap(); let points: u64 = lines[2].split_whitespace().nth(3).unwrap().parse().unwrap(); let grid = Arc::new( EvalGrid::try_new(GridSpec::try_new(100_000, 10_000, points).unwrap()).unwrap(), @@ -486,18 +480,17 @@ fn parameterized_math_matches_prometheus() { .into(), eval_grid: Some(grid.clone()), }); - let dense = |schema: &Arc, labels: &Labels, values: &Points| { - crate::grid_series::reference::sparse_series(schema, labels, values) - }; + let sparse_series = crate::grid_series::reference::sparse_series; let (mut inputs, mut parameters, mut expected) = (Vec::new(), Vec::new(), BTreeMap::new()); for line in &lines[3..] { let (kind, rest) = line.split_once(' ').unwrap(); let (labels, values) = parse(rest); match kind { - "series" => inputs.extend(dense(&input_schema, &labels, &values)), + "series" => inputs.extend(sparse_series(&input_schema, &labels, &values)), // A parameter is `scalar(p)`: unlabelled, with a value at every point. "param" => parameters.push( - dense(&input_schema, &Labels::new(), &values).expect("a value at every point"), + sparse_series(&input_schema, &Labels::new(), &values) + .expect("a value at every point"), ), "result" => { expected.insert(labels, values); @@ -505,11 +498,16 @@ fn parameterized_math_matches_prometheus() { other => panic!("unexpected line {other}"), } } - let parameters: Vec<&Series> = parameters.iter().collect(); + let function = match (name, parameters.as_slice()) { + ("round", [to_nearest]) => ParameterizedMath::Round { to_nearest }, + ("clamp", [min, max]) => ParameterizedMath::Clamp { min, max }, + ("clamp_min", [min]) => ParameterizedMath::ClampMin { min }, + ("clamp_max", [max]) => ParameterizedMath::ClampMax { max }, + other => panic!("unknown case {other:?}"), + }; let mut actual = BTreeMap::new(); for input in &inputs { - if let Some(output) = - map_math_with_parameters(&input_schema, input, function, ¶meters).unwrap() + if let Some(output) = map_math_with_parameters(&input_schema, input, function).unwrap() { actual.insert(labels_of(&output.metric), points_of(&output)); } @@ -538,14 +536,79 @@ fn parameterized_math_matches_prometheus() { assert!(checked >= 40, "{checked}"); } -/// The parameter count must match the function. +/// Parameters are read at each point of the input, whether they cover the grid or not: a point +/// where a parameter has no value, absent from its axis or null, has no result, and every field +/// takes the parameters of its point. #[test] -fn parameterized_math_checks_its_parameter_count() { - let schema = Arc::new(schema(&["value"])); - let series = series(&schema, 0, vec![floats(&[Some(1.0)])]); - let parameter = scalar(&schema, 0, floats(&[Some(1.0)])); - assert!( - map_math_with_parameters(&schema, &series, ParameterizedMath::Clamp, &[¶meter]) - .is_err() +fn parameters_are_read_per_point_and_missing_ones_drop_the_point() { + let schema = Arc::new(schema(&["a", "b"])); + let input = series( + &schema, + 2, + vec![ + floats(&[Some(1.4), Some(2.6), Some(3.5), Some(4.4)]), + floats(&[Some(-1.4), None, Some(-3.5), Some(9.0)]), + ], ); + // `min` covers 2..=3 and 5 with a null at 3; `max` covers 2..=5. + let min = Series::try_new( + &SeriesSchema { + fields: vec![FieldSpec { + name: "scalar".into(), + }] + .into(), + eval_grid: schema.eval_grid.clone(), + }, + MetricName::try_new(None, vec![]).unwrap(), + TimeAxis::from_grid_spans( + schema.eval_grid.clone().unwrap(), + vec![GridSpan::new(2, 2), GridSpan::new(5, 1)], + ) + .unwrap(), + SeriesFields::one(floats(&[Some(0.0), None, Some(5.0)])), + ) + .unwrap(); + let max = scalar( + &schema, + 2, + floats(&[Some(2.0), Some(2.0), Some(2.0), Some(4.0)]), + ); + let output = map_math_with_parameters( + &schema, + &input, + ParameterizedMath::Clamp { + min: &min, + max: &max, + }, + ) + .unwrap() + .unwrap(); + // Point 2 clamps both fields to [0, 2]; point 3 has a null `min`; point 4 has no `min`; point 5 + // has `max < min`, so no result. + assert_eq!(run_floats(&output, 0), vec![((2, 1), vec![Some(1.4)])]); + assert_eq!(run_floats(&output, 1), vec![((2, 1), vec![Some(0.0)])]); +} + +/// Like `map_math`, the parameterized functions skip native histogram samples. +#[test] +fn parameterized_math_skips_histogram_samples() { + let schema = Arc::new(schema(&["mixed"])); + let mixed = Samples::try_from_mixed( + Float64Array::from(vec![Some(1.26), None]), + HistogramSamples::try_new(UInt32Array::from(vec![None, Some(0)]), histogram_batch()) + .unwrap(), + ) + .unwrap(); + let input = series(&schema, 2, vec![mixed]); + let to_nearest = scalar(&schema, 2, floats(&[Some(0.5), Some(0.5)])); + let output = map_math_with_parameters( + &schema, + &input, + ParameterizedMath::Round { + to_nearest: &to_nearest, + }, + ) + .unwrap() + .unwrap(); + assert_eq!(run_floats(&output, 0), vec![((2, 1), vec![Some(1.5)])]); } From 607719dc9d8e06b58395573484d80562f9213a4f Mon Sep 17 00:00:00 2001 From: Dennis Zhuang Date: Thu, 22 Oct 2026 02:37:27 +0800 Subject: [PATCH 09/18] fix(promql): validate instant selections in one pass and format info errors like Prometheus Signed-off-by: Dennis Zhuang --- src/promql/src/grid_series.rs | 9 ++ src/promql/src/info.rs | 19 +-- src/promql/src/instant_eval.rs | 181 ++++++++++++++------------- src/promql/src/instant_eval/tests.rs | 46 ++++++- 4 files changed, 151 insertions(+), 104 deletions(-) diff --git a/src/promql/src/grid_series.rs b/src/promql/src/grid_series.rs index 42a87e131ff..15edb845c86 100644 --- a/src/promql/src/grid_series.rs +++ b/src/promql/src/grid_series.rs @@ -302,6 +302,15 @@ pub(crate) const METADATA_LABELS: [&str; 3] = ["__name__", "__type__", "__unit__ pub(crate) type LabelSet = Vec<(Arc, Arc)>; +/// A label set as Prometheus prints one in errors: `{name="value", ...}`. +pub(crate) fn render_label_set(labels: &LabelSet) -> String { + let labels: Vec = labels + .iter() + .map(|(name, value)| format!("{name}=\"{value}\"")) + .collect(); + format!("{{{}}}", labels.join(", ")) +} + /// The label set Prometheus compares, which has no empty values. pub(crate) fn label_set(metric: &MetricName) -> LabelSet { let name = metric diff --git a/src/promql/src/info.rs b/src/promql/src/info.rs index af619b9d280..85c9ce73e0c 100644 --- a/src/promql/src/info.rs +++ b/src/promql/src/info.rs @@ -33,7 +33,9 @@ use datatypes::series::MetricName; use datatypes::series::samples::{SampleRef, Series, SeriesSchema}; use regex::Regex; -use crate::grid_series::{GridSample, GridSeriesBuilder, LabelSet, external, label_set, owned}; +use crate::grid_series::{ + GridSample, GridSeriesBuilder, LabelSet, external, label_set, owned, render_label_set, +}; use crate::select::MatchOp; /// The labels an info series is joined on, as in Prometheus. @@ -171,9 +173,9 @@ pub fn info( format!( "found duplicate series for info metric: existing {} @ {}, new \ {} @ {}", - render(&label_set(&info[existing_index].metric)), + render_label_set(&label_set(&info[existing_index].metric)), existing as i64, - render(&label_set(&series.metric)), + render_label_set(&label_set(&series.metric)), timestamp as i64 ) }); @@ -281,7 +283,7 @@ pub fn info( error.record(point, 2, || { format!( "vector cannot contain metrics with the same labelset {}", - render(&label_set(metric)) + render_label_set(&label_set(metric)) ) }); } @@ -340,15 +342,6 @@ impl FirstError { } } -/// A label set as Prometheus prints one. -fn render(labels: &LabelSet) -> String { - let labels: Vec = labels - .iter() - .map(|(name, value)| format!("{name}=\"{value}\"")) - .collect(); - format!("{{{}}}", labels.join(", ")) -} - /// An info metric name and the identifying label values present on a series. type Signature = (Arc, Vec>>); diff --git a/src/promql/src/instant_eval.rs b/src/promql/src/instant_eval.rs index 887a0816b88..2b980fe5c3d 100644 --- a/src/promql/src/instant_eval.rs +++ b/src/promql/src/instant_eval.rs @@ -21,7 +21,7 @@ use datatypes::series::MetricName; use datatypes::series::raw::RawTimestamps; use datatypes::series::samples::{SampleRef, Series, SeriesFields, SeriesSchema}; -use crate::grid_series::GridSeriesBuilder; +use crate::grid_series::{GridSeriesBuilder, label_set, render_label_set}; use crate::map::drop_metric_name; #[cfg(test)] @@ -153,9 +153,13 @@ pub fn select_instants( /// which sample is selected but not the time reported. The metric name, `__type__` and `__unit__` /// are dropped. /// -/// `indices` and `evaluation_timestamps` are what [`select_instants`] returns for `time` and -/// `fields`; `schema` holds the evaluation grid the timestamps lie on and one field per field of -/// `fields`. A field without a sample in the selected row has no result there. +/// `indices` and `evaluation_timestamps` are a selection of `time` and `fields` as +/// [`select_instants`] returns one: grid positions increasing, rows not decreasing. `schema` holds +/// the evaluation grid the timestamps lie on and one field per field of `fields`. A field without a +/// sample in the selected row has no result there. +/// +/// `timestamp(x @ t)` selects at `t` for every point, so its selection repeats one row; Prometheus +/// ignores an offset next to `@` here, while `info()` applies it. pub fn timestamp_of_selection( schema: &Arc, metric: &MetricName, @@ -164,9 +168,13 @@ pub fn timestamp_of_selection( indices: &[u64], evaluation_timestamps: &[i64], ) -> Result> { - let selection = Selection::try_new(schema, time, fields, indices, evaluation_timestamps)?; - selection.project( + project_selection( + schema, drop_metric_name(metric)?, + time, + fields, + indices, + evaluation_timestamps, |_, time| Ok(time as f64 / 1000.0), ) } @@ -184,13 +192,21 @@ pub fn info_sample_times( indices: &[u64], evaluation_timestamps: &[i64], ) -> Result> { - let selection = Selection::try_new(schema, time, fields, indices, evaluation_timestamps)?; - selection.project(metric.clone(), |sample, time| match sample { - SampleRef::Histogram(_) => Err(DataFusionError::Execution(format!( - "this should be an info metric, with float samples: {metric:?}" - ))), - _ => Ok(time as f64), - }) + project_selection( + schema, + metric.clone(), + time, + fields, + indices, + evaluation_timestamps, + |sample, time| match sample { + SampleRef::Histogram(_) => Err(DataFusionError::Execution(format!( + "this should be an info metric, with float samples: {}", + render_label_set(&label_set(metric)) + ))), + _ => Ok(time as f64), + }, + ) } /// `timestamp(v)` over an evaluated `v`, such as `timestamp(abs(x))`: the evaluation time in @@ -228,94 +244,79 @@ pub fn timestamp_of_evaluated( builder.finish(drop_metric_name(&series.metric)?) } -/// A checked instant selection: the grid position and source row of each selected point. -struct Selection<'a> { - schema: &'a Arc, - time: &'a RawTimestamps, - fields: &'a SeriesFields, - /// `(grid position, source row)` per selected point, positions increasing. - points: Vec<(usize, usize)>, -} - -impl<'a> Selection<'a> { - fn try_new( - schema: &'a Arc, - time: &'a RawTimestamps, - fields: &'a SeriesFields, - indices: &[u64], - evaluation_timestamps: &[i64], - ) -> Result { - let grid = schema.eval_grid.as_ref().ok_or_else(|| { - DataFusionError::Execution("an instant selection requires an evaluation grid".into()) - })?; - if indices.len() != evaluation_timestamps.len() || fields.len() != schema.fields.len() { - return Err(DataFusionError::Execution( - "instant selection does not match its series or schema".into(), - )); - } - let spec = grid.spec(); - let mut points = Vec::with_capacity(indices.len()); - for (&row, ×tamp) in indices.iter().zip(evaluation_timestamps) { - let offset = timestamp as i128 - spec.start_ms as i128; - let position = offset / spec.step_ms as i128; - let row = usize::try_from(row).ok().filter(|row| *row < time.len()); - match row { - Some(row) - if offset % spec.step_ms as i128 == 0 - && (0..spec.len as i128).contains(&position) - && points.last().is_none_or(|&(last, last_row)| { - (last as i128) < position && last_row <= row - }) => - { - points.push((position as usize, row)); - } - _ => { - return Err(DataFusionError::Execution( - "instant selection points off the grid or out of the series".into(), - )); - } - } - } - Ok(Self { - schema, - time, - fields, - points, - }) +/// One series over an instant selection, each field holding `value(sample, original time)` where +/// the selected row has a sample for it. +/// +/// The selection is `indices` and `evaluation_timestamps` as [`select_instants`] returns them: one +/// source row and one grid timestamp per selected point, with positions increasing and rows not +/// decreasing. An `@` selection, which reads one row at every point, repeats that row; it is the +/// caller's to build. A selection off the grid, going back, or naming a row the series does not +/// have fails. +fn project_selection( + schema: &Arc, + metric: MetricName, + time: &RawTimestamps, + fields: &SeriesFields, + indices: &[u64], + evaluation_timestamps: &[i64], + value: impl Fn(SampleRef<'_>, i64) -> Result, +) -> Result> { + let grid = schema.eval_grid.as_ref().ok_or_else(|| { + DataFusionError::Execution("an instant selection requires an evaluation grid".into()) + })?; + if indices.len() != evaluation_timestamps.len() + || fields.len() != schema.fields.len() + || fields.iter().any(|field| field.len() != time.len()) + { + return Err(DataFusionError::Execution( + "instant selection does not match its series or schema".into(), + )); } - - /// One series over the selected points, each field holding `value(sample, original time)` - /// where its selected sample is present. - fn project( - &self, - metric: MetricName, - value: impl Fn(SampleRef<'_>, i64) -> Result, - ) -> Result> { - let mut builder = GridSeriesBuilder::try_new(self.schema.clone())?; - let mut cursors: Vec<_> = self.fields.iter().map(|field| field.cursor()).collect(); - // Consecutive points often select the same row, which a cursor reads only once. - let mut row: Vec> = Vec::with_capacity(self.fields.len()); - let mut row_index = None; - for &(position, index) in &self.points { - if row_index != Some(index) { - let time = self - .time - .timestamp(index) - .expect("checked against the axis"); + let spec = grid.spec(); + let mut builder = GridSeriesBuilder::try_new(schema.clone())?; + let mut cursors: Vec<_> = fields.iter().map(|field| field.cursor()).collect(); + // Consecutive points often select the same row, which a cursor reads only once. + let mut row: Vec> = Vec::with_capacity(fields.len()); + let mut last: Option<(i128, usize)> = None; + for (&index, ×tamp) in indices.iter().zip(evaluation_timestamps) { + let offset = timestamp as i128 - spec.start_ms as i128; + let position = offset / spec.step_ms as i128; + let index = usize::try_from(index) + .ok() + .filter(|index| *index < time.len()); + let (Some(index), true) = ( + index, + offset % spec.step_ms as i128 == 0 + && (0..spec.len as i128).contains(&position) + && last.is_none_or(|(last_position, _)| last_position < position), + ) else { + return Err(DataFusionError::Execution( + "instant selection points off the grid or out of the series".into(), + )); + }; + match last { + Some((_, last_index)) if last_index == index => {} + Some((_, last_index)) if last_index > index => { + return Err(DataFusionError::Execution( + "instant selection rows must not go back".into(), + )); + } + _ => { + let time = time.timestamp(index).expect("checked against the axis"); row.clear(); for cursor in &mut cursors { let sample = cursor .at(index) - .expect("selected rows only increase and lie in the fields"); + .expect("rows increase and lie in fields as long as the axis"); row.push(match sample { SampleRef::Missing => None, sample => Some(value(sample, time)?), }); } - row_index = Some(index); } - builder.push(position, &row)?; } - builder.finish(metric) + last = Some((position, index)); + builder.push(position as usize, &row)?; } + builder.finish(metric) } diff --git a/src/promql/src/instant_eval/tests.rs b/src/promql/src/instant_eval/tests.rs index a85b8b48022..0426b1ca2e7 100644 --- a/src/promql/src/instant_eval/tests.rs +++ b/src/promql/src/instant_eval/tests.rs @@ -426,8 +426,14 @@ fn info_sample_times_keep_milliseconds_labels_and_missing_fields() { let schema = grid_schema(1, 100, 10, 1); let time = axis(&[95]); let (indices, timestamps) = select_instants(&time, &histograms, 100..=100, 10, 20, 0).unwrap(); + let error = info_sample_times(&schema, &metric, &time, &histograms, &indices, ×tamps) + .unwrap_err() + .to_string(); assert!( - info_sample_times(&schema, &metric, &time, &histograms, &indices, ×tamps).is_err() + error.contains( + r#"this should be an info metric, with float samples: {__name__="target_info", job="x"}"# + ), + "{error}" ); assert!( timestamp_of_selection(&schema, &metric, &time, &histograms, &indices, ×tamps) @@ -458,3 +464,41 @@ fn selections_off_the_grid_or_going_back_are_rejected() { ); } } + +/// Fields shorter than the time axis are refused, not read past their end. +#[test] +fn a_selection_over_fields_shorter_than_the_axis_fails() { + let schema = grid_schema(1, 100, 10, 3); + let time = axis(&[95, 107]); + let fields = SeriesFields::one(Samples::from_float(Float64Array::from(vec![1.0]))); + let metric = metric_of(&Labels::new()); + assert!(timestamp_of_selection(&schema, &metric, &time, &fields, &[1], &[110]).is_err()); +} + +/// The evaluation time is reported per field: a field without a value at a point has no result +/// there, while the other field does. +#[test] +fn evaluated_timestamps_follow_each_field() { + let schema = grid_schema(2, 100, 10, 3); + let mut builder = GridSeriesBuilder::try_new(schema.clone()).unwrap(); + builder.push(0, &[Some(1.0), None]).unwrap(); + builder.push(2, &[Some(1.0), Some(5.0)]).unwrap(); + let evaluated = builder + .finish(metric_of(&Labels::from([( + "__name__".to_string(), + "a".to_string(), + )]))) + .unwrap() + .unwrap(); + let result = timestamp_of_evaluated(&schema, &evaluated) + .unwrap() + .unwrap(); + assert!(result.metric.name().is_none()); + assert_eq!( + values_by_position(&result), + [ + (0, vec![Some(0.1f64.to_bits()), None]), + (2, vec![Some(0.12f64.to_bits()), Some(0.12f64.to_bits())]), + ] + ); +} From d79a5f6bfe58ddbb001f6f947430fb5aa3dcf39d Mon Sep 17 00:00:00 2001 From: Dennis Zhuang Date: Thu, 22 Oct 2026 02:38:52 +0800 Subject: [PATCH 10/18] test(promql): keep Go float format tests with go_format and pin histogram_quantiles constants Signed-off-by: Dennis Zhuang --- src/promql/src/aggregate/tests.rs | 22 ----------------- .../float_cases.txt} | 0 src/promql/src/go_format/tests.rs | 24 ++++++++++++++++++- src/promql/src/histogram_eval.rs | 3 +++ src/promql/src/histogram_eval/tests.rs | 2 +- 5 files changed, 27 insertions(+), 24 deletions(-) rename src/promql/src/{aggregate/format_float_cases.txt => go_format/float_cases.txt} (100%) diff --git a/src/promql/src/aggregate/tests.rs b/src/promql/src/aggregate/tests.rs index 9d71cb0ca48..eec4e8de068 100644 --- a/src/promql/src/aggregate/tests.rs +++ b/src/promql/src/aggregate/tests.rs @@ -898,28 +898,6 @@ fn an_aggregation_of_sparse_inputs_is_compared_in_full_against_the_reference() { assert_eq!(piece_ranges(&counts), vec![(2, 1), (6, 1)]); } -/// `count_values` writes value labels the way Go's `strconv.FormatFloat(v, 'f', -1, 64)` does, -/// including the round-half-even choice of the last shortest digit; see the header of -/// `format_float_cases.txt` (promql-compat `cmd/narthex-golden-formatfloat`). -#[test] -fn value_labels_format_like_go() { - let mut checked = 0; - for line in include_str!("format_float_cases.txt").lines() { - let (bits, expected) = line.split_once(' ').unwrap(); - let value = f64::from_bits(u64::from_str_radix(bits, 16).unwrap()); - assert_eq!(crate::go_format::format_float_f(value), expected, "{bits}"); - checked += 1; - } - assert!(checked > 2_400, "{checked}"); - for (value, expected) in [ - (f64::NAN, "NaN"), - (f64::INFINITY, "+Inf"), - (f64::NEG_INFINITY, "-Inf"), - ] { - assert_eq!(crate::go_format::format_float_f(value), expected); - } -} - /// A point where the `q` scalar has no value has no `quantile` result, as a missing scalar point /// yields no output in the other scalar operators. #[test] diff --git a/src/promql/src/aggregate/format_float_cases.txt b/src/promql/src/go_format/float_cases.txt similarity index 100% rename from src/promql/src/aggregate/format_float_cases.txt rename to src/promql/src/go_format/float_cases.txt diff --git a/src/promql/src/go_format/tests.rs b/src/promql/src/go_format/tests.rs index ef7a02591a4..a8ab2d7a14e 100644 --- a/src/promql/src/go_format/tests.rs +++ b/src/promql/src/go_format/tests.rs @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -use crate::go_format::format_open_metrics_float; +use crate::go_format::{format_float_f, format_open_metrics_float}; /// Prometheus `labels.FormatOpenMetricsFloat`; `open_metrics_cases.txt` comes from promql-compat /// `narthex-golden-formatfloat -openmetrics` as ` `. @@ -27,3 +27,25 @@ fn open_metrics_floats_format_like_prometheus() { } assert!(checked > 300, "{checked}"); } + +/// Go's `strconv.FormatFloat(v, 'f', -1, 64)`, the form `count_values` writes value labels in, +/// including the round-half-even choice of the last shortest digit and the round-trip check at +/// powers of two; `float_cases.txt` comes from promql-compat `narthex-golden-formatfloat`. +#[test] +fn floats_format_like_go() { + let mut checked = 0; + for line in include_str!("float_cases.txt").lines() { + let (bits, expected) = line.split_once(' ').unwrap(); + let value = f64::from_bits(u64::from_str_radix(bits, 16).unwrap()); + assert_eq!(format_float_f(value), expected, "{bits}"); + checked += 1; + } + assert!(checked > 2_400, "{checked}"); + for (value, expected) in [ + (f64::NAN, "NaN"), + (f64::INFINITY, "+Inf"), + (f64::NEG_INFINITY, "-Inf"), + ] { + assert_eq!(format_float_f(value), expected); + } +} diff --git a/src/promql/src/histogram_eval.rs b/src/promql/src/histogram_eval.rs index 4aa577fc54a..4a53635c52a 100644 --- a/src/promql/src/histogram_eval.rs +++ b/src/promql/src/histogram_eval.rs @@ -62,6 +62,9 @@ pub enum HistogramFunction { /// writes it. Results that then share a label set merge as [`merge_same_labelset`] does, so a /// repeated `q`, or a `label` that overwrites the label telling two histograms apart, fails at a /// point both hold. +/// +/// Each `q` is a constant. Prometheus evaluates the quantile arguments at every step, so a +/// quantile that varies over time is not this kernel's to evaluate. pub fn histogram_quantiles( label: &str, quantiles: &[f64], diff --git a/src/promql/src/histogram_eval/tests.rs b/src/promql/src/histogram_eval/tests.rs index 052dd80e0d6..41db4cd3ba5 100644 --- a/src/promql/src/histogram_eval/tests.rs +++ b/src/promql/src/histogram_eval/tests.rs @@ -151,7 +151,7 @@ fn histogram_quantiles_match_prometheus() { let actual = histogram_quantiles(&case.spec[1], &quantiles, &schema, &input); failed += usize::from(assert_outcome(case, actual, &case.outcome)); } - assert!(failed > 5, "{failed} failed"); + assert_eq!(failed, 13, "{failed} failed"); } #[test] From bc782ac5da2d283307b2e0756ee6932d0616ec58 Mon Sep 17 00:00:00 2001 From: Dennis Zhuang Date: Thu, 22 Oct 2026 06:05:38 +0800 Subject: [PATCH 11/18] feat(promql): count native histograms in count_values by their Prometheus text Signed-off-by: Dennis Zhuang --- src/promql/src/aggregate.rs | 55 +++++++++++----- src/promql/src/aggregate/tests.rs | 45 +++++++++++++ src/promql/src/go_format.rs | 45 +++++++------ src/promql/src/histogram_eval.rs | 6 ++ src/promql/src/histogram_eval/native.rs | 63 +++++++++++++++++++ .../src/histogram_eval/string_cases.txt | 47 ++++++++++++++ src/promql/src/histogram_eval/tests.rs | 17 ++++- src/promql/src/map.rs | 19 ++++-- 8 files changed, 256 insertions(+), 41 deletions(-) create mode 100644 src/promql/src/histogram_eval/string_cases.txt diff --git a/src/promql/src/aggregate.rs b/src/promql/src/aggregate.rs index d164cf3bac5..36d2cba4a9a 100644 --- a/src/promql/src/aggregate.rs +++ b/src/promql/src/aggregate.rs @@ -30,7 +30,8 @@ use datatypes::series::samples::{SampleRef, Series, SeriesSchema}; use crate::go_format::format_float_f; use crate::grid_series::{GridSeriesBuilder, external}; -use crate::map::{drop_empty_labels, validate_float_series, validate_scalar}; +use crate::histogram_eval::histogram_string; +use crate::map::{drop_empty_labels, validate_float_series, validate_grid_series, validate_scalar}; use crate::range_eval::quantile; /// Float aggregations without a parameter. @@ -263,7 +264,8 @@ pub fn aggregate_quantile( /// `count_values(label, v)`: at each grid point, the number of samples in each group of series /// that share a value, with the value written to `label` in Prometheus' `FormatFloat(v, 'f', -1)` -/// form. With `by`, `label` is part of the grouping; with `without`, it is dropped if listed. +/// form, or for a native histogram as its `FloatHistogram.String`. With `by`, `label` is part of +/// the grouping; with `without`, it is dropped if listed. pub fn count_values( schema: &Arc, input: &[Series], @@ -284,7 +286,11 @@ pub fn count_values( } without => without, }; - validate_input(schema, input, checked)?; + // Unlike the other aggregations, this one reads histogram samples too. + validate_grouping(schema, checked)?; + for series in input { + validate_grid_series(schema, series)?; + } if label.is_empty() { return Err(DataFusionError::Execution(format!( "invalid label name {label:?}" @@ -305,20 +311,26 @@ pub fn count_values( let mut groups: Vec<(MetricName, BTreeMap>)> = Vec::new(); for series in input { // Equal bits format to the same label value, so each distinct value of a series - // resolves its group once. - let mut group_of_value: HashMap = HashMap::new(); + // resolves its group once. A histogram is keyed by the text it is written as. + let mut group_of_value: HashMap = HashMap::new(); for (field_index, field) in series.fields.iter().enumerate() { for ((point, _), sample) in series.time.positions().zip(field.iter()) { - let SampleRef::Float(value) = sample else { - continue; + let key = match sample { + SampleRef::Missing => continue, + SampleRef::Float(value) => Value::Float(value.to_bits()), + SampleRef::Histogram(h) => Value::Histogram(histogram_string(&h)?), }; - let group = match group_of_value.entry(value.to_bits()) { + let group = match group_of_value.entry(key) { Entry::Occupied(entry) => *entry.get(), Entry::Vacant(entry) => { + // Prometheus writes a float in Go's `'f'` form and a histogram as + // `FloatHistogram.String` does. + let text = match entry.key() { + Value::Float(bits) => format_float_f(f64::from_bits(*bits)), + Value::Histogram(text) => text.clone(), + }; let mut metric = series.metric.clone(); - metric - .set(label, Arc::from(format_float_f(value))) - .map_err(external)?; + metric.set(label, Arc::from(text)).map_err(external)?; let metric = grouping.apply(&metric)?; let index = *group_index.entry(label_key(&metric)).or_insert_with(|| { groups.push((metric, BTreeMap::new())); @@ -349,18 +361,29 @@ pub fn count_values( Ok(output) } +/// The value of a `count_values` sample, as it is grouped. +#[derive(PartialEq, Eq, Hash)] +enum Value { + Float(u64), + Histogram(String), +} + /// Schema validation is repeated because Series exposes its fields publicly. fn validate_input(schema: &SeriesSchema, input: &[Series], grouping: Grouping<'_>) -> Result<()> { + validate_grouping(schema, grouping)?; + for series in input { + validate_float_series(schema, series)?; + } + Ok(()) +} + +fn validate_grouping(schema: &SeriesSchema, grouping: Grouping<'_>) -> Result<()> { if schema.eval_grid.is_none() { return Err(DataFusionError::Execution( "Aggregate requires an evaluated grid".into(), )); } - grouping.validate(schema)?; - for series in input { - validate_float_series(schema, series)?; - } - Ok(()) + grouping.validate(schema) } impl Grouping<'_> { diff --git a/src/promql/src/aggregate/tests.rs b/src/promql/src/aggregate/tests.rs index eec4e8de068..c6b17bf2039 100644 --- a/src/promql/src/aggregate/tests.rs +++ b/src/promql/src/aggregate/tests.rs @@ -1042,3 +1042,48 @@ fn count_values_keeps_neighbours_of_a_power_of_two_apart() { ] ); } + +/// Prometheus counts a histogram sample under the text `FloatHistogram.String` writes it as, so +/// equal histograms share a group and a float beside them has its own. +#[test] +fn count_values_counts_histograms_by_their_text() { + use crate::grid_series::reference::OwnedHistogram; + use crate::grid_series::{GridSample, GridSeriesBuilder}; + use crate::histogram_eval::float_histogram::FloatHistogram; + use crate::histogram_eval::histogram_string; + + let schema = schema_with_grid_len(&["value"], 1); + let histogram = + OwnedHistogram::parse("0/0/0/4000000000000000/3ff0000000000000/0/1_1/4000000000000000///"); + let text = histogram_string(&histogram.view()).unwrap(); + let series = |job: &str, sample: GridSample| { + let mut builder = GridSeriesBuilder::try_new(schema.clone()).unwrap(); + builder.push_samples(0, &[Some(sample)]).unwrap(); + builder + .finish(metric("m", &[("job", job)])) + .unwrap() + .unwrap() + }; + let sample = || { + GridSample::Histogram(Box::new( + FloatHistogram::try_from_view(&histogram.view()).unwrap(), + )) + }; + let input = [ + series("a", sample()), + series("b", sample()), + series("c", GridSample::Float(2.0)), + ]; + let mut counts: Vec<(String, f64)> = count_values(&schema, &input, "v", Grouping::By(&[])) + .unwrap() + .iter() + .map(|series| { + let SampleRef::Float(count) = series.fields.as_slice()[0].sample(0) else { + panic!("a count is a float"); + }; + (series.metric.get("v").unwrap().to_string(), count) + }) + .collect(); + counts.sort_by(|a, b| a.0.cmp(&b.0)); + assert_eq!(counts, [("2".to_string(), 1.0), (text, 2.0)]); +} diff --git a/src/promql/src/go_format.rs b/src/promql/src/go_format.rs index bf19922df25..1c3e640a442 100644 --- a/src/promql/src/go_format.rs +++ b/src/promql/src/go_format.rs @@ -44,31 +44,38 @@ pub(crate) fn format_open_metrics_float(value: f64) -> String { if let Some(text) = special(value) { return text.to_string(); } - let (negative, digits, point) = shortest(value); - // The shortest `'g'` format switches to an exponent below 1e-4 and from 1e6 on. - let exponent = point - 1; - let mut text = if !(-4..6).contains(&exponent) { - let mut text = String::from(if negative { "-" } else { "" }); - text.push_str(&digits[..1]); - if digits.len() > 1 { - text.push('.'); - text.push_str(&digits[1..]); - } - text.push_str(&format!( - "e{}{:02}", - if exponent < 0 { '-' } else { '+' }, - exponent.abs() - )); - text - } else { - positional(negative, &digits, point) - }; + let mut text = format_float_g(value); if !text.contains(['e', '.']) { text.push_str(".0"); } text } +/// Go's `strconv.FormatFloat(value, 'g', -1, 64)`, which `%g` and `%v` print a float in: the +/// shortest digits, with an exponent of at least two digits below 1e-4 and from 1e6 on. +pub(crate) fn format_float_g(value: f64) -> String { + if let Some(text) = special(value) { + return text.to_string(); + } + let (negative, digits, point) = shortest(value); + let exponent = point - 1; + if (-4..6).contains(&exponent) { + return positional(negative, &digits, point); + } + let mut text = String::from(if negative { "-" } else { "" }); + text.push_str(&digits[..1]); + if digits.len() > 1 { + text.push('.'); + text.push_str(&digits[1..]); + } + text.push_str(&format!( + "e{}{:02}", + if exponent < 0 { '-' } else { '+' }, + exponent.abs() + )); + text +} + fn special(value: f64) -> Option<&'static str> { match value { f64::INFINITY => Some("+Inf"), diff --git a/src/promql/src/histogram_eval.rs b/src/promql/src/histogram_eval.rs index 4a53635c52a..d38b860c99d 100644 --- a/src/promql/src/histogram_eval.rs +++ b/src/promql/src/histogram_eval.rs @@ -254,6 +254,12 @@ fn has_histograms(series: &Series) -> bool { }) } +/// A native histogram as Go's `FloatHistogram.String` writes it, the label value `count_values` +/// gives a histogram sample. +pub(crate) fn histogram_string(h: &HistogramView<'_>) -> Result { + native::go_string(h) +} + /// A function of one native histogram. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum NativeHistogramFunction { diff --git a/src/promql/src/histogram_eval/native.rs b/src/promql/src/histogram_eval/native.rs index 4ec4dcc2c31..cc72515a1d7 100644 --- a/src/promql/src/histogram_eval/native.rs +++ b/src/promql/src/histogram_eval/native.rs @@ -19,6 +19,7 @@ use datafusion::error::{DataFusionError, Result}; use datatypes::series::histogram::{BucketSideView, HistogramView}; use crate::functions::kahan_inc; +use crate::go_format::format_float_g; use crate::histogram_eval::bounds::EXPONENTIAL_BOUNDS; pub(super) const CUSTOM_BUCKETS_SCHEMA: i32 = -53; @@ -28,6 +29,8 @@ pub(super) struct NativeBucket { lower: f64, upper: f64, count: f64, + /// The bucket's index on its side; meaningless for the zero bucket. + index: i32, } /// The buckets of `h` from the lowest to the highest, or the reverse: negative buckets, the zero @@ -57,6 +60,7 @@ pub(super) fn all_buckets( lower: -h.zero_threshold, upper: h.zero_threshold, count: h.zero_count, + index: 0, }); } side(h, high, !reverse, out)?; @@ -107,16 +111,74 @@ fn bucket(h: &HistogramView<'_>, idx: i32, count: f64, positive: bool) -> Result lower: bound(idx - 1)?, upper: bound(idx)?, count, + index: idx, } } else { NativeBucket { lower: -bound(idx)?, upper: -bound(idx - 1)?, count, + index: idx, } }) } +/// Go `FloatHistogram.String`, the form `count_values` writes a histogram value in: the count and +/// sum, then the buckets holding observations from the lowest up -- negative, zero, positive -- +/// each as `[lower,upper):count` with its bounds' inclusiveness, every number in Go's `%g`. +/// Unlike [`all_buckets`], the buckets keep the bounds their index gives, unclipped by the zero +/// bucket. +pub(super) fn go_string(h: &HistogramView<'_>) -> Result { + let mut text = format!( + "{{count:{}, sum:{}", + format_float_g(h.count), + format_float_g(h.sum) + ); + let custom = h.schema == CUSTOM_BUCKETS_SCHEMA; + let mut buckets = Vec::new(); + side(h, h.negative, false, &mut buckets)?; + for bucket in buckets.iter().rev().filter(|bucket| bucket.count != 0.0) { + push_bucket(&mut text, bucket, custom); + } + if h.zero_count != 0.0 { + if custom { + return Err(DataFusionError::Execution( + "histograms with custom buckets have no zero bucket".into(), + )); + } + text.push_str(&format!( + ", [{},{}]:{}", + format_float_g(-h.zero_threshold), + format_float_g(h.zero_threshold), + format_float_g(h.zero_count) + )); + } + buckets.clear(); + side(h, h.positive, true, &mut buckets)?; + for bucket in buckets.iter().filter(|bucket| bucket.count != 0.0) { + push_bucket(&mut text, bucket, custom); + } + text.push('}'); + Ok(text) +} + +/// Go `Bucket.String`, with the inclusiveness `baseBucketIterator.at` gives the bucket. +fn push_bucket(text: &mut String, bucket: &NativeBucket, custom: bool) { + let (lower_inclusive, upper_inclusive) = if custom { + (bucket.index == 0, true) + } else { + (bucket.lower < 0.0, bucket.upper > 0.0) + }; + text.push_str(&format!( + ", {}{},{}{}:{}", + if lower_inclusive { '[' } else { '(' }, + format_float_g(bucket.lower), + format_float_g(bucket.upper), + if upper_inclusive { ']' } else { ')' }, + format_float_g(bucket.count) + )); +} + /// Go `getBound`. pub(super) fn bound(idx: i32, schema: i32, custom_values: &[f64]) -> Result { if schema == CUSTOM_BUCKETS_SCHEMA { @@ -203,6 +265,7 @@ pub(super) fn quantile( lower: 0.0, upper: 0.0, count: 0.0, + index: 0, }; let mut count = 0.0; let mut visited = 0; diff --git a/src/promql/src/histogram_eval/string_cases.txt b/src/promql/src/histogram_eval/string_cases.txt new file mode 100644 index 00000000000..fa6870f843a --- /dev/null +++ b/src/promql/src/histogram_eval/string_cases.txt @@ -0,0 +1,47 @@ +0/0/4000000000000000/4014000000000000/400c000000000000/0/0_3/3ff0000000000000,0,4000000000000000/// {count:5, sum:3.5, [-0,0]:2, (0.5,1]:1, (2,4]:2} +1/3f50624dd2f1a9fc/0/4132d68700000000/be7ad7f29abcaf48/0/20_1/0/-2_2,3_1/3ff0000000000000,4000000000000000,4132d68400000000/ {count:1.234567e+06, sum:-1e-07, [-2.82842712474619,-2):1.234564e+06, [-0.7071067811865475,-0.5):2, [-0.5,-0.35355339059327373):1} +-53/0/0/4018000000000000/4028000000000000/0/0_3/3ff0000000000000,4000000000000000,4008000000000000///3ff0000000000000,4014000000000000 {count:6, sum:12, [-Inf,1]:1, (1,5]:2, (5,+Inf]:3} +-53/0/0/4000000000000000/7ff8000000000001/0/1_2/3fe0000000000000,3ff8000000000000///bff0000000000000,0,3fe0000000000000 {count:2, sum:NaN, (-1,0]:0.5, (0,0.5]:1.5} +3/0/0/7ff0000000000000/fff0000000000000/0///// {count:+Inf, sum:-Inf} +0/0/0/4008000000000000/4000000000000000/0/0_1,0_0,2_1/3ff0000000000000,7ff8000000000001/// {count:3, sum:2, (0.5,1]:1, (4,8]:NaN} +-2/3fe0000000000000/3ff0000000000000/4008000000000000/3ff0000000000000/0/-1_2/3ff0000000000000,3ff0000000000000/// {count:3, sum:1, [-0.5,0.5]:1, (0.00390625,0.0625]:1, (0.0625,1]:1} +3/3fe0000000000000/0/4055001175c76074/40adb352ce0f464f/3/-2_1/0/-19_1,2_4/4042800000000000,3f2aa1d1faf86ea9,0,3f4c431a422a645b,4047800000000000/ {count:84.00106567831409, sum:3801.6617283604933, [-0.3242098886627524,-0.29730177875068026):47, [-0.29730177875068026,-0.2726269331663144):0.0008624914374478864, [-0.25,-0.22925101080116778):0.00020318687664732285, [-0.1927763531759926,-0.17677669529663687):37} +-53/0/0/405bc00000000000/c068dcd4a171fac8/3/0_2/4040800000000000,4053800000000000///3ff0000000000000,4014000000000000,4024000000000000 {count:111, sum:-198.90095588933605, [-Inf,1]:33, (1,5]:78} +0/255bba08cf8c979d/0/0/8000000000000000/1///// {count:0, sum:-0} +5/255bba08cf8c979d/404c000000000000/404c000000000000/40a0bdb814b160c1/3///// {count:56, sum:2142.8595328741653, [-1e-128,1e-128]:56} +-1/255bba08cf8c979d/3f36cdb2e4f0a26e/3f52159408fe4cb1/bf9324164071cb30/3/4_1/3f48c44e9f84482b/// {count:0.0011037774711198207, sum:-0.018692348178272755, [-1e-128,1e-128]:0.0003479539636282229, (64,256]:0.0007558235074915978} +-1/3f847ae147ae147b/0/406308039013db44/40b9864b9433bdfa/3/-9_1,0_4/0,4053a00000000000,4014000000000000,3f3c809eda1bc597,4051300000000000/// {count:152.2504349124739, sum:6534.2952301348505, (3.814697265625e-06,1.52587890625e-05]:78.5, (1.52587890625e-05,6.103515625e-05]:5, (6.103515625e-05,0.000244140625]:0.0004349124738914577, (0.000244140625,0.0009765625]:68.75} +-2/0/0/407a5c05888d260f/40d2aa08d6c06f03/1/-7_1,1_4,1_4/0,3f43aa8dcd5315f5,4053c00000000000,4051800000000000,4055800000000000,4059700000000000,0,4055400000000000,3f4899db6326e870/// {count:421.7513509286518, sum:19112.138107403192, (5.960464477539063e-08,9.5367431640625e-07]:0.0006001655953233308, (9.5367431640625e-07,1.52587890625e-05]:79, (1.52587890625e-05,0.000244140625]:70, (0.000244140625,0.00390625]:86, (0.0625,1]:101.75, (16,256]:85, (256,4096]:0.0007507630564795986} +-4/3fe0000000000000/0/0/0/3///// {count:0, sum:0} +-1/255bba08cf8c979d/4014000000000000/4073bc05b4869dbb/40d03bb730a8c0e4/0/-7_3/3f483e1bc460aff5,4053800000000000,0/-17_4,1_3/0,4044000000000000,3f4566192979cc81,4054700000000000,4043800000000000,402a000000000000,404d800000000000/ {count:315.75139286598613, sum:16622.862344921465, [-9.5367431640625e-07,-2.384185791015625e-07):59, [-2.384185791015625e-07,-5.960464477539063e-08):13, [-5.960464477539063e-08,-1.4901161193847656e-08):39, [-3.725290298461914e-09,-9.313225746154785e-10):81.75, [-9.313225746154785e-10,-2.3283064365386963e-10):0.0006530402051353608, [-2.3283064365386963e-10,-5.820766091346741e-11):40, [-1e-128,1e-128]:5, (1.52587890625e-05,6.103515625e-05]:0.0007398257810158337, (6.103515625e-05,0.000244140625]:78} +0/3f50624dd2f1a9fc/404f000000000000/4076e803fd306cca/40ca9dc9569918a8/0///-4_1,2_2,0_4/403f600000000000,3f4fe983664f7c80,0,405d880000000000,4026000000000000,4056000000000000,404c000000000000/ {count:366.5009738818741, sum:13627.572955262222, [-16,-8):56, [-8,-4):88, [-4,-2):11, [-2,-1):118.125, [-0.5,-0.25):0.0009738818740706728, [-0.0625,-0.03125):31.375, [-0.001,0.001]:62} +1/0/0/406c800000000000/40cb50bfd34524c0/0/3_2,1_1,1_1/404e000000000000,403d000000000000,404e000000000000,4053c00000000000/// {count:228, sum:13985.498634951538, (2,2.82842712474619]:60, (2.82842712474619,4]:29, (5.65685424949238,8]:60, (11.31370849898476,16]:79} +2/0/0/4054480000000000/40b0f29c9a52a00d/0/-13_1/4054480000000000/// {count:81.125, sum:4338.611729778361, (0.08838834764831843,0.1051120519067143]:81.125} +-4/0/3f4b4dbd2c7610e7/40826a048a221018/40a75eb15b3b6ea7/2/-15_3,2_2,2_1/3f3bc20af1adbdf4,4057c00000000000,0,4038000000000000,4018000000000000,3f4b85fd5a261456/-8_4,2_3,1_1/4058100000000000,404b800000000000,4045800000000000,4050800000000000,4050c00000000000,3f1f6b081088c64f,404a800000000000,4055000000000000/ {count:589.2522165928995, sum:2991.346399171121, [-4.294967296e+09,-65536):84, [-1,-1.52587890625e-05):53, [-1.52587890625e-05,-2.3283064365386963e-10):0.0001198505089028631, [-2.3283064365386963e-10,-3.552713678800501e-15):67, [-8.271806125530277e-25,-1.262177448353619e-29):66, [-1.262177448353619e-29,-1.925929944387236e-34):43, [-1.925929944387236e-34,-2.938735877055719e-39):55, [-2.938735877055719e-39,-4.484155085839415e-44):96.25, [-0,0]:0.0008332418155281546, (8.636168555094445e-78,5.659799424266695e-73]:0.00042355315388584104, (5.659799424266695e-73,3.7092061506874214e-68]:95, (1.044048714879764e-53,6.842277657836021e-49]:24, (6.842277657836021e-49,4.484155085839415e-44]:6, (1.925929944387236e-34,1.262177448353619e-29]:0.0008399474211705598} +5/255bba08cf8c979d/0/40770c0000000000/40b5fa8d74c0ae79/1/4_3,0_1/404c800000000000,4043800000000000,404ef00000000000,4056380000000000/-4_1/405e800000000000/ {count:368.75, sum:5626.552562754246, [-0.9170040432046711,-0.8973545375015533):122, (1.0671404006768235,1.0905077326652577]:57, (1.0905077326652577,1.1143867425958924]:39, (1.1143867425958924,1.1387886347566916]:61.875, (1.1387886347566916,1.1637248587775775]:88.875} +5/0/0/4064400710556858/40c19327f5a61c63/3/-2_3,2_2,2_2/3f4015141ed88149,4014000000000000,3f385883050fd155,0,0,4054000000000000,4053400000000000/// {count:162.00086228066607, sum:8998.312184108601, (0.9370838170551498,0.9576032806985735]:0.0004907940144143554, (0.9576032806985735,0.9785720620876999]:5, (0.9785720620876999,1]:0.0003714866516582223, (1.1387886347566916,1.1637248587775775]:80, (1.1637248587775775,1.189207115002721]:77} +0/3f847ae147ae147b/0/4075b203e4a2a823/40b7d2e96b597241/0/-15_3,0_3/4052000000000000,405a480000000000,4058000000000000,3f4d7879838a47e5,4052800000000000,3f0ac9bbd92a4212/// {count:347.1259504655629, sum:6098.911794271845, (1.52587890625e-05,3.0517578125e-05]:72, (3.0517578125e-05,6.103515625e-05]:105.125, (6.103515625e-05,0.0001220703125]:96, (0.0001220703125,0.000244140625]:0.0008993714646570143, (0.000244140625,0.00048828125]:74, (0.00048828125,0.0009765625]:5.1094098258212245e-05} +5/3f847ae147ae147b/4055c00000000000/4055c00000000000/4094695b49a7cbae/2///// {count:87, sum:1306.33914816075, [-0.01,0.01]:87} +-2/3f50624dd2f1a9fc/0/4050000000000000/c074ba1db757b67f/1/4_1,0_1/401c000000000000,404c800000000000/// {count:64, sum:-331.6322549272808, (4096,65536]:7, (65536,1.048576e+06]:57} +0/3fe0000000000000/0/4075ce01dd4d0692/40ce28744fb7f0d2/1/-11_4/4053b80000000000,405e400000000000,4052000000000000,4053400000000000/3_4/3f3dd4d069223133,0,0,0/ {count:348.8754551895919, sum:15440.908682816113, [-8,-4):0.00045518959189718154, (0.000244140625,0.00048828125]:78.875, (0.00048828125,0.0009765625]:121, (0.0009765625,0.001953125]:72, (0.001953125,0.00390625]:77} +0/3fe0000000000000/3f20901594b4379e/406c14063509d9f0/40c9c9a9524a50e8/1/-6_2,1_2,1_1/4041000000000000,4042500000000000,4055c00000000000,3f44b0220295c4b2,4050c00000000000/// {count:224.6257577125939, sum:13203.322823800569, [-0.5,0.5]:0.00012636435926832457, (0.0078125,0.015625]:34, (0.015625,0.03125]:36.625, (0.0625,0.125]:87, (0.125,0.25]:0.0006313482346468528, (0.5,1]:67} +-53/0/0/4024000000000000/4038a4e446df92d7/2/0_2/4024000000000000,0///3ff0000000000000,4014000000000000,4024000000000000 {count:10, sum:24.644108228286168, [-Inf,1]:10} +-1/255bba08cf8c979d/0/4081a80000000000/c0c61d084603aa97/1/-16_4,0_4,1_3/404bd00000000000,403e000000000000,0,4041000000000000,4050800000000000,4046800000000000,403c600000000000,4057800000000000,4052800000000000,4049000000000000,4056000000000000/// {count:565, sum:-11322.064636667534, (5.820766091346741e-11,2.3283064365386963e-10]:55.625, (2.3283064365386963e-10,9.313225746154785e-10]:30, (3.725290298461914e-09,1.4901161193847656e-08]:34, (1.4901161193847656e-08,5.960464477539063e-08]:66, (5.960464477539063e-08,2.384185791015625e-07]:45, (2.384185791015625e-07,9.5367431640625e-07]:28.375, (9.5367431640625e-07,3.814697265625e-06]:94, (1.52587890625e-05,6.103515625e-05]:74, (6.103515625e-05,0.000244140625]:50, (0.000244140625,0.0009765625]:88} +-4/255bba08cf8c979d/402e400000000000/407caa0113ee4a12/40de3cd98f49b46d/1/-3_3,2_4,1_2/403b000000000000,404a000000000000,0,4047800000000000,4028000000000000,0,4010000000000000,3f313ee4a128a361,4037000000000000/-16_4,2_1,2_2/402a000000000000,403c000000000000,4036800000000000,4054c00000000000,4049000000000000,4044000000000000,4045000000000000/ {count:458.62526314813397, sum:30963.399370599935, [-1.262177448353619e-29,-1.925929944387236e-34):42, [-1.925929944387236e-34,-2.938735877055719e-39):40, [-6.842277657836021e-49,-1.044048714879764e-53):50, [-2.4308653429145085e-63,-3.7092061506874214e-68):83, [-3.7092061506874214e-68,-5.659799424266695e-73):22.5, [-5.659799424266695e-73,-8.636168555094445e-78):28, [-8.636168555094445e-78,-1.3177747429038154e-82):13, [-1e-128,1e-128]:15.125, (5.421010862427522e-20,3.552713678800501e-15]:27, (3.552713678800501e-15,2.3283064365386963e-10]:52, (65536,4.294967296e+09]:47, (4.294967296e+09,2.81474976710656e+14]:12, (1.8446744073709552e+19,1.2089258196146292e+24]:4, (7.922816251426434e+28,5.192296858534828e+33]:0.00026314813399835017, (5.192296858534828e+33,3.402823669209385e+38]:23} +5/3f50624dd2f1a9fc/0/0/8000000000000000/3///// {count:0, sum:-0} +8/3f847ae147ae147b/4054800000000000/40716a00d423d86a/c088bf7fb4b025f5/2/-16_1,1_3/4039000000000000,3f2a847b0d4f57f6,4049800000000000,405e280000000000/// {count:278.6252023124895, sum:-791.9373563539108, [-0.01,0.01]:82, (0.9550139751351947,0.9576032806985735]:25, (0.9601996065815236,0.9628029718180622]:0.0002023124895049186, (0.9628029718180622,0.9654133954938133]:51, (0.9654133954938133,0.9680308967461471]:120.625} +-1/255bba08cf8c979d/4055400000000000/4072be0000000000/40a8ecb19995b81f/2/-9_1,2_4/4048000000000000,0,403b600000000000,4053a00000000000,404e800000000000/// {count:299.875, sum:3190.346874884344, [-1e-128,1e-128]:85, (9.5367431640625e-07,3.814697265625e-06]:48, (0.000244140625,0.0009765625]:27.375, (0.0009765625,0.00390625]:78.5, (0.00390625,0.015625]:61} +0/3f847ae147ae147b/404a000000000000/4063e8030e6cf560/4081209123fc57ba/3/-20_1,2_3/4036400000000000,4036000000000000,3f387367ab034118,404f000000000000/-13_2/3ff0000000000000,0/ {count:159.25037308960873, sum:548.0708694185516, [-0.0001220703125,-6.103515625e-05):1, [-0.01,0.01]:52, (4.76837158203125e-07,9.5367431640625e-07]:22.25, (3.814697265625e-06,7.62939453125e-06]:22, (7.62939453125e-06,1.52587890625e-05]:0.00037308960874062177, (1.52587890625e-05,3.0517578125e-05]:62} +-4/3f50624dd2f1a9fc/0/408b9402008af180/c0b46b244e186d84/2/4_3,0_4/0,4010000000000000,404a000000000000,4053200000000000,404a800000000000,4052400000000000,4059000000000000/5_3,2_4,1_4/404e000000000000,4057400000000000,4028000000000000,4054400000000000,4053c00000000000,4055400000000000,4048000000000000,3f5004578c010910,0,404e000000000000,4018000000000000/ {count:882.5009775977087, sum:-5227.141816641557, [-4.9732323640978664e+86,-7.588550360256754e+81):6, [-7.588550360256754e+81,-1.157920892373162e+77):60, [-1.7668470647783843e+72,-2.695994666715064e+67):0.0009775977087121447, [-4.113761393303015e+62,-6.277101735386681e+57):48, [-6.277101735386681e+57,-9.578097130411805e+52):85, [-9.578097130411805e+52,-1.461501637330903e+48):79, [-1.461501637330903e+48,-2.2300745198530623e+43):81, [-5.192296858534828e+33,-7.922816251426434e+28):12, [-7.922816251426434e+28,-1.2089258196146292e+24):93, [-1.2089258196146292e+24,-1.8446744073709552e+19):60, (1.8446744073709552e+19,1.2089258196146292e+24]:4, (1.2089258196146292e+24,7.922816251426434e+28]:52, (7.922816251426434e+28,5.192296858534828e+33]:76.5, (5.192296858534828e+33,3.402823669209385e+38]:53, (3.402823669209385e+38,2.2300745198530623e+43]:73, (2.2300745198530623e+43,1.461501637330903e+48]:100} +-53/0/0/0/0/0/////4000000000000000 {count:0, sum:0} +-1/3fe0000000000000/3f451c23ba20a83c/4064d005d3d0315d/40a5598b12aa98db/1/-17_4,1_3/4056000000000000,4044800000000000,3ff8000000000000,0,4042000000000000,0,3f1198e85aa23b6b/// {count:166.50071135199224, sum:2732.771626788291, [-0.5,0.5]:0.0006442236374415498, (1.4551915228366852e-11,5.820766091346741e-11]:88, (5.820766091346741e-11,2.3283064365386963e-10]:41, (2.3283064365386963e-10,9.313225746154785e-10]:1.5, (1.4901161193847656e-08,5.960464477539063e-08]:36, (2.384185791015625e-07,9.5367431640625e-07]:6.712835480214361e-05} +5/3f50624dd2f1a9fc/0/407da004bdf97b56/40bf8a2ca0dadb2a/3/9_3,0_1,0_2/0,402e000000000000,3f4d33d2909fe965,3f3177f2941fd20e,4010000000000000,0/-1_2,0_4,2_2/4056b00000000000,404f000000000000,404d000000000000,4043000000000000,4056000000000000,4047200000000000,0,4052000000000000/ {count:474.00115773633786, sum:8074.174329451091, [-1.189207115002721,-1.1637248587775775):72, [-1.0905077326652577,-1.0671404006768235):46.25, [-1.0671404006768235,-1.0442737824274138):88, [-1.0442737824274138,-1.0218971486541166):38, [-1.0218971486541166,-1):58, [-1,-0.9785720620876999):62, [-0.9785720620876999,-0.9576032806985735):90.75, (1.2152473599804687,1.241857812073484]:15, (1.241857812073484,1.2690509571917332]:0.0008911874913331395, (1.2690509571917332,1.2968395546510096]:0.0002665488465248008, (1.2968395546510096,1.325236643159741]:4} +-53/0/0/0/0/2/1_1/0///3fe0000000000000,3ff0000000000000,4000000000000000,4014000000000000 {count:0, sum:0} +-1/255bba08cf8c979d/0/0/0/2///// {count:0, sum:0} +8/255bba08cf8c979d/0/0/0/3///// {count:0, sum:0} +-53/0/0/0/0/0/////3ff0000000000000,4014000000000000,4024000000000000 {count:0, sum:0} +-2/255bba08cf8c979d/0/407aec0000000000/c0c5f6b501bf6e81/3/-16_4,1_1,0_3/4047800000000000,0,4050000000000000,4059500000000000,4054400000000000,0,4034200000000000,0/-5_2/405c580000000000,4010000000000000/ {count:430.75, sum:-11245.41411583801, [-1.52587890625e-05,-9.5367431640625e-07):4, [-9.5367431640625e-07,-5.960464477539063e-08):113.375, (3.3881317890172014e-21,5.421010862427522e-20]:47, (8.673617379884035e-19,1.3877787807814457e-17]:64, (1.3877787807814457e-17,2.220446049250313e-16]:101.25, (3.552713678800501e-15,5.684341886080802e-14]:81, (9.094947017729282e-13,1.4551915228366852e-11]:20.125} +-2/0/0/4064b80edc14cbae/40b53ea5f0cc1841/0/-10_1,0_3,0_2/4052000000000000,4050b80000000000,3f4262e9d0820b8b,4030e00000000000,0,0/-10_2,2_2/4024000000000000,3efb4dc57a9c81a3,3f3d19806b2a8e0f,3f49a63afccc53b9/ {count:165.75181392728467, sum:5438.64820552437, [-9.5367431640625e-07,-5.960464477539063e-08):0.0007827556633822542, [-5.960464477539063e-08,-3.725290298461914e-09):0.000444024898721391, [-1.4551915228366852e-11,-9.094947017729282e-13):2.6038927592898807e-05, [-9.094947017729282e-13,-5.684341886080802e-14):10, (5.684341886080802e-14,9.094947017729282e-13]:72, (9.094947017729282e-13,1.4551915228366852e-11]:66.875, (1.4551915228366852e-11,2.3283064365386963e-10]:0.0005611077949797411, (2.3283064365386963e-10,3.725290298461914e-09]:16.875} +-1/3f847ae147ae147b/0/4052b80000000000/4095b92e3d3d165a/1/6_2/4051b80000000000,4010000000000000/// {count:74.875, sum:1390.295155481805, (1024,4096]:70.875, (4096,16384]:4} +1/3fe0000000000000/0/40718a00b3c301d4/c0acbe7f64b2f3a9/1/-11_3/3f2678603a7aeac0,4050380000000000,403f000000000000/-12_3,2_2/0,4052f00000000000,402e000000000000,0,4057800000000000/ {count:280.62517143416085, sum:-3679.2488151476323, [-0.125,-0.08838834764831843):94, [-0.03125,-0.022097086912079608):15, [-0.022097086912079608,-0.015625):75.75, (0.015625,0.022097086912079608]:0.00017143416083860961, (0.022097086912079608,0.03125]:64.875, (0.03125,0.044194173824159216]:31} +3/3f50624dd2f1a9fc/4044400000000000/406290088825a224/40a922533829a9d0/2/4_1,1_2,0_3/0,3f435c1278746a1d,3f3d89082035127a,403d000000000000,4036000000000000,404c800000000000/// {count:148.50104148245111, sum:3217.1625378627577, [-0.001,0.001]:40.5, (1.5422108254079407,1.6817928305074288]:0.0005908098400227466, (1.6817928305074288,1.8340080864093422]:0.00045067261108425844, (1.8340080864093422,2]:29, (2,2.1810154653305154]:22, (2.1810154653305154,2.378414230005442]:57} diff --git a/src/promql/src/histogram_eval/tests.rs b/src/promql/src/histogram_eval/tests.rs index 41db4cd3ba5..122a94fc047 100644 --- a/src/promql/src/histogram_eval/tests.rs +++ b/src/promql/src/histogram_eval/tests.rs @@ -25,7 +25,7 @@ use datatypes::series::time::{EvalGrid, GridRange, GridSpec, TimeAxis}; use super::float_histogram::{AddOutcome, FloatHistogram}; use super::{ HistogramAggregate, HistogramFunction, NativeHistogramFunction, aggregate_histograms, - evaluate_histograms, histogram_quantiles, map_native_histogram, + evaluate_histograms, histogram_quantiles, histogram_string, map_native_histogram, }; use crate::grid_series::merge_same_labelset; use crate::grid_series::reference::{ @@ -862,3 +862,18 @@ fn native_histogram_functions_keep_the_runs_of_a_sparse_series() { ); } } + +/// Go `FloatHistogram.String` over fixed and random histograms (`narthex-golden-histstring`): +/// bucket order, inclusiveness, empty buckets skipped, the zero bucket, custom bounds and `%g`. +#[test] +fn histogram_string_matches_prometheus() { + for line in include_str!("string_cases.txt").lines() { + let (encoded, expected) = line.split_once('\t').expect("tab-separated case"); + let histogram = OwnedHistogram::parse(encoded); + assert_eq!( + histogram_string(&histogram.view()).unwrap(), + expected, + "{encoded}" + ); + } +} diff --git a/src/promql/src/map.rs b/src/promql/src/map.rs index 122003e84bf..b267486b9e2 100644 --- a/src/promql/src/map.rs +++ b/src/promql/src/map.rs @@ -365,11 +365,8 @@ fn series_error(error: datatypes::error::Error) -> DataFusionError { DataFusionError::External(Box::new(error)) } -/// Checks an evaluated float series up front, so no segment of another type yields a result. -/// -/// Returns the validated axis: it is addressed on a grid, so the caller reads the rows it carries -/// with the axis traversal rather than assuming a contiguous range. -pub(crate) fn validate_float_series<'a>( +/// Checks `series` against `schema` and its evaluation grid, whatever its sample types. +pub(crate) fn validate_grid_series<'a>( schema: &SeriesSchema, series: &'a Series, ) -> Result<&'a TimeAxis> { @@ -386,6 +383,18 @@ pub(crate) fn validate_float_series<'a>( series.fields.clone(), ) .map_err(series_error)?; + Ok(time) +} + +/// Checks an evaluated float series up front, so no segment of another type yields a result. +/// +/// Returns the validated axis: it is addressed on a grid, so the caller reads the rows it carries +/// with the axis traversal rather than assuming a contiguous range. +pub(crate) fn validate_float_series<'a>( + schema: &SeriesSchema, + series: &'a Series, +) -> Result<&'a TimeAxis> { + let time = validate_grid_series(schema, series)?; for field in series.fields.iter() { if field .segments() From 70201c9cf9544010d9b172206d410bf0422c14e8 Mon Sep 17 00:00:00 2001 From: discord9 Date: Thu, 22 Oct 2026 15:27:21 +0800 Subject: [PATCH 12/18] feat(promql): compile constants, negation and scalar-vector arithmetic (#9832) * feat(promql): compile a constant, a negation and a scalar-vector operation Three shapes reach the tree now: a constant binds as a `Scalar` on the statement's grid, a negation binds as the multiplication by `-1` it is, and a binary operation between one vector and one constant binds as a `Binary` node whose output is the vector side's own binding. The arithmetic and comparison operators all map, and `return_bool` is carried as its own field so `x > 0` (which filters) and `x > bool 0` (which reports zero or one) are one node with one field changed. The operands keep the order they were written in, because most operators are not commutative: `1 / x` is not `x / 1`, and which side the constant is on is the only thing that tells them apart. A constant is bound by the same `constant_value` fold a call's parameters already use, so `x * (1 + 2)` carries `3` rather than an addition the tree would have to know how to run. Three shapes are refused rather than approximated. An operation between two vectors needs each `Input` to name the selector it reads, which the one binding does not carry, so `a + b` and `a + a` would compile to the same tree; a set operator combines two vectors' series and has no node at all; and a statement whose value is a scalar has nothing to scan, so a constant is only bound next to a vector that gives it something to broadcast over. Each refusal names what it found. Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> (cherry picked from commit e672a9c5264422e1d1452d89563d8e68b3632852) (cherry picked from commit 77ea4d1f4eec7fedbdcb80841b5610dc90487ef7) * fix(promql): refuse a modifier before folding a constant Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> --------- Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> --- src/promql/src/map/comparison.rs | 8 +- src/query/src/promql/native_compile.rs | 308 +++++++- src/query/src/promql/native_compile/tests.rs | 723 ++++++++++++++++++- 3 files changed, 1009 insertions(+), 30 deletions(-) diff --git a/src/promql/src/map/comparison.rs b/src/promql/src/map/comparison.rs index f9ec3a994da..f878fe7072f 100644 --- a/src/promql/src/map/comparison.rs +++ b/src/promql/src/map/comparison.rs @@ -119,7 +119,13 @@ impl Comparison { } impl ComparisonOp { - fn apply(self, left: f64, right: f64) -> bool { + /// Whether `left op right` holds, by the same IEEE rules the kernels use. + /// + /// Public because a compiler that folds a constant comparison has to fold it with the same + /// answer the kernel would compute: NaN satisfies only `NotEqual`, and signed zeros compare + /// equal. Defining the six rules a second time is how the folded number and the evaluated one + /// would start to disagree. + pub fn apply(self, left: f64, right: f64) -> bool { match self { Self::Equal => left == right, Self::NotEqual => left != right, diff --git a/src/query/src/promql/native_compile.rs b/src/query/src/promql/native_compile.rs index 56ea7445b76..d3315f13961 100644 --- a/src/query/src/promql/native_compile.rs +++ b/src/query/src/promql/native_compile.rs @@ -23,15 +23,20 @@ //! ([]) e.g. rate(x[5m]) //! (, []) e.g. quantile_over_time(0.9, x[5m]) //! ([, ]*) e.g. abs(rate(x[5m])), clamp_min(x, 0) +//! e.g. x * 100, x > bool 0 +//! e.g. 1 / x, 0 < x +//! - e.g. -x //! ``` //! //! which become `Input` (raw samples) → `Instant` (the last sample within the statement's lookback), -//! `Input` (raw samples) → `Window` (the range function over the statement's grid) and -//! `` → `Map` (a pointwise math function over what its argument evaluates to). A -//! selector may carry `offset` in either selection shape and, in the window shape, an `@` anchor. -//! Everything else is a [`DataFusionError::NotImplemented`] that names what was found and quotes the -//! shapes above, because rejecting a query the compiler does not understand is the only honest -//! alternative to compiling it into something else. +//! `Input` (raw samples) → `Window` (the range function over the statement's grid), +//! `` → `Map` (a pointwise math function over what its argument evaluates to) and, over +//! any of those, `Binary` (an arithmetic or comparison operation with a constant on one side) and +//! `Scalar` (that constant). A selector may carry +//! `offset` in either selection shape and, in the window shape, an `@` anchor. Everything else is a +//! [`DataFusionError::NotImplemented`] that names what was found and quotes the shapes above, +//! because rejecting a query the compiler does not understand is the only honest alternative to +//! compiling it into something else. //! //! Two decisions keep the recursion from growing a branch per query. A constant argument — a //! quantile, a rounding step, a clamp bound — is folded out of the statement by [`constant_value`], @@ -42,6 +47,20 @@ //! alone, so the number this compiler binds and the number the kernel computes cannot disagree //! about `%`, `^` or `atan2`. //! +//! Two more are about what a node means rather than about which node to build. A binary operation +//! keeps its operands in the order they were written, because most operators are not commutative: +//! `1 / x` is not `x / 1`, and which side the constant is on is the only thing that tells them +//! apart. `return_bool` is carried as its own field, so `x > 0` (which filters) and `x > bool 0` +//! (which reports zero or one) are one node with one field changed. A negation then needs no node of +//! its own: `-x` binds as the multiplication by `-1` that it is, which the multiplication kernel +//! already computes. +//! +//! What a tree cannot yet express is deliberate too. The one input binding carries the ordering it +//! guarantees and what it produces, but not the selector it reads, so a tree with two selectors +//! cannot tell its inputs apart and an operation between two vectors is refused. A statement whose +//! value is a scalar has nothing to read, so it is refused as well: a constant is bound next to a +//! vector that gives it something to broadcast over. +//! //! The statement's grid, the selector's lookback, window, offset and anchor are the only numbers //! derived here; the legality of the tree is not checked here. [`NativeTree::try_new`] is the //! single authority on that, so a node added later cannot be legal in one place and illegal in @@ -56,16 +75,18 @@ use datafusion::error::{DataFusionError, Result}; use datatypes::series::SeriesOrdering; use datatypes::series::samples::SeriesSchema; use datatypes::series::time::{EvalGrid, GridSpec}; -use promql::map::{ArithmeticOp, MathOp}; +use promql::map::{ArithmeticOp, ComparisonOp, MathOp}; use promql::native::plan::{ - BoundInput, BoundInstant, BoundMap, BoundWindow, NativeExpr, NativeNode, NativeTree, NativeType, + BoundInput, BoundInstant, BoundMap, BoundScalar, BoundWindow, NativeExpr, NativeNode, + NativeTree, NativeType, }; use promql::range_eval::{RangeFunction, RangeWindow}; +use promql::vector_match::{BinaryMatching, BinaryOp, Cardinality, FillValues, MatchingLabels}; use promql_parser::parser::token::TokenType; use promql_parser::parser::value::ValueType; use promql_parser::parser::{ - AtModifier, Call, EvalStmt, Expr as PromExpr, MatrixSelector, NumberLiteral, Offset, - VectorSelector, token, + AtModifier, BinModifier, BinaryExpr, Call, EvalStmt, Expr as PromExpr, MatrixSelector, + NumberLiteral, Offset, VectorMatchCardinality, VectorMatchFillValues, VectorSelector, token, }; use crate::promql::label_values::signed_millis_since_epoch; @@ -73,9 +94,11 @@ use crate::promql::label_values::signed_millis_since_epoch; /// The shapes this compiler accepts, quoted in every rejection. const SUPPORTED: &str = "a selector, a range function over a range selector — \ `([])`, with its constants in front of \ - the selector as in `quantile_over_time(0.9, x[5m])` — or a math function \ - over any of them, as in `abs(rate(x[5m]))`: for example `x`, \ - `rate(x[5m])`, `quantile_over_time(0.9, x[5m])` or `abs(rate(x[5m]))`"; + the selector as in `quantile_over_time(0.9, x[5m])` — a math function over \ + any of them, a binary operation between one of them and a constant, or a \ + negation of one of them, as in `x`, `rate(x[5m])`, \ + `quantile_over_time(0.9, x[5m])`, `abs(rate(x[5m]))`, `2 / (x + 1)` or \ + `-x`"; /// Compiles `stmt` into a tree that reads `input` and evaluates the statement's expression over it. /// @@ -114,6 +137,18 @@ pub fn compile( series may not interleave", )); } + // A tree reads a scan and reports evaluated samples, so an expression whose value is not a + // vector has nothing to compile into: `1 + 2` is a scalar, and the scalar node is bound as a + // constant next to a vector that gives it something to broadcast over rather than on its own. + // The check is on the statement's type rather than on the compiled node, so it does not depend on + // which kinds of expression happen to compile to a scalar today. + if stmt.expr.value_type() != ValueType::Vector { + return Err(not_implemented(&format!( + "the expression `{}`, which is a {} where a tree reports evaluated samples", + stmt.expr, + stmt.expr.value_type() + ))); + } let context = Context { stmt, input, @@ -145,16 +180,200 @@ struct Context<'a> { /// its argument, so a query nests as deeply as it was written. A kind this compiler has no node for /// is refused with what was found, which is why the rejection is useful without a coverage list. fn compile_expr(context: &Context, expr: &PromExpr) -> Result { + // A constant is a value rather than a computation, so it binds as the number it folds to, by the + // same fold a call's parameters use: `x * (1 + 2)` carries `3` rather than an addition the tree + // would have to know how to run. The fold is checked first so that no shape below has to say + // what it does with a constant operand. + if let Some(value) = constant_value(expr) { + return scalar_node(context, value); + } match expr { // Parentheses group, so they change how a statement was typed rather than what it asks for: // the expression inside them is the one that compiles. PromExpr::Paren(paren) => compile_expr(context, &paren.expr), PromExpr::Call(call) => compile_call(context, call), PromExpr::VectorSelector(selector) => instant_selection(context, selector), + PromExpr::Unary(unary) => negate(context, &unary.expr), + PromExpr::Binary(binary) => binary_operation(context, binary), found => Err(not_implemented(&format!("the expression `{found}`"))), } } +/// Binds `value` as a constant scalar on the statement's grid. +/// +/// The grid is the statement's rather than the constant's own: a constant has no timestamps, so the +/// only positions it can be broadcast over are the ones the statement is evaluated at, and +/// [`NativeTree::validate`] compares a binary operation's two grids by spec rather than by +/// allocation. +fn scalar_node(context: &Context, value: f64) -> Result { + Ok(NativeNode { + expr: NativeExpr::Scalar(BoundScalar { value }), + output: NativeType::Scalar(statement_grid(context.stmt)?), + }) +} + +/// Compiles a negation as the multiplication it is: `-x` is `-1 * x`. +/// +/// The tree has no negate node and needs none: broadcasting a constant over the expression's value +/// is the same arithmetic, and the multiplication kernel is the one that already computes it. A +/// dedicated negate kernel would be an optimization of this node's work rather than a different +/// meaning, so binding the multiplication does not bind anything the query did not ask for. +fn negate(context: &Context, operand: &PromExpr) -> Result { + // The constant goes on the left, where the minus was: `-x` subtracts nothing, but a reader of + // the tree should see the same order the query was written in. + Ok(broadcast( + scalar_node(context, -1.0)?, + compile_expr(context, operand)?, + BinaryOp::Mul, + false, + )) +} + +/// Compiles a binary operation whose operands are not both vectors. +/// +/// The tree holds one input binding, and that binding carries the ordering it guarantees and what it +/// produces but not the selector it reads, so two vectors cannot be told apart: `a + b` and `a + a` +/// would compile to the same tree. That is why a vector–vector operation is refused until an input +/// names its selector, and why an operation over one vector and one scalar is the shape the tree can +/// bind: a scalar carries no labels, so broadcasting it is unambiguous. +fn binary_operation(context: &Context, binary: &BinaryExpr) -> Result { + // A set operator combines two vectors' series rather than their values, and the tree has no node + // for it at all. Its name is reported before the operands are looked at, so a set operation + // between two vectors is refused as the set operation it is rather than as a binary operation + // the compiler has no node for. + if binary.op.is_set_operator() { + return Err(not_implemented(&format!( + "the set operator `{}` in `{binary}`, which has no native node: it combines two \ + vectors' series, and every binary node here is an arithmetic or comparison operation \ + over values", + binary.op + ))); + } + let Some(op) = binary_op(&binary.op) else { + return Err(not_implemented(&format!( + "the binary operator `{}` in `{binary}`", + binary.op + ))); + }; + // Two vectors are refused before the modifier is looked at, so a vector–vector query with + // matching says what is actually missing. The refusal names the missing identity rather than the + // query, because that is what whoever adds it has to add. + if binary.lhs.value_type() == ValueType::Vector && binary.rhs.value_type() == ValueType::Vector + { + return Err(not_implemented(&format!( + "the binary operation `{binary}` between two vectors, which needs each input to name \ + the selector it reads: an input binding carries its ordering and what it produces, but \ + not which selector it was bound to" + ))); + } + // What is left has a constant on one side, and a modifier is refused rather than dropped. This + // is deliberately narrower than Prometheus, which drops an `on` or `ignoring` written on a + // scalar rather than refusing it; refusing is the conservative choice here, because the tree's + // matching has no field that could carry the modifier, so binding the operation would compute + // something the query did not ask for. A fill value is refused for the same reason: the tree's + // matching has no field for one, and dropping it would compute the operation without the fill + // the query asked for. + if refuses_modifier(binary.modifier.as_ref()) { + return Err(not_implemented(&format!( + "the vector matching in `{binary}`, which a constant operand has nothing to match: \ + `on`, `ignoring`, `group_left` and `group_right` name labels of a vector, and a fill \ + value fills in a match group that could miss" + ))); + } + // Two constants are a value rather than an operation over a vector. A `bool` comparison of two + // of them folded to its number before this point, so what arrives here is a comparison without + // `bool`, which Prometheus refuses to parse at all. Binding it would produce a node that only + // fails when it is run, so it is refused here with what was found. + if binary.lhs.value_type() == ValueType::Scalar && binary.rhs.value_type() == ValueType::Scalar + { + return Err(not_implemented(&format!( + "the binary operation `{binary}` between two constants, which reports no value: a \ + comparison of two scalars is written with `bool` to say whether it holds, and one \ + written that way is folded to the number it means" + ))); + } + // Both operands are compiled before the node is built, so `2 / (x + 1)` nests. The constant side + // is bound by [`compile_expr`]'s fold, which is what makes it a scalar node rather than an + // expression the tree would have to run. + Ok(broadcast( + compile_expr(context, &binary.lhs)?, + compile_expr(context, &binary.rhs)?, + op, + binary.return_bool(), + )) +} + +/// Wraps two compiled operands in the binary node that applies `op` to them. +/// +/// The output is the vector side's own binding, because a scalar carries no fields: a broadcast keeps +/// the vector's fields and the grid it is evaluated on, which is the statement's grid on both sides. +/// Two scalars are possible as a nested operand — a pair that [`constant_value`] refuses to fold, such +/// as a comparison between two constants — and the node reports the left one's grid, which is the +/// statement's too. The operands keep the order they were written in, because a non-commutative +/// operator means what that order says. +fn broadcast(left: NativeNode, right: NativeNode, op: BinaryOp, return_bool: bool) -> NativeNode { + // `compile_expr` binds a constant to a scalar node and a selection or a function to a vector, so + // this is where the side the operation reports is decided. + let output = if matches!(left.output, NativeType::Scalar(_)) { + right.output.clone() + } else { + left.output.clone() + }; + NativeNode { + expr: NativeExpr::Binary { + left: Box::new(left), + right: Box::new(right), + matching: scalar_matching(op, return_bool), + }, + output, + } +} + +/// The matching a vector-and-constant operation records. +/// +/// Nothing is matched: a scalar carries no labels, so [`NativeTree::validate`] broadcasts it and +/// reads neither `labels` nor `cardinality`. They carry the default a vector–vector operation of the +/// same operator would have, which is also the only matching PromQL allows here — it rejects +/// `on`, `ignoring`, `group_left` and `group_right` on a scalar–vector operation. `return_bool` is +/// the one modifier that changes what this operation computes: `x > 0` filters the vector and +/// `x > bool 0` reports zero or one for each of its values. +fn scalar_matching(op: BinaryOp, return_bool: bool) -> BinaryMatching { + BinaryMatching { + op, + return_bool, + labels: MatchingLabels::Ignoring(Vec::new()), + cardinality: Cardinality::OneToOne, + // A fill value fills in a match group that could miss, which a broadcast over a constant never + // does: the constant exists at every grid point, so there is no missing side to fill. + fill: FillValues::default(), + } +} + +/// The binary operator a PromQL token denotes, or `None` when there is no node for it. +/// +/// A comparison is bound like an arithmetic operator, because the node carries `return_bool` +/// separately: `x > 0` and `x > bool 0` are one operator with one field changed. [`arithmetic_op`] +/// is not reused because it decides what a *fold* may do at compile time and a comparison folds +/// nothing, while this map decides what a *node* computes once the query runs. +fn binary_op(op: &TokenType) -> Option { + match op.id() { + token::T_ADD => Some(BinaryOp::Add), + token::T_SUB => Some(BinaryOp::Sub), + token::T_MUL => Some(BinaryOp::Mul), + token::T_DIV => Some(BinaryOp::Div), + token::T_MOD => Some(BinaryOp::Mod), + token::T_POW => Some(BinaryOp::Pow), + token::T_ATAN2 => Some(BinaryOp::Atan2), + token::T_EQLC => Some(BinaryOp::Eq), + token::T_NEQ => Some(BinaryOp::Ne), + token::T_GTR => Some(BinaryOp::Gt), + token::T_LSS => Some(BinaryOp::Lt), + token::T_GTE => Some(BinaryOp::Ge), + token::T_LTE => Some(BinaryOp::Le), + _ => None, + } +} + /// Compiles a call: a range function opens a window over the selector it is given, and a math /// function maps the value of its first argument. /// @@ -168,9 +387,8 @@ fn compile_call(context: &Context, call: &Call) -> Result { return window_call(context, func.name, signature, &args.args); } // The name is looked up before the call's arguments are read, so a name this compiler has no - // kernel for is refused as such whichever arguments it was given: `pi()` and `time()` have no - // vector to map, and the rejection names the name rather than describing a vector they never - // wrote. + // kernel for is refused as such whichever argument it was given: `absent(x)` is refused as a + // name with no kernel, not as a selector this compiler could not compile. let Some((op, vector)) = math_op(func.name, &args.args)? else { return Err(not_implemented(&format!( "a call to `{}`, which has no native kernel", @@ -673,6 +891,21 @@ fn argument_position(position: usize) -> &'static str { POSITIONS[position] } +/// Whether a binary operation carries a modifier this compiler refuses to bind. +/// +/// A scalar carries no labels, so there is nothing to match or name, and the tree's matching has no +/// field for a fill value: binding the operation would compute it without the modifier the query +/// asked for. The fold and [`binary_operation`] share this test rather than restating it, because a +/// fold that computed a constant without reading its modifier would answer a query the dispatch +/// below refuses. +fn refuses_modifier(modifier: Option<&BinModifier>) -> bool { + modifier.is_some_and(|modifier| { + modifier.matching.is_some() + || modifier.card != VectorMatchCardinality::OneToOne + || modifier.fill_values != VectorMatchFillValues::default() + }) +} + /// The number `expr` folds to, or `None` when its value is not the statement's to know. /// /// Only what the statement itself decides is folded: a number literal, a group around one, a negated @@ -692,10 +925,28 @@ fn constant_value(expr: &PromExpr) -> Option { PromExpr::Paren(paren) => constant_value(&paren.expr), PromExpr::Unary(unary) => constant_value(&unary.expr).map(|value| -value), PromExpr::Binary(binary) => { - let op = arithmetic_op(&binary.op)?; + // A modifier is read before the operands are folded: `x * (1 + fill(0) 2)` asks for a + // fill, and folding the inner addition would answer `x * 3` with the fill gone. The + // expression is left unfolded so the dispatch below refuses it with the reason it + // writes, which is also why this is a `None` rather than an error here. + if refuses_modifier(binary.modifier.as_ref()) { + return None; + } let left = constant_value(&binary.lhs)?; let right = constant_value(&binary.rhs)?; - Some(op.calculate(left, right)) + if let Some(op) = arithmetic_op(&binary.op) { + return Some(op.calculate(left, right)); + } + // A comparison of two constants is the number it reports when it is written with + // `bool`: Prometheus reads `1 > bool 2` as `0`, and [`ComparisonOp::apply`] is the same + // answer the kernel gives. Without `bool` there is no number to report -- `1 > 2` is a + // parse error in Prometheus -- so it is left unfolded and the dispatch refuses it. + if binary.return_bool() + && let Some(op) = comparison_op(&binary.op) + { + return Some(f64::from(op.apply(left, right))); + } + None } // Calls (`time()`, `scalar(v)`), selectors, range selectors, aggregates and subqueries all // read something this compiler cannot know before the query runs. @@ -707,8 +958,9 @@ fn constant_value(expr: &PromExpr) -> Option { /// /// The token is matched by its id, which is stable and is what the rest of the planner matches on, /// rather than by the text it displays as: a symbol this compiler does not recognise is not -/// arithmetic here rather than a wrong operator. A comparison and a set operator are the cases that -/// reach this and fold nothing, which is this batch's boundary rather than an accident of the map. +/// arithmetic here rather than a wrong operator. A comparison is `None` here and is folded by +/// [`comparison_op`] instead, because only a `bool` comparison of two constants has a number to +/// report; a set operator folds nothing at all. fn arithmetic_op(op: &TokenType) -> Option { match op.id() { token::T_ADD => Some(ArithmeticOp::Add), @@ -722,6 +974,22 @@ fn arithmetic_op(op: &TokenType) -> Option { } } +/// The comparison operator a PromQL token denotes, or `None` when the token is not a comparison. +/// +/// This is the folding half of [`binary_op`], which binds the same tokens to a node: the fold and +/// the node have to agree on which token is which comparison, so the two maps sit beside each other. +fn comparison_op(op: &TokenType) -> Option { + match op.id() { + token::T_EQLC => Some(ComparisonOp::Equal), + token::T_NEQ => Some(ComparisonOp::NotEqual), + token::T_GTR => Some(ComparisonOp::Greater), + token::T_LSS => Some(ComparisonOp::Less), + token::T_GTE => Some(ComparisonOp::GreaterEqual), + token::T_LTE => Some(ComparisonOp::LessEqual), + _ => None, + } +} + /// The range function a PromQL name denotes, or `None` when this compiler has no kernel for it. /// /// The map is explicit on purpose: a name that is not listed here is rejected, so supporting one diff --git a/src/query/src/promql/native_compile/tests.rs b/src/query/src/promql/native_compile/tests.rs index f16bb74b072..a2f793f58c4 100644 --- a/src/query/src/promql/native_compile/tests.rs +++ b/src/query/src/promql/native_compile/tests.rs @@ -23,7 +23,10 @@ use promql::native::plan::{ BoundInstant, BoundMap, BoundWindow, NativeExpr, NativeNode, NativeTree, NativeType, }; use promql::range_eval::RangeFunction; -use promql_parser::parser::{EvalStmt, Expr as PromExpr, NumberLiteral}; +use promql::vector_match::{BinaryMatching, BinaryOp}; +use promql_parser::parser::{ + BinModifier, EvalStmt, Expr as PromExpr, NumberLiteral, VectorMatchFillValues, +}; use super::compile; @@ -70,6 +73,19 @@ fn with_selector_range(range: Duration) -> EvalStmt { stmt } +/// `query`'s statement with its binary operation's modifier edited by `edit`. +/// +/// The parser rewrites and rejects some modifiers, so a modifier the compiler has to refuse as +/// written can only reach it on a tree the parser accepted: this is the way in. +fn with_binary_modifier(query: &str, edit: impl FnOnce(&mut BinModifier)) -> EvalStmt { + let mut stmt = eval_stmt(query); + let PromExpr::Binary(binary) = &mut stmt.expr else { + unreachable!("`{query}` parses to a binary operation"); + }; + edit(binary.modifier.get_or_insert_with(BinModifier::default)); + stmt +} + /// `query`'s statement with its call's arguments edited by `edit`. /// /// The parser checks arity, so a call with the wrong number of arguments cannot be parsed: the only @@ -140,6 +156,42 @@ fn evaluated(node: &NativeNode) -> &Arc { schema } +/// The two operands and the matching of a `Binary` node. +fn binary_parts(node: &NativeNode) -> (&NativeNode, &NativeNode, &BinaryMatching) { + let NativeExpr::Binary { + left, + right, + matching, + } = &node.expr + else { + panic!("the node is not a binary operation: {:?}", node.expr); + }; + (left, right, matching) +} + +/// The constant a `Scalar` node binds, if it is one. +fn constant(node: &NativeNode) -> Option { + match &node.expr { + NativeExpr::Scalar(scalar) => Some(scalar.value), + _ => None, + } +} + +/// The grid spec a `Scalar` node reports, as `(start_ms, step_ms, len)`. +fn scalar_grid(node: &NativeNode) -> (i64, i64, u64) { + let NativeType::Scalar(grid) = &node.output else { + panic!("the node is not a scalar: {:?}", node.output); + }; + let spec = grid.spec(); + (spec.start_ms, spec.step_ms, spec.len) +} + +/// The grid spec a node's evaluated output carries, as `(start_ms, step_ms, len)`. +fn grid_of(node: &NativeNode) -> (i64, i64, u64) { + let spec = evaluated(node).eval_grid.as_ref().unwrap().spec(); + (spec.start_ms, spec.step_ms, spec.len) +} + /// `rate(x[5m])` becomes a window over the raw input, on the statement's grid. #[test] fn a_rate_over_a_selector_becomes_a_window_over_raw_input() { @@ -706,13 +758,14 @@ fn the_numeric_edges_of_a_statement_are_bound_as_given() { Expected::Ordering(SeriesOrdering::Contiguous), ), // A shape outside the subset is refused for its shape, whatever its timing: the statement - // range of an unsupported query is not read, so it cannot turn a refusal into a plan error. + // range of an unsupported shape is not read, so it cannot turn a refusal into a plan + // error. A subquery is the reachable case of a shape with no node at all. ( "an unsupported shape with an inverted range", EvalStmt { end: UNIX_EPOCH, start: UNIX_EPOCH + Duration::from_secs(100), - ..eval_stmt("x + 1") + ..eval_stmt("x[5m:]") }, SeriesOrdering::KeyThenTime, Expected::Rejected(Rejection::NotImplemented), @@ -810,8 +863,6 @@ fn an_interleaved_scan_is_refused_whatever_the_shape() { #[test] fn an_unsupported_shape_is_rejected_and_names_what_it_found() { let cases = [ - ("x + 1", "the expression `x + 1`"), - ("sum(x)", "the expression `sum(x)`"), ("x[5m:]", "the expression `x[5m:]`"), // Nested shapes are refused by the argument check, not skipped over. A subquery is the // reachable case of an argument that is not a range selector: the parser type-checks @@ -829,10 +880,6 @@ fn an_unsupported_shape_is_rejected_and_names_what_it_found() { "absent(x)", "a call to `absent`, which has no native kernel", ), - // The two functions PromQL calls with no arguments at all: neither has a kernel, and the - // rejection names the name rather than describing a vector they were never given. - ("pi()", "a call to `pi`, which has no native kernel"), - ("time()", "a call to `time`, which has no native kernel"), ]; for (query, named) in cases { @@ -876,6 +923,45 @@ fn the_display_of_a_compiled_tree_is_the_tree() { ); } +/// The display of a tree the batch added nodes to, checked as a whole: the shapes are read by +/// someone reading an `EXPLAIN`, so a wrong nesting, operator, constant or grouping has to show up on +/// a line rather than only in an assertion over the tree. +#[test] +fn the_display_of_a_tree_the_batch_added_to_is_the_query() { + let cases: [(&str, &[&str]); 2] = [ + ( + "-x", + &[ + "NativeTree", + " root Binary * ignoring [] one-to-one", + " left Scalar -1", + " right Instant lookback 5000ms offset 0 on grid 0/5000x21", + " input Input raw samples", + ], + ), + ( + "0 < x", + &[ + "NativeTree", + " root Binary < ignoring [] one-to-one", + " left Scalar 0", + " right Instant lookback 5000ms offset 0 on grid 0/5000x21", + " input Input raw samples", + ], + ), + ]; + + for (query, expected) in cases { + let tree = compile(&eval_stmt(query), raw_schema(), SeriesOrdering::KeyThenTime) + .unwrap_or_else(|error| panic!("`{query}` was refused: {error}")); + assert_eq!( + tree.to_string(), + expected.join("\n"), + "`{query}` is displayed as a different query" + ); + } +} + /// Parentheses group; they do not change what a statement asks for. A compiler that refused them /// would reject a query on how it was typed rather than on what it means, and a statement written /// with them is otherwise exactly the shape the compiler accepts. @@ -1159,3 +1245,622 @@ fn a_math_function_over_a_non_vector_argument_is_refused_and_names_it() { ); } } + +/// A constant on the right of a vector binds as the number it is, next to the vector the rest of the +/// query compiles to: `x * 100` is `x`'s instant selection multiplied by `100`. +#[test] +fn a_constant_binds_as_a_scalar_next_to_the_vector() { + let input = raw_schema(); + let tree = compile( + &eval_stmt("x * 100"), + Arc::clone(&input), + SeriesOrdering::KeyThenTime, + ) + .unwrap(); + + let (left, right, matching) = binary_parts(tree.root()); + assert_eq!(matching.op, BinaryOp::Mul); + assert!( + !matching.return_bool, + "`x * 100` is not a comparison and has no bool" + ); + + let NativeExpr::Instant { .. } = &left.expr else { + panic!( + "the left operand is not the selector's instant: {:?}", + left.expr + ); + }; + assert_eq!( + constant(right), + Some(100.0), + "the right operand is not the constant the query wrote" + ); + assert_eq!( + scalar_grid(right), + (0, 5_000, 21), + "the constant is not on the statement's grid" + ); + + // A scalar carries no fields, so the operation reports the vector side's layout and grid. + let output = evaluated(tree.root()); + assert!( + Arc::ptr_eq(&output.fields, &input.fields), + "the broadcast did not keep the vector's fields" + ); + assert_eq!( + grid_of(tree.root()), + (0, 5_000, 21), + "the operation does not land on the statement's grid" + ); +} + +/// The operands keep the order they were written in, because most operators are not commutative: +/// `1 / x` is not `x / 1`. The constant side is what tells them apart, so a compiler that sorted the +/// operands would compile both queries to the same, wrong tree. +#[test] +fn a_non_commutative_operation_keeps_the_operands_where_they_were_written() { + let cases = [ + // (query, operator, the constant, whether the constant is on the left) + ("1 / x", BinaryOp::Div, 1.0, true), + ("x / 1", BinaryOp::Div, 1.0, false), + ("2 - x", BinaryOp::Sub, 2.0, true), + ("x - 2", BinaryOp::Sub, 2.0, false), + ("x % 2", BinaryOp::Mod, 2.0, false), + ("2 ^ x", BinaryOp::Pow, 2.0, true), + ]; + + for (query, op, value, constant_on_the_left) in cases { + let tree = compile(&eval_stmt(query), raw_schema(), SeriesOrdering::KeyThenTime) + .unwrap_or_else(|error| panic!("`{query}` was refused: {error}")); + let (left, right, matching) = binary_parts(tree.root()); + assert_eq!(matching.op, op, "`{query}` bound the wrong operator"); + let (scalar_side, vector_side) = if constant_on_the_left { + (left, right) + } else { + (right, left) + }; + assert_eq!( + constant(scalar_side), + Some(value), + "`{query}` put the constant on the wrong side" + ); + assert!( + !matches!(vector_side.expr, NativeExpr::Scalar(_)), + "`{query}` compiled the vector operand to a constant: {:?}", + vector_side.expr + ); + } +} + +/// A binary operation compiles by compiling both operands, so it nests as deeply as the query was +/// written: `2 / (x + 1)` is a division whose right operand is the addition, not a shape the +/// compiler knows by name. +#[test] +fn a_binary_operation_nests_over_the_operands_it_was_written_with() { + let tree = compile( + &eval_stmt("2 / (x + 1)"), + raw_schema(), + SeriesOrdering::KeyThenTime, + ) + .unwrap_or_else(|error| panic!("`2 / (x + 1)` was refused: {error}")); + + let (left, right, matching) = binary_parts(tree.root()); + assert_eq!(matching.op, BinaryOp::Div); + assert_eq!(constant(left), Some(2.0), "the divisor was not bound"); + + let (inner_left, inner_right, inner_matching) = binary_parts(right); + assert_eq!(inner_matching.op, BinaryOp::Add); + let NativeExpr::Instant { .. } = &inner_left.expr else { + panic!( + "the sum's left operand is not the selector: {:?}", + inner_left.expr + ); + }; + assert_eq!( + constant(inner_right), + Some(1.0), + "the summand was not bound as a constant" + ); + + // Every node is on the statement's grid, which is what lets the operation align them. + assert_eq!(grid_of(tree.root()), (0, 5_000, 21)); + assert_eq!(grid_of(right), (0, 5_000, 21)); +} + +/// `return_bool` is a field of the operation rather than a different tree: `x > 0` filters and +/// `x > bool 0` reports zero or one, and the two differ in nothing else. Both directions of a +/// comparison are asserted, because which side the constant is on decides what the filter keeps and +/// a compiler that always put the constant on one side would compile `0 < x` as `x > 0`. +#[test] +fn a_comparison_carries_whether_it_filters_or_reports_bool() { + let cases = [ + ("x > 0", BinaryOp::Gt, false), + ("x > bool 0", BinaryOp::Gt, true), + ("0 < x", BinaryOp::Lt, false), + ("0 < bool x", BinaryOp::Lt, true), + ("x == 0", BinaryOp::Eq, false), + ("x != 0", BinaryOp::Ne, false), + ("x >= 0", BinaryOp::Ge, false), + ("x <= 0", BinaryOp::Le, false), + ]; + + for (query, op, return_bool) in cases { + let tree = compile(&eval_stmt(query), raw_schema(), SeriesOrdering::KeyThenTime) + .unwrap_or_else(|error| panic!("`{query}` was refused: {error}")); + let (_, _, matching) = binary_parts(tree.root()); + assert_eq!(matching.op, op, "`{query}` bound the wrong operator"); + assert_eq!( + matching.return_bool, return_bool, + "`{query}` bound the wrong comparison mode" + ); + } + + // The constant keeps the side it was written on. `0 < x` and `x > 0` keep the same values but + // are not the same tree: a compiler that normalised the comparison would answer the other + // query's filter, which is why the side is asserted rather than only the operator. + let left_constant = compile( + &eval_stmt("0 < x"), + raw_schema(), + SeriesOrdering::KeyThenTime, + ) + .unwrap(); + let (left, right, _) = binary_parts(left_constant.root()); + assert_eq!( + constant(left), + Some(0.0), + "`0 < x` did not keep its constant on the left" + ); + assert!( + constant(right).is_none(), + "`0 < x` put the constant on the right, which is `x > 0`" + ); + + // The two modes are one tree otherwise: the query text is what differs, and a tree that recorded + // the mode somewhere else would fail the assertions above rather than this one. + let filters = compile( + &eval_stmt("x > 0"), + raw_schema(), + SeriesOrdering::KeyThenTime, + ) + .unwrap(); + let reports = compile( + &eval_stmt("x > bool 0"), + raw_schema(), + SeriesOrdering::KeyThenTime, + ) + .unwrap(); + assert_eq!(grid_of(filters.root()), grid_of(reports.root())); +} + +/// A negation binds as the multiplication it is: `-x` is `-1 * x`. The tree has no negate node, and +/// the multiplication kernel computes exactly the value the query asks for. +#[test] +fn a_negation_binds_as_a_multiplication_by_minus_one() { + for query in ["-x", "-(x)"] { + let tree = compile(&eval_stmt(query), raw_schema(), SeriesOrdering::KeyThenTime) + .unwrap_or_else(|error| panic!("`{query}` was refused: {error}")); + let (left, right, matching) = binary_parts(tree.root()); + assert_eq!( + matching.op, + BinaryOp::Mul, + "`{query}` is not bound as a multiplication" + ); + assert_eq!( + constant(left), + Some(-1.0), + "`{query}` did not bind `-1` on the left" + ); + let NativeExpr::Instant { .. } = &right.expr else { + panic!("`{query}`'s operand is not the selector: {:?}", right.expr); + }; + } +} + +/// A doubled negation is a negation of a negation, not a compile error or a dropped sign. +/// +/// `--x` binds as `-1 * (-1 * x)`: the outer multiplication is the one the query's first minus asks +/// for, and the inner one is what the second minus lowered to. Nothing here cancels the two into +/// `x`, because the tree has no rule for that and the kernel computes the product anyway. +#[test] +fn a_doubled_negation_nests_two_multiplications() { + let tree = compile(&eval_stmt("--x"), raw_schema(), SeriesOrdering::KeyThenTime) + .unwrap_or_else(|error| panic!("`--x` was refused: {error}")); + let (left, right, matching) = binary_parts(tree.root()); + assert_eq!(matching.op, BinaryOp::Mul, "`--x` is not a multiplication"); + assert_eq!( + constant(left), + Some(-1.0), + "`--x` did not bind its outer `-1` on the left" + ); + let (inner_left, _, inner_matching) = binary_parts(right); + assert_eq!( + inner_matching.op, + BinaryOp::Mul, + "the operand of `--x` is not the inner multiplication" + ); + assert_eq!( + constant(inner_left), + Some(-1.0), + "the inner negation did not bind its own `-1`" + ); +} + +/// The negation applies to whatever its operand compiles to, so a window can be negated: `-rate(x[5m])` +/// is a multiplication over the window rather than a new shape. +#[test] +fn a_negation_applies_to_the_node_its_operand_compiles_to() { + let tree = compile( + &eval_stmt("-rate(x[5m])"), + raw_schema(), + SeriesOrdering::KeyThenTime, + ) + .unwrap_or_else(|error| panic!("`-rate(x[5m])` was refused: {error}")); + + let (left, right, matching) = binary_parts(tree.root()); + assert_eq!(matching.op, BinaryOp::Mul); + assert_eq!(constant(left), Some(-1.0)); + let NativeExpr::Window { op, .. } = &right.expr else { + panic!("the negation's operand is not a window: {:?}", right.expr); + }; + assert_eq!(op.function, RangeFunction::Rate); + assert_eq!(op.window.range_ms, 300_000); +} + +/// A constant written as an expression binds the number it folds to, by the fold a call's parameter +/// already uses. The value is asserted exactly, so a fold that produced a neighbouring number would +/// fail here. +#[test] +fn a_constant_operand_binds_the_value_it_folds_to() { + let cases = [ + ("x * (1 + 2)", 3.0), + ("x + 2 ^ 10", 1024.0), + // `^` binds tighter than `*`, so this is `x * 8` rather than `(x * 2) ^ 3`. + ("x * 2 ^ 3", 8.0), + ("x * -0.5", -0.5), + ("x * (1 - 0.1)", 0.9), + ]; + + for (query, value) in cases { + let tree = compile(&eval_stmt(query), raw_schema(), SeriesOrdering::KeyThenTime) + .unwrap_or_else(|error| panic!("`{query}` was refused: {error}")); + let (left, right, _) = binary_parts(tree.root()); + let scalar_side = if constant(left).is_some() { + left + } else { + right + }; + assert_eq!( + constant(scalar_side), + Some(value), + "`{query}` did not bind the number it folds to" + ); + } +} + +/// An operation between two vectors is refused because the tree cannot say which selector each +/// `Input` reads: `a + b` and `a + a` would compile to the same tree. The refusal names the missing +/// identity rather than the query it found, so whoever adds it knows what to add. +#[test] +fn an_operation_between_two_vectors_is_refused_for_the_missing_selector_identity() { + for query in [ + "a + b", + "a > b", + "rate(a[5m]) * rate(b[5m])", + "abs(a) + abs(b)", + ] { + let error = compile(&eval_stmt(query), raw_schema(), SeriesOrdering::KeyThenTime) + .expect_err(&format!("`{query}` compiled, and it should not have")); + assert!( + matches!(error, DataFusionError::NotImplemented(_)), + "`{query}` did not report NotImplemented: {error}" + ); + let message = error.to_string(); + assert!( + message.contains("between two vectors"), + "`{query}` did not say what it found: {message}" + ); + assert!( + message.contains("selector"), + "`{query}` did not name what an input is missing: {message}" + ); + } +} + +/// A set operator has no node at all, so it is refused as the set operator it is rather than as an +/// arithmetic operation the compiler could not bind: `and`, `or` and `unless` combine two vectors' +/// series, which is not what a binary value node does. +#[test] +fn a_set_operator_is_refused_as_a_set_operator() { + for (query, name) in [ + ("a and b", "and"), + ("a or b", "or"), + ("a unless b", "unless"), + ] { + let error = compile(&eval_stmt(query), raw_schema(), SeriesOrdering::KeyThenTime) + .expect_err(&format!("`{query}` compiled, and it should not have")); + assert!( + matches!(error, DataFusionError::NotImplemented(_)), + "`{query}` did not report NotImplemented: {error}" + ); + let message = error.to_string(); + assert!( + message.contains(&format!("set operator `{name}`")), + "`{query}` did not name the operator it found: {message}" + ); + } +} + +/// A statement with no vector has nothing to read, so it is refused: a tree reads a scan and reports +/// evaluated samples, and a constant is bound next to a vector rather than on its own. The refusal +/// names the expression and its type, because the type is why it cannot be bound. +#[test] +fn a_statement_with_no_vector_is_refused() { + // `pi()` and `time()` are the two functions PromQL calls with no arguments. Both are scalars, so + // they reach this refusal before any function-name lookup: what a tree cannot do with them is + // report them, not fold them. The rejection still names the expression it found, so a reader + // learns which function it was. + for (query, found) in [ + ("1 + 2", "which is a scalar"), + ("1", "which is a scalar"), + ("-3", "which is a scalar"), + ("pi()", "which is a scalar"), + ("time()", "which is a scalar"), + ] { + let error = compile(&eval_stmt(query), raw_schema(), SeriesOrdering::KeyThenTime) + .expect_err(&format!("`{query}` compiled, and it should not have")); + assert!( + matches!(error, DataFusionError::NotImplemented(_)), + "`{query}` did not report NotImplemented: {error}" + ); + let message = error.to_string(); + assert!( + message.contains(&format!("`{query}`")), + "`{query}` did not name the expression it found: {message}" + ); + assert!( + message.contains(found), + "`{query}` did not name the type it found: {message}" + ); + } +} + +/// A vector-matching modifier has nothing to match when one operand is a constant, and the tree's +/// matching has no field for a fill value. Both are refused rather than dropped: dropping one would +/// compile a query into an operation other than the one it wrote. `bool` is not a matching modifier +/// and is bound, which is why it is not in this list. +#[test] +fn a_vector_matching_modifier_over_a_constant_is_refused() { + let cases = [ + (eval_stmt("x + ignoring() 1"), "x + ignoring() 1"), + (eval_stmt("x + on() group_left 1"), "x + on() group_left 1"), + ( + eval_stmt("x + on() group_right 1"), + "x + on() group_right 1", + ), + // A fill value is a matching extension the tree has no field for, so a fill is refused + // rather than bound without it. The parser accepts a fill only between two vectors, so the + // value is set on a tree the parser accepted. + ( + with_binary_modifier("x + 1", |modifier| { + modifier.fill_values = VectorMatchFillValues::new(0.0, 0.0); + }), + "x + 1 with a fill of 0 on both sides", + ), + ]; + + for (stmt, query) in cases { + let error = compile(&stmt, raw_schema(), SeriesOrdering::KeyThenTime) + .expect_err(&format!("`{query}` compiled, and it should not have")); + assert!( + matches!(error, DataFusionError::NotImplemented(_)), + "`{query}` did not report NotImplemented: {error}" + ); + let message = error.to_string(); + assert!( + message.contains("vector matching"), + "`{query}` did not say what it found: {message}" + ); + assert!( + message.contains("constant operand"), + "`{query}` blamed the wrong operand: {message}" + ); + } +} + +/// A modifier inside a constant argument is refused for every call that folds one, not just for an +/// arithmetic operand. +/// +/// A call's constant is folded by the same [`constant_value`], so a fill written inside one has to +/// reach the same refusal: `quantile(0.5 + fill(0) 0.1, x)` used to bind `Quantile{0.6}`, and +/// `predict_linear(x[5m], 1 + fill(0) 2)` used to bind its second argument as `3`. Both dropped the +/// fill the query wrote. +#[test] +fn a_modifier_inside_a_folded_call_argument_is_refused() { + for (stmt, query, argument) in [ + ( + eval_stmt("quantile(0.5 + fill(0) 0.1, x)"), + "quantile(0.5 + fill(0) 0.1, x)", + "first argument", + ), + ( + eval_stmt("predict_linear(x[5m], 1 + fill(0) 2)"), + "predict_linear(x[5m], 1 + fill(0) 2)", + "second argument", + ), + ] { + let error = compile(&stmt, raw_schema(), SeriesOrdering::KeyThenTime) + .expect_err(&format!("`{query}` compiled, and it should not have")); + assert!( + matches!(error, DataFusionError::NotImplemented(_)), + "`{query}` did not report NotImplemented: {error}" + ); + // The two calls refuse it on different paths -- `quantile` reads its parameter as a constant + // expression, a range function's constant is bound by the signature -- so what is asserted is + // what both have to say: which argument was folded, and that a fill is what it found. + let message = error.to_string(); + assert!( + message.contains(argument), + "`{query}` blamed the wrong argument: {message}" + ); + assert!( + message.contains("fill"), + "`{query}` did not name the fill it found: {message}" + ); + } +} + +/// A `bool` comparison of two constants folds to the number it reports, so the query runs. +/// +/// `x * (1 < bool 2)` is `x * 1`: the inner comparison is a value, not an operation over a vector. +/// Leaving it unfolded produces a `Binary` of two constants, which `validate` accepts and the +/// executor refuses only when it builds the state — a legal query that fails at run time instead of +/// answering. The fold keeps it a number, and the number is the kernel's own answer. +#[test] +fn a_bool_comparison_of_two_constants_folds_to_its_number() { + for (query, folded) in [ + ("x * (1 < bool 2)", 1.0), + ("x * (1 > bool 2)", 0.0), + ("x * (1 == bool 1)", 1.0), + ("x * (1 != bool 1)", 0.0), + // NaN satisfies only `!=`, which the fold inherits from the kernel rather than restating. + ("x * (0 / 0 == bool 0)", 0.0), + ("x * (0 / 0 != bool 0)", 1.0), + ] { + let tree = compile(&eval_stmt(query), raw_schema(), SeriesOrdering::KeyThenTime) + .unwrap_or_else(|error| panic!("`{query}` was refused: {error}")); + let (left, right, _) = binary_parts(tree.root()); + let scalar_side = if constant(left).is_some() { + left + } else { + right + }; + assert_eq!( + constant(scalar_side), + Some(folded), + "`{query}` did not fold its comparison to {folded}" + ); + } +} + +/// A comparison of two constants without `bool` has no value to report, and is refused as such. +/// +/// The parser refuses this shape outright — "comparisons between scalars must use BOOL modifier" is +/// its own message for `1 > 2` — so the only way to hand the compiler one is to clear the flag on a +/// tree it accepted. The compiler has to refuse it all the same: a caller that builds a statement +/// without going through the parser would otherwise get a node that only fails when it runs. +#[test] +fn a_comparison_of_two_constants_without_bool_is_refused() { + let mut stmt = eval_stmt("x * (1 > bool 2)"); + let PromExpr::Binary(outer) = &mut stmt.expr else { + unreachable!("`x * (1 > bool 2)` parses to a binary operation"); + }; + // The comparison is written in parentheses, which the parser keeps as a node of their own. + let PromExpr::Paren(grouped) = &mut *outer.rhs else { + unreachable!("the right operand is a parenthesised expression"); + }; + let PromExpr::Binary(inner) = &mut *grouped.expr else { + unreachable!("the parenthesised operand is the comparison"); + }; + inner + .modifier + .get_or_insert_with(BinModifier::default) + .return_bool = false; + + let error = compile(&stmt, raw_schema(), SeriesOrdering::KeyThenTime).expect_err( + "a comparison of two constants without `bool` compiled, and it should not have", + ); + assert!( + matches!(error, DataFusionError::NotImplemented(_)), + "the comparison did not report NotImplemented: {error}" + ); + let message = error.to_string(); + assert!( + message.contains("between two constants"), + "the comparison did not say what it found: {message}" + ); +} + +/// An aggregate is a shape this batch does not bind, and it is refused as the expression it is. +/// +/// The later batch that binds `sum` and the rest replaces the shape this test names, which is why +/// the refusal is asserted here rather than left to whichever expression the match happens to reach. +#[test] +fn an_aggregate_is_refused_as_an_expression() { + for query in ["sum(x)", "avg by(job)(rate(x[5m]))"] { + let error = compile(&eval_stmt(query), raw_schema(), SeriesOrdering::KeyThenTime) + .expect_err(&format!("`{query}` compiled, and it should not have")); + assert!( + matches!(error, DataFusionError::NotImplemented(_)), + "`{query}` did not report NotImplemented: {error}" + ); + let message = error.to_string(); + assert!( + message.contains("the expression `"), + "`{query}` did not name the expression it found: {message}" + ); + } +} + +/// A modifier inside a constant subexpression is refused rather than folded away. +/// +/// `x * (1 + fill(0) 2)` asks for a fill, which the tree has no field for. The inner addition is a +/// constant, so the fold runs before the binary operation is dispatched: a fold that computes the +/// number without reading the modifier answers `x * 3` and the fill disappears from a query that +/// wrote one. +#[test] +fn a_modifier_inside_a_folded_constant_is_refused() { + let error = compile( + &eval_stmt("x * (1 + fill(0) 2)"), + raw_schema(), + SeriesOrdering::KeyThenTime, + ) + .expect_err("`x * (1 + fill(0) 2)` compiled, and it should not have"); + assert!( + matches!(error, DataFusionError::NotImplemented(_)), + "`x * (1 + fill(0) 2)` did not report NotImplemented: {error}" + ); + let message = error.to_string(); + assert!( + message.contains("vector matching"), + "`x * (1 + fill(0) 2)` did not say what it found: {message}" + ); + + // An unmodified constant subexpression still folds: the refusal is about the modifier, not + // about the fold. + compile( + &eval_stmt("x * (1 + 2)"), + raw_schema(), + SeriesOrdering::KeyThenTime, + ) + .expect("`x * (1 + 2)` has no modifier to refuse"); +} + +/// The shared acceptance queries of this batch, compiled whole: each one combines the shapes the +/// batch added, so a compiler that bound any of them wrongly would fail here rather than in a +/// narrower test. +#[test] +fn the_batch_acceptance_queries_compile() { + let cases = [ + // Recursion plus a constant on the left. + "2 / (x + 1)", + // Filter against bool comparison, in both directions. + "x > 0", + "0 < x", + "x > bool 0", + // A negation. + "-x", + ]; + + for query in cases { + let tree = compile(&eval_stmt(query), raw_schema(), SeriesOrdering::KeyThenTime) + .unwrap_or_else(|error| panic!("`{query}` was refused: {error}")); + // Every tree the compiler accepts reports a grid, whether it ends in a comparison or a + // scalar broadcast. + assert_eq!( + grid_of(tree.root()), + (0, 5_000, 21), + "`{query}` does not land on the statement's grid" + ); + } +} From 3876831342772e2a9b5f5a24dd70aa646a5708dc Mon Sep 17 00:00:00 2001 From: discord9 Date: Thu, 22 Oct 2026 15:55:43 +0800 Subject: [PATCH 13/18] feat(promql): compile sum over an optionally grouped expression (#9833) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(promql): bind every aggregator that folds a group's values `AggregateKind` named only `Sum`, so the compiler bound `sum` and refused every other aggregator by name. Six of the ones it refused fold exactly as `sum` does — each reports one value per group per grid point — and two more (`stddev`, `stdvar`) plus `quantile` fold the same way without a kernel yet. Binding them is the same decision as binding `sum`: the compiler says what an aggregate means, and the runtime refuses a kind it cannot execute. A kernel landing later then changes no binding. `quantile` is the one of the nine with a parameter, and it carries it in the variant (`Quantile { quantile: f64 }`) as `RangeFunction::QuantileOverTime` does, because `BoundAggregate` has no field a constant could go in and a field that meant nothing for the other eight kinds would split a kind from its own parameter. That costs `AggregateKind` `Eq`, which nothing on this tree needed: the only exhaustive match is the display, and no collection is keyed on it. The five that do not fold are refused with the reason rather than mapped to a kind that would compute something else: `topk`, `bottomk`, `limitk` and `limit_ratio` select some of the input's series, so they do not report one value per group per grid point, and `count_values` writes one output label per distinct value. Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> (cherry picked from commit f1d1f3124ede003ae6df18fa0617aa13a56f3907) Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> # Conflicts: # src/query/src/promql/native_compile.rs # src/query/src/promql/native_compile/tests.rs * test(promql): keep folded argument regressions in their compiler layer Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> --------- Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> --- src/promql/src/native/display.rs | 19 +- src/promql/src/native/plan.rs | 37 +- src/query/src/promql/native_compile.rs | 159 +++++- src/query/src/promql/native_compile/tests.rs | 561 ++++++++++++++++--- 4 files changed, 697 insertions(+), 79 deletions(-) diff --git a/src/promql/src/native/display.rs b/src/promql/src/native/display.rs index 86d40946cc1..70c40b1089f 100644 --- a/src/promql/src/native/display.rs +++ b/src/promql/src/native/display.rs @@ -104,9 +104,7 @@ fn describe(node: &NativeNode) -> String { } NativeExpr::Aggregate { op, .. } => format!( "{} {} [{}] {}", - match op.kind { - AggregateKind::Sum => "sum", - }, + aggregate_kind(op.kind), match &op.group { Grouping::By(_) => "by", Grouping::Without(_) => "without", @@ -151,6 +149,21 @@ fn fill(values: FillValues) -> String { ) } +/// The PromQL name of an aggregate, with the constant argument it was bound to. +fn aggregate_kind(kind: AggregateKind) -> String { + match kind { + AggregateKind::Sum => "sum".to_string(), + AggregateKind::Avg => "avg".to_string(), + AggregateKind::Count => "count".to_string(), + AggregateKind::Min => "min".to_string(), + AggregateKind::Max => "max".to_string(), + AggregateKind::Group => "group".to_string(), + AggregateKind::Stddev => "stddev".to_string(), + AggregateKind::Stdvar => "stdvar".to_string(), + AggregateKind::Quantile { quantile } => format!("quantile {quantile}"), + } +} + /// A window: what it runs, over which range, and where it lands. fn window(op: &BoundWindow) -> String { format!( diff --git a/src/promql/src/native/plan.rs b/src/promql/src/native/plan.rs index f5715b21f72..c4842b74bfa 100644 --- a/src/promql/src/native/plan.rs +++ b/src/promql/src/native/plan.rs @@ -178,10 +178,43 @@ pub struct BoundMap { } /// Which aggregate a node computes. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +/// +/// Every kind here folds a group's values into one value per grid point. The aggregates that +/// instead select some of the input's series (`topk`, `bottomk`, `limitk`, `limit_ratio`) or write +/// one output label per distinct value (`count_values`) are a different shape and have no variant, +/// so a node cannot claim to compute one of them. +/// +/// A kind may be bound before its kernel exists: whether the tree can run is +/// [`NativeTree::try_new`]'s and the runtime's question, not this enum's. +/// +/// `Eq` is missing because [`AggregateKind::Quantile`] carries an `f64`, as +/// [`RangeFunction::QuantileOverTime`] does. +/// +/// [`RangeFunction::QuantileOverTime`]: crate::range_eval::RangeFunction::QuantileOverTime +#[derive(Clone, Copy, Debug, PartialEq)] pub enum AggregateKind { - /// `sum` over the values of each group at each grid point. + /// `sum`: the total of each group's values at each grid point. Sum, + /// `avg`: the mean of each group's values at each grid point. + Avg, + /// `count`: how many values each group has at each grid point. + Count, + /// `min`: the smallest of each group's values at each grid point. + Min, + /// `max`: the largest of each group's values at each grid point. + Max, + /// `group`: `1` where a group has any value at a grid point. + Group, + /// `stddev`: the standard deviation of each group's values at each grid point. + Stddev, + /// `stdvar`: the variance of each group's values at each grid point. + Stdvar, + /// `quantile`: the φ-quantile of each group's values at each grid point, as written. + Quantile { + /// The φ the query wrote, which `0` to `1` does not bound: Prometheus reads a negative φ as + /// `-Infinity` and one above `1` as `+Infinity`. + quantile: f64, + }, } /// Where an aggregate sits in the execution, which decides whether its output is a query value. diff --git a/src/query/src/promql/native_compile.rs b/src/query/src/promql/native_compile.rs index d3315f13961..1f69fb45610 100644 --- a/src/query/src/promql/native_compile.rs +++ b/src/query/src/promql/native_compile.rs @@ -26,13 +26,22 @@ //! e.g. x * 100, x > bool 0 //! e.g. 1 / x, 0 < x //! - e.g. -x +//! [(by|without)()] () +//! e.g. avg by(job)(rate(x[5m]) * 100) +//! () [(by|without)()] +//! e.g. count (x) by(job) +//! quantile(, ) [(by|without)()] +//! e.g. quantile(0.9, x) by(job) //! ``` //! +//! where `` is `sum`, `avg`, `count`, `min`, `max`, `group`, `stddev` or `stdvar`. +//! //! which become `Input` (raw samples) → `Instant` (the last sample within the statement's lookback), //! `Input` (raw samples) → `Window` (the range function over the statement's grid), //! `` → `Map` (a pointwise math function over what its argument evaluates to) and, over -//! any of those, `Binary` (an arithmetic or comparison operation with a constant on one side) and -//! `Scalar` (that constant). A selector may carry +//! any of those, `Binary` (an arithmetic or comparison operation with a constant on one side), +//! `Scalar` (that constant) and `Aggregate` (one of the nine aggregators that fold a group's values +//! into one value per grid point, optionally grouped). A selector may carry //! `offset` in either selection shape and, in the window shape, an `@` anchor. Everything else is a //! [`DataFusionError::NotImplemented`] that names what was found and quotes the shapes above, //! because rejecting a query the compiler does not understand is the only honest alternative to @@ -61,6 +70,14 @@ //! value is a scalar has nothing to read, so it is refused as well: a constant is bound next to a //! vector that gives it something to broadcast over. //! +//! An aggregate is bound as [`AggregateStage::Complete`], which is a claim about the whole execution +//! rather than about the node: it says that every contribution to every group is in this execution's +//! input. The caller has to keep that claim. One tree per partition does not: handing every +//! partition the same `Complete` tree sums each partition's part and reports it as the group's +//! whole, so a partitioned caller has to feed whole groups to one execution or split the aggregation +//! into `Partial` and `Final` instead. What a `Complete` node guarantees here is only the arithmetic +//! — that the values it folded are all the values it was given. +//! //! The statement's grid, the selector's lookback, window, offset and anchor are the only numbers //! derived here; the legality of the tree is not checked here. [`NativeTree::try_new`] is the //! single authority on that, so a node added later cannot be legal in one place and illegal in @@ -77,16 +94,17 @@ use datatypes::series::samples::SeriesSchema; use datatypes::series::time::{EvalGrid, GridSpec}; use promql::map::{ArithmeticOp, ComparisonOp, MathOp}; use promql::native::plan::{ - BoundInput, BoundInstant, BoundMap, BoundScalar, BoundWindow, NativeExpr, NativeNode, - NativeTree, NativeType, + AggregateKind, AggregateStage, BoundAggregate, BoundInput, BoundInstant, BoundMap, BoundScalar, + BoundWindow, Grouping, NativeExpr, NativeNode, NativeTree, NativeType, }; use promql::range_eval::{RangeFunction, RangeWindow}; use promql::vector_match::{BinaryMatching, BinaryOp, Cardinality, FillValues, MatchingLabels}; use promql_parser::parser::token::TokenType; use promql_parser::parser::value::ValueType; use promql_parser::parser::{ - AtModifier, BinModifier, BinaryExpr, Call, EvalStmt, Expr as PromExpr, MatrixSelector, - NumberLiteral, Offset, VectorMatchCardinality, VectorMatchFillValues, VectorSelector, token, + AggregateExpr, AtModifier, BinModifier, BinaryExpr, Call, EvalStmt, Expr as PromExpr, + LabelModifier, MatrixSelector, NumberLiteral, Offset, VectorMatchCardinality, + VectorMatchFillValues, VectorSelector, token, }; use crate::promql::label_values::signed_millis_since_epoch; @@ -95,10 +113,13 @@ use crate::promql::label_values::signed_millis_since_epoch; const SUPPORTED: &str = "a selector, a range function over a range selector — \ `([])`, with its constants in front of \ the selector as in `quantile_over_time(0.9, x[5m])` — a math function over \ - any of them, a binary operation between one of them and a constant, or a \ - negation of one of them, as in `x`, `rate(x[5m])`, \ - `quantile_over_time(0.9, x[5m])`, `abs(rate(x[5m]))`, `2 / (x + 1)` or \ - `-x`"; + any of them, a binary operation between one of them and a constant, a \ + negation of one of them, or one of the aggregators that folds a group's \ + values into one value per grid point — `sum`, `avg`, `count`, `min`, `max`, \ + `group`, `stddev`, `stdvar` and `quantile(, ...)` — over any of \ + them, optionally grouped by `by` or `without`, as in `x`, `rate(x[5m])`, \ + `quantile_over_time(0.9, x[5m])`, `abs(rate(x[5m]))`, `2 / (x + 1)`, \ + `-x`, `avg by(job)(rate(x[5m]) * 100)` or `quantile(0.9, x)`"; /// Compiles `stmt` into a tree that reads `input` and evaluates the statement's expression over it. /// @@ -195,6 +216,7 @@ fn compile_expr(context: &Context, expr: &PromExpr) -> Result { PromExpr::VectorSelector(selector) => instant_selection(context, selector), PromExpr::Unary(unary) => negate(context, &unary.expr), PromExpr::Binary(binary) => binary_operation(context, binary), + PromExpr::Aggregate(aggregate) => aggregate_expr(context, aggregate), found => Err(not_implemented(&format!("the expression `{found}`"))), } } @@ -374,6 +396,123 @@ fn binary_op(op: &TokenType) -> Option { } } +/// Compiles an aggregation into an aggregate node over the expression it groups. +/// +/// Every aggregator that folds a group's values into one value per grid point binds, whether or not +/// its kernel exists yet: `sum`, `avg`, `count`, `min`, `max`, `group`, `stddev`, `stdvar` and +/// `quantile`. Binding a shape the compiler can describe and letting the runtime refuse a kind it +/// cannot execute keeps this the one place that says what an aggregate means, so a kernel landing +/// later changes no binding. +/// +/// The rest of the parser's aggregates are a different shape, and are refused with the reason rather +/// than folded into a kind that would compute something else: `topk`, `bottomk`, `limitk` and +/// `limit_ratio` select some of the input's series, so they do not report one value per group per +/// grid point, and `count_values` writes one output label per distinct value rather than folding a +/// group's values at all. +/// +/// The grouping is bound in the tree's own terms, because `by` and `without` are not each other's +/// inverse: `by(job)` keeps `job` and drops every other label, while `without(instance)` keeps every +/// label it does not name and drops `__name__` as well, so a list of the surviving labels could not +/// say the same thing. A bare `sum(x)` groups by no label at all, which is Prometheus's reading of +/// "no modifier": every label is dropped, `__name__` included, and every series of the input falls +/// into one group. +/// +/// The stage is [`AggregateStage::Complete`], which says that every contribution to each group is in +/// this execution's input. The module documentation says what keeping that claim costs a caller. +fn aggregate_expr(context: &Context, aggregate: &AggregateExpr) -> Result { + let AggregateExpr { + op, + expr, + param, + modifier, + } = aggregate; + let kind = match op.id() { + token::T_SUM => AggregateKind::Sum, + token::T_AVG => AggregateKind::Avg, + token::T_COUNT => AggregateKind::Count, + token::T_MIN => AggregateKind::Min, + token::T_MAX => AggregateKind::Max, + token::T_GROUP => AggregateKind::Group, + token::T_STDDEV => AggregateKind::Stddev, + token::T_STDVAR => AggregateKind::Stdvar, + token::T_QUANTILE => { + // `quantile` is the only one of the nine with a parameter, and the parser always gives + // it one: a `quantile` with no quantile written is refused before the compiler sees it. + // The quantile is the call's first argument, so a parameter that is not a constant is + // refused under that name rather than answered from a number bound too early. + let param = param.as_ref().ok_or_else(|| { + not_implemented(&format!( + "the aggregate `{op}` in `{aggregate}`, which takes its quantile as its first \ + argument" + )) + })?; + AggregateKind::Quantile { + quantile: constant_argument(&op.to_string(), param, 0)?, + } + } + // A ranking aggregate keeps some of the input's series, so what it reports is not one value + // per group per grid point: `topk(3, x)` answers with three series, not with the groups' + // folded values. + token::T_TOPK | token::T_BOTTOMK | token::T_LIMITK | token::T_LIMIT_RATIO => { + return Err(not_implemented(&format!( + "the aggregate `{op}` in `{aggregate}`, which selects some of its input's series \ + rather than folding each group's values into one value per grid point" + ))); + } + // `count_values` folds nothing: it writes one output label per distinct value it saw, which + // is a shape no folded value can spell. + token::T_COUNT_VALUES => { + return Err(not_implemented(&format!( + "the aggregate `{op}` in `{aggregate}`, which writes one output label per distinct \ + value rather than folding each group's values into one value per grid point" + ))); + } + // The parser's `aggregate_op` rule lists exactly the fourteen names above, so this arm is + // an operator this compiler was not taught rather than one it means to fold. + _ => { + return Err(not_implemented(&format!( + "the aggregate `{op}` in `{aggregate}`" + ))); + } + }; + let input = compile_expr(context, expr)?; + // A complete aggregate folds each group's values and reports them on its input's grid, one value + // per input field, so its layout is its input's. + let output = input.output.clone(); + Ok(NativeNode { + expr: NativeExpr::Aggregate { + input: Box::new(input), + op: BoundAggregate { + kind, + group: grouping(modifier.as_ref()), + stage: AggregateStage::Complete, + }, + }, + output, + }) +} + +/// How a `by` or `without` modifier identifies a group, in the tree's own terms. +/// +/// The labels keep the order they were written in: a grouping is a set, and the tree records the +/// query rather than a key it derived from one. +fn grouping(modifier: Option<&LabelModifier>) -> Grouping { + /// The written labels, as the tree stores them. + fn labels(labels: &[String]) -> Arc<[Arc]> { + labels + .iter() + .map(|label| Arc::::from(label.as_str())) + .collect() + } + match modifier { + // No modifier and `by()` ask for the same grouping: no label is kept, so every series of the + // input falls into one group. + None => Grouping::By(Arc::new([])), + Some(LabelModifier::Include(by)) => Grouping::By(labels(&by.labels)), + Some(LabelModifier::Exclude(without)) => Grouping::Without(labels(&without.labels)), + } +} + /// Compiles a call: a range function opens a window over the selector it is given, and a math /// function maps the value of its first argument. /// diff --git a/src/query/src/promql/native_compile/tests.rs b/src/query/src/promql/native_compile/tests.rs index a2f793f58c4..27064809939 100644 --- a/src/query/src/promql/native_compile/tests.rs +++ b/src/query/src/promql/native_compile/tests.rs @@ -20,7 +20,8 @@ use datatypes::series::SeriesOrdering; use datatypes::series::samples::{FieldSpec, SeriesSchema}; use promql::map::MathOp; use promql::native::plan::{ - BoundInstant, BoundMap, BoundWindow, NativeExpr, NativeNode, NativeTree, NativeType, + AggregateKind, AggregateStage, BoundAggregate, BoundInstant, BoundMap, BoundWindow, Grouping, + NativeExpr, NativeNode, NativeTree, NativeType, }; use promql::range_eval::RangeFunction; use promql::vector_match::{BinaryMatching, BinaryOp}; @@ -186,6 +187,14 @@ fn scalar_grid(node: &NativeNode) -> (i64, i64, u64) { (spec.start_ms, spec.step_ms, spec.len) } +/// The input and the operation of an `Aggregate` node. +fn aggregate_parts(node: &NativeNode) -> (&NativeNode, &BoundAggregate) { + let NativeExpr::Aggregate { input, op } = &node.expr else { + panic!("the node is not an aggregate: {:?}", node.expr); + }; + (input, op) +} + /// The grid spec a node's evaluated output carries, as `(start_ms, step_ms, len)`. fn grid_of(node: &NativeNode) -> (i64, i64, u64) { let spec = evaluated(node).eval_grid.as_ref().unwrap().spec(); @@ -928,7 +937,18 @@ fn the_display_of_a_compiled_tree_is_the_tree() { /// a line rather than only in an assertion over the tree. #[test] fn the_display_of_a_tree_the_batch_added_to_is_the_query() { - let cases: [(&str, &[&str]); 2] = [ + let cases: [(&str, &[&str]); 3] = [ + ( + "sum by(job)(rate(x[5m]) * 100)", + &[ + "NativeTree", + " root Aggregate sum by [job] complete", + " input Binary * ignoring [] one-to-one", + " left Window rate [300000ms] offset 0 anchor - on grid 0/5000x21", + " input Input raw samples", + " right Scalar 100", + ], + ), ( "-x", &[ @@ -1547,7 +1567,7 @@ fn an_operation_between_two_vectors_is_refused_for_the_missing_selector_identity "a + b", "a > b", "rate(a[5m]) * rate(b[5m])", - "abs(a) + abs(b)", + "sum(a) + sum(b)", ] { let error = compile(&eval_stmt(query), raw_schema(), SeriesOrdering::KeyThenTime) .expect_err(&format!("`{query}` compiled, and it should not have")); @@ -1668,46 +1688,27 @@ fn a_vector_matching_modifier_over_a_constant_is_refused() { } } -/// A modifier inside a constant argument is refused for every call that folds one, not just for an -/// arithmetic operand. -/// -/// A call's constant is folded by the same [`constant_value`], so a fill written inside one has to -/// reach the same refusal: `quantile(0.5 + fill(0) 0.1, x)` used to bind `Quantile{0.6}`, and -/// `predict_linear(x[5m], 1 + fill(0) 2)` used to bind its second argument as `3`. Both dropped the -/// fill the query wrote. +/// A modifier inside a constant argument is refused, not folded into the value it computes. #[test] fn a_modifier_inside_a_folded_call_argument_is_refused() { - for (stmt, query, argument) in [ - ( - eval_stmt("quantile(0.5 + fill(0) 0.1, x)"), - "quantile(0.5 + fill(0) 0.1, x)", - "first argument", - ), - ( - eval_stmt("predict_linear(x[5m], 1 + fill(0) 2)"), - "predict_linear(x[5m], 1 + fill(0) 2)", - "second argument", - ), - ] { - let error = compile(&stmt, raw_schema(), SeriesOrdering::KeyThenTime) - .expect_err(&format!("`{query}` compiled, and it should not have")); - assert!( - matches!(error, DataFusionError::NotImplemented(_)), - "`{query}` did not report NotImplemented: {error}" - ); - // The two calls refuse it on different paths -- `quantile` reads its parameter as a constant - // expression, a range function's constant is bound by the signature -- so what is asserted is - // what both have to say: which argument was folded, and that a fill is what it found. - let message = error.to_string(); - assert!( - message.contains(argument), - "`{query}` blamed the wrong argument: {message}" - ); - assert!( - message.contains("fill"), - "`{query}` did not name the fill it found: {message}" - ); - } + let query = "quantile_over_time(0.5 + fill(0) 0.1, x[5m])"; + let error = compile(&eval_stmt(query), raw_schema(), SeriesOrdering::KeyThenTime) + .expect_err(&format!("`{query}` compiled, and it should not have")); + assert!( + matches!(error, DataFusionError::NotImplemented(_)), + "`{query}` did not report NotImplemented: {error}" + ); + // The argument is read as a constant expression, so the refusal names the position it was read + // from and quotes the expression -- which is where the fill the query wrote is still visible. + let message = error.to_string(); + assert!( + message.contains("first argument"), + "`{query}` blamed the wrong argument: {message}" + ); + assert!( + message.contains("fill"), + "`{query}` did not name the fill it found: {message}" + ); } /// A `bool` comparison of two constants folds to the number it reports, so the query runs. @@ -1781,27 +1782,6 @@ fn a_comparison_of_two_constants_without_bool_is_refused() { ); } -/// An aggregate is a shape this batch does not bind, and it is refused as the expression it is. -/// -/// The later batch that binds `sum` and the rest replaces the shape this test names, which is why -/// the refusal is asserted here rather than left to whichever expression the match happens to reach. -#[test] -fn an_aggregate_is_refused_as_an_expression() { - for query in ["sum(x)", "avg by(job)(rate(x[5m]))"] { - let error = compile(&eval_stmt(query), raw_schema(), SeriesOrdering::KeyThenTime) - .expect_err(&format!("`{query}` compiled, and it should not have")); - assert!( - matches!(error, DataFusionError::NotImplemented(_)), - "`{query}` did not report NotImplemented: {error}" - ); - let message = error.to_string(); - assert!( - message.contains("the expression `"), - "`{query}` did not name the expression it found: {message}" - ); - } -} - /// A modifier inside a constant subexpression is refused rather than folded away. /// /// `x * (1 + fill(0) 2)` asks for a fill, which the tree has no field for. The inner addition is a @@ -1836,6 +1816,455 @@ fn a_modifier_inside_a_folded_constant_is_refused() { .expect("`x * (1 + 2)` has no modifier to refuse"); } +/// An empty `by()` groups by no label, which is what a bare aggregate already does. +/// +/// Prometheus reads `sum by() (x)` as "keep no label", the same grouping as `sum(x)`: the modifier +/// names which labels survive, and naming none keeps none. The two queries therefore have to bind +/// the same tree rather than one of them falling back to `without`, which would keep everything the +/// empty list does not name. +#[test] +fn an_empty_by_groups_the_way_a_bare_aggregate_does() { + let bare = compile( + &eval_stmt("sum(x)"), + raw_schema(), + SeriesOrdering::KeyThenTime, + ) + .unwrap_or_else(|error| panic!("`sum(x)` was refused: {error}")); + let empty = compile( + &eval_stmt("sum by() (x)"), + raw_schema(), + SeriesOrdering::KeyThenTime, + ) + .unwrap_or_else(|error| panic!("`sum by() (x)` was refused: {error}")); + + let (_, bare_op) = aggregate_parts(bare.root()); + let (_, empty_op) = aggregate_parts(empty.root()); + assert_eq!( + empty_op.group, bare_op.group, + "`sum by() (x)` grouped differently from `sum(x)`" + ); + assert!( + !matches!(empty_op.group, Grouping::Without(_)), + "an empty `by()` fell back to `without`, which keeps every label it does not name" + ); +} + +/// The labels of a `by` or `without` modifier, in the order they were written. +fn grouping_labels(op: &BoundAggregate) -> Vec { + let labels = match &op.group { + Grouping::By(labels) | Grouping::Without(labels) => labels, + }; + labels.iter().map(|label| label.to_string()).collect() +} + +/// Asserts that `op`'s grouping names exactly `expected`, in the order it was written. +fn assert_grouping_labels(op: &BoundAggregate, expected: &[&str]) { + let expected: Vec = expected.iter().map(|label| label.to_string()).collect(); + assert_eq!( + grouping_labels(op), + expected, + "the grouping names the wrong labels" + ); +} + +/// A bare `sum(x)` groups by no label at all, which is how Prometheus reads an aggregate with no +/// modifier: every label is dropped, `__name__` included, so every series of the input falls into one +/// group. The tree records that as `By` with no label rather than as `Without` of everything, which +/// is why the modifier's shape is asserted and not only its label list. +#[test] +fn a_bare_sum_groups_by_no_label() { + let tree = compile( + &eval_stmt("sum(x)"), + raw_schema(), + SeriesOrdering::KeyThenTime, + ) + .unwrap_or_else(|error| panic!("`sum(x)` was refused: {error}")); + + let (input, op) = aggregate_parts(tree.root()); + assert_eq!(op.kind, AggregateKind::Sum); + assert_eq!(op.stage, AggregateStage::Complete); + let Grouping::By(labels) = &op.group else { + panic!("a bare `sum` grouped without labels: {:?}", op.group); + }; + assert!(labels.is_empty(), "a bare `sum` kept the labels {labels:?}"); + + // A complete aggregate folds each group's values and reports them on its input's grid, one value + // per input field, so its layout and grid are its input's. + assert_eq!(evaluated(tree.root()).fields.len(), 1); + assert_eq!(grid_of(tree.root()), (0, 5_000, 21)); + assert_eq!( + grid_of(input), + (0, 5_000, 21), + "the aggregate's input is not on the statement's grid" + ); +} + +/// `by` keeps the labels it names and drops the rest, and the tree records them in the order they +/// were written: a grouping is a set, so both orders group the same way, but a display that reordered +/// them would not be the query. +#[test] +fn a_sum_by_keeps_the_labels_it_names() { + let cases = [ + ("sum by(job)(x)", &["job"][..]), + ("sum by(job, instance)(x)", &["job", "instance"][..]), + // The modifier is accepted after the call body as well, and binds the same way. + ("sum(x) by (job)", &["job"][..]), + ]; + + for (query, expected) in cases { + let tree = compile(&eval_stmt(query), raw_schema(), SeriesOrdering::KeyThenTime) + .unwrap_or_else(|error| panic!("`{query}` was refused: {error}")); + let (_, op) = aggregate_parts(tree.root()); + assert!( + matches!(&op.group, Grouping::By(_)), + "`{query}` did not group by labels: {:?}", + op.group + ); + assert_grouping_labels(op, expected); + } +} + +/// `without` keeps every label it does not name, which no list of kept labels can spell: `without` +/// also drops `__name__`, so a compiler that turned it into a `by` of the surviving labels would +/// group a query by labels it meant to remove. The tree records the labels to drop. +#[test] +fn a_sum_without_records_the_labels_to_drop() { + let cases = [ + ("sum without(instance)(x)", &["instance"][..]), + ("sum without(instance, job)(x)", &["instance", "job"][..]), + ("sum(x) without (instance)", &["instance"][..]), + ]; + + for (query, expected) in cases { + let tree = compile(&eval_stmt(query), raw_schema(), SeriesOrdering::KeyThenTime) + .unwrap_or_else(|error| panic!("`{query}` was refused: {error}")); + let (_, op) = aggregate_parts(tree.root()); + assert!( + matches!(&op.group, Grouping::Without(_)), + "`{query}` did not group without labels: {:?}", + op.group + ); + assert_grouping_labels(op, expected); + } +} + +/// An aggregate is an expression like any other, so it nests: `abs(sum(x))` is a map over an +/// aggregate, and `sum by(job)(rate(x[5m]) * 100)` is an aggregate over a multiplication over a +/// window. The assertions go all the way down, because a compiler that stopped at the top node would +/// still compile both queries. +#[test] +fn an_aggregate_nests_wherever_a_value_can() { + let tree = compile( + &eval_stmt("abs(sum(x))"), + raw_schema(), + SeriesOrdering::KeyThenTime, + ) + .unwrap_or_else(|error| panic!("`abs(sum(x))` was refused: {error}")); + assert!( + matches!(map_op(tree.root()), MathOp::Abs), + "`abs(sum(x))` is not an abs over the sum: {:?}", + map_parts(tree.root()).1.ops + ); + let (aggregate, _) = map_parts(tree.root()); + let (_, sum) = aggregate_parts(aggregate); + assert_eq!(sum.kind, AggregateKind::Sum); + + let tree = compile( + &eval_stmt("sum by(job)(rate(x[5m]) * 100)"), + raw_schema(), + SeriesOrdering::KeyThenTime, + ) + .unwrap_or_else(|error| panic!("`sum by(job)(rate(x[5m]) * 100)` was refused: {error}")); + let (input, op) = aggregate_parts(tree.root()); + assert_grouping_labels(op, &["job"]); + let (left, right, matching) = binary_parts(input); + assert_eq!(matching.op, BinaryOp::Mul); + assert_eq!(constant(right), Some(100.0), "the factor was not bound"); + let NativeExpr::Window { op: window, .. } = &left.expr else { + panic!( + "the multiplication's left operand is not a window: {:?}", + left.expr + ); + }; + assert_eq!(window.function, RangeFunction::Rate); + assert_eq!(window.window.range_ms, 300_000); +} + +/// `sum without(instance)(abs(x))` composes a grouping with a pointwise function, and the grouping is +/// the one the query wrote rather than the complement of it. +#[test] +fn a_grouping_composes_with_a_math_function() { + let tree = compile( + &eval_stmt("sum without(instance)(abs(x))"), + raw_schema(), + SeriesOrdering::KeyThenTime, + ) + .unwrap_or_else(|error| panic!("`sum without(instance)(abs(x))` was refused: {error}")); + + let (input, op) = aggregate_parts(tree.root()); + assert!( + matches!(&op.group, Grouping::Without(_)), + "the grouping became {:?}", + op.group + ); + assert_grouping_labels(op, &["instance"]); + assert!( + matches!(map_op(input), MathOp::Abs), + "`abs(x)` is not an abs over the selector: {:?}", + map_parts(input).1.ops + ); + let (instant, _) = map_parts(input); + let NativeExpr::Instant { .. } = &instant.expr else { + panic!("the map's input is not the selector: {:?}", instant.expr); + }; +} + +/// Every aggregate that folds a group's values into one value per grid point binds, and binds to its +/// own kind: a name table that mapped two names to one kind would compile `avg(x)` into a sum, which +/// is why the kind is asserted exactly rather than merely checked to exist. `stddev`, `stdvar` and +/// `quantile` have no kernel yet and still bind, because a shape this compiler can describe is not +/// the runtime's to refuse first. +#[test] +fn the_supported_aggregates_map_to_their_kinds() { + let cases = [ + ("sum(x)", AggregateKind::Sum), + ("avg(x)", AggregateKind::Avg), + ("count(x)", AggregateKind::Count), + ("min(x)", AggregateKind::Min), + ("max(x)", AggregateKind::Max), + ("group(x)", AggregateKind::Group), + ("stddev(x)", AggregateKind::Stddev), + ("stdvar(x)", AggregateKind::Stdvar), + ( + "quantile(0.9, x)", + AggregateKind::Quantile { quantile: 0.9 }, + ), + ]; + + for (query, kind) in cases { + let tree = compile(&eval_stmt(query), raw_schema(), SeriesOrdering::KeyThenTime) + .unwrap_or_else(|error| panic!("`{query}` was refused: {error}")); + let (_, op) = aggregate_parts(tree.root()); + assert_eq!(op.kind, kind, "`{query}` produced the wrong kind"); + assert_eq!( + op.stage, + AggregateStage::Complete, + "`{query}` is not a complete aggregate" + ); + } +} + +/// `quantile`'s parameter is the tree's constant like any other, so it folds: `quantile(1 - 0.1, x)` +/// is `quantile(0.9, x)` written two ways. A quantile outside `[0, 1]` is bound exactly as written, +/// because Prometheus defines it — below zero is `-Infinity` and above one is `+Infinity` — so +/// refusing it would be this compiler inventing a rule the query engine does not have. +#[test] +fn a_quantile_aggregate_binds_the_quantile_it_was_written_with() { + let cases = [ + ("quantile(0.9, x)", 0.9), + // The fold's own result, asserted exactly: a neighbouring number is a different query. + ("quantile(1 - 0.1, x)", 0.9), + ("quantile((0.9), x)", 0.9), + ("quantile(0, x)", 0.0), + ("quantile(1, x)", 1.0), + ("quantile(-1.0, x)", -1.0), + ("quantile(2.0, x)", 2.0), + ]; + + for (query, quantile) in cases { + let tree = compile(&eval_stmt(query), raw_schema(), SeriesOrdering::KeyThenTime) + .unwrap_or_else(|error| panic!("`{query}` was refused: {error}")); + let (_, op) = aggregate_parts(tree.root()); + assert_eq!( + op.kind, + AggregateKind::Quantile { quantile }, + "`{query}` did not bind the quantile it wrote" + ); + assert_eq!( + op.stage, + AggregateStage::Complete, + "`{query}` is not a complete aggregate" + ); + } +} + +/// An aggregate's quantile computed at query time is refused, and the error names the argument: the +/// tree binds a quantile as a constant, so one that only exists at run time has nowhere to go. This +/// is the same refusal a parameterised range function gets, because it is the same missing field. +#[test] +fn a_computed_aggregate_quantile_is_refused_and_names_the_argument() { + let query = "quantile(time(), x)"; + let error = compile(&eval_stmt(query), raw_schema(), SeriesOrdering::KeyThenTime) + .expect_err(&format!("`{query}` compiled, and it should not have")); + assert!( + matches!(error, DataFusionError::NotImplemented(_)), + "`{query}` did not report NotImplemented: {error}" + ); + let message = error.to_string(); + assert!( + message.contains("whose first argument is `time()`"), + "`{query}` did not name the argument it found: {message}" + ); + assert!( + message.contains("number literal"), + "`{query}` did not say what the argument has to be: {message}" + ); +} + +#[test] +fn a_fill_inside_an_aggregate_quantile_is_refused_and_names_the_argument() { + let query = "quantile(0.5 + fill(0) 0.1, x)"; + let error = compile(&eval_stmt(query), raw_schema(), SeriesOrdering::KeyThenTime) + .expect_err(&format!("`{query}` compiled, and it should not have")); + assert!( + matches!(error, DataFusionError::NotImplemented(_)), + "`{query}` did not report NotImplemented: {error}" + ); + let message = error.to_string(); + assert!( + message.contains("first argument"), + "`{query}` blamed the wrong argument: {message}" + ); + assert!( + message.contains("fill"), + "`{query}` did not name the fill it found: {message}" + ); +} + +/// Every aggregate that folds binds `by`, `without` and no modifier the way the query wrote them, in +/// both modifier positions. The two are asserted apart because they are not each other's inverse: +/// `by(job)` keeps `job` and drops the rest, while `without(instance)` keeps everything it does not +/// name and drops `__name__` too, and a bare aggregate groups by no label at all. +#[test] +fn every_folding_aggregate_groups_the_way_it_was_written() { + // The eight names that take only their vector, and `quantile`, which takes its quantile first: + // the three shapes are written per aggregate because the parameter sits inside the call. + let shapes = [ + ["sum(x)", "sum by(job)(x)", "sum without(instance)(x)"], + ["avg(x)", "avg by(job)(x)", "avg without(instance)(x)"], + ["count(x)", "count by(job)(x)", "count without(instance)(x)"], + ["min(x)", "min by(job)(x)", "min without(instance)(x)"], + ["max(x)", "max by(job)(x)", "max without(instance)(x)"], + ["group(x)", "group by(job)(x)", "group without(instance)(x)"], + [ + "stddev(x)", + "stddev by(job)(x)", + "stddev without(instance)(x)", + ], + [ + "stdvar(x)", + "stdvar by(job)(x)", + "stdvar without(instance)(x)", + ], + [ + "quantile(0.9, x)", + "quantile by(job)(0.9, x)", + "quantile without(instance)(0.9, x)", + ], + ]; + + for [bare, by, without] in shapes { + let tree = compile(&eval_stmt(bare), raw_schema(), SeriesOrdering::KeyThenTime) + .unwrap_or_else(|error| panic!("`{bare}` was refused: {error}")); + let (_, op) = aggregate_parts(tree.root()); + let Grouping::By(labels) = &op.group else { + panic!("`{bare}` grouped without labels: {:?}", op.group); + }; + assert!(labels.is_empty(), "`{bare}` kept the labels {labels:?}"); + + let tree = compile(&eval_stmt(by), raw_schema(), SeriesOrdering::KeyThenTime) + .unwrap_or_else(|error| panic!("`{by}` was refused: {error}")); + let (_, op) = aggregate_parts(tree.root()); + assert!( + matches!(&op.group, Grouping::By(_)), + "`{by}` did not group by labels: {:?}", + op.group + ); + assert_grouping_labels(op, &["job"]); + + let tree = compile( + &eval_stmt(without), + raw_schema(), + SeriesOrdering::KeyThenTime, + ) + .unwrap_or_else(|error| panic!("`{without}` was refused: {error}")); + let (_, op) = aggregate_parts(tree.root()); + assert!( + matches!(&op.group, Grouping::Without(_)), + "`{without}` did not group without labels: {:?}", + op.group + ); + assert_grouping_labels(op, &["instance"]); + } + + // The trailing modifier binds the same grouping as the leading one does. + let tree = compile( + &eval_stmt("quantile(0.9, x) without(instance)"), + raw_schema(), + SeriesOrdering::KeyThenTime, + ) + .unwrap_or_else(|error| panic!("`quantile(0.9, x) without(instance)` was refused: {error}")); + let (_, op) = aggregate_parts(tree.root()); + assert!( + matches!(&op.group, Grouping::Without(_)), + "a trailing `without` did not group without labels: {:?}", + op.group + ); + assert_grouping_labels(op, &["instance"]); +} + +/// The aggregates that are not a fold are refused with the reason they are a different shape rather +/// than as "unsupported": a ranking aggregate keeps some of its input's series, and `count_values` +/// writes one output label per distinct value, so neither reports one value per group per grid point +/// and neither could be bound as a kind of this tree. +#[test] +fn the_aggregates_that_are_not_a_fold_say_why_they_are_a_different_shape() { + let ranking = "selects some of its input's series"; + let cases = [ + ("topk(3, x)", "topk", ranking), + ("bottomk(3, x)", "bottomk", ranking), + ("limitk(3, x)", "limitk", ranking), + ("limit_ratio(0.5, x)", "limit_ratio", ranking), + ( + "count_values(\"v\", x)", + "count_values", + "writes one output label per distinct value", + ), + ]; + + for (query, name, reason) in cases { + let error = compile(&eval_stmt(query), raw_schema(), SeriesOrdering::KeyThenTime) + .expect_err(&format!("`{query}` compiled, and it should not have")); + assert!( + matches!(error, DataFusionError::NotImplemented(_)), + "`{query}` did not report NotImplemented: {error}" + ); + let message = error.to_string(); + assert!( + message.contains(&format!("the aggregate `{name}` in")), + "`{query}` did not name the aggregate it found: {message}" + ); + assert!( + message.contains(reason), + "`{query}` did not say why it is a different shape: {message}" + ); + assert!( + message.contains("one value per grid point"), + "`{query}` did not say what a fold would report instead: {message}" + ); + // The rejection quotes the accepted subset, so that quote has to name the whole aggregate + // set: one stale word there makes every rejection lie about what this compiler takes. + for aggregator in [ + "sum", "avg", "count", "min", "max", "group", "stddev", "stdvar", "quantile", + ] { + assert!( + message.contains(&format!("`{aggregator}")), + "`{query}`'s rejection does not quote `{aggregator}` as accepted: {message}" + ); + } + } +} + /// The shared acceptance queries of this batch, compiled whole: each one combines the shapes the /// batch added, so a compiler that bound any of them wrongly would fail here rather than in a /// narrower test. @@ -1848,6 +2277,10 @@ fn the_batch_acceptance_queries_compile() { "x > 0", "0 < x", "x > bool 0", + // Window, scalar arithmetic and aggregation in one tree. + "sum by(job)(rate(x[5m]) * 100)", + // Grouping over a pointwise function. + "sum without(instance)(abs(x))", // A negation. "-x", ]; @@ -1855,8 +2288,8 @@ fn the_batch_acceptance_queries_compile() { for query in cases { let tree = compile(&eval_stmt(query), raw_schema(), SeriesOrdering::KeyThenTime) .unwrap_or_else(|error| panic!("`{query}` was refused: {error}")); - // Every tree the compiler accepts reports a grid, whether it ends in a comparison or a - // scalar broadcast. + // Every tree the compiler accepts reports a grid, whether it ends in an aggregate, a + // comparison or a scalar broadcast. assert_eq!( grid_of(tree.root()), (0, 5_000, 21), From 10dc8940f6e31499e706e38393a992011171f557 Mon Sep 17 00:00:00 2001 From: discord9 Date: Thu, 22 Oct 2026 16:05:26 +0800 Subject: [PATCH 14/18] feat(promql): bind the remaining range kernels to their PromQL names (#9834) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(promql): bind the remaining range kernels to their PromQL names `RangeFunction` already names 26 functions and every one of them answers: the kernels for `first_over_time`, `mad_over_time`, the four `ts_of_*` functions, `predict_linear` and `double_exponential_smoothing` are all in `range_eval`. What was missing was the compiler's name table, so these queries were refused as names with no kernel although the kernel existed. Six of them are the functions Prometheus marks experimental. This crate implements them unconditionally — `RangeFunction` names them without a gate and the kernels compute them — so binding them is the same decision as binding `rate` rather than a new policy. If a gate is ever wanted it belongs where a query is admitted, not scattered across the compiler's name table. The two parameterised ones needed a second look at where the selector sits, which is not one place in PromQL: `quantile_over_time(0.9, x[5m])` writes its quantile before the selector, while `predict_linear(x[5m], 300)` and `double_exponential_smoothing(x[5m], 0.5, 0.1)` write theirs after it. A single "parameters come first" rule read the selector as a constant for the second group and rejected an ordinary query, so the signature now states the selector's position as data instead of relying on a convention. Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> (cherry picked from commit 8b15ab65c0852639edb5824c7a0e18704a311658) (cherry picked from commit d89e3064fc179d105487d4f8d1fc0ee81d6153c2) * fix(promql): preserve range argument positions in diagnostics Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * fix(promql): bind range constants without cloning their expressions Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> --------- Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> --- src/query/src/promql/native_compile.rs | 119 ++++++--- src/query/src/promql/native_compile/tests.rs | 241 ++++++++++++++++--- 2 files changed, 306 insertions(+), 54 deletions(-) diff --git a/src/query/src/promql/native_compile.rs b/src/query/src/promql/native_compile.rs index 1f69fb45610..84ff6d039a5 100644 --- a/src/query/src/promql/native_compile.rs +++ b/src/query/src/promql/native_compile.rs @@ -21,7 +21,10 @@ //! ```text //! e.g. x //! ([]) e.g. rate(x[5m]) -//! (, []) e.g. quantile_over_time(0.9, x[5m]) +//! (, []) e.g. quantile_over_time(0.9, x[5m]) +//! ([], [, ]) +//! e.g. predict_linear(x[5m], 1), +//! double_exponential_smoothing(x[5m], 0.5, 0.5) //! ([, ]*) e.g. abs(rate(x[5m])), clamp_min(x, 0) //! e.g. x * 100, x > bool 0 //! e.g. 1 / x, 0 < x @@ -111,8 +114,10 @@ use crate::promql::label_values::signed_millis_since_epoch; /// The shapes this compiler accepts, quoted in every rejection. const SUPPORTED: &str = "a selector, a range function over a range selector — \ - `([])`, with its constants in front of \ - the selector as in `quantile_over_time(0.9, x[5m])` — a math function over \ + `([])`, with constants before the selector \ + as in `quantile_over_time(0.9, x[5m])` or after it as in \ + `predict_linear(x[5m], 1)` and \ + `double_exponential_smoothing(x[5m], 0.5, 0.5)` — a math function over \ any of them, a binary operation between one of them and a constant, a \ negation of one of them, or one of the aggregators that folds a group's \ values into one value per grid point — `sum`, `avg`, `count`, `min`, `max`, \ @@ -539,8 +544,9 @@ fn compile_call(context: &Context, call: &Call) -> Result { /// Binds a range function over the range selector it is given, and the window that selector opens. /// -/// The parameters come first and the range selector is last, for both signatures: a function without -/// parameters has no `parameters` to read. +/// The selector's place is the signature's, so the constants on either side of it are read as the +/// signature says: `quantile_over_time` writes its parameter in front of the selector, while +/// `predict_linear` and `double_exponential_smoothing` write theirs behind it. fn window_call( context: &Context, name: &str, @@ -549,7 +555,7 @@ fn window_call( ) -> Result { // The count is the signature's, so the rejection names what this function takes rather than // what one argument happens to be. - let expected = signature.parameters() + 1; + let expected = signature.arity(); if args.len() != expected { return Err(not_implemented(&format!( "a call to `{name}` with {} argument(s), where it takes {expected}: {}", @@ -557,17 +563,18 @@ fn window_call( signature.arguments() ))); } - let (parameters, selector) = args.split_at(signature.parameters()); - let function = match signature { - RangeSignature::Selector(function) => function, - RangeSignature::Scalar(bind) => bind(constant_argument(name, ¶meters[0], 0)?), - }; - let argument = &selector[0]; + // The selector sits where the signature says it does, and everything else is a constant. Reading + // it by position rather than by "the last argument" is what lets `predict_linear(x[5m], 300)` and + // `quantile_over_time(0.9, x[5m])` share one path. + let selector_index = signature.selector_index(); + let argument = &args[selector_index]; let PromExpr::MatrixSelector(MatrixSelector { vs, range }) = unparen(argument) else { return Err(not_implemented(&format!( - "a call to `{name}` whose argument is `{argument}`" + "a call to `{name}` whose range selector is `{argument}`, where it has to be a range \ + selector like `x[5m]`" ))); }; + let function = signature.bind(name, args)?; window_selection(context, function, vs, range) } @@ -804,34 +811,74 @@ fn grid_error(error: datatypes::error::Error) -> DataFusionError { DataFusionError::Plan(format!("the statement's grid is invalid: {error}")) } -/// What a call to a range function has to be: the parameters it reads, and how to bind them. +/// What a call to a range function has to be: where its range selector sits, how many constants it +/// reads, and how they bind. +/// +/// The selector's position is a variant rather than a convention because PromQL does not put it in +/// one place: `quantile_over_time(0.9, x[5m])` writes its quantile before the selector, while +/// `predict_linear(x[5m], 300)` and `double_exponential_smoothing(x[5m], 0.5, 0.1)` write theirs +/// after it. A single "parameters come first" rule would read the selector as a constant in the +/// second group and reject an ordinary query, so each shape states its own layout. #[derive(Clone, Copy)] enum RangeSignature { /// A function that reads only its range selector, as in `rate(x[5m])`. Selector(RangeFunction), - /// A function that reads one constant before its range selector, and the function that constant - /// binds, as in `quantile_over_time(0.9, x[5m])`. - /// - /// The constant is kept out of the variant name because only the call site knows it. - Scalar(fn(f64) -> RangeFunction), + /// One constant, then the range selector, as in `quantile_over_time(0.9, x[5m])`. + ConstantBeforeSelector(fn(f64) -> RangeFunction), + /// The range selector, then one constant, as in `predict_linear(x[5m], 300)`. + ConstantAfterSelector(fn(f64) -> RangeFunction), + /// The range selector, then two constants, as in + /// `double_exponential_smoothing(x[5m], 0.5, 0.1)`. + TwoConstantsAfterSelector(fn(f64, f64) -> RangeFunction), } impl RangeSignature { - /// How many scalar parameters the function reads before its range selector. - fn parameters(self) -> usize { + /// The position of the range selector among the call's arguments. + fn selector_index(self) -> usize { + match self { + Self::Selector(_) + | Self::ConstantAfterSelector(_) + | Self::TwoConstantsAfterSelector(_) => 0, + Self::ConstantBeforeSelector(_) => 1, + } + } + + /// How many constants the function reads. + fn constants(self) -> usize { match self { Self::Selector(_) => 0, - Self::Scalar(_) => 1, + Self::ConstantBeforeSelector(_) | Self::ConstantAfterSelector(_) => 1, + Self::TwoConstantsAfterSelector(_) => 2, } } + /// How many arguments the call writes, its range selector counted in. + fn arity(self) -> usize { + self.constants() + 1 + } + /// What the call's arguments are, for the arity rejection to name them. fn arguments(self) -> &'static str { match self { Self::Selector(_) => "its range selector", - Self::Scalar(_) => "a number and its range selector", + Self::ConstantBeforeSelector(_) => "a number and its range selector", + Self::ConstantAfterSelector(_) => "its range selector and a number", + Self::TwoConstantsAfterSelector(_) => "its range selector and two numbers", } } + + /// Binds the function from the call's arguments, which have already passed the arity check. + fn bind(self, name: &str, args: &[Box]) -> Result { + Ok(match self { + Self::Selector(function) => function, + Self::ConstantBeforeSelector(bind) => bind(constant_argument(name, &args[0], 0)?), + Self::ConstantAfterSelector(bind) => bind(constant_argument(name, &args[1], 1)?), + Self::TwoConstantsAfterSelector(bind) => bind( + constant_argument(name, &args[1], 1)?, + constant_argument(name, &args[2], 2)?, + ), + }) + } } /// The signature the PromQL name calls, or `None` when this compiler has no range kernel for it. @@ -842,7 +889,17 @@ impl RangeSignature { /// its name alone cannot produce the variant the tree binds. fn range_signature(name: &str) -> Option { match name { - "quantile_over_time" => Some(RangeSignature::Scalar(RangeFunction::QuantileOverTime)), + "quantile_over_time" => Some(RangeSignature::ConstantBeforeSelector( + RangeFunction::QuantileOverTime, + )), + // These two write their constants after the selector, which is the opposite order to + // `quantile_over_time`. + "predict_linear" => Some(RangeSignature::ConstantAfterSelector( + RangeFunction::PredictLinear, + )), + "double_exponential_smoothing" => Some(RangeSignature::TwoConstantsAfterSelector( + |smoothing, trend| RangeFunction::DoubleExponentialSmoothing { smoothing, trend }, + )), _ => range_function(name).map(RangeSignature::Selector), } } @@ -1022,9 +1079,10 @@ fn constant_argument(name: &str, argument: &PromExpr, position: usize) -> Result /// How a rejection names the argument a parameter was read from. /// -/// The three positions are the ones a signature can read: the vector, and the two constants behind -/// it. Every caller passes the literal position its signature reads, so there is no fourth position -/// to name and no default to invent one for. +/// The three positions are the ones a supported call can write: a math function's vector and the two +/// constants behind it, or a range function's selector and the constants on either side of it. Every +/// caller passes the literal position it read, so there is no fourth position to name and no default +/// to invent one for. fn argument_position(position: usize) -> &'static str { const POSITIONS: [&str; 3] = ["first argument", "second argument", "third argument"]; POSITIONS[position] @@ -1153,6 +1211,13 @@ fn range_function(name: &str) -> Option { "resets" => Some(RangeFunction::Resets), "changes" => Some(RangeFunction::Changes), "deriv" => Some(RangeFunction::Deriv), + // These functions are supported unconditionally by this native compiler. + "first_over_time" => Some(RangeFunction::FirstOverTime), + "mad_over_time" => Some(RangeFunction::MadOverTime), + "ts_of_first_over_time" => Some(RangeFunction::TsOfFirstOverTime), + "ts_of_last_over_time" => Some(RangeFunction::TsOfLastOverTime), + "ts_of_min_over_time" => Some(RangeFunction::TsOfMinOverTime), + "ts_of_max_over_time" => Some(RangeFunction::TsOfMaxOverTime), _ => None, } } diff --git a/src/query/src/promql/native_compile/tests.rs b/src/query/src/promql/native_compile/tests.rs index 27064809939..494021183a3 100644 --- a/src/query/src/promql/native_compile/tests.rs +++ b/src/query/src/promql/native_compile/tests.rs @@ -262,6 +262,14 @@ fn the_supported_range_functions_map_to_their_kernels() { ("resets", RangeFunction::Resets), ("changes", RangeFunction::Changes), ("deriv", RangeFunction::Deriv), + // Every named range function is supported unconditionally by this compiler; a test per + // name keeps a rename or a drop from passing as a typo. + ("first_over_time", RangeFunction::FirstOverTime), + ("mad_over_time", RangeFunction::MadOverTime), + ("ts_of_first_over_time", RangeFunction::TsOfFirstOverTime), + ("ts_of_last_over_time", RangeFunction::TsOfLastOverTime), + ("ts_of_min_over_time", RangeFunction::TsOfMinOverTime), + ("ts_of_max_over_time", RangeFunction::TsOfMaxOverTime), ]; for (name, kernel) in cases { @@ -878,13 +886,10 @@ fn an_unsupported_shape_is_rejected_and_names_what_it_found() { // function arguments, so `rate(x)` never gets past parsing. ( "rate(x[5m:])", - "a call to `rate` whose argument is `x[5m:]`", - ), - // A function with no kernel, whether or not it takes a constant argument. - ( - "predict_linear(x[5m], 300)", - "a call to `predict_linear`, which has no native kernel", + "a call to `rate` whose range selector is `x[5m:]`", ), + // A function with no kernel. `predict_linear` used to stand here as the parameterised case + // and is now bound, so what is left is a name this compiler has no kernel for at all. ( "absent(x)", "a call to `absent`, which has no native kernel", @@ -1691,24 +1696,36 @@ fn a_vector_matching_modifier_over_a_constant_is_refused() { /// A modifier inside a constant argument is refused, not folded into the value it computes. #[test] fn a_modifier_inside_a_folded_call_argument_is_refused() { - let query = "quantile_over_time(0.5 + fill(0) 0.1, x[5m])"; - let error = compile(&eval_stmt(query), raw_schema(), SeriesOrdering::KeyThenTime) - .expect_err(&format!("`{query}` compiled, and it should not have")); - assert!( - matches!(error, DataFusionError::NotImplemented(_)), - "`{query}` did not report NotImplemented: {error}" - ); - // The argument is read as a constant expression, so the refusal names the position it was read - // from and quotes the expression -- which is where the fill the query wrote is still visible. - let message = error.to_string(); - assert!( - message.contains("first argument"), - "`{query}` blamed the wrong argument: {message}" - ); - assert!( - message.contains("fill"), - "`{query}` did not name the fill it found: {message}" - ); + let cases = [ + ( + "quantile_over_time(0.5 + fill(0) 0.1, x[5m])", + "first argument", + ), + ("predict_linear(x[5m], 1 + fill(0) 2)", "second argument"), + ( + "double_exponential_smoothing(x[5m], 0.5, 1 + fill(0) 2)", + "third argument", + ), + ]; + for (query, position) in cases { + let error = compile(&eval_stmt(query), raw_schema(), SeriesOrdering::KeyThenTime) + .expect_err(&format!("`{query}` compiled, and it should not have")); + assert!( + matches!(error, DataFusionError::NotImplemented(_)), + "`{query}` did not report NotImplemented: {error}" + ); + // The argument is read as a constant expression, so the refusal names its position and + // quotes the expression -- where the fill the query wrote is still visible. + let message = error.to_string(); + assert!( + message.contains(position), + "`{query}` blamed the wrong argument: {message}" + ); + assert!( + message.contains("fill"), + "`{query}` did not name the fill it found: {message}" + ); + } } /// A `bool` comparison of two constants folds to the number it reports, so the query runs. @@ -2021,9 +2038,9 @@ fn a_grouping_composes_with_a_math_function() { /// Every aggregate that folds a group's values into one value per grid point binds, and binds to its /// own kind: a name table that mapped two names to one kind would compile `avg(x)` into a sum, which -/// is why the kind is asserted exactly rather than merely checked to exist. `stddev`, `stdvar` and -/// `quantile` have no kernel yet and still bind, because a shape this compiler can describe is not -/// the runtime's to refuse first. +/// is why the kind is asserted exactly rather than merely checked to exist. Binding `stddev`, +/// `stdvar` and `quantile` is separate from native execution support: the tree can describe them +/// independently of the executor's kernels. #[test] fn the_supported_aggregates_map_to_their_kinds() { let cases = [ @@ -2297,3 +2314,173 @@ fn the_batch_acceptance_queries_compile() { ); } } + +/// `predict_linear` reads its horizon after its range selector, so the number the query wrote after +/// the selector is the horizon the kernel extrapolates to. The value is asserted apart from the +/// window, because a compiler that bound the horizon on the wrong side of the call would still +/// produce a window. +#[test] +fn a_predict_linear_binds_its_horizon_and_its_window() { + let tree = compile( + &eval_stmt("predict_linear(x[5m], 300)"), + raw_schema(), + SeriesOrdering::KeyThenTime, + ) + .unwrap_or_else(|error| panic!("`predict_linear(x[5m], 300)` was refused: {error}")); + + let (_, op) = root_window(&tree); + assert_eq!(op.function, RangeFunction::PredictLinear(300.0)); + assert_eq!(op.window.range_ms, 300_000, "the window was not bound"); + assert!(op.input_grid.is_none(), "raw input has no grid to declare"); +} + +/// `double_exponential_smoothing` reads two constants, and both reach the kernel in the order they +/// were written: swapping them would smooth with the trend factor and trend with the smoothing one, +/// which is a different series rather than an error. +/// +/// The pair is asserted with two different numbers, because a compiler that bound the second +/// constant twice would agree with a test that passed the same number twice. +#[test] +fn a_double_exponential_smoothing_binds_both_factors_in_order() { + let cases = [ + ("double_exponential_smoothing(x[5m], 0.5, 0.1)", 0.5, 0.1), + ("double_exponential_smoothing(x[5m], 0.1, 0.5)", 0.1, 0.5), + // The constants are folded like every other constant this compiler binds. + ( + "double_exponential_smoothing(x[5m], 1 / 2, 0.1 + 0.2)", + 0.5, + 0.30000000000000004, + ), + ]; + + for (query, smoothing, trend) in cases { + let tree = compile(&eval_stmt(query), raw_schema(), SeriesOrdering::KeyThenTime) + .unwrap_or_else(|error| panic!("`{query}` was refused: {error}")); + let (_, op) = root_window(&tree); + assert_eq!( + op.function, + RangeFunction::DoubleExponentialSmoothing { smoothing, trend }, + "`{query}` bound its factors wrongly" + ); + assert_eq!(op.window.range_ms, 300_000, "`{query}` lost its window"); + } +} + +/// The kernel needs both smoothing factors, so a call that wrote one is refused and the rejection +/// says what the function reads. The parser enforces the same count, so the only way to hand the +/// compiler such a call is to edit a tree it accepted: the same shape as the arity test above, +/// applied to a function whose constants follow its selector. +#[test] +fn a_smoothing_with_one_factor_is_refused_and_names_what_it_reads() { + let stmt = with_call_arguments("double_exponential_smoothing(x[5m], 0.5, 0.1)", |args| { + args.pop(); + }); + let error = compile(&stmt, raw_schema(), SeriesOrdering::KeyThenTime) + .expect_err("a smoothing with one factor compiled, and it should not have"); + assert!( + matches!(error, DataFusionError::NotImplemented(_)), + "the refusal is not NotImplemented: {error}" + ); + let message = error.to_string(); + assert!( + message.contains("with 2 argument(s), where it takes 3"), + "the refusal did not name the count the function takes: {message}" + ); + assert!( + message.contains("its range selector and two numbers"), + "the refusal did not say what the function reads: {message}" + ); +} + +/// A parameter computed at query time is refused by the parameterised range functions, and the +/// rejection names the argument's position in the call: a smoothing factor that only exists at run +/// time has no field in the tree, the same as a quantile. The position is the call's rather than the +/// constants': `quantile_over_time` writes its quantile before the range selector, while +/// `predict_linear` and `double_exponential_smoothing` write theirs after it, and the selector still +/// counts as an argument. +#[test] +fn a_computed_range_parameter_is_refused_and_names_its_position() { + let cases = [ + ("quantile_over_time(time(), x[5m])", "first argument"), + ("predict_linear(x[5m], time())", "second argument"), + ( + "double_exponential_smoothing(x[5m], time(), 0.1)", + "second argument", + ), + ( + "double_exponential_smoothing(x[5m], 0.5, time())", + "third argument", + ), + ]; + + for (query, position) in cases { + let error = compile(&eval_stmt(query), raw_schema(), SeriesOrdering::KeyThenTime) + .expect_err(&format!("`{query}` compiled, and it should not have")); + assert!( + matches!(error, DataFusionError::NotImplemented(_)), + "`{query}` did not report NotImplemented: {error}" + ); + let message = error.to_string(); + assert!( + message.contains(position), + "`{query}` did not name which argument it found: {message}" + ); + assert!( + message.contains("constant expression"), + "`{query}` did not say what the argument has to be: {message}" + ); + } +} + +/// The parameterised functions keep the anchor and the offset a function without parameters binds, +/// because the parameters decide what folds the window rather than which window is folded. +#[test] +fn a_two_constant_function_binds_an_anchor_and_an_offset_too() { + let tree = compile( + &eval_stmt("double_exponential_smoothing(x[5m] @ 1234 offset 1m, 0.5, 0.1)"), + raw_schema(), + SeriesOrdering::KeyThenTime, + ) + .unwrap_or_else(|error| panic!("a smoothed window with an anchor was refused: {error}")); + + let (_, op) = root_window(&tree); + assert_eq!( + op.function, + RangeFunction::DoubleExponentialSmoothing { + smoothing: 0.5, + trend: 0.1, + } + ); + assert_eq!(op.window.range_ms, 300_000); + assert_eq!(op.window.anchor_ms, Some(1_234_000), "the anchor was lost"); + assert_eq!(op.window.offset_ms, 60_000, "the offset was lost"); +} + +/// The two parameterised functions nest under the rest of the compiler, so a smoothed window can be +/// compared or aggregated like any other: an aggregate over a smoothing is a tree, not a shape the +/// compiler knows by name. +#[test] +fn a_parameterised_window_nests_under_a_comparison_and_an_aggregate() { + let tree = compile( + &eval_stmt("sum by(job)(predict_linear(x[5m], 300) > 0)"), + raw_schema(), + SeriesOrdering::KeyThenTime, + ) + .unwrap_or_else(|error| { + panic!("`sum by(job)(predict_linear(x[5m], 300) > 0)` refused: {error}") + }); + + let (input, aggregate) = aggregate_parts(tree.root()); + assert_eq!(aggregate.kind, AggregateKind::Sum); + let (left, right, matching) = binary_parts(input); + assert_eq!(matching.op, BinaryOp::Gt); + assert!(!matching.return_bool, "`>` without `bool` filters"); + assert_eq!(constant(right), Some(0.0)); + let NativeExpr::Window { op, .. } = &left.expr else { + panic!( + "the comparison's left operand is not a window: {:?}", + left.expr + ); + }; + assert_eq!(op.function, RangeFunction::PredictLinear(300.0)); +} From bf97de2ad1cdd6cde1e769d6ac75ff8cf19a4a2a Mon Sep 17 00:00:00 2001 From: discord9 Date: Thu, 22 Oct 2026 16:48:41 +0800 Subject: [PATCH 15/18] feat(promql): compile subqueries with deferred grid materialization (#9835) Compile range functions over explicit/default-step, offset, anchored, and nested subqueries using the shared range-evaluator grid calculation. Retain complete sampled coverage and reject empty child grids explicitly. Keep compilation allocation proportional to plan structure, not timestamp count: validate deferred root and child grids; preserve eager EvalGrid::try_new; materialize shared timestamps fallibly only when a consumer requests them. Migrate Rust timestamp-buffer callers to Result without changing wire or persisted formats. Regression coverage includes grid phase/coverage, rounded sampled endpoints, offset overflow, huge and nested compile-only grids, sparse gathering, sharing, and allocation-error propagation. Deferred compilation does not bound execution memory. No public Fused entrypoint or Scalar runtime is added here. Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> --- Cargo.lock | 1 + src/datatypes/Cargo.toml | 1 + src/datatypes/src/series/time.rs | 99 ++- src/datatypes/src/series/time/tests.rs | 105 ++- src/promql/src/aggregate/state.rs | 4 +- src/promql/src/aggregate/tests.rs | 4 +- src/promql/src/golden.rs | 2 +- src/promql/src/grid_series.rs | 4 +- src/promql/src/grid_series/reference.rs | 6 +- src/promql/src/grid_series/tests.rs | 14 +- src/promql/src/info.rs | 2 +- src/promql/src/instant_eval.rs | 2 +- src/promql/src/map/tests/broadcast.rs | 4 +- src/promql/src/map/tests/prometheus.rs | 2 +- src/promql/src/map_time.rs | 7 +- src/promql/src/native/plan.rs | 10 +- src/promql/src/range_eval.rs | 6 +- src/promql/src/range_eval/tests.rs | 29 +- src/promql/src/select.rs | 2 +- src/promql/src/vector_match.rs | 17 +- src/promql/src/vector_match/tests.rs | 6 +- src/query/src/promql/native_compile.rs | 302 +++++-- src/query/src/promql/native_compile/tests.rs | 778 ++++++++++++++++++- 23 files changed, 1236 insertions(+), 171 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a42a20af081..a00d5b123f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4559,6 +4559,7 @@ dependencies = [ "jsonb", "num", "num-traits", + "once_cell", "ordered-float 4.6.0", "parquet-variant", "parquet-variant-compute", diff --git a/src/datatypes/Cargo.toml b/src/datatypes/Cargo.toml index d1dd70acef1..555aac1813e 100644 --- a/src/datatypes/Cargo.toml +++ b/src/datatypes/Cargo.toml @@ -26,6 +26,7 @@ enum_dispatch = "0.3" jsonb.workspace = true num = "0.4" num-traits = "0.2" +once_cell.workspace = true ordered-float.workspace = true parquet-variant.workspace = true parquet-variant-compute.workspace = true diff --git a/src/datatypes/src/series/time.rs b/src/datatypes/src/series/time.rs index 5bff2d45493..966ee2f6e15 100644 --- a/src/datatypes/src/series/time.rs +++ b/src/datatypes/src/series/time.rs @@ -16,6 +16,7 @@ use std::borrow::Cow; use std::sync::Arc; use arrow::buffer::ScalarBuffer; +use once_cell::sync::OnceCell; use snafu::OptionExt; use crate::error::{InvalidSeriesSnafu, Result}; @@ -77,31 +78,27 @@ impl GridSpec { } } -/// A materialized, shareable timestamp grid. +/// A lazily materialized, shareable timestamp grid. #[derive(Debug)] pub struct EvalGrid { spec: GridSpec, - timestamps: ScalarBuffer, + timestamps: OnceCell>, } impl EvalGrid { /// Allocates the grid timestamps once for sharing across result series. pub fn try_new(spec: GridSpec) -> Result { - GridSpec::try_new(spec.start_ms, spec.step_ms, spec.len)?; - let len = - usize::try_from(spec.len).map_err(|_| invalid_series("grid length overflows usize"))?; - let mut values = Vec::new(); - values - .try_reserve_exact(len) - .map_err(|_| invalid_series("grid timestamp allocation failed"))?; - for index in 0..len { - // The validated endpoint bounds every point in this positive-step grid. - let timestamp = (spec.start_ms as i128) + (index as i128) * (spec.step_ms as i128); - values.push(timestamp as i64); - } + let grid = Self::try_deferred(spec)?; + grid.timestamps()?; + Ok(grid) + } + + /// Creates a validated grid without allocating its timestamp array. + pub fn try_deferred(spec: GridSpec) -> Result { + let spec = GridSpec::try_new(spec.start_ms, spec.step_ms, spec.len)?; Ok(Self { spec, - timestamps: values.into(), + timestamps: OnceCell::new(), }) } @@ -110,9 +107,32 @@ impl EvalGrid { self.spec } - /// Returns the shared timestamp buffer. - pub fn timestamps(&self) -> &ScalarBuffer { - &self.timestamps + /// Returns the shared timestamp buffer, materializing it on first access. + pub fn timestamps(&self) -> Result<&ScalarBuffer> { + self.timestamps.get_or_try_init(|| { + let len = usize::try_from(self.spec.len) + .map_err(|_| invalid_series("grid length overflows usize"))?; + let mut values = Vec::new(); + values + .try_reserve_exact(len) + .map_err(|_| invalid_series("grid timestamp allocation failed"))?; + for index in 0..len { + let timestamp = + (self.spec.start_ms as i128) + (index as i128) * (self.spec.step_ms as i128); + values.push(timestamp as i64); + } + Ok(values.into()) + }) + } + + fn timestamp_at(&self, position: usize) -> Option { + if position as u64 >= self.spec.len { + return None; + } + let timestamp = + (self.spec.start_ms as i128) + (position as i128) * (self.spec.step_ms as i128); + // GridSpec validation bounds every position, and i128 holds usize * i64. + Some(timestamp as i64) } } @@ -130,7 +150,10 @@ impl GridRange { let end = start.checked_add(len).context(InvalidSeriesSnafu { reason: "grid range overflow".to_string(), })?; - if end > grid.timestamps.len() { + if end + > usize::try_from(grid.spec.len) + .map_err(|_| invalid_series("grid length overflows usize"))? + { return invalid("grid range exceeds its grid"); } Ok(Self { grid, start, len }) @@ -153,8 +176,8 @@ impl GridRange { self.len == 0 } /// Returns this range as a zero-copy Arrow buffer slice. - pub fn timestamps(&self) -> ScalarBuffer { - self.grid.timestamps.slice(self.start, self.len) + pub fn timestamps(&self) -> Result> { + Ok(self.grid.timestamps()?.slice(self.start, self.len)) } } @@ -230,7 +253,8 @@ impl GridRanges { /// path expects, so callers outside this crate build axes through /// [`TimeAxis::from_grid_spans`], which keeps [`TimeAxis::Grid`] for a single span. pub(crate) fn try_new(grid: Arc, spans: Vec) -> Result { - let grid_len = grid.timestamps().len(); + let grid_len = usize::try_from(grid.spec.len) + .map_err(|_| invalid_series("grid length overflows usize"))?; let mut rows = Vec::new(); rows.try_reserve_exact(spans.len()) .map_err(|_| invalid_series("grid span allocation failed"))?; @@ -584,11 +608,11 @@ impl TimeAxis { pub fn timestamp(&self, index: usize) -> Option { match self { Self::Grid(range) => (index < range.len) - .then(|| range.grid.timestamps.get(range.start + index).copied()) + .then(|| range.grid.timestamp_at(range.start.checked_add(index)?)) .flatten(), Self::GridRanges(ranges) => { let (span, offset) = ranges.span_of(index)?; - ranges.grid.timestamps.get(span.start + offset).copied() + ranges.grid.timestamp_at(span.start.checked_add(offset)?) } Self::Explicit(values) => values.get(index).copied(), Self::ExplicitChunks(chunks) => { @@ -751,28 +775,29 @@ impl TimeAxis { /// /// A single-run grid axis lends its buffer without copying; an axis of several runs, and a /// chunked explicit axis, allocate one array of the carried rows. - pub fn timestamps(&self) -> Cow<'_, [i64]> { + pub fn timestamps(&self) -> Result> { match self { - Self::Grid(range) => { - Cow::Borrowed(&range.grid.timestamps()[range.start()..range.start() + range.len()]) - } + Self::Grid(range) => Ok(Cow::Borrowed( + &range.grid.timestamps()?[range.start()..range.start() + range.len()], + )), Self::GridRanges(ranges) => { let mut timestamps = Vec::with_capacity(ranges.len()); for (position, _) in self.positions() { - match ranges.grid.timestamps.get(position) { - Some(timestamp) => timestamps.push(*timestamp), - // Unreachable: the constructor checks every span against the grid. - None => break, - } + timestamps.push( + ranges + .grid + .timestamp_at(position) + .ok_or_else(|| invalid_series("grid span timestamp is out of range"))?, + ); } - Cow::Owned(timestamps) + Ok(Cow::Owned(timestamps)) } - Self::Explicit(values) => Cow::Borrowed(values), - Self::ExplicitChunks(_) => Cow::Owned( + Self::Explicit(values) => Ok(Cow::Borrowed(values)), + Self::ExplicitChunks(_) => Ok(Cow::Owned( (0..self.len()) .filter_map(|row| self.timestamp(row)) .collect(), - ), + )), } } diff --git a/src/datatypes/src/series/time/tests.rs b/src/datatypes/src/series/time/tests.rs index de3ba29f621..9e45bf38ee8 100644 --- a/src/datatypes/src/series/time/tests.rs +++ b/src/datatypes/src/series/time/tests.rs @@ -32,18 +32,99 @@ fn grid_specs_validate_and_ranges_are_inclusive_without_rounding_up() { assert!(GridSpec::try_new(0, -1, 1).is_err()); assert!(GridSpec::try_new(0, 1, 0).is_err()); assert!(GridSpec::try_new(i64::MAX, 1, 2).is_err()); + let extreme = + Arc::new(EvalGrid::try_new(GridSpec::try_new(i64::MIN, i64::MAX, 3).unwrap()).unwrap()); assert_eq!( - &EvalGrid::try_new(GridSpec::try_new(i64::MIN, i64::MAX, 3).unwrap()) - .unwrap() - .timestamps()[..], + &extreme.timestamps().unwrap()[..], &[i64::MIN, -1, i64::MAX - 1] ); + let axis = TimeAxis::Grid(GridRange::try_new(extreme, 0, 3).unwrap()); + assert_eq!(axis.timestamp(0), Some(i64::MIN)); + assert_eq!(axis.timestamp(1), Some(-1)); + assert_eq!(axis.timestamp(2), Some(i64::MAX - 1)); + assert_eq!(axis.timestamp(3), None); + let deferred_extreme = Arc::new( + EvalGrid::try_deferred(GridSpec::try_new(i64::MIN, i64::MAX, 3).unwrap()).unwrap(), + ); + let axis = TimeAxis::Grid(GridRange::try_new(deferred_extreme.clone(), 0, 3).unwrap()); + assert_eq!(axis.timestamp(0), Some(i64::MIN)); + assert_eq!(axis.timestamp(1), Some(-1)); + assert_eq!(axis.timestamp(2), Some(i64::MAX - 1)); + assert_eq!(axis.timestamp(3), None); + assert!(deferred_extreme.timestamps.get().is_none()); assert!(GridSpec::try_new(i64::MIN, i64::MAX, 4).is_err()); assert!(GridSpec::try_new(0, i64::MAX, u64::MAX).is_err()); assert!(GridSpec::try_new(i64::MIN, 1, 2).is_ok()); assert!(GridSpec::try_new(i64::MAX - 1, 1, 2).is_ok()); } +#[test] +fn deferred_grid_keeps_large_grid_unmaterialized_until_requested() { + let len = (isize::MAX as u64 / std::mem::size_of::() as u64) + 1; + let spec = GridSpec::try_new(i64::MIN, 1, len).unwrap(); + let grid = Arc::new(EvalGrid::try_deferred(spec).unwrap()); + assert!(grid.timestamps.get().is_none()); + let range = GridRange::try_new(grid.clone(), 0, len as usize).unwrap(); + let ranges = GridRanges::try_new(grid.clone(), vec![GridSpan::new(1, 2)]).unwrap(); + assert_eq!(ranges.len(), 2); + let range_axis = TimeAxis::Grid(range); + assert_eq!(range_axis.timestamp(1), Some(i64::MIN + 1)); + assert_eq!( + range_axis.timestamp(len as usize - 1), + Some(i64::MIN + (len as i64 - 1)) + ); + assert_eq!(range_axis.timestamp(len as usize), None); + let axis = TimeAxis::GridRanges(ranges); + assert_eq!(axis.timestamp(1), Some(i64::MIN + 2)); + assert_eq!(axis.timestamp(2), None); + assert_eq!(axis.timestamp(len as usize - 1), None); + assert!(grid.timestamps.get().is_none()); +} + +#[test] +fn deferred_grid_materialization_is_shared_across_concurrent_accesses() { + let grid = Arc::new(EvalGrid::try_deferred(GridSpec::try_new(-10, 2, 4).unwrap()).unwrap()); + let eager = EvalGrid::try_new(GridSpec::try_new(-10, 2, 4).unwrap()).unwrap(); + assert!(eager.timestamps.get().is_some()); + let threads: Vec<_> = (0..8) + .map(|_| { + let grid = grid.clone(); + std::thread::spawn(move || grid.timestamps().unwrap().as_ptr() as usize) + }) + .collect(); + let pointers: Vec = threads + .into_iter() + .map(|thread| thread.join().unwrap()) + .collect(); + assert!(pointers.iter().all(|pointer| *pointer == pointers[0])); + assert_eq!(&grid.timestamps().unwrap()[..], &[-10, -8, -6, -4]); + assert!(grid.timestamps.get().is_some()); +} + +#[test] +fn deferred_sparse_gather_materializes_only_requested_timestamps() { + let grid = Arc::new(EvalGrid::try_deferred(GridSpec::try_new(10, 10, 6).unwrap()).unwrap()); + let axis = TimeAxis::GridRanges( + GridRanges::try_new(grid.clone(), vec![GridSpan::new(1, 2), GridSpan::new(5, 1)]).unwrap(), + ); + assert_eq!(axis.timestamps().unwrap().as_ref(), &[20, 30, 60]); + assert!(grid.timestamps.get().is_none()); +} + +#[test] +fn deferred_grid_allocation_failure_is_reported_and_not_cached() { + let len = (isize::MAX as u64 / std::mem::size_of::() as u64) + 1; + let grid = + Arc::new(EvalGrid::try_deferred(GridSpec::try_new(i64::MIN, 1, len).unwrap()).unwrap()); + assert!(grid.timestamps().is_err()); + assert!(grid.timestamps.get().is_none()); + let range = GridRange::try_new(grid.clone(), 0, 1).unwrap(); + assert!(range.timestamps().is_err()); + assert!(TimeAxis::Grid(range).timestamps().is_err()); + assert!(grid.timestamps().is_err()); + assert!(grid.timestamps.get().is_none()); +} + #[test] fn grid_materialization_revalidates_public_specs_and_shares_ranges_by_value() { assert!( @@ -70,14 +151,26 @@ fn grid_materialization_revalidates_public_specs_and_shares_ranges_by_value() { }) .is_err() ); + assert!( + EvalGrid::try_deferred(GridSpec { + start_ms: 0, + step_ms: 0, + len: 1 + }) + .is_err() + ); let grid = Arc::new(EvalGrid::try_new(GridSpec::try_new(-5, 3, 5).unwrap()).unwrap()); - assert_eq!(&grid.timestamps()[..], &[-5, -2, 1, 4, 7]); + assert!(grid.timestamps.get().is_some()); + assert_eq!(&grid.timestamps().unwrap()[..], &[-5, -2, 1, 4, 7]); let a = GridRange::try_new(grid.clone(), 1, 3).unwrap(); let b = GridRange::try_new(Arc::new(EvalGrid::try_new(grid.spec()).unwrap()), 1, 3).unwrap(); assert_eq!(a, b); - let values = a.timestamps(); - assert_eq!(values.as_ptr(), grid.timestamps().as_ptr().wrapping_add(1)); + let values = a.timestamps().unwrap(); + assert_eq!( + values.as_ptr(), + grid.timestamps().unwrap().as_ptr().wrapping_add(1) + ); assert_eq!(&values[..], &[-2, 1, 4]); assert!(GridRange::try_new(grid.clone(), 5, 0).unwrap().is_empty()); assert!(GridRange::try_new(grid.clone(), 4, 2).is_err()); diff --git a/src/promql/src/aggregate/state.rs b/src/promql/src/aggregate/state.rs index d609334aa10..b70d5c7fe17 100644 --- a/src/promql/src/aggregate/state.rs +++ b/src/promql/src/aggregate/state.rs @@ -161,8 +161,8 @@ impl FloatMerger { .eval_grid .as_ref() .expect("checked at construction") - .timestamps() - .len(); + .spec() + .len as usize; if partial.op != self.op { return Err(DataFusionError::Execution(format!( "cannot merge {:?} state into {:?}", diff --git a/src/promql/src/aggregate/tests.rs b/src/promql/src/aggregate/tests.rs index b230427e2fc..fef8bb651ac 100644 --- a/src/promql/src/aggregate/tests.rs +++ b/src/promql/src/aggregate/tests.rs @@ -395,7 +395,7 @@ fn fields_are_aggregated_independently() { }) .collect() }; - let grid_len = multi.eval_grid.as_ref().unwrap().timestamps().len(); + let grid_len = multi.eval_grid.as_ref().unwrap().spec().len as usize; let q_values: Vec> = (0..grid_len) .map(|i| Some([0.0, 0.25, 0.5, 0.9, 1.0][i % 5])) .collect(); @@ -1149,7 +1149,7 @@ fn partials_merge_to_the_serial_result() { .collect(); let names: Vec<&str> = fields.iter().map(String::as_str).collect(); let schema = schema_with_grid_len(&names, rng.random_range(1..80)); - let points = schema.eval_grid.as_ref().unwrap().timestamps().len(); + let points = schema.eval_grid.as_ref().unwrap().spec().len as usize; let mut builder = GridSeriesBuilder::try_new(schema.clone()).unwrap(); let mut series = Vec::new(); for index in 0..rng.random_range(1..12) { diff --git a/src/promql/src/golden.rs b/src/promql/src/golden.rs index f15f2b126c7..cab57aa3856 100644 --- a/src/promql/src/golden.rs +++ b/src/promql/src/golden.rs @@ -254,7 +254,7 @@ fn fold_one(pieces: Vec) -> Series { None => panic!("expected a grid series, got {:?}", pieces[0].time), }; let mut rows: Vec>>> = - (0..grid.timestamps().len()).map(|_| None).collect(); + (0..grid.spec().len as usize).map(|_| None).collect(); for piece in &pieces { let mut cursors = piece .fields diff --git a/src/promql/src/grid_series.rs b/src/promql/src/grid_series.rs index 15edb845c86..c9d8bc19d1f 100644 --- a/src/promql/src/grid_series.rs +++ b/src/promql/src/grid_series.rs @@ -163,10 +163,10 @@ impl GridSeriesBuilder { self.schema.fields.len() ))); } - if index < self.next || index >= self.grid.timestamps().len() { + if index < self.next || index >= self.grid.spec().len as usize { return Err(DataFusionError::Execution(format!( "grid series index {index} is not increasing or exceeds grid length {}", - self.grid.timestamps().len() + self.grid.spec().len as usize ))); } self.next = index + 1; diff --git a/src/promql/src/grid_series/reference.rs b/src/promql/src/grid_series/reference.rs index cee219b0dfd..fb59611ac2b 100644 --- a/src/promql/src/grid_series/reference.rs +++ b/src/promql/src/grid_series/reference.rs @@ -43,7 +43,7 @@ pub(crate) fn expand(series: &Series) -> Dense { panic!("expected a grid series, got {:?}", series.time); }; let fields = series.fields.as_slice(); - let mut dense = vec![vec![None; fields.len()]; grid.timestamps().len()]; + let mut dense = vec![vec![None; fields.len()]; grid.spec().len as usize]; let mut cursors = fields .iter() .map(|field| field.cursor()) @@ -124,7 +124,7 @@ fn assert_series_matches( last + 1 - first, "{context}: the result carries a row for a position that holds no value" ); - assert_eq!(grid.timestamps().len(), expected.len()); + assert_eq!(grid.spec().len as usize, expected.len()); assert_eq!(&actual.metric, metric); let bits = |dense: &Dense| -> Vec>> { dense @@ -148,7 +148,7 @@ pub(crate) fn stitch(pieces: &[Series]) -> Dense { panic!("expected a grid series, got {:?}", piece.time); }; let dense = dense - .get_or_insert_with(|| vec![vec![None; piece.fields.len()]; grid.timestamps().len()]); + .get_or_insert_with(|| vec![vec![None; piece.fields.len()]; grid.spec().len as usize]); let mut cursors = piece .fields .iter() diff --git a/src/promql/src/grid_series/tests.rs b/src/promql/src/grid_series/tests.rs index 7692513a036..fbc6ebd0dc1 100644 --- a/src/promql/src/grid_series/tests.rs +++ b/src/promql/src/grid_series/tests.rs @@ -312,12 +312,7 @@ fn runs_never_bridge_positions_without_a_value() { let series = builder.finish(metric(0)).unwrap().expect("two rows"); // One series over a million-position grid, with two runs, and the axis says where they sit. assert_eq!( - series - .time - .grid() - .expect("a grid series") - .timestamps() - .len(), + series.time.grid().expect("a grid series").spec().len as usize, SPAN, "the grid really holds a million positions" ); @@ -787,7 +782,7 @@ fn merge_of_many_segments_keeps_one_row_per_value() { assert_eq!(merged.len(), 1); let series = &merged[0]; assert_eq!( - series.time.grid().unwrap().timestamps().len(), + series.time.grid().unwrap().spec().len as usize, GRID, "the grid really holds a million positions" ); @@ -971,7 +966,10 @@ mod acceptance { // The axis lends the grid's buffer instead of copying it: the fast path is not only a // label, it is the same rows without per-series timestamp storage. assert!( - matches!(series.time.timestamps(), std::borrow::Cow::Borrowed(_)), + matches!( + series.time.timestamps().unwrap(), + std::borrow::Cow::Borrowed(_) + ), "a single range lends its timestamps" ); } diff --git a/src/promql/src/info.rs b/src/promql/src/info.rs index 85c9ce73e0c..295f483a59f 100644 --- a/src/promql/src/info.rs +++ b/src/promql/src/info.rs @@ -75,7 +75,7 @@ pub fn info( .eval_grid .as_ref() .ok_or_else(|| DataFusionError::Execution("info requires an evaluated grid".into()))?; - let points = grid.timestamps().len(); + let points = grid.spec().len as usize; for series in base { if series.time.grid().map(|grid| grid.spec()) != Some(grid.spec()) || series.fields.len() != schema.fields.len() diff --git a/src/promql/src/instant_eval.rs b/src/promql/src/instant_eval.rs index 2b980fe5c3d..0e76ec616a5 100644 --- a/src/promql/src/instant_eval.rs +++ b/src/promql/src/instant_eval.rs @@ -226,7 +226,7 @@ pub fn timestamp_of_evaluated( "timestamp input does not match its schema".into(), )); } - let timestamps = grid.timestamps(); + let timestamps = grid.timestamps().map_err(crate::grid_series::external)?; let mut builder = GridSeriesBuilder::try_new(schema.clone())?; let mut cursors: Vec<_> = series.fields.iter().map(|field| field.cursor()).collect(); let mut row = Vec::with_capacity(cursors.len()); diff --git a/src/promql/src/map/tests/broadcast.rs b/src/promql/src/map/tests/broadcast.rs index 7d8022ef967..8bb4fe2c5b2 100644 --- a/src/promql/src/map/tests/broadcast.rs +++ b/src/promql/src/map/tests/broadcast.rs @@ -376,8 +376,8 @@ fn a_vector_scalar_operation_of_sparse_operands_keeps_the_shared_positions() { .eval_grid .as_ref() .expect("the fixture schema has a grid") - .timestamps() - .len(); + .spec() + .len as usize; let mut expected: Dense = vec![vec![None]; points_on_grid]; expected[5] = vec![Some(52.0)]; expected[9] = vec![Some(93.0)]; diff --git a/src/promql/src/map/tests/prometheus.rs b/src/promql/src/map/tests/prometheus.rs index 386f153dafb..3db701129bf 100644 --- a/src/promql/src/map/tests/prometheus.rs +++ b/src/promql/src/map/tests/prometheus.rs @@ -174,7 +174,7 @@ fn evaluate(case: &Case) -> Vec { } fn grid_len(case: &Case) -> usize { - case.schema.eval_grid.as_ref().unwrap().timestamps().len() + case.schema.eval_grid.as_ref().unwrap().spec().len as usize } #[test] diff --git a/src/promql/src/map_time.rs b/src/promql/src/map_time.rs index c0a2c5b94c3..c962f46be49 100644 --- a/src/promql/src/map_time.rs +++ b/src/promql/src/map_time.rs @@ -170,7 +170,12 @@ fn eval_time_series(schema: &SeriesSchema, value: impl Fn(i64) -> f64) -> Result let grid = schema.eval_grid.clone().ok_or_else(|| { DataFusionError::Execution("time functions require an evaluation grid".into()) })?; - let values: Float64Array = grid.timestamps().iter().map(|&ms| value(ms)).collect(); + let values: Float64Array = grid + .timestamps() + .map_err(external)? + .iter() + .map(|&ms| value(ms)) + .collect(); let range = GridRange::try_new(grid.clone(), 0, values.len()).map_err(external)?; let fields = vec![Samples::from_float(values); schema.fields.len()]; Series::try_new( diff --git a/src/promql/src/native/plan.rs b/src/promql/src/native/plan.rs index c4842b74bfa..517cf4a8678 100644 --- a/src/promql/src/native/plan.rs +++ b/src/promql/src/native/plan.rs @@ -388,11 +388,11 @@ impl NativeNode { } ensure_not_interleaved(input)?; // What is checked here and just below is that the declared input grid equals the - // input's grid, and that the output grid matches the declared one. The grid is - // not checked to cover the window's read range - // `(out.start - offset - range, out.end - offset]`: only a subquery feeds an - // evaluated input to a window, and this compiler does not accept subqueries yet, - // so that coverage check waits for it. + // input's grid, and that the output grid matches the declared one. That the grid + // covers the window's read range + // `(out.start - offset - range, out.end - offset]` is not checked here: only a + // subquery feeds an evaluated input to a window, and deriving that coverage is the + // compiler's, which builds the subquery's child grid to span it. match (op.input_grid.as_ref(), schema.eval_grid.as_ref()) { (None, None) => {} (Some(bound), Some(actual)) if bound.spec() == actual.spec() => {} diff --git a/src/promql/src/range_eval.rs b/src/promql/src/range_eval.rs index 3a7e2384809..02af5288cdc 100644 --- a/src/promql/src/range_eval.rs +++ b/src/promql/src/range_eval.rs @@ -714,7 +714,7 @@ impl RangeEvaluator { } // The input may skip positions: it is ingested by the positions it carries, and the points // it skips hold no sample, which is exactly what a hole means. - let timestamps = time.timestamps(); + let timestamps = time.timestamps().map_err(external)?; let last_ts = timestamps.last().copied(); if !last && last_ts.is_none() { // An empty delivery inside a series decides nothing: it is indistinguishable from a @@ -1045,7 +1045,7 @@ impl RangeEvaluator { let points_read = self.points_read(); let state = self.series.as_mut().expect("checked by check_chunk"); let spec = self.grid.spec(); - let grid = self.grid.timestamps(); + let grid = self.grid.timestamps().map_err(external)?; let range = i128::from(self.window.range_ms); let mut empty_tail = false; while state.cursor < points_read { @@ -1212,7 +1212,7 @@ impl RangeEvaluator { /// only the first. fn points_read(&self) -> usize { match self.replay { - Replay::PerPoint => self.grid.timestamps().len(), + Replay::PerPoint => self.grid.spec().len as usize, Replay::Once | Replay::EveryPoint => 1, } } diff --git a/src/promql/src/range_eval/tests.rs b/src/promql/src/range_eval/tests.rs index 56c830841a0..0c9aba0e139 100644 --- a/src/promql/src/range_eval/tests.rs +++ b/src/promql/src/range_eval/tests.rs @@ -279,7 +279,7 @@ fn fold_chunks(chunks: &[EvaluatedChunk], schema: &Arc) -> Option< .metric .clone(); let width = chunks[0].series.fields.len(); - let grid_len = schema.eval_grid.as_ref().unwrap().timestamps().len(); + let grid_len = schema.eval_grid.as_ref().unwrap().spec().len as usize; let mut rows: Vec>>> = (0..grid_len).map(|_| None).collect(); let mut closing = false; let mut previous_end = 0; @@ -495,6 +495,7 @@ fn window_end(grid: &EvalGrid, window: RangeWindow, eval_ts: i64) -> i64 { /// Rows of `rows`, already on the evaluation timeline, inside each grid point's window. fn windows_of(rows: &[Row], grid: &EvalGrid, window: RangeWindow) -> Vec> { grid.timestamps() + .unwrap() .iter() .map(|&eval_ts| { let end = window_end(grid, window, eval_ts); @@ -520,6 +521,7 @@ fn reference( ) -> Dense { let (kept, windows) = reference_windows(rows, grid, window, drop_stale); grid.timestamps() + .unwrap() .iter() .zip(windows) .map(|(&eval_ts, rows)| { @@ -2610,7 +2612,7 @@ fn random_evaluated( fields: usize, host: u64, ) -> (Option, Vec) { - let mut dense = random_dense(rng, grid.timestamps().len(), fields); + let mut dense = random_dense(rng, grid.spec().len as usize, fields); for value in dense.iter_mut().flatten().flatten() { if rng.random_bool(0.05) { *value = stale(); @@ -2626,6 +2628,7 @@ fn random_evaluated( } let rows = grid .timestamps() + .unwrap() .iter() .zip(&dense) .filter(|(_, row)| row.iter().any(Option::is_some)) @@ -2750,7 +2753,7 @@ fn subquery_grid_covers_every_step_multiple_in_the_windows() { let fold = fold_offset(&outer, window); let last_eval = match window.anchor_ms { Some(_) => spec.start_ms, - None => *outer.timestamps().last().unwrap(), + None => *outer.timestamps().unwrap().last().unwrap(), }; let lower = spec.start_ms - fold - window.range_ms; let expected: Vec = ((lower.div_euclid(step) - 1) * step..=last_eval - fold) @@ -2759,7 +2762,13 @@ fn subquery_grid_covers_every_step_multiple_in_the_windows() { .collect(); let actual = subquery_grid(spec, window, step) .unwrap() - .map(|grid| EvalGrid::try_new(grid).unwrap().timestamps().to_vec()) + .map(|grid| { + EvalGrid::try_new(grid) + .unwrap() + .timestamps() + .unwrap() + .to_vec() + }) .unwrap_or_default(); assert_eq!(actual, expected, "seed {seed}"); } @@ -3764,17 +3773,12 @@ fn a_million_position_grid_with_two_values_is_one_series_of_two_rows() { // What the axis does carry is the shared grid: every result of the query addresses rows on it. assert_eq!( - series - .time - .grid() - .expect("a grid series") - .timestamps() - .len(), + series.time.grid().expect("a grid series").spec().len as usize, SPAN as usize, "the grid really holds a million positions" ); assert_eq!( - series.time.timestamps().len(), + series.time.timestamps().unwrap().len(), 2, "the axis hands out two timestamps, not one per grid position" ); @@ -4148,6 +4152,7 @@ fn a_prefix_hands_out_its_rows_before_the_series_ends() { fn dense_sum_over_time(rows: &[Row], grid: &EvalGrid, window: RangeWindow) -> Dense { assert_eq!(window.offset_ms, 0, "this reference folds no offset"); grid.timestamps() + .unwrap() .iter() .map(|&eval_ts| { let inside: Vec = rows @@ -4462,7 +4467,7 @@ fn two_composed_evaluators_agree_with_the_dense_reference() { .iter() .enumerate() .filter_map(|(index, row)| { - row[0].map(|value| (inner.timestamps()[index], vec![Some(value)])) + row[0].map(|value| (inner.timestamps().unwrap()[index], vec![Some(value)])) }) .collect(); let expected = dense_sum_over_time(&stage_one_rows, &outer, outer_window); diff --git a/src/promql/src/select.rs b/src/promql/src/select.rs index c65a7955c36..6d37d638d16 100644 --- a/src/promql/src/select.rs +++ b/src/promql/src/select.rs @@ -610,7 +610,7 @@ fn grid_points(schema: &SeriesSchema, series: &[Series]) -> Result { )); } } - Ok(grid.timestamps().len()) + Ok(grid.spec().len as usize) } /// The first field's value of `series` at grid `point`, looked up by position. diff --git a/src/promql/src/vector_match.rs b/src/promql/src/vector_match.rs index 83e50a71df2..e5e069f5485 100644 --- a/src/promql/src/vector_match.rs +++ b/src/promql/src/vector_match.rs @@ -720,7 +720,10 @@ fn binary_field( Cardinality::ManyToOne(include) => (lhs, rhs, &include[..], false), Cardinality::OneToMany(include) => (rhs, lhs, &include[..], true), }; - let points = grid.timestamps().len(); + let points = grid.spec().len as usize; + let timestamp_at = |point: usize| { + (grid.spec().start_ms as i128 + point as i128 * grid.spec().step_ms as i128) as i64 + }; let errors_before = errors.len(); // The pairing phase is entered once, before the field allocates anything: its presence flags, // its match groups, the widest label set a pairing builds and the histogram payload one row of @@ -948,7 +951,7 @@ fn binary_field( operation at {} ms; many-to-many matching not allowed: matching labels must be \ unique on one side", render(key), - grid.timestamps()[point] + timestamp_at(point) ), )); } @@ -961,7 +964,7 @@ fn binary_field( "multiple matches for labels {} at {} ms: many-to-one matching must be explicit \ (group_left/group_right)", render(key), - grid.timestamps()[point] + timestamp_at(point) ), )); } @@ -975,14 +978,14 @@ fn binary_field( "multiple matches for labels {} at {} ms: grouping labels must ensure \ unique matches", render(&duplicate.key), - grid.timestamps()[duplicate.point] + timestamp_at(duplicate.point) ), ) } else { ( duplicate.point, 2, - same_labelset(&duplicate.key, grid.timestamps()[duplicate.point]), + same_labelset(&duplicate.key, timestamp_at(duplicate.point)), ) }); } @@ -1151,7 +1154,9 @@ pub fn set_vectors( let rhs = Operand::try_new(rhs_schema, rhs, field)?; let mut results = set_field(op, labels, &lhs, &rhs)?; if let Some(duplicate) = results.sort_rows() { - let timestamp = grid.timestamps()[duplicate.point]; + let timestamp = (grid.spec().start_ms as i128 + + duplicate.point as i128 * grid.spec().step_ms as i128) + as i64; errors.push((duplicate.point, 0, same_labelset(&duplicate.key, timestamp))); } fields.push(results); diff --git a/src/promql/src/vector_match/tests.rs b/src/promql/src/vector_match/tests.rs index 52ef09d3435..ec7671c9eb6 100644 --- a/src/promql/src/vector_match/tests.rs +++ b/src/promql/src/vector_match/tests.rs @@ -130,7 +130,7 @@ fn random_side(rng: &mut StdRng, grid: &Arc, fields: usize) -> Side { } // Each label set starts its own series: only `begin_series` resets the builder. builder.begin_series(); - let values = random_dense(rng, grid.timestamps().len(), fields); + let values = random_dense(rng, grid.spec().len as usize, fields); for (index, row) in values.iter().enumerate() { builder.push(index, row).unwrap(); } @@ -489,7 +489,7 @@ fn binary_vectors_match_prometheus_reference() { fill: FillValues::default(), }; let fields = lhs_fields.min(rhs_fields); - let points = grid.timestamps().len(); + let points = grid.spec().len as usize; let expected = per_field(fields, points, |field| { reference_binary( &matching, @@ -561,7 +561,7 @@ fn set_vectors_match_prometheus_reference() { let rhs = random_side(&mut rng, &grid, fields); let op = [SetOp::And, SetOp::Or, SetOp::Unless][rng.random_range(0..3)]; let labels = random_labels(&mut rng); - let points = grid.timestamps().len(); + let points = grid.spec().len as usize; let expected = per_field(fields, points, |field| { reference_set( op, diff --git a/src/query/src/promql/native_compile.rs b/src/query/src/promql/native_compile.rs index 84ff6d039a5..7465be279a1 100644 --- a/src/query/src/promql/native_compile.rs +++ b/src/query/src/promql/native_compile.rs @@ -19,37 +19,64 @@ //! accepts is one recursive rule: //! //! ```text -//! e.g. x -//! ([]) e.g. rate(x[5m]) -//! (, []) e.g. quantile_over_time(0.9, x[5m]) -//! ([], [, ]) -//! e.g. predict_linear(x[5m], 1), -//! double_exponential_smoothing(x[5m], 0.5, 0.5) -//! ([, ]*) e.g. abs(rate(x[5m])), clamp_min(x, 0) -//! e.g. x * 100, x > bool 0 -//! e.g. 1 / x, 0 < x -//! - e.g. -x -//! [(by|without)()] () -//! e.g. avg by(job)(rate(x[5m]) * 100) -//! () [(by|without)()] -//! e.g. count (x) by(job) -//! quantile(, ) [(by|without)()] -//! e.g. quantile(0.9, x) by(job) +//! one of, recursively: +//! e.g. x, x offset -5m +//! () e.g. rate(x[5m]), rate(x[5m:1m] @ 1234) +//! quantile_over_time(, ) e.g. quantile_over_time(0.9, x[5m]) +//! predict_linear(, ) e.g. predict_linear(x[5m], 300) +//! double_exponential_smoothing(, , ) +//! e.g. double_exponential_smoothing(x[5m], .5, .1) +//! ([, ]*) e.g. abs(rate(x[5m])), clamp_min(x, 0) +//! e.g. x * 100, x > bool 0 +//! e.g. 1 / x, 0 < x +//! - e.g. -x +//! [(by|without)()] () +//! e.g. avg by(job)(rate(x[5m]) * 100) +//! () [(by|without)()] +//! e.g. count (x) by(job) +//! quantile(, ) [(by|without)()] +//! e.g. quantile(0.9, x) by(job) +//! +//! [] a range selector: raw samples +//! | [:] +//! a subquery: evaluated on a grid; +//! the step may be omitted as `[:]` +//! nothing, `offset `, `@ `, or +//! both, in either order — e.g. `x[5m] @ 1234 +//! offset 1m` //! ``` //! -//! where `` is `sum`, `avg`, `count`, `min`, `max`, `group`, `stddev` or `stdvar`. +//! where `` is `sum`, `avg`, `count`, `min`, `max`, `group`, `stddev`, `stdvar` or +//! `quantile`, the function names are [`range_signature`]'s and [`math_signature`]'s, and which side +//! of the matrix a range function's constants go on is [`RangeSignature`]'s: before it for +//! `quantile_over_time`, after it for `predict_linear` and `double_exponential_smoothing`. A math +//! function's constants always follow its vector. //! -//! which become `Input` (raw samples) → `Instant` (the last sample within the statement's lookback), -//! `Input` (raw samples) → `Window` (the range function over the statement's grid), +//! Parentheses are transparent wherever they appear — `rate((x[5m]))` is `rate(x[5m])` — because +//! they say how a statement was typed rather than what it asks for. +//! +//! A subquery's `` is any shape above, so a subquery nests inside a subquery on a grid +//! of its own, as in `rate(rate(x[5m:1m])[10m:30s])`; a `` left out means Prometheus's default +//! evaluation interval, one minute. Its `offset` and its `@` anchor are its own, not the inner +//! expression's, so `rate(x[5m:1m] @ end() offset 1m)` reads one fixed interval. A `` +//! outside a `` takes an `offset` and no anchor: the instant shape has no field for one, so +//! `x @ 100` is refused and `rate(x[5m] @ 100)` is not. +//! +//! Those shapes become `Input` (raw samples) → `Instant` (the last sample within the statement's +//! lookback), `Input` (raw samples) → `Window` (the range function over the statement's grid), +//! `` → `Window` whose input is evaluated (the subquery, whose input grid is its own), //! `` → `Map` (a pointwise math function over what its argument evaluates to) and, over //! any of those, `Binary` (an arithmetic or comparison operation with a constant on one side), //! `Scalar` (that constant) and `Aggregate` (one of the nine aggregators that fold a group's values -//! into one value per grid point, optionally grouped). A selector may carry -//! `offset` in either selection shape and, in the window shape, an `@` anchor. Everything else is a +//! into one value per grid point, optionally grouped). Everything else is a //! [`DataFusionError::NotImplemented`] that names what was found and quotes the shapes above, //! because rejecting a query the compiler does not understand is the only honest alternative to //! compiling it into something else. //! +//! The compiler binds a subquery's child grid and `RangeWindow` independently of runtime execution: +//! compilation describes the anchored interval, while runtime support determines how that bound +//! plan is consumed. +//! //! Two decisions keep the recursion from growing a branch per query. A constant argument — a //! quantile, a rounding step, a clamp bound — is folded out of the statement by [`constant_value`], //! so `quantile_over_time(1 - 0.1, x[5m])` binds the number it means rather than the expression it @@ -100,31 +127,42 @@ use promql::native::plan::{ AggregateKind, AggregateStage, BoundAggregate, BoundInput, BoundInstant, BoundMap, BoundScalar, BoundWindow, Grouping, NativeExpr, NativeNode, NativeTree, NativeType, }; -use promql::range_eval::{RangeFunction, RangeWindow}; +use promql::range_eval::{RangeFunction, RangeWindow, subquery_grid}; use promql::vector_match::{BinaryMatching, BinaryOp, Cardinality, FillValues, MatchingLabels}; use promql_parser::parser::token::TokenType; use promql_parser::parser::value::ValueType; use promql_parser::parser::{ AggregateExpr, AtModifier, BinModifier, BinaryExpr, Call, EvalStmt, Expr as PromExpr, - LabelModifier, MatrixSelector, NumberLiteral, Offset, VectorMatchCardinality, + LabelModifier, MatrixSelector, NumberLiteral, Offset, SubqueryExpr, VectorMatchCardinality, VectorMatchFillValues, VectorSelector, token, }; use crate::promql::label_values::signed_millis_since_epoch; /// The shapes this compiler accepts, quoted in every rejection. -const SUPPORTED: &str = "a selector, a range function over a range selector — \ - `([])`, with constants before the selector \ - as in `quantile_over_time(0.9, x[5m])` or after it as in \ - `predict_linear(x[5m], 1)` and \ - `double_exponential_smoothing(x[5m], 0.5, 0.5)` — a math function over \ - any of them, a binary operation between one of them and a constant, a \ - negation of one of them, or one of the aggregators that folds a group's \ - values into one value per grid point — `sum`, `avg`, `count`, `min`, `max`, \ - `group`, `stddev`, `stdvar` and `quantile(, ...)` — over any of \ - them, optionally grouped by `by` or `without`, as in `x`, `rate(x[5m])`, \ - `quantile_over_time(0.9, x[5m])`, `abs(rate(x[5m]))`, `2 / (x + 1)`, \ - `-x`, `avg by(job)(rate(x[5m]) * 100)` or `quantile(0.9, x)`"; +/// +/// It is one sentence of grammar rather than a list of examples, because it is what a refused query +/// reads: the shapes have to be true of every query the compiler binds, and the examples only show +/// them. The two matrix shapes and the recursion through them are stated first, because that +/// recursion is what a reader is most likely to get wrong. +const SUPPORTED: &str = "a selector — `x`, optionally with an `offset` — a range function over \ + a matrix, where a matrix is `[]` as in `rate(x[5m])` or \ + `[:]` as in `rate(x[5m:1m])`, with the step \ + optionally omitted as `[:]`, where the subquery's `` \ + is any shape here, so a subquery nests on a grid of its own as in \ + `rate(rate(x[5m:1m])[10m:30s])`, and where either matrix shape carries the \ + `offset` and `@` anchor written on it as in `rate(x[5m] @ 1234)`; a math \ + function over any of them (`abs(rate(x[5m]))`, `clamp_min(x, 0)`), a binary \ + operation between one of them and a constant (`2 / (x + 1)`, \ + `x > bool 0`), a negation of one of them (`-x`), or one of the aggregators \ + that folds a group's values into one value per grid point — `sum`, `avg`, \ + `count`, `min`, `max`, `group`, `stddev`, `stdvar` and \ + `quantile(, )` — over any of them, optionally \ + grouped by `by` or `without` as in `avg by(job)(rate(x[5m]) * 100)`; a \ + range function's own constants sit where that function takes them, before \ + the matrix for `quantile_over_time(0.9, x[5m])` and after it for \ + `predict_linear(x[5m], 300)` and \ + `double_exponential_smoothing(x[5m], 0.5, 0.1)`"; /// Compiles `stmt` into a tree that reads `input` and evaluates the statement's expression over it. /// @@ -179,6 +217,9 @@ pub fn compile( stmt, input, ordering, + // The grid is built once for the whole compilation rather than per node: every node of a + // tree without a subquery lands on this one, and a subquery replaces it for its own subtree. + grid: statement_grid(stmt)?, }; // The whole tree is validated once, here: a node compiled as another node's input is checked as // part of the tree rather than on its own, so the same node cannot be legal in one place and @@ -198,6 +239,13 @@ struct Context<'a> { input: Arc, /// The ordering the caller's scan guarantees. ordering: SeriesOrdering, + /// The grid the expression being compiled is evaluated on. + /// + /// It is the statement's grid for everything except the inside of a subquery, which opens a grid + /// of its own: `rate(x[5m:1m])` evaluates `x` at one-minute steps over the five-minute lookback, + /// not at the statement's steps. Carrying it here is what lets [`compile_expr`] recurse into a + /// subquery without every node builder having to ask which grid it is on. + grid: Arc, } /// Compiles `expr` into the node it denotes, recursing into whatever it is made of. @@ -211,7 +259,7 @@ fn compile_expr(context: &Context, expr: &PromExpr) -> Result { // would have to know how to run. The fold is checked first so that no shape below has to say // what it does with a constant operand. if let Some(value) = constant_value(expr) { - return scalar_node(context, value); + return Ok(scalar_node(context, value)); } match expr { // Parentheses group, so they change how a statement was typed rather than what it asks for: @@ -226,17 +274,19 @@ fn compile_expr(context: &Context, expr: &PromExpr) -> Result { } } -/// Binds `value` as a constant scalar on the statement's grid. +/// Binds `value` as a constant scalar on the grid of the expression being compiled. /// -/// The grid is the statement's rather than the constant's own: a constant has no timestamps, so the -/// only positions it can be broadcast over are the ones the statement is evaluated at, and -/// [`NativeTree::validate`] compares a binary operation's two grids by spec rather than by -/// allocation. -fn scalar_node(context: &Context, value: f64) -> Result { - Ok(NativeNode { +/// The grid is the context's rather than the constant's own: a constant has no timestamps, so the +/// only positions it can be broadcast over are the ones the expression it sits next to is evaluated +/// at — the statement's grid, or a subquery's inside one. [`NativeTree::validate`] compares a binary +/// operation's two grids by spec rather than by allocation. +/// +/// Nothing here can fail: the grid is the one [`Context`] already carries, so a caller needs no `?`. +fn scalar_node(context: &Context, value: f64) -> NativeNode { + NativeNode { expr: NativeExpr::Scalar(BoundScalar { value }), - output: NativeType::Scalar(statement_grid(context.stmt)?), - }) + output: NativeType::Scalar(Arc::clone(&context.grid)), + } } /// Compiles a negation as the multiplication it is: `-x` is `-1 * x`. @@ -249,7 +299,7 @@ fn negate(context: &Context, operand: &PromExpr) -> Result { // The constant goes on the left, where the minus was: `-x` subtracts nothing, but a reader of // the tree should see the same order the query was written in. Ok(broadcast( - scalar_node(context, -1.0)?, + scalar_node(context, -1.0), compile_expr(context, operand)?, BinaryOp::Mul, false, @@ -542,11 +592,17 @@ fn compile_call(context: &Context, call: &Call) -> Result { map_call(context, func.name, op, vector) } -/// Binds a range function over the range selector it is given, and the window that selector opens. +/// Binds a range function over the matrix it is given, and the window that matrix opens. /// -/// The selector's place is the signature's, so the constants on either side of it are read as the -/// signature says: `quantile_over_time` writes its parameter in front of the selector, while -/// `predict_linear` and `double_exponential_smoothing` write theirs behind it. +/// Where the matrix sits is the signature's, not a convention: `quantile_over_time(0.9, x[5m])` writes +/// its constant before the matrix, while `predict_linear(x[5m], 300)` and +/// `double_exponential_smoothing(x[5m], 0.5, 0.1)` write theirs after it, and a function with no +/// constants at all reads the matrix first. [`RangeSignature::selector_index`] is that position, and +/// everything else the call wrote is a constant — which keeps the position it was written at, so a +/// rejection names the argument the query wrote rather than the constant's offset among the constants. +/// +/// The matrix is either shape: a range selector, which reads raw samples, or a subquery, which +/// evaluates its own expression on a grid of its own. fn window_call( context: &Context, name: &str, @@ -563,19 +619,24 @@ fn window_call( signature.arguments() ))); } - // The selector sits where the signature says it does, and everything else is a constant. Reading - // it by position rather than by "the last argument" is what lets `predict_linear(x[5m], 300)` and + // The matrix sits where the signature says it does, and everything else is a constant. Reading it + // by position rather than by "the last argument" is what lets `predict_linear(x[5m], 300)` and // `quantile_over_time(0.9, x[5m])` share one path. let selector_index = signature.selector_index(); + // The selector stays in its original argument position for signature binding, while either + // matrix form can provide the samples folded by the range function. let argument = &args[selector_index]; - let PromExpr::MatrixSelector(MatrixSelector { vs, range }) = unparen(argument) else { - return Err(not_implemented(&format!( - "a call to `{name}` whose range selector is `{argument}`, where it has to be a range \ - selector like `x[5m]`" - ))); - }; let function = signature.bind(name, args)?; - window_selection(context, function, vs, range) + match unparen(argument) { + PromExpr::MatrixSelector(MatrixSelector { vs, range }) => { + window_selection(context, function, vs, range) + } + PromExpr::Subquery(subquery) => subquery_window(context, name, function, subquery), + other => Err(not_implemented(&format!( + "a call to `{name}` whose matrix is `{other}`, where it has to be a range \ + selector like `x[5m]` or a subquery like `x[5m:1m]`" + ))), + } } /// Wraps `op` over the value of `vector`, which is the first argument of a math function. @@ -629,7 +690,7 @@ fn window_selection( let anchor_ms = selector_anchor_ms(&vs.at, context.stmt)?; // The grid is the last thing built: a query refused above, or a window that cannot exist, // never pays for a grid nothing would read. - let output_grid = statement_grid(context.stmt)?; + let output_grid = Arc::clone(&context.grid); // A range function reports one value per input field, so the layout is the input's. let output = NativeType::Samples(Arc::new(SeriesSchema { @@ -667,6 +728,115 @@ fn window_selection( }) } +/// The step a subquery names when it names none, as in `x[5m:]`. +/// +/// Prometheus falls back to the global evaluation interval, whose default is a minute, rather than to +/// the statement's own step. The existing planner folds the same default; the constant is restated +/// here because that one is private to the planner, and it is a Prometheus protocol constant rather +/// than a number either caller may choose. +const DEFAULT_SUBQUERY_STEP_MS: i64 = 60_000; + +/// Binds `function` over `subquery`, whose inner expression is evaluated on a grid of its own. +/// +/// A range function folds a matrix, and PromQL writes a matrix two ways. A range selector reads raw +/// samples from the scan; a subquery builds one by evaluating an expression. That is why this node +/// declares `input_grid`: `rate(x[5m:1m])` samples `x` at one-minute steps over the five-minute +/// lookback, and the window folds those points rather than raw samples. The inner expression is +/// compiled against the subquery's grid for the same reason — a selector inside it has to land where +/// the window will read it. +/// +/// An `@` anchor makes the subquery step-invariant: every output point reads the same interval, +/// `(anchor - offset - range, anchor - offset]`. The child grid is built for that interval, while +/// the output grid remains the statement's grid; the anchor, range and offset are bound together in +/// the window. +fn subquery_window( + context: &Context, + name: &str, + function: RangeFunction, + subquery: &SubqueryExpr, +) -> Result { + let SubqueryExpr { + expr, + range, + step, + offset, + at, + } = subquery; + // The anchor is resolved by the same function a range selector's is, so `start()`, `end()` and a + // literal — including a negative one — mean the same thing in both shapes. + let anchor_ms = selector_anchor_ms(at, context.stmt)?; + // A subquery evaluates an expression and hands the window one value per grid point per field, so + // the expression inside it has to be a vector: a scalar has no fields for the window to fold. + if expr.value_type() != ValueType::Vector { + return Err(not_implemented(&format!( + "a call to `{name}` over the subquery `{subquery}`, whose expression is a {}, where a \ + subquery has to evaluate a vector", + expr.value_type() + ))); + } + let range_ms = range_millis(range.as_millis())?; + // A subquery step of zero names no evaluation point at all, so it is reported as its own value + // rather than as an empty grid. + let step_ms = match step { + Some(step) => millis(step.as_millis()).ok_or_else(|| { + DataFusionError::Plan(format!( + "the subquery's step of {}ms is out of range", + step.as_millis() + )) + })?, + None => DEFAULT_SUBQUERY_STEP_MS, + }; + if step_ms <= 0 { + return Err(DataFusionError::Plan(format!( + "the subquery's step is {step_ms}ms, and a step has to be longer than zero" + ))); + } + let offset_ms = selector_offset_ms(offset)?; + let window = RangeWindow { + range_ms, + offset_ms, + anchor_ms, + }; + let child_spec = subquery_grid(context.grid.spec(), window, step_ms)?.ok_or_else(|| { + not_implemented(&format!( + "the subquery over {range_ms}ms at {step_ms}ms per step, which names no evaluation point \ + in its anchored interval or the statement's range" + )) + })?; + let child_grid = Arc::new(EvalGrid::try_deferred(child_spec).map_err(grid_error)?); + let inner = compile_expr( + &Context { + stmt: context.stmt, + input: Arc::clone(&context.input), + ordering: context.ordering, + grid: Arc::clone(&child_grid), + }, + expr, + )?; + + // A range function reports one value per input field, and the inner expression reports one per + // field of the same raw input, so the layout is the input's. + let output = NativeType::Samples(Arc::new(SeriesSchema { + fields: Arc::clone(&context.input.fields), + eval_grid: Some(Arc::clone(&context.grid)), + })); + + Ok(NativeNode { + expr: NativeExpr::Window { + input: Box::new(inner), + op: BoundWindow { + function, + window, + // The one field that tells this window from a raw one: its input was evaluated on the + // subquery's grid rather than read from the scan. + input_grid: Some(child_grid), + output_grid: Arc::clone(&context.grid), + }, + }, + output, + }) +} + /// Compiles a bare selector into an instant selection over the raw input. /// /// The lookback, the offset and the grid are the whole of an instant selection: it reports the last @@ -684,7 +854,7 @@ fn instant_selection(context: &Context, selector: &VectorSelector) -> Result Result> { {error}" )) })?; - Ok(Arc::new(EvalGrid::try_new(spec).map_err(grid_error)?)) + Ok(Arc::new(EvalGrid::try_deferred(spec).map_err(grid_error)?)) } /// The window length of a range selector, which has to be longer than zero to select anything. @@ -1064,14 +1234,18 @@ fn arity_error(name: &str, signature: MathSignature, given: usize) -> DataFusion /// case — has nowhere to go. It is refused rather than answered from the wrong number, and the /// rejection names the expression, because that is what the query has to change. /// +/// `name` is whoever reads the argument: a call's function name, or an aggregator's, since +/// `quantile(0.9, x)` reads a constant too. The rejection therefore names that and the position +/// rather than calling everything a call, which would be false for an aggregate. +/// /// A quantile outside `[0, 1]` is not refused here: Prometheus binds it and the kernel reports /// `-Infinity` below zero and `+Infinity` above one, so refusing it would be this compiler inventing /// a rule the query engine does not have. fn constant_argument(name: &str, argument: &PromExpr, position: usize) -> Result { constant_value(argument).ok_or_else(|| { not_implemented(&format!( - "a call to `{name}` whose {} is `{argument}`, where it has to be a constant expression \ - this compiler folds to a number literal", + "`{name}`, whose {} is `{argument}`, where it has to be a constant expression this \ + compiler folds to a number literal", argument_position(position) )) }) diff --git a/src/query/src/promql/native_compile/tests.rs b/src/query/src/promql/native_compile/tests.rs index 494021183a3..76697ce8633 100644 --- a/src/query/src/promql/native_compile/tests.rs +++ b/src/query/src/promql/native_compile/tests.rs @@ -26,7 +26,8 @@ use promql::native::plan::{ use promql::range_eval::RangeFunction; use promql::vector_match::{BinaryMatching, BinaryOp}; use promql_parser::parser::{ - BinModifier, EvalStmt, Expr as PromExpr, NumberLiteral, VectorMatchFillValues, + AtModifier, BinModifier, EvalStmt, Expr as PromExpr, NumberLiteral, Offset, + VectorMatchFillValues, }; use super::compile; @@ -51,9 +52,17 @@ fn eval_stmt(query: &str) -> EvalStmt { /// The end is a parameter because a grid stops before an end it does not land on, which is the only /// way `end()` and the last grid point can be told apart. fn eval_stmt_over(query: &str, end: Duration) -> EvalStmt { + eval_stmt_between(query, Duration::ZERO, end) +} + +/// The statement `query` over `UNIX_EPOCH + start..=UNIX_EPOCH + end`, on a 5s grid. +/// +/// The start is a parameter because a grid that begins off the epoch is the only way to tell a +/// child grid built from absolute step boundaries from one built from the statement's start. +fn eval_stmt_between(query: &str, start: Duration, end: Duration) -> EvalStmt { EvalStmt { expr: promql_parser::parser::parse(query).unwrap(), - start: UNIX_EPOCH, + start: UNIX_EPOCH + start, end: UNIX_EPOCH + end, interval: Duration::from_secs(5), lookback_delta: Duration::from_secs(5), @@ -100,6 +109,15 @@ fn with_call_arguments(query: &str, edit: impl FnOnce(&mut Vec>)) stmt } +/// The bare selector `x`, as the parser builds it. +/// +/// The parser type-checks function arguments, so a vector selector where a matrix belongs can only be +/// handed to the compiler by editing a tree the parser accepted: this supplies one. +fn a_bare_selector() -> PromExpr { + let stmt = eval_stmt("x"); + stmt.expr +} + /// The range selector out of `rate(x[5m])`. /// /// The parser type-checks function arguments, so a range selector can only be handed to a function @@ -201,6 +219,21 @@ fn grid_of(node: &NativeNode) -> (i64, i64, u64) { (spec.start_ms, spec.step_ms, spec.len) } +/// A long valid statement exposes its checked grid without allocating query-sized timestamps. +#[test] +fn a_long_statement_grid_is_deferred() { + let stmt = EvalStmt { + expr: promql_parser::parser::parse("x").unwrap(), + start: UNIX_EPOCH, + end: UNIX_EPOCH + Duration::from_secs(86_400), + interval: Duration::from_millis(1), + lookback_delta: Duration::from_secs(5), + }; + let tree = compile(&stmt, raw_schema(), SeriesOrdering::KeyThenTime).unwrap(); + + assert_eq!(grid_of(tree.root()), (0, 1, 86_400_001)); +} + /// `rate(x[5m])` becomes a window over the raw input, on the statement's grid. #[test] fn a_rate_over_a_selector_becomes_a_window_over_raw_input() { @@ -880,24 +913,30 @@ fn an_interleaved_scan_is_refused_whatever_the_shape() { #[test] fn an_unsupported_shape_is_rejected_and_names_what_it_found() { let cases = [ - ("x[5m:]", "the expression `x[5m:]`"), - // Nested shapes are refused by the argument check, not skipped over. A subquery is the - // reachable case of an argument that is not a range selector: the parser type-checks - // function arguments, so `rate(x)` never gets past parsing. + // A bare subquery is a matrix, and a tree reports evaluated samples: only a range function + // can fold a matrix, so a subquery on its own has no node. + (eval_stmt("x[5m:]"), "x[5m:]", "the expression `x[5m:]`"), + // A matrix position that is neither a range selector nor a subquery: a bare vector selector + // where a matrix belongs. The parser type-checks function arguments, so this can only reach + // the compiler as an edited tree. ( - "rate(x[5m:])", - "a call to `rate` whose range selector is `x[5m:]`", + with_call_arguments("rate(x[5m])", |args| { + *args[0] = a_bare_selector(); + }), + "rate(x)", + "whose matrix is `x`", ), // A function with no kernel. `predict_linear` used to stand here as the parameterised case // and is now bound, so what is left is a name this compiler has no kernel for at all. ( + eval_stmt("absent(x)"), "absent(x)", "a call to `absent`, which has no native kernel", ), ]; - for (query, named) in cases { - let error = compile(&eval_stmt(query), raw_schema(), SeriesOrdering::KeyThenTime) + for (stmt, query, named) in cases { + let error = compile(&stmt, raw_schema(), SeriesOrdering::KeyThenTime) .expect_err(&format!("`{query}` compiled, and it should not have")); assert!( matches!(error, DataFusionError::NotImplemented(_)), @@ -2484,3 +2523,722 @@ fn a_parameterised_window_nests_under_a_comparison_and_an_aggregate() { }; assert_eq!(op.function, RangeFunction::PredictLinear(300.0)); } + +/// The grid a subquery evaluates its inner expression on, as `(start_ms, step_ms, len)`. +fn child_grid(window: &BoundWindow) -> (i64, i64, u64) { + let grid = window + .input_grid + .as_ref() + .expect("a subquery's window declares the grid its input was evaluated on"); + let spec = grid.spec(); + (spec.start_ms, spec.step_ms, spec.len) +} + +/// `rate(x[5m:1m])` folds a subquery rather than a range selector: the inner selector is evaluated on +/// the subquery's own grid, and the window declares that grid as its input's. The two grids are +/// asserted apart, because a compiler that bound the child onto the statement's grid would still +/// produce a window. +#[test] +fn a_subquery_feeds_a_window_evaluated_on_its_own_grid() { + let tree = compile( + &eval_stmt("rate(x[5m:1m])"), + raw_schema(), + SeriesOrdering::KeyThenTime, + ) + .unwrap_or_else(|error| panic!("`rate(x[5m:1m])` was refused: {error}")); + + let (input, op) = root_window(&tree); + assert_eq!(op.function, RangeFunction::Rate); + assert_eq!(op.window.range_ms, 300_000, "the subquery range was lost"); + assert_eq!(op.window.offset_ms, 0); + assert_eq!(op.window.anchor_ms, None); + + // The output lands on the statement's grid: 0, 5s, ..., 100s. + let spec = op.output_grid.spec(); + assert_eq!((spec.start_ms, spec.step_ms, spec.len), (0, 5_000, 21)); + + // The inner selector is evaluated at one-minute steps, and the earliest read window opens at + // `0 - 300000`, so the first step boundary strictly after it is `-240000`. + assert_eq!( + child_grid(op), + (-240_000, 60_000, 6), + "the subquery's own grid is wrong" + ); + // The input is that evaluation, not raw samples: this is the field that tells a subquery's window + // from one over a range selector. + let inner = evaluated(input); + assert_eq!( + inner.eval_grid.as_ref().unwrap().spec().start_ms, + -240_000, + "the inner expression was not evaluated on the subquery's grid" + ); + + // Below the window's input sits the one raw scan, so the tree still reads the scan exactly once. + let NativeExpr::Instant { input: scan, .. } = &input.expr else { + panic!( + "the subquery's input is not an instant selection: {:?}", + input.expr + ); + }; + let NativeExpr::Input(bound) = &scan.expr else { + panic!("the innermost node is not an input: {:?}", scan.expr); + }; + assert!( + matches!(&bound.output, NativeType::Samples(schema) if schema.eval_grid.is_none()), + "the subquery's innermost input is not raw samples: {:?}", + bound.output + ); +} + +/// The child grid is derived from the statement's grid, the range, the offset and the step, and each +/// of those changes it. The cases are asserted as whole specs, because a formula that dropped one +/// term would still produce a plausible grid. +#[test] +fn a_subquery_grid_follows_its_range_step_and_offset() { + let cases = [ + // (query, start_ms, step_ms, len) + // The plain case: one minute steps, five minute windows, no offset. + ("rate(x[5m:1m])", -240_000, 60_000, 6), + // The statement's own step is not the subquery's step. + ("rate(x[5m:5s])", -295_000, 5_000, 80), + // A step that does not divide the range, so the first boundary is not `-range`, and the last + // point is the last multiple of the step rather than the statement's end. + ("rate(x[5m:7s])", -294_000, 7_000, 57), + // A positive offset reads earlier samples, so the whole child grid moves back. + ("rate(x[5m:1m] offset 1m)", -300_000, 60_000, 6), + // A negative offset reads later ones, so it moves forward. + ("rate(x[5m:1m] offset -1m)", -180_000, 60_000, 6), + ]; + + for (query, start_ms, step_ms, len) in cases { + let tree = compile(&eval_stmt(query), raw_schema(), SeriesOrdering::KeyThenTime) + .unwrap_or_else(|error| panic!("`{query}` was refused: {error}")); + let (_, op) = root_window(&tree); + assert_eq!( + child_grid(op), + (start_ms, step_ms, len), + "`{query}` bound the wrong child grid" + ); + // Whatever the child grid is, the window still reports the statement's. + let spec = op.output_grid.spec(); + assert_eq!( + (spec.start_ms, spec.step_ms, spec.len), + (0, 5_000, 21), + "`{query}` moved the output off the statement's grid" + ); + } +} + +/// A subquery whose child interval contains no step boundary is refused as unsupported. +#[test] +fn a_subquery_with_an_empty_child_grid_is_refused() { + let error = compile( + &eval_stmt("rate(x[1ms:1m] @ 0.001)"), + raw_schema(), + SeriesOrdering::KeyThenTime, + ) + .expect_err("an empty subquery child grid compiled"); + assert!( + matches!(error, DataFusionError::NotImplemented(_)), + "an empty child grid is an unsupported subquery shape: {error}" + ); + assert!( + error.to_string().contains("names no evaluation point"), + "the refusal did not identify the empty child grid: {error}" + ); +} + +/// Large subquery child grids compile as deferred grids without allocating one timestamp per point. +#[test] +fn large_subquery_child_grids_compile_deferred() { + const DAY_STEP_GRID: (i64, i64, u64) = (-86_399_999, 1, 86_400_000); + let cases = [ + ("rate(x[1d:1ms] @ 0)", false), + ("rate(rate(x[1d:1ms] @ 0)[1d:1ms] @ 0)", true), + ]; + + for (query, nested) in cases { + let mut stmt = eval_stmt(query); + stmt.end = UNIX_EPOCH; + stmt.interval = Duration::from_millis(1); + let tree = compile(&stmt, raw_schema(), SeriesOrdering::KeyThenTime) + .unwrap_or_else(|error| panic!("`{query}` was refused: {error}")); + let (input, outer) = root_window(&tree); + assert_eq!(child_grid(outer), DAY_STEP_GRID, "`{query}` outer grid"); + if nested { + let NativeExpr::Window { op: inner, .. } = &input.expr else { + panic!( + "`{query}` inner expression is not a window: {:?}", + input.expr + ); + }; + assert_eq!(child_grid(inner), DAY_STEP_GRID, "`{query}` inner grid"); + } + } + + // This range makes the eager timestamp vector's capacity exceed isize::MAX bytes, while its + // child GridSpec remains valid and the deferred compiler path should require no such allocation. + let range_ms = (isize::MAX / 8 + 1) as u64; + let mut stmt = eval_stmt("rate(x[1ms:1ms] @ 0)"); + stmt.end = UNIX_EPOCH; + stmt.interval = Duration::from_millis(1); + let PromExpr::Call(call) = &mut stmt.expr else { + unreachable!("query parses to a call"); + }; + let PromExpr::Subquery(subquery) = &mut *call.args.args[0] else { + unreachable!("call argument parses to a subquery"); + }; + subquery.range = Duration::from_millis(range_ms); + + let tree = compile(&stmt, raw_schema(), SeriesOrdering::KeyThenTime) + .expect("a huge but valid deferred child grid should compile"); + let (_, window) = root_window(&tree); + let grid = child_grid(window); + assert_eq!(grid.0, -(range_ms as i64) + 1); + assert_eq!(grid.1, 1); + assert_eq!(grid.2, range_ms); +} + +/// A sampled child endpoint can fit even when the unrounded read-window endpoint does not. +#[test] +fn a_subquery_accepts_an_in_range_sampled_endpoint() { + let mut stmt = eval_stmt("rate(x[1s:1s] offset -1ms)"); + let endpoint = UNIX_EPOCH + Duration::from_millis(i64::MAX as u64); + stmt.start = endpoint; + stmt.end = endpoint; + stmt.interval = Duration::from_millis(1); + + let tree = compile(&stmt, raw_schema(), SeriesOrdering::KeyThenTime) + .expect("the in-range sampled endpoint should compile"); + let (_, op) = root_window(&tree); + let spec = op.input_grid.as_ref().unwrap().spec(); + assert_eq!( + (spec.start_ms, spec.step_ms, spec.len), + (9_223_372_036_854_775_000, 1_000, 1), + "the child grid should contain the last in-range 1-second sample" + ); +} + +/// An anchored fold offset that cannot be represented as i64 is a plan error. +#[test] +fn a_subquery_with_an_overflowing_fold_offset_is_refused() { + let mut stmt = eval_stmt("rate(x[5m:1m] @ 0)"); + let PromExpr::Call(call) = &mut stmt.expr else { + unreachable!("query parses to a call"); + }; + let PromExpr::Subquery(subquery) = &mut *call.args.args[0] else { + unreachable!("call argument parses to a subquery"); + }; + subquery.at = Some(AtModifier::At( + UNIX_EPOCH + Duration::from_millis(i64::MAX as u64), + )); + subquery.offset = Some(Offset::Neg(Duration::from_millis(1))); + + let error = compile(&stmt, raw_schema(), SeriesOrdering::KeyThenTime) + .expect_err("an overflowing subquery fold offset compiled"); + assert!( + matches!(error, DataFusionError::Plan(_)), + "an overflowing folded offset is a plan error: {error}" + ); + assert!( + error.to_string().contains("out of range"), + "the rejection did not identify the offset overflow: {error}" + ); +} + +/// A subquery step that names no point is refused: `x[5m:]` falls back to Prometheus's default +/// evaluation interval of one minute, and a step of zero has no point at all. The rejection names the +/// number rather than reporting an empty grid. +#[test] +fn a_subquery_step_must_be_positive() { + // The parser refuses `x[5m:0s]`, so a zero step can only be handed to the compiler as an edited + // tree — the same way the range and arity tests reach their shapes. + let mut stmt = eval_stmt("rate(x[5m:1m])"); + let PromExpr::Call(call) = &mut stmt.expr else { + unreachable!("`rate(x[5m:1m])` parses to a call"); + }; + let PromExpr::Subquery(subquery) = &mut *call.args.args[0] else { + unreachable!("`x[5m:1m]` parses to a subquery"); + }; + subquery.step = Some(Duration::ZERO); + + let error = compile(&stmt, raw_schema(), SeriesOrdering::KeyThenTime) + .expect_err("a zero subquery step compiled, and it should not have"); + assert!( + matches!(error, DataFusionError::Plan(_)), + "a zero step is a plan error, not a shape this compiler lacks: {error}" + ); + assert!( + error.to_string().contains("has to be longer than zero"), + "the rejection did not say what a step has to be: {error}" + ); +} + +/// A subquery with no step falls back to Prometheus's default evaluation interval of one minute, +/// which is not the statement's step: the statement here steps in five seconds, so a compiler that +/// reused it would produce a grid five times longer. +#[test] +fn a_subquery_without_a_step_uses_the_default_interval() { + let tree = compile( + &eval_stmt("rate(x[5m:])"), + raw_schema(), + SeriesOrdering::KeyThenTime, + ) + .unwrap_or_else(|error| panic!("`rate(x[5m:])` was refused: {error}")); + + let (_, op) = root_window(&tree); + assert_eq!(op.window.range_ms, 300_000); + assert_eq!( + child_grid(op).1, + 60_000, + "a subquery without a step did not use the default interval" + ); +} + +/// The child grid an anchored subquery must declare, derived from its read interval rather than from +/// the compiler's formula: the samples of `(anchor - offset - range, anchor - offset]` at `step`. +/// +/// The multiples are found by walking a two-step-wide margin around the interval and keeping the ones +/// the interval itself contains, so no division and no rounding convention appears here: a wrong +/// formula in the compiler cannot be reproduced by this helper, which is the whole point of it. +fn anchored_child_grid( + anchor_ms: i64, + range_ms: i64, + step_ms: i64, + offset_ms: i64, +) -> (i64, i64, u64) { + let end = anchor_ms - offset_ms; + let start = end - range_ms; + let mut first = None; + let mut len = 0_u64; + for k in (start / step_ms - 2)..=(end / step_ms + 2) { + let sample = k * step_ms; + if sample <= start || sample > end { + continue; + } + first.get_or_insert(sample); + len += 1; + } + ( + first.expect("the read interval names no sample at all"), + step_ms, + len, + ) +} + +/// An anchored subquery reads one fixed interval instead of one interval per output point, and the +/// interval is the anchor's: `(anchor - offset - range, anchor - offset]` at the subquery's step. +/// +/// The expected grid comes from the interval, not from the compiler, and the anchor itself is stated +/// in the case table even where the query writes `start()` or `end()`, so the two have to agree. +#[test] +fn an_anchored_subquery_reads_one_fixed_interval() { + // (query, anchor_ms, range_ms, step_ms, offset_ms, statement start_ms, statement end_ms) + let cases = [ + ( + "rate(x[5m:1m] @ 1234)", + 1_234_000, + 300_000, + 60_000, + 0, + 0, + 100_000, + ), + // `start()` and `end()` are the statement's own bounds, not points of its grid. + ( + "rate(x[5m:1m] @ start())", + 0, + 300_000, + 60_000, + 0, + 0, + 100_000, + ), + ( + "rate(x[5m:1m] @ end())", + 100_000, + 300_000, + 60_000, + 0, + 0, + 100_000, + ), + // A pre-epoch anchor, which a `duration_since(UNIX_EPOCH).unwrap_or_default()` would make 0. + ( + "rate(x[5m:1m] @ -1.5)", + -1_500, + 300_000, + 60_000, + 0, + 0, + 100_000, + ), + // The subquery's own offset moves the anchored interval, as it moves an unanchored one, in + // either order: the parser allows `@` and `offset` to be written both ways round. + ( + "rate(x[5m:1m] offset 1m @ 1234)", + 1_234_000, + 300_000, + 60_000, + 60_000, + 0, + 100_000, + ), + ( + "rate(x[5m:1m] @ 1234 offset 1m)", + 1_234_000, + 300_000, + 60_000, + 60_000, + 0, + 100_000, + ), + // A step that divides neither the range nor the anchor, so a grid rounded to either would + // name different samples. + ( + "rate(x[5m:7s] @ 1234)", + 1_234_000, + 300_000, + 7_000, + 0, + 0, + 100_000, + ), + // A statement whose end is not a grid point and whose start is off the child step, so neither + // `end()` nor the statement's start can stand in for the anchor or for the child grid. + ( + "rate(x[5m:1m] @ end())", + 103_000, + 300_000, + 60_000, + 0, + 3_000, + 103_000, + ), + ]; + + for (query, anchor_ms, range_ms, step_ms, offset_ms, start_ms, end_ms) in cases { + let stmt = eval_stmt_between( + query, + Duration::from_millis(start_ms as u64), + Duration::from_millis(end_ms as u64), + ); + let tree = compile(&stmt, raw_schema(), SeriesOrdering::KeyThenTime) + .unwrap_or_else(|error| panic!("`{query}` was refused: {error}")); + let (input, op) = root_window(&tree); + + assert_eq!( + op.window.anchor_ms, + Some(anchor_ms), + "`{query}` bound the wrong anchor" + ); + assert_eq!(op.window.range_ms, range_ms, "`{query}`: wrong range"); + assert_eq!(op.window.offset_ms, offset_ms, "`{query}`: wrong offset"); + + let expected = anchored_child_grid(anchor_ms, range_ms, step_ms, offset_ms); + assert_eq!( + child_grid(op), + expected, + "`{query}` bound the wrong child grid: the interval \ + ({}, {}] at {step_ms}ms per step holds {expected:?}", + anchor_ms - offset_ms - range_ms, + anchor_ms - offset_ms + ); + // The inner expression lands on that grid, so the window reads the points it declares. + let inner = evaluated(input); + assert_eq!( + inner.eval_grid.as_ref().unwrap().spec().start_ms, + expected.0, + "`{query}` evaluated its expression off the declared child grid" + ); + // The statement's grid is untouched: an anchor changes what is read, not what is reported. + let out = op.output_grid.spec(); + assert_eq!( + (out.start_ms, out.step_ms), + (start_ms, 5_000), + "`{query}` moved the output off the statement's grid" + ); + assert_eq!( + out.len as i64, + (end_ms - start_ms) / 5_000 + 1, + "`{query}` changed the statement's grid length" + ); + } +} + +/// An anchored child grid does not depend on the statement at all: the same anchored subquery in two +/// statements of different spans reads the same interval, while the two report on their own grids. +#[test] +fn an_anchored_subquery_grid_ignores_the_statement() { + let short = eval_stmt_between( + "rate(x[5m:1m] @ 1234)", + Duration::ZERO, + Duration::from_secs(100), + ); + let long = eval_stmt_between( + "rate(x[5m:1m] @ 1234)", + Duration::from_secs(10), + Duration::from_secs(600), + ); + + let mut grids = Vec::new(); + for stmt in [&short, &long] { + let tree = compile(stmt, raw_schema(), SeriesOrdering::KeyThenTime) + .unwrap_or_else(|error| panic!("an anchored subquery was refused: {error}")); + let (_, op) = root_window(&tree); + grids.push((child_grid(op), grid_of(tree.root()))); + } + + let expected_child = anchored_child_grid(1_234_000, 300_000, 60_000, 0); + assert_eq!( + grids[0].0, expected_child, + "the short statement's child grid is wrong" + ); + assert_eq!( + grids[1].0, expected_child, + "the child grid moved with the statement, so the anchor is not what it reads" + ); + assert_ne!( + grids[0].1, grids[1].1, + "the two statements have the same output grid, so this case proves nothing" + ); +} + +/// A window folds one value per grid point, so the expression inside a subquery has to produce a +/// vector. A scalar has no fields to fold and no points at all, so it is refused by type. +#[test] +fn a_subquery_over_a_scalar_is_refused() { + let mut stmt = eval_stmt("rate(x[5m:1m])"); + let PromExpr::Call(call) = &mut stmt.expr else { + unreachable!("`rate(x[5m:1m])` parses to a call"); + }; + let PromExpr::Subquery(subquery) = &mut *call.args.args[0] else { + unreachable!("`x[5m:1m]` parses to a subquery"); + }; + *subquery.expr = PromExpr::NumberLiteral(NumberLiteral::new(1.0)); + + let error = compile(&stmt, raw_schema(), SeriesOrdering::KeyThenTime) + .expect_err("a subquery over a scalar compiled, and it should not have"); + assert!( + matches!(error, DataFusionError::NotImplemented(_)), + "the refusal is not NotImplemented: {error}" + ); + assert!( + error.to_string().contains("has to evaluate a vector"), + "the refusal did not say what a subquery evaluates: {error}" + ); +} + +/// A subquery nests like any other matrix: a window over a subquery can itself sit inside a subquery, +/// and the whole chain still reads the scan once. The grids are asserted level by level, because a +/// compiler that passed the outer grid down would produce a tree that validates but reads the wrong +/// points. +#[test] +fn a_subquery_nests_and_each_level_has_its_own_grid() { + let tree = compile( + &eval_stmt("rate(rate(x[5m:1m])[10m:30s])"), + raw_schema(), + SeriesOrdering::KeyThenTime, + ) + .unwrap_or_else(|error| panic!("`rate(rate(x[5m:1m])[10m:30s])` was refused: {error}")); + + let (outer_input, outer) = root_window(&tree); + assert_eq!(outer.window.range_ms, 600_000); + // The outer subquery reads ten minutes back at thirty-second steps, so its first boundary after + // `-600000` is `-570000` and its last multiple of 30000 below the statement's end is `90000`. + assert_eq!( + child_grid(outer), + (-570_000, 30_000, 23), + "the outer subquery's grid is wrong" + ); + + let NativeExpr::Window { + input: inner_input, + op: inner, + } = &outer_input.expr + else { + panic!( + "the outer subquery's input is not a window: {:?}", + outer_input.expr + ); + }; + assert_eq!(inner.function, RangeFunction::Rate); + assert_eq!(inner.window.range_ms, 300_000); + // The inner subquery is evaluated on the outer one's grid, not on the statement's: its earliest + // read window opens at `-570000 - 300000`, whose first one-minute boundary is `-840000`. + assert_eq!( + child_grid(inner), + (-840_000, 60_000, 16), + "the inner subquery's grid is wrong, so the outer grid was not passed down" + ); + + // The innermost node is still the one raw scan. + let NativeExpr::Instant { input: scan, .. } = &inner_input.expr else { + panic!( + "the inner window's input is not an instant selection: {:?}", + inner_input.expr + ); + }; + assert!( + matches!(scan.expr, NativeExpr::Input(_)), + "the innermost node is not the scan: {:?}", + scan.expr + ); +} + +/// A subquery composes with the rest of the compiler: a sum over a rate over a subquery is a tree, +/// not a shape the compiler knows by name. +#[test] +fn a_subquery_composes_with_an_aggregate_and_a_comparison() { + let tree = compile( + &eval_stmt("sum by(job)(rate(x[5m:1m]) > 0)"), + raw_schema(), + SeriesOrdering::KeyThenTime, + ) + .unwrap_or_else(|error| panic!("`sum by(job)(rate(x[5m:1m]) > 0)` was refused: {error}")); + + let (input, aggregate) = aggregate_parts(tree.root()); + assert_eq!(aggregate.kind, AggregateKind::Sum); + let (left, _, matching) = binary_parts(input); + assert_eq!(matching.op, BinaryOp::Gt); + let NativeExpr::Window { op, .. } = &left.expr else { + panic!( + "the comparison's left operand is not a window: {:?}", + left.expr + ); + }; + assert_eq!(op.window.range_ms, 300_000); + assert!( + op.input_grid.is_some(), + "the window over a subquery forgot its input grid" + ); +} + +/// The grid a subquery declares has to hold every sample its windows read — and no sample none of +/// them can read. Both halves are checked here, against an oracle that is the query's own numbers +/// rather than the compiler's. +/// +/// Nothing but the grid itself is read from the produced window: the range, the step and the offset +/// come from the query text (stated again in the case table, so the two can disagree), the read +/// windows come from the statement's output grid as `p - offset - range < t <= p - offset`, and the +/// samples are every multiple of the step in such a window. The membership test is the modulo one, +/// because a grid whose step or phase is wrong still spans the right two bounds: interval +/// containment is what let a wrong step pass before. +#[test] +fn a_subquery_grid_holds_every_sample_its_windows_read() { + // The statement's end: the start is a column, because a grid that begins off the epoch is the + // only way to see a child grid built from the statement's start rather than from the epoch. + let end = Duration::from_secs(100); + + // (query, statement start, range_ms, step_ms, offset_ms, some point reads no sample) + // + // The last flag is asserted rather than assumed: the narrow case below passes trivially if the + // compiler refused to bind a window at all, so the test would not notice losing it. + let cases = [ + // A whole-minute step over a five-minute window. + ("rate(x[5m:1m])", 0, 300_000, 60_000, 0, false), + // A step that divides neither the range nor the statement's 5s step. + ("rate(x[5m:7s])", 0, 300_000, 7_000, 0, false), + // A step the statement's own step does divide, so the two cannot be confused either. + ("rate(x[5m:5s])", 0, 300_000, 5_000, 0, false), + // A step that divides neither the range nor the offset. + ( + "rate(x[90s:40s] offset 30s)", + 0, + 90_000, + 40_000, + 30_000, + false, + ), + // Offsets in both directions move every read window. + ( + "rate(x[5m:1m] offset 1m)", + 0, + 300_000, + 60_000, + 60_000, + false, + ), + ( + "rate(x[5m:1m] offset -1m)", + 0, + 300_000, + 60_000, + -60_000, + false, + ), + // A window narrower than the child step: some output points read no sample at all, so a + // grid that dropped the samples the other points read would still look plausible. + ("rate(x[30s:1m])", 0, 30_000, 60_000, 0, true), + // A statement whose start is three seconds off the child step, so a grid stepped from the + // statement's start would be phase-shifted by exactly that much. + ("rate(x[5m:1m])", 3_000, 300_000, 60_000, 0, false), + ("rate(x[30s:1m])", 3_000, 30_000, 60_000, 0, true), + ]; + + for (query, start, range_ms, step_ms, offset_ms, some_points_read_nothing) in cases { + let stmt = eval_stmt_between(query, Duration::from_millis(start as u64), end); + let tree = compile(&stmt, raw_schema(), SeriesOrdering::KeyThenTime) + .unwrap_or_else(|error| panic!("`{query}` was refused: {error}")); + let (_, op) = root_window(&tree); + + // The grid under test, and the statement's own grid, which is not the subquery's. + let (child_start, child_step, child_len) = child_grid(op); + let child_last = child_start + (child_len as i64 - 1) * child_step; + let out = op.output_grid.spec(); + let out_last = out.start_ms + (out.len as i64 - 1) * out.step_ms; + + // Every sample a window can read lies in the union of the read windows: strictly after the + // earliest window's left end, and no later than the latest window's right one. A grid that + // reached before the earliest read would evaluate a sample no window folds. + let earliest = out.start_ms - offset_ms - range_ms; + let latest = out_last - offset_ms; + for index in 0..child_len as i64 { + let point = child_start + index * child_step; + assert!( + point > earliest && point <= latest, + "`{query}`: the child grid point {point}ms lies outside the read windows \ + {earliest}ms..={latest}ms, where no window can read it" + ); + } + + let mut every_point_reads = true; + let mut samples_checked = 0_usize; + for index in 0..out.len as i64 { + let point = out.start_ms + index * out.step_ms; + let read_start = point - offset_ms - range_ms; + let read_end = point - offset_ms; + // Every multiple of the step in this window, enumerated two steps wider on each side + // and filtered by the window itself: the slack absorbs the difference between + // truncating and floor division, so this enumeration assumes no rounding convention. + let mut read_anything = false; + for k in (read_start / step_ms - 2)..=(read_end / step_ms + 2) { + let sample = k * step_ms; + if sample <= read_start || sample > read_end { + continue; + } + read_anything = true; + samples_checked += 1; + assert!( + sample >= child_start + && sample <= child_last + && (sample - child_start) % child_step == 0, + "`{query}`: the window at {point}ms reads {sample}ms, which is not a point of \ + the declared child grid {child_start}ms..={child_last}ms at {child_step}ms per \ + step" + ); + } + every_point_reads &= read_anything; + } + + assert!( + samples_checked > 0, + "`{query}`: no point of the statement's grid reads a sample, so this case checks nothing" + ); + assert_eq!( + every_point_reads, !some_points_read_nothing, + "`{query}`: the case table says some point reads no sample, so every point reading one \ + (or vice versa) means the window is not the one the query wrote" + ); + } +} From cff91d99bf399510a04b96552afdfae8252b1c47 Mon Sep 17 00:00:00 2001 From: Yingwen Date: Thu, 22 Oct 2026 17:16:18 +0800 Subject: [PATCH 16/18] feat(mito): implement columnar series index format (#9841) * feat(mito): implement columnar series index format Signed-off-by: evenyag * test(mito): remove columnar compatibility fixtures Signed-off-by: evenyag * refactor(mito): use recorded artifact sizes and inline columnar codecs Signed-off-by: evenyag * fix(mito): bound columnar series writer memory Signed-off-by: evenyag --------- Signed-off-by: evenyag --- Cargo.lock | 1 + docs/formats/mito-series.md | 604 ++++++++ src/mito2/AGENTS.md | 2 +- src/mito2/Cargo.toml | 1 + src/mito2/src/series_index.rs | 9 +- src/mito2/src/series_index/artifact.rs | 22 +- src/mito2/src/series_index/catalog.rs | 1 + src/mito2/src/series_index/columnar.rs | 166 ++ src/mito2/src/series_index/columnar/codec.rs | 259 ++++ .../src/series_index/columnar/metadata.rs | 456 ++++++ src/mito2/src/series_index/columnar/reader.rs | 557 +++++++ src/mito2/src/series_index/columnar/tests.rs | 1376 +++++++++++++++++ src/mito2/src/series_index/columnar/writer.rs | 614 ++++++++ src/mito2/src/series_index/format.rs | 40 +- src/mito2/src/series_index/format_tests.rs | 32 +- src/mito2/src/series_index/maintenance.rs | 6 +- src/mito2/src/series_index/version.rs | 10 +- src/mito2/src/series_index/writer.rs | 40 +- 18 files changed, 4161 insertions(+), 35 deletions(-) create mode 100644 docs/formats/mito-series.md create mode 100644 src/mito2/src/series_index/columnar.rs create mode 100644 src/mito2/src/series_index/columnar/codec.rs create mode 100644 src/mito2/src/series_index/columnar/metadata.rs create mode 100644 src/mito2/src/series_index/columnar/reader.rs create mode 100644 src/mito2/src/series_index/columnar/tests.rs create mode 100644 src/mito2/src/series_index/columnar/writer.rs diff --git a/Cargo.lock b/Cargo.lock index a00d5b123f7..2ef45f7114a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8764,6 +8764,7 @@ dependencies = [ "tokio-util", "toml 0.8.23", "tracing", + "twox-hash", "uuid", "winner_tree", "zstd", diff --git a/docs/formats/mito-series.md b/docs/formats/mito-series.md new file mode 100644 index 00000000000..78048ab3b45 --- /dev/null +++ b/docs/formats/mito-series.md @@ -0,0 +1,604 @@ +# Mito columnar series format + +**Status: implemented as an opt-in internal backend.** The byte assignments +below are the version 1 compatibility contract. This document describes series-index artifacts only; +sample files and query-consumer integration are separate work. + +The presentation follows the field tables and diagrams in the +[Prometheus chunks format documentation](https://github.com/prometheus/prometheus/blob/main/tsdb/docs/format/chunks.md). +This is an independent format, not a Prometheus-compatible file. + +The format sections follow storage order: artifact manifest, then each part's +header, column payloads, metadata pages, and trailer. Writer/reader behavior and +validation follow the layout specification. + +## Artifact manifest + +The catalog format identifier is `columnar-series`. Its wire version is separate +from that identifier and from all column-encoding and compression identifiers. +Legacy catalog entries and the `parquet` identifier keep their existing meaning. + +One immutable artifact owns the following objects under a unique generation: + +```text +/series//manifest.json +/series//part-0.series +/series//part-1.series +... +``` + +`manifest.json` contains these fields: + +| Field | Meaning | +| --- | --- | +| `format` | String `columnar-series` | +| `version` | Integer 1 | +| `region_id` | Physical region ID | +| `generation` | Index UUID, using the existing `FileId` serialization | +| `tags` | Ordered objects with `column_id` and `name`; all are nullable UTF-8 | +| `coverage` | Existing `SeriesIndexEntry` representation, with `artifact` omitted and `file_size` zero | +| `files` | Ordered objects with `part_id`, `file_size`, `num_series`, `first`, and `last` | + +`part_id` is an unsigned 32-bit integer, starting at zero and increasing by one +in file order. The object name is derived as `part-.series`, using the +decimal ID without leading zeros, under the artifact's generation prefix. No +path is stored in a file entry. `first` and `last` are inclusive identifiers, +represented as objects with `table_id` and `tsid`. Ranges are strictly ordered +and disjoint. Coverage is captured from +the build request; source SST IDs remain diagnostic information, not a reuse +criterion. Validate coverage and generation against the catalog handle on open. + +The tag list records the schema at build time, including tags currently absent +from some logical tables. The logical Arrow schema is `__table_id`, `__tsid`, +`__series_row_count`, followed by stored tags in manifest order. It has no +timestamp columns and is separate from the existing Parquet index schema. + +The manifest is written only after every part is closed successfully. The +existing catalog publication commits the artifact. `ArtifactMetadata.objects` +lists the manifest and every part as `{ path, file_size }` objects, and the +artifact-level `file_size` is their total stored size. Object sizes are recorded +by the writer; the manifest does not store its own size. +These lifecycle object paths are derived from the generation and part IDs; +artifact ownership and deferred purge behavior remain unchanged. +The reader obtains part order from the manifest, not object-list ordering. +An empty build has a manifest with an empty file list and creates no part files. + +## Part file + +```text +┌──────────────────────────────────────────────┐ +│ Header 16 bytes │ +├──────────────────────────────────────────────┤ +│ Independently encoded/compressed columns │ +│ Block 0: TSIDs, counts, tags │ +│ Block 1: ... │ +├──────────────────────────────────────────────┤ ← payload_end +│ Leaf pages (one is the root at depth 1) │ +│ Optional internal root (depth 2) │ +├──────────────────────────────────────────────┤ +│ Trailer 96 bytes │ +└──────────────────────────────────────────────┘ +``` + +All fixed-width integers are little-endian. Offsets are absolute from the start +of the part; ranges are half-open. There is +no alignment padding. A string is `length ` followed by that many UTF-8 +bytes. Parse fields explicitly; Rust struct layout, `usize`, and enum declaration +order are not wire contracts. Reject trailing bytes in decoded structures. + +### Header + +| Field | Bytes | Value | +| --- | ---: | --- | +| Magic | 8 | ASCII `GTSERIES` | +| File version | 4 | 1 | +| Reserved | 4 | Zero | + +### Column payloads + +An artifact indexes the series in one Mito series-index bucket. Authoritative +data remains in Parquet SSTs; these indexes can be rebuilt. Each record contains: + +| Logical column | Type | Storage | +| --- | --- | --- | +| `__table_id` | UInt32 | One constant in each block descriptor | +| `__tsid` | UInt64 | Independently encoded column chunk | +| `__series_row_count` | UInt64 | Independently encoded column chunk | +| Tag columns | Nullable UTF-8 | Independent dictionary/code chunks | + +Group records from one table into a block, using a writer row limit that defaults +to 8,192. This is a packing choice, not a wire-format limit: every block records +its actual number of series. A writer can use another limit without changing +the file version, provided the encoded counts and payload sizes remain supported. +Readers must not assume that blocks contain 8,192 rows. Write each stored column +as an independent encoded and compressed chunk, then continue with the next +block. Chunks have no inline header: their locations, lengths, codecs, and +checksums are recorded in the later [metadata pages](#metadata-pages). + +`(__table_id, __tsid)` is the complete identifier **under the current uniqueness +assumption**. Equal TSIDs in different tables are distinct. Records are ordered +lexicographically by this pair and identifiers do not repeat after aggregation. +There is no stored `__primary_key` column. The writer extracts identifiers and +labels from input sparse primary keys without retaining their bytes on disk. + +Repeated input rows for a series, including rows split across input batches, +contribute to its row count. Tags are constant for that identifier under the +uniqueness assumption. Aggregation keeps +the current index writer's input visibility semantics; this format does not add +sample deduplication or make the row count an exact query-visible count for +update-mode data. + +No per-series or per-block timestamps are stored, and the series schema has no +timestamp unit. The catalog's bucket coverage allows coarse time pruning. A series selected +from an overlapping bucket is only a candidate for a time-constrained query; +SST/sample reads must apply the exact time filter. The format reader cannot prune +individual series by time or accept filters on timestamp columns. + +TODO: evaluate support for optional per-series minimum/maximum timestamps and +benchmark their storage size, build cost, and time-pruning benefit before adding +them to the format. Keep this version free of per-series and per-block time bounds. + +TODO: benchmark storing exact input primary-key bytes and reconsider collision +handling. Different label sets can hash to the same TSID within one table. This +version neither preserves a collision discriminator nor promises collision-safe +identity. Add the corresponding TODO at the implementation's identity boundary. + +Dedicated `job_id` and `instance_id` identifier/locality components are deferred. +Ordinary tags named `job` or `instance` remain ordinary tag columns. + +#### Integer encoding 1 + +Start each chunk with `previous = 0`. For each value, compute wrapping u64 +subtraction from the previous value, reinterpret the result as i64, +ZigZag-encode it, and write an unsigned base-128 varint. The previous value +becomes the current value. Decoding +uses wrapping addition after reversing ZigZag. This preserves all UInt64 values, +including differences beyond the signed range. + +A varint has seven low data bits per byte and the high continuation bit. It is +at most ten bytes; the tenth byte can contain only bit zero and cannot continue. +The writer uses the shortest representation. Decode exactly the block's number +of series and reject truncation, overflow, and trailing bytes. TSID values must +be strictly increasing and agree with the descriptor's first/last values. + +#### Dictionary encoding 2 + +```text +dictionary_byte_length +dictionary: + entry_count + repeated entries: + present # 0 = null, 1 = UTF-8 string + string # only if present = 1 +row_codes +``` + +Entry zero is null; remaining entries are distinct non-null strings in +first-occurrence order. Null cannot occur at another entry. The empty string is +a non-null entry with zero bytes. Dictionary length includes its count and entry +framing. Codes must be in range; the dictionary and code streams must each be +consumed exactly. Keep dictionary/code buffers during filtering rather than +expanding one owned string per row. + +TODO: implement and benchmark a plain UTF-8 encoding alternative for string +chunks, particularly high-cardinality labels. Compare dictionary encoding with +null bitmap + offsets + UTF-8 bytes, including Zstd-compressed size, build cost, +and filtering/decoding cost. Evaluate per-chunk selection using estimated encoded +size rather than cardinality alone. A plain encoding gets its own encoding ID; +it can use the existing compression IDs and coexist with dictionary chunks. +This port implements dictionary strings only; the alternative and automatic +selection remain future work. + +### Metadata pages + +The metadata is a tree that maps identifier ranges to column locations: + +- A **leaf page** describes data blocks and where their column chunks are stored. +- An **internal page** points to other metadata pages, with an identifier range + for each child so the reader can skip unrelated children. +- The **root page** is the tree's entrypoint, referenced by the trailer. It is + either a leaf or an internal page; there is no separate root page encoding. + +This implementation supports only depths 1 and 2. It does not build or traverse +internal pages below the root. The existing depth field and page references +leave room for deeper trees as future work. + +Each page is encoded and compressed independently. The diagrams below show its +**decoded bytes**. The stored bytes are the compressed form of that entire page; +the kind and entry count are inside the compressed payload. The parent's page +reference supplies the location, lengths, checksum, and codecs needed to read it. +For the root, that reference is in the trailer. + +#### Leaf page + +A leaf uses kind 0. Its entry count is the number of block descriptors, not the +number of series. Block descriptors have variable length because their column +names and label statistics have variable length. + +```text +┌───────────────────────────────────────────────────────┐ +│ kind = 0 u32 │ +│ entry_count = N u64 │ +├───────────────────────────────────────────────────────┤ +│ Block descriptor 0 │ +│ table_id u32 │ +│ first_tsid, last_tsid 2 × u64│ +│ num_series, num_columns 2 × u32│ +│ Column descriptor 0 │ +│ name string │ +│ payload reference 32 bytes │──→ column chunk +│ null_count, statistics_kind 2 × u32│ in payload area +│ optional label min/max strings │ +│ ... │ +│ Column descriptor num_columns - 1 │ +├───────────────────────────────────────────────────────┤ +│ ... │ +│ Block descriptor N - 1 │ +└───────────────────────────────────────────────────────┘ +``` + +The leaf contains locations and statistics, not the column values themselves. +Its table ID supplies the constant `__table_id` value for each series in a block. +The exact descriptor fields are specified under [Block descriptor](#block-descriptor). + +#### Internal page + +An internal page uses kind 1. Its entry count is the number of child metadata +pages. Each entry is a fixed-size 56-byte page reference: + +```text +┌───────────────────────────────────────────────────────┐ +│ kind = 1 u32 │ +│ entry_count = N u64 │ +├───────────────────────────────────────────────────────┤ +│ Child page reference 0 56 bytes │ +│ first = (table_id, tsid) 12 bytes │ +│ last = (table_id, tsid) 12 bytes │ +│ payload reference 32 bytes │──→ child page +├───────────────────────────────────────────────────────┤ +│ ... │ +│ Child page reference N - 1 56 bytes │ +└───────────────────────────────────────────────────────┘ +``` + +An internal page's decoded size is `12 + N × 56` bytes. In this implementation, +it is always the root and all its children are leaves. An internal page has no +block descriptors or column data. At the 65,536-byte page limit, the root holds +at most `floor((65536 - 12) / 56) = 1170` leaf references. + +#### Root page and traversal example + +If all block descriptors fit in one leaf, that leaf is also the root and the +trailer records depth 1. Otherwise the root is an internal page and the trailer +records depth 2: root → leaves. Depth 3 would mean root → internal pages → leaves, +but is not implemented in this port. + +For example, four data blocks can be indexed by two leaves and an internal root. +The ranges below are inclusive `(table_id, tsid)` bounds; they do not imply that +every ID between the endpoints exists. + +```text +Trailer: depth = 2 + root reference: [(7,10), (7,49)] + │ + ▼ + Root R: internal page, kind = 1, entry_count = 2 + ├─ reference [(7,10), (7,29)] ──→ Leaf A: kind = 0, entry_count = 2 + │ ├─ Block 0: table 7, TSIDs [10,19] + │ └─ Block 1: table 7, TSIDs [20,29] + └─ reference [(7,30), (7,49)] ──→ Leaf B: kind = 0, entry_count = 2 + ├─ Block 2: table 7, TSIDs [30,39] + └─ Block 3: table 7, TSIDs [40,49] + +Physical order, from payload_end to end of part: + [compressed Leaf A] [compressed Leaf B] [compressed Root R] [Trailer] +``` + +To find `(7,35)`, read the trailer and Root R, then follow only the reference to +Leaf B. Its Block 2 descriptor locates the TSID and requested label/count chunks +in the earlier column payload area. Decode the TSID chunk to find the exact row; +metadata bounds only identify a candidate block. Leaf A and Block 3's payloads +are not needed. + +The root's own bounds and storage reference live in the trailer; its page bytes +have exactly the leaf or internal layout above. At depth 2 the writer emits all +leaves and then the internal root. At depth 1 it emits only the single leaf. + +TODO: evaluate depth 3+ if larger parts or metadata volumes justify it. The +current page-reference representation can point to another internal page, so +the extension need not change the byte layout. It would require new writer and +reader traversal support, benchmarks, and a rollout policy for older readers. +Current readers reject depths other than 1 or 2 explicitly before traversal; +they must never interpret unsupported trees as empty indexes. Future writers +must not assume that an unchanged file version implies old-reader support for +new depths. Older files remain readable by a future deeper-tree implementation. + +#### Page ordering and size limits + +Pages are nonempty. A leaf's bounds are its first/last block bounds; an internal +page's bounds are its first/last child bounds. Child key ranges are strictly +ordered and disjoint, as are block ranges. Children physically precede their +parent and their stored ranges do not overlap. Columns within a leaf are likewise +physically ordered and non-overlapping. + +Pages target 65,536 decoded bytes including the 12-byte prefix. A descriptor +never straddles pages. A descriptor larger than the target gets its own leaf, +capped at 8 MiB including framing; a larger descriptor fails the build. Internal +pages never exceed 65,536 decoded bytes. Stored pages are capped at 8 MiB plus +64 KiB. Readers validate sizes and counts before allocating or decompressing. + +#### Payload reference + +Every column chunk and metadata page has this 32-byte reference: + +| Field | Bytes | Meaning | +| --- | ---: | --- | +| Offset | 8 | Absolute location of stored bytes | +| Stored length | 4 | Bytes to fetch | +| Decoded length | 4 | Bytes after decompression, before decoding | +| Checksum | 8 | XXH64 of stored bytes, seed zero | +| Encoding ID | 2 | Column or metadata encoding, according to context | +| Compression ID | 2 | Compression of the encoded payload | +| Reserved | 4 | Zero | + +The offset is u64, so a payload can start beyond the 4 GiB boundary. The two u32 +lengths limit a single stored or decoded payload to at most `2^32 - 1` bytes; +they do not limit the whole part to 4 GiB. File length and `payload_end` are also +u64. This version keeps u32 payload lengths because chunks and pages have much +smaller bounded sizes. Check `offset + stored_length` in u64 with overflow +handling, and validate decoded sizes before converting to allocation sizes. + +The checksum is verified before decompression. Decompression must produce exactly +the declared length. Encoding and compression are independent: encode first, +compress second. Compression parameters needed for decoding must be in the codec +frame; the Zstd level is a writer choice, not a decoding requirement. + +#### Page reference + +A page reference is 56 bytes: `first table ID `, `first TSID `, +`last table ID `, `last TSID `, then a 32-byte payload reference. +Bounds are inclusive. Compare identifiers as integer pairs, not by the byte +ordering of their little-endian representation. + +#### Block descriptor + +A block contains series from one table; its actual row count is recorded below. +The writer defaults to 8,192 series per block. A table boundary ends the current +block without necessarily ending the part. The decoded block descriptor has the +following fields in order: + +| Field | Encoding | +| --- | --- | +| Table ID | u32 | +| First and last TSID | u64 each, inclusive | +| Number of series | u32, nonzero | +| Number of stored columns | u32 | +| Column descriptors | Repeated in logical-schema order, excluding table ID | + +Each column descriptor contains `name `, `payload <32-byte reference>`, +`null_count `, and `statistics_kind `. Statistics kind 0 has no extra +fields; kind 1 adds minimum and maximum non-null strings, in that order. Only +tag columns use kind 1. String bounds use UTF-8 byte lexicographic order. + +TSID and row count are mandatory, non-null columns. An all-null tag column can +be omitted; its logical values are all null +and its inferred null count equals the number of series. A present tag chunk +has string bounds if it contains any non-null values. Null and the empty string +are distinct. Names must be unique and belong to the manifest schema. + +TODO: benchmark replacing repeated column names with numeric references to a +shared schema, including metadata size, decode cost, and selective-read latency. +The manifest already stores the tag schema; leaf descriptors currently repeat +names to identify their chunks. Keep the current descriptors for this port. +Names can be deduplicated later, but payload locations, codecs, null counts, and +statistics remain specific to each block's column chunk. + +### Trailer + +| Field | Bytes | Meaning | +| --- | ---: | --- | +| Root page reference | 56 | Root bounds and payload location/codecs | +| Index depth | 4 | Leaf-only index = 1 | +| File version | 4 | 1 | +| File length | 8 | Includes header and trailer | +| `payload_end` | 8 | End of column area, start of metadata area | +| Magic | 8 | ASCII `GTSERIES` | +| Trailer checksum | 8 | XXH64 of the preceding 88 trailer bytes, seed zero | + +The root ends immediately before the trailer. Supported depths are 1 and 2. Page ranges +lie in the metadata area; column ranges lie between the header and `payload_end`. +Checksums detect accidental corruption; they do not authenticate data. + +TODO: evaluate and benchmark a variable-length Protobuf footer located by a +small fixed trailer, so optional file metadata can evolve without enlarging the +fixed trailer. Measure footer size, open/read cost, and compatibility behavior +before adopting it. This port retains the 96-byte trailer above; no Protobuf +footer or schema-reference changes are included. + +## Codec selection and evolution + +Encoding IDs have separate column and metadata namespaces. IDs are explicitly +assigned and never reused. Version 1 supports: + +| Namespace | ID | Meaning | +| --- | ---: | --- | +| Column encoding | 1 | Wrapping delta, ZigZag, unsigned varint integer stream | +| Column encoding | 2 | Nullable UTF-8 dictionary and unsigned varint codes | +| Metadata encoding | 1 | Block descriptors / page references defined above | +| Compression | 0 | None; stored length equals decoded length | +| Compression | 1 | Zstd frame; writer default is level 3 | + +The default writer uses compression 1 for columns and metadata. Compression 0 +provides an explicit uncompressed representation and a second dispatch path to +test. Codec selection is internal; this change adds no public codec configuration. + +Changing the writer's block row limit (for example, from 8,192 to 16,384) requires +no codec ID, trailer extension, or file-version change: the block descriptor +already supplies the actual row count. The same applies to part rollover choices. +These choices remain subject to field-width and reader resource limits. They +are distinct from changing the serialized structure of a descriptor or trailer. + +A new encoding gets a new ID and its own documented payload interpretation; +it can coexist with older encodings in the same file. A codec can define its +own payload prefix for parameters. A new compression method likewise receives +a new ID. Neither change requires rewriting the outer layout or changing the +format API. Changing the outer structures or the meaning of an existing field +requires a new file version. Reserved fields must remain zero in version 1. + +Unknown versions, reserved bits, or metadata codecs fail open/read explicitly. +An unknown column codec may be skipped when its column is neither projected nor +needed for filtering; attempting to read it returns an unsupported-codec error. +It must never be interpreted as null or silently decoded with a default codec. + +## Writer and resource bounds + +The implementation lives behind `SeriesIndexFormat::writer` and `FormatWriter`. +Its input remains sorted flat `RecordBatch` data. Reuse the existing writer's +sparse-primary-key and string-tag validation, but do not depend on its timestamp +aggregation or timestamp-bearing output schema. This writer consumes primary +keys and counts input rows without decoding timestamp values. Preserve +aggregation across `write` calls; reject decreasing identifiers and checked-count +overflow. + +Write column payloads as blocks complete, retaining only the current series, +current block, and bounded per-part descriptors. Finish a part at block boundaries +when the stored position reaches 512 MiB or cumulative serialized descriptors +reach 8 MiB. These are soft limits: the completing block may overshoot them. +The root's leaf-reference capacity is a separate hard packing limit: account for +the next block descriptor's leaf-page placement before writing that block. If +it would require more than 1,170 leaves, finish the current part and write the +block to a new part. Never create a third metadata level. Emit leaf pages, the +internal root only when needed, and then the trailer. + +For bounded decoding, an individual column chunk is capped at 64 MiB decoded and +64 MiB plus 1 MiB stored. Oversized chunks fail the build; no partial artifact is +published. A block contains at most 8,388,608 series, bounding each decoded u64 +column and row-selection buffer to 64 MiB. Bound the manifest to 64 MiB before parsing. These safety limits are +part of version 1, not new server configuration. Check offsets, lengths, counts, +and arithmetic before allocation; row counts and lengths alone must not authorize +allocations larger than the enclosing decoded data permits. + +## OpenDAL I/O and lifecycle + +Use `object_store::ObjectStore` (the OpenDAL operator) and its `Reader`/`Writer` +types, as other Mito index implementations do. Do not add a parallel storage trait +or a native filesystem adapter. Memory and filesystem OpenDAL services and the +existing mock layers provide test backends. + +- Use the artifact metadata's recorded object sizes to read the manifest and + locate part trailers without stat requests. Validate part sizes against the + manifest and reject duplicate object paths or inconsistent totals. Fetch headers, + trailers, metadata pages, and selected column ranges through asynchronous range reads. + Reuse `fetch_byte_ranges` where applicable. Its range coalescing can fetch gaps + between selected chunks; those bytes do not cause unselected columns to decode. +- Open outputs through `writer_with`, using `DEFAULT_WRITE_BUFFER_SIZE` and + `DEFAULT_WRITE_CONCURRENCY`. Stream bytes without buffering an entire part. + Call `close` before reporting success; count all committed object bytes. +- Use existing `OpenDalSnafu` error conversion. Keep codec work separate from I/O + and run codecs inline, matching the format benchmark. TODO: compare inline + and offloaded codecs for latency, concurrent throughput, and async-worker + responsiveness before choosing an offloading policy. No comparative benchmark + currently establishes a performance advantage for either policy. +- Until successful finish, the writer owns every output in its generation. + Failure or explicit abort aborts live OpenDAL writers, removes completed + unpublished objects, and aborts each live OpenDAL writer to clean its own atomic-write temporary + file. Cleanup never scans temporary files by generic part basenames. A cancellation/drop guard schedules the same cleanup + on the shared runtime. Cleanup errors are reported without hiding the original + operation error. Process-crash orphan recovery remains the existing lifecycle's + responsibility; an incomplete generation is never published as valid. +- After successful finish, ownership transfers to the build/catalog lifecycle. + Reuse existing publication-failure cleanup and deferred retirement. A reader + and each returned stream retain a `SeriesIndexFileHandle` until dropped. + +## Reader API and execution + +Add a reader factory to `SeriesIndexFormat`, accepting the store and pinned +artifact handle, and returning a format reader. The reader exposes its stored +Arrow schema and a `read` method returning a stream of projected record batches. +Reads have independent traversal state and may outlive the reader factory. + +The initial read request contains an ordered projection of column names, an +optional table ID, an optional set of `MetricSeriesId` values, and a conjunction +of `SimpleFilterEvaluator` filters. No ID restriction means all IDs; an explicitly +empty set means no rows. Reject unknown projection/filter columns instead of +guessing their meaning. An empty projection still preserves selected row counts. +Returned rows preserve identifier order, even when IDs are not projected. + +The reader accepts filters only on its stored logical columns; it has no time +filter. Bucket-level time selection belongs to the caller, and exact time +filtering belongs to subsequent SST/sample reads. This API evaluates supplied +filters exactly; conversion of arbitrary query expressions or schema-evolution predicates into +conservative index candidates remains the future query adapter's responsibility. + +1. Open and validate the manifest and selected parts' framing. Use file bounds + to skip irrelevant parts and page bounds to traverse only relevant metadata. +2. Apply conservative block pruning using identifier and label bounds. + Missing statistics never exclude possible matches. Label filters do not prune + internal pages. Full scans need not load the entire metadata tree at once. +3. Decode IDs when needed for selection and predicate columns before output + columns. Apply existing Mito filter/null semantics, including evaluation on + tag dictionaries where possible. Null is not converted to the empty string. +4. Fetch and decode remaining projected columns only for blocks with surviving + rows; each selected chunk is still decompressed as a whole. Materialize output + only for selected rows. Synthesize table IDs from the block constant and + omitted tags as null arrays. Reuse already decoded filter columns. Output + batches target 8 MiB of materialized values (one larger row is allowed), so + repeated dictionary values do not expand an entire block at once. This is a + reader packing choice and does not change the wire format. + +Keep page traversal state to the active leaf and pending references. There is no +cross-read cache in this implementation. Centralize fetch/validate/decode work in +`load_metadata_page` and `load_column_chunk` entrypoints. + +TODO: integrate these entrypoints with Mito's existing cache system. Future keys +need region/store identity, immutable artifact generation, part ID, byte range, +checksum, codec IDs, and representation. Keep that information available; do not +introduce a new cache framework, global cache, configuration, or unused parameter. + +Ordinary series scans continue using the existing Parquet path and fall back to +SST discovery for this format. Merely adding a format reader must not mark these +artifacts as supported query coverage. Samples remain disabled by default. + +## Compatibility and validation + +Existing Parquet files and catalogs remain readable. The new backend is opt-in +through the internal format selection; defaults do not write this format. An +older binary need not decode new codec bytes. These rebuildable artifacts must +not become necessary for reading authoritative SST data. There is no promise +that old binaries understand the new catalog identifier. + +Validation covers the layouts, identifier assumption, codec IDs, schema handling, +and safety limits above. Implementation acceptance includes: + +- Codec unit tests and writer/reader round trips. Dedicated compatibility tests + and frozen format fixtures are outside this PR's scope. +- Empty input, repeated series across batches, mixed tables/schemas, equal TSIDs + across tables, nullable/empty tags, forced rollover, depths 1 and 2, and + oversized payloads/descriptors. +- Root-capacity rollover before a third level is needed, using reduced packing + limits in tests. Reject depth 0 and unsupported depths including 3 before + traversal, and reject an internal child where a leaf is required at depth 2. +- Exact projection/filter results, no irrelevant payload decoding, selective + range reads, and independent readers/streams with caching absent. +- Different block row limits, including 16,384, read by the same format reader + without a version change. Validate references beyond 4 GiB with small mocked + payloads, plus overflowing offsets and oversized individual payload lengths. +- Part-ID-to-path derivation, invalid/misordered part IDs, absence of timestamp + columns, and rejection of timestamp filters. Varying input timestamps must not + change encoded series records when identifiers, labels, and row counts match. +- Truncation, bad checksums, invalid offsets/counts/order, dictionary corruption, + unsupported codec/version IDs, and bounded decompression/decoding failures. +- Real-format reconciliation, reopen, publication failures, OpenDAL write/close + failures, abort/cancellation cleanup, accounting, and retirement with pinned + streams. Failed builds must not replace a valid catalog entry. +- `cargo nextest run -p mito2`, crate Clippy, formatting, and license checks for + new source files. No SQL query behavior change is claimed by this phase. + +Performance must be measured again after the port: the row-count column, checksums, +metadata framing, OpenDAL range coalescing, and the absence of decoded caches +change the measured workload. Future experiments also cover exact primary keys, +dedicated job/instance locality components, and optional per-series time bounds. +Shared-schema column references and a Protobuf footer require separate benchmarks +and remain outside this port. +Depth 3+ metadata is also deferred until larger parts justify the added traversal. +No performance equivalence is asserted by this document. diff --git a/src/mito2/AGENTS.md b/src/mito2/AGENTS.md index 95b9eddae07..be100512f10 100644 --- a/src/mito2/AGENTS.md +++ b/src/mito2/AGENTS.md @@ -66,7 +66,7 @@ filtered `RecordBatch` stream. and defer new builds when the shared estimate is full. Cleanup still prunes obsolete range entries and expires series indexes to reclaim published usage. Concurrent builds may overshoot; closed-region files are not counted. Series handles and the SST purger own file deletion. -- **Series format PoC**: samples are rebuildable local SST sidecars. Optional writer interfaces (`format.rs`) integrate with reconciliation, catalog publication, and deferred purge. Defaults retain the original Parquet writer and disable samples. Prepared sources, read APIs, and concrete new formats are deferred; ordinary scans only use Parquet index artifacts. +- **Series format PoC**: samples are rebuildable local SST sidecars. Optional writer interfaces (`format.rs`) integrate with reconciliation, catalog publication, and deferred purge. Defaults retain the original Parquet writer and disable samples. The opt-in columnar backend (`series_index/columnar.rs`) implements streaming OpenDAL writes and pinned format reads; its wire contract is in `docs/formats/mito-series.md`. Artifact objects record paths and sizes, including the manifest, so reads need no stat. Codecs currently run inline pending scheduling benchmarks. Prepared sources and query-consumer integration are deferred; ordinary scans only use Parquet index artifacts. - **Series-index coverage** (`series_index/catalog.rs`): `SeriesIndexEntry` stores compaction-window width and SST summaries keyed by aligned start in both catalogs and Parquet footers. - **Request types** (`request.rs`): usually tied to proto definitions consumed by `datanode`. diff --git a/src/mito2/Cargo.toml b/src/mito2/Cargo.toml index 02c134e964c..efeaef6ee80 100644 --- a/src/mito2/Cargo.toml +++ b/src/mito2/Cargo.toml @@ -88,6 +88,7 @@ tokio.workspace = true tokio-stream.workspace = true tokio-util.workspace = true tracing.workspace = true +twox-hash.workspace = true uuid.workspace = true winner_tree.workspace = true zstd.workspace = true diff --git a/src/mito2/src/series_index.rs b/src/mito2/src/series_index.rs index 585120628e8..57b3611f999 100644 --- a/src/mito2/src/series_index.rs +++ b/src/mito2/src/series_index.rs @@ -20,6 +20,11 @@ pub(crate) mod artifact; mod bucket; mod builder; mod catalog; +#[allow( + dead_code, + reason = "Opt-in format; query consumer integration is deferred." +)] +pub(crate) mod columnar; pub(crate) mod format; #[cfg(test)] mod format_tests; @@ -70,7 +75,9 @@ pub(crate) const ROW_COUNT_COLUMN: &str = "__series_row_count"; pub(crate) const METRIC_SERIES_ID_BATCH_SIZE: usize = 500; /// Identifies one series in a physical metric region. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize, +)] pub struct MetricSeriesId { /// Logical table ID inside the physical metric region. pub table_id: u32, diff --git a/src/mito2/src/series_index/artifact.rs b/src/mito2/src/series_index/artifact.rs index e1412920f6f..fdce49b31fd 100644 --- a/src/mito2/src/series_index/artifact.rs +++ b/src/mito2/src/series_index/artifact.rs @@ -28,6 +28,8 @@ use crate::sst::file::RegionFileId; pub(crate) enum ArtifactFormat { #[serde(rename = "parquet")] Parquet, + #[serde(rename = "columnar-series")] + ColumnarSeries, #[cfg(test)] #[serde(rename = "fake-samples")] FakeSamples, @@ -36,13 +38,20 @@ pub(crate) enum ArtifactFormat { FakeIndex, } +/// One immutable object produced by a backend build. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct ArtifactObject { + pub(crate) path: String, + pub(crate) file_size: u64, +} + /// Identifies the exact bytes produced by one backend build. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub(crate) struct ArtifactMetadata { pub(crate) format: ArtifactFormat, pub(crate) generation: FileId, /// Every object owned by this build, including metadata and auxiliary files. - pub(crate) objects: Vec, + pub(crate) objects: Vec, pub(crate) file_size: u64, } @@ -86,8 +95,15 @@ impl SamplesFileHandle { impl Drop for SamplesFileInner { fn drop(&mut self) { if self.retired.load(Ordering::Acquire) { - self.purger - .purge_objects(self.file_id, self.entry.artifact.objects.clone()); + self.purger.purge_objects( + self.file_id, + self.entry + .artifact + .objects + .iter() + .map(|object| object.path.clone()) + .collect(), + ); } } } diff --git a/src/mito2/src/series_index/catalog.rs b/src/mito2/src/series_index/catalog.rs index 66355ace359..24c8fb27086 100644 --- a/src/mito2/src/series_index/catalog.rs +++ b/src/mito2/src/series_index/catalog.rs @@ -313,6 +313,7 @@ mod tests { ); for (format, name) in [ (ArtifactFormat::Parquet, "parquet"), + (ArtifactFormat::ColumnarSeries, "columnar-series"), (ArtifactFormat::FakeSamples, "fake-samples"), (ArtifactFormat::FakeIndex, "fake-index"), ] { diff --git a/src/mito2/src/series_index/columnar.rs b/src/mito2/src/series_index/columnar.rs new file mode 100644 index 00000000000..d010e7356fd --- /dev/null +++ b/src/mito2/src/series_index/columnar.rs @@ -0,0 +1,166 @@ +// Copyright 2023 Greptime Team +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Columnar series format. See `docs/formats/mito-series.md` for the wire contract. + +mod codec; +mod metadata; +mod reader; +#[cfg(test)] +mod tests; +mod writer; + +use std::sync::Arc; + +use async_trait::async_trait; +use datatypes::arrow::datatypes::{DataType, Field, Schema, SchemaRef}; +use object_store::ObjectStore; +use serde::{Deserialize, Serialize}; +use store_api::metadata::RegionMetadataRef; +use store_api::storage::FileId; + +use crate::error::{InvalidMetaSnafu, Result}; +use crate::series_index::artifact::ArtifactFormat; +use crate::series_index::catalog::SeriesIndexEntry; +use crate::series_index::format::{FormatReader, FormatWriter, SeriesIndexFormat}; +use crate::series_index::{ + MetricSeriesId, ROW_COUNT_COLUMN, SeriesIndexFileHandle, TABLE_ID_COLUMN, TSID_COLUMN, +}; + +pub(crate) const VERSION: u32 = 1; +pub(crate) const MANIFEST_LIMIT: usize = 64 * 1024 * 1024; + +/// Packing and compression choices are internal and do not change the wire version. +#[derive(Debug, Clone)] +pub(crate) struct ColumnarSeriesFormat { + pub(crate) block_rows: usize, + pub(crate) zstd_level: i32, + pub(crate) compression: codec::Compression, + pub(crate) part_bytes: u64, + pub(crate) metadata_bytes: usize, + pub(crate) page_bytes: usize, + pub(crate) root_capacity: usize, +} + +impl Default for ColumnarSeriesFormat { + fn default() -> Self { + Self { + block_rows: 8192, + zstd_level: 3, + compression: codec::Compression::Zstd, + part_bytes: 512 * 1024 * 1024, + metadata_bytes: 8 * 1024 * 1024, + page_bytes: metadata::PAGE_TARGET, + root_capacity: metadata::ROOT_CAPACITY, + } + } +} + +#[async_trait] +impl SeriesIndexFormat for ColumnarSeriesFormat { + fn format(&self) -> ArtifactFormat { + ArtifactFormat::ColumnarSeries + } + + async fn writer( + &self, + metadata: RegionMetadataRef, + store: ObjectStore, + entry: &SeriesIndexEntry, + ) -> Result> { + Ok(Box::new(writer::ColumnarWriter::new( + self.clone(), + metadata, + store, + entry, + )?)) + } + + async fn reader( + &self, + store: ObjectStore, + handle: SeriesIndexFileHandle, + ) -> Result> { + Ok(Box::new(reader::ColumnarReader::open(store, handle).await?)) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct Tag { + pub(crate) column_id: u32, + pub(crate) name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct Part { + pub(crate) part_id: u32, + pub(crate) file_size: u64, + pub(crate) num_series: u64, + pub(crate) first: MetricSeriesId, + pub(crate) last: MetricSeriesId, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct Manifest { + pub(crate) format: ArtifactFormat, + pub(crate) version: u32, + pub(crate) region_id: u64, + pub(crate) generation: FileId, + pub(crate) tags: Vec, + pub(crate) coverage: SeriesIndexEntry, + pub(crate) files: Vec, +} + +impl Manifest { + pub(crate) fn prefix(&self) -> String { + format!("{}/series/{}/", self.region_id, self.generation) + } + pub(crate) fn part_path(&self, id: u32) -> String { + format!("{}part-{id}.series", self.prefix()) + } + pub(crate) fn path(&self) -> String { + format!("{}manifest.json", self.prefix()) + } + pub(crate) fn schema(&self) -> SchemaRef { + let mut fields = vec![ + Field::new(TABLE_ID_COLUMN, DataType::UInt32, false), + Field::new(TSID_COLUMN, DataType::UInt64, false), + Field::new(ROW_COUNT_COLUMN, DataType::UInt64, false), + ]; + fields.extend( + self.tags + .iter() + .map(|tag| Field::new(&tag.name, DataType::Utf8, true)), + ); + Arc::new(Schema::new(fields)) + } +} + +pub(crate) fn invalid(reason: impl Into) -> crate::error::Error { + InvalidMetaSnafu { + reason: reason.into(), + } + .build() +} + +pub(crate) fn require(valid: bool, reason: &str) -> Result<()> { + if valid { Ok(()) } else { Err(invalid(reason)) } +} + +pub(crate) fn unsupported(reason: impl Into) -> crate::error::Error { + crate::error::UnsupportedOperationSnafu { + err_msg: reason.into(), + } + .build() +} diff --git a/src/mito2/src/series_index/columnar/codec.rs b/src/mito2/src/series_index/columnar/codec.rs new file mode 100644 index 00000000000..53e0a678432 --- /dev/null +++ b/src/mito2/src/series_index/columnar/codec.rs @@ -0,0 +1,259 @@ +// Copyright 2023 Greptime Team +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Bounded, explicit wire codecs. Encoding and compression have independent IDs. + +use std::collections::HashMap; +use std::io::Read; + +use crate::error::Result; +use crate::series_index::columnar::{invalid, require, unsupported}; + +pub(crate) const COLUMN_LIMIT: usize = 64 * 1024 * 1024; +pub(crate) const COLUMN_STORED_LIMIT: usize = COLUMN_LIMIT + 1024 * 1024; +pub(crate) const INTEGER: u16 = 1; +pub(crate) const DICTIONARY: u16 = 2; + +#[derive(Debug, Clone, Copy)] +#[repr(u16)] +pub(crate) enum Compression { + None = 0, + Zstd = 1, +} + +pub(crate) fn checksum(bytes: &[u8]) -> u64 { + twox_hash::XxHash64::oneshot(0, bytes) +} + +pub(crate) fn compress(bytes: &[u8], compression: Compression, level: i32) -> Result> { + match compression { + Compression::None => Ok(bytes.to_vec()), + Compression::Zstd => zstd::stream::encode_all(bytes, level) + .map_err(|e| invalid(format!("series compression: {e}"))), + } +} + +pub(crate) fn decompress(bytes: &[u8], compression: u16, decoded: usize) -> Result> { + let output = match compression { + 0 => bytes.to_vec(), + 1 => { + let decoder = zstd::stream::read::Decoder::new(bytes) + .map_err(|e| invalid(format!("series zstd header: {e}")))?; + let mut output = Vec::new(); + // Never let a frame's claimed size control allocation or output growth. + decoder + .take(decoded as u64 + 1) + .read_to_end(&mut output) + .map_err(|e| invalid(format!("series decompression: {e}")))?; + output + } + id => return Err(unsupported(format!("series compression {id}"))), + }; + require(output.len() == decoded, "series decoded length mismatch")?; + Ok(output) +} + +pub(crate) fn put_u32(out: &mut Vec, value: u32) { + out.extend(value.to_le_bytes()); +} +pub(crate) fn put_u64(out: &mut Vec, value: u64) { + out.extend(value.to_le_bytes()); +} +pub(crate) fn put_string(out: &mut Vec, value: &str) { + put_u64(out, value.len() as u64); + out.extend(value.as_bytes()); +} +pub(crate) fn put_varint(out: &mut Vec, mut value: u64) { + while value >= 128 { + out.push(value as u8 | 128); + value >>= 7; + } + out.push(value as u8); +} + +pub(crate) struct Cursor<'a> { + bytes: &'a [u8], + offset: usize, +} +impl<'a> Cursor<'a> { + pub(crate) fn new(bytes: &'a [u8]) -> Self { + Self { bytes, offset: 0 } + } + pub(crate) fn remaining(&self) -> usize { + self.bytes.len() - self.offset + } + pub(crate) fn take(&mut self, count: usize) -> Result<&'a [u8]> { + let end = self + .offset + .checked_add(count) + .ok_or_else(|| invalid("series length overflow"))?; + let result = self + .bytes + .get(self.offset..end) + .ok_or_else(|| invalid("truncated series payload"))?; + self.offset = end; + Ok(result) + } + pub(crate) fn u8(&mut self) -> Result { + Ok(self.take(1)?[0]) + } + pub(crate) fn u16(&mut self) -> Result { + let b = self.take(2)?; + Ok(u16::from_le_bytes([b[0], b[1]])) + } + pub(crate) fn u32(&mut self) -> Result { + let b = self.take(4)?; + Ok(u32::from_le_bytes([b[0], b[1], b[2], b[3]])) + } + pub(crate) fn u64(&mut self) -> Result { + let b = self.take(8)?; + Ok(u64::from_le_bytes([ + b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7], + ])) + } + pub(crate) fn length(&mut self) -> Result { + usize::try_from(self.u64()?).map_err(|_| invalid("series length exceeds address space")) + } + pub(crate) fn string(&mut self) -> Result { + let len = self.length()?; + std::str::from_utf8(self.take(len)?) + .map(str::to_owned) + .map_err(|_| invalid("invalid series UTF-8")) + } + pub(crate) fn varint(&mut self) -> Result { + let mut value = 0; + for shift in (0..70).step_by(7) { + let byte = self.u8()?; + require(shift != 63 || byte <= 1, "series varint overflow")?; + value |= u64::from(byte & 127) << shift; + if byte & 128 == 0 { + return Ok(value); + } + } + Err(invalid("series varint overflow")) + } + pub(crate) fn end(self) -> Result<()> { + require(self.remaining() == 0, "trailing series payload bytes") + } +} + +pub(crate) fn encode_integers(values: &[u64]) -> Vec { + let mut out = Vec::new(); + let mut previous = 0u64; + for &value in values { + let delta = value.wrapping_sub(previous) as i64; + put_varint(&mut out, ((delta as u64) << 1) ^ ((delta >> 63) as u64)); + previous = value; + } + out +} + +pub(crate) fn decode_integers(bytes: &[u8], count: usize) -> Result> { + require(count <= bytes.len(), "integer count exceeds payload")?; + let mut cursor = Cursor::new(bytes); + let mut out = Vec::with_capacity(count); + let mut previous = 0u64; + for _ in 0..count { + let value = cursor.varint()?; + let delta = (value >> 1) ^ (0u64.wrapping_sub(value & 1)); + previous = previous.wrapping_add(delta); + out.push(previous); + } + cursor.end()?; + Ok(out) +} + +#[derive(Debug)] +pub(crate) struct Dictionary { + pub(crate) values: Vec>, + pub(crate) codes: Vec, +} + +pub(crate) fn encode_dictionary(values: &[Option<&str>]) -> Result> { + let mut lookup: HashMap<&str, u32> = HashMap::new(); + let mut dictionary = Vec::new(); + put_u64(&mut dictionary, 0); // patched with the entry count below + dictionary.push(0); // slot zero is always null + let mut codes = Vec::new(); + for value in values { + let code = match value { + None => 0, + Some(value) => { + if let Some(code) = lookup.get(value) { + *code + } else { + let code = u32::try_from(lookup.len() + 1) + .map_err(|_| invalid("too many dictionary entries"))?; + lookup.insert(value, code); + dictionary.push(1); + put_string(&mut dictionary, value); + code + } + } + }; + put_varint(&mut codes, u64::from(code)); + require( + dictionary.len() + codes.len() <= COLUMN_LIMIT, + "series dictionary exceeds chunk limit", + )?; + } + dictionary[..8].copy_from_slice(&((lookup.len() + 1) as u64).to_le_bytes()); + let mut out = Vec::new(); + put_varint(&mut out, dictionary.len() as u64); + out.extend(dictionary); + out.extend(codes); + Ok(out) +} + +pub(crate) fn decode_dictionary(bytes: &[u8], count: usize) -> Result { + let mut cursor = Cursor::new(bytes); + let length = + usize::try_from(cursor.varint()?).map_err(|_| invalid("dictionary length overflow"))?; + let mut dictionary = Cursor::new(cursor.take(length)?); + let entries = dictionary.length()?; + require( + entries > 0 && entries <= dictionary.remaining(), + "invalid dictionary count", + )?; + require( + count <= cursor.remaining(), + "dictionary code count exceeds payload", + )?; + let mut values = Vec::new(); + let mut unique = std::collections::HashSet::new(); + for index in 0..entries { + let present = dictionary.u8()?; + if index == 0 { + require(present == 0, "dictionary slot zero must be null")?; + values.push(None); + } else { + require(present == 1, "nonzero dictionary slot must be present")?; + let value = dictionary.string()?; + require(unique.insert(value.clone()), "duplicate dictionary value")?; + values.push(Some(value)); + } + } + dictionary.end()?; + let mut codes = Vec::with_capacity(count); + for _ in 0..count { + let code = cursor.varint()?; + require( + code < entries as u64 && code <= u32::MAX as u64, + "invalid dictionary code", + )?; + codes.push(code as u32); + } + cursor.end()?; + Ok(Dictionary { values, codes }) +} diff --git a/src/mito2/src/series_index/columnar/metadata.rs b/src/mito2/src/series_index/columnar/metadata.rs new file mode 100644 index 00000000000..b98ff7f3991 --- /dev/null +++ b/src/mito2/src/series_index/columnar/metadata.rs @@ -0,0 +1,456 @@ +// Copyright 2023 Greptime Team +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Explicit little-endian framing and the two-level metadata index. + +use crate::error::Result; +use crate::series_index::columnar::codec::{ + self, Compression, Cursor, put_string, put_u32, put_u64, +}; +use crate::series_index::columnar::{VERSION, invalid, require, unsupported}; +use crate::series_index::{MetricSeriesId, ROW_COUNT_COLUMN, TSID_COLUMN}; + +pub(crate) const MAGIC: &[u8; 8] = b"GTSERIES"; +pub(crate) const HEADER_SIZE: u64 = 16; +pub(crate) const TRAILER_SIZE: u64 = 96; +pub(crate) const PAGE_TARGET: usize = 65536; +pub(crate) const PAGE_LIMIT: usize = 8 * 1024 * 1024; +pub(crate) const PAGE_STORED_LIMIT: usize = PAGE_LIMIT + PAGE_TARGET; +pub(crate) const ROOT_CAPACITY: usize = (PAGE_TARGET - 12) / 56; + +#[derive(Debug, Clone, Copy)] +pub(crate) struct PayloadReference { + pub(crate) offset: u64, + pub(crate) stored: u32, + pub(crate) decoded: u32, + pub(crate) checksum: u64, + pub(crate) encoding: u16, + pub(crate) compression: u16, +} +impl PayloadReference { + pub(crate) fn end(&self) -> Result { + self.offset + .checked_add(u64::from(self.stored)) + .ok_or_else(|| invalid("series offset overflow")) + } + pub(crate) fn validate(&self, start: u64, end: u64, page: bool) -> Result<()> { + let (decoded_limit, stored_limit) = if page { + (PAGE_LIMIT, PAGE_STORED_LIMIT) + } else { + (codec::COLUMN_LIMIT, codec::COLUMN_STORED_LIMIT) + }; + require( + self.stored > 0 + && self.decoded > 0 + && self.stored as usize <= stored_limit + && self.decoded as usize <= decoded_limit, + "series payload exceeds size limits", + )?; + require( + self.offset >= start && self.end()? <= end, + "series payload outside its area", + ) + } + pub(crate) fn encode(&self, out: &mut Vec) { + put_u64(out, self.offset); + put_u32(out, self.stored); + put_u32(out, self.decoded); + put_u64(out, self.checksum); + out.extend(self.encoding.to_le_bytes()); + out.extend(self.compression.to_le_bytes()); + put_u32(out, 0); + } + pub(crate) fn decode(c: &mut Cursor<'_>) -> Result { + let reference = Self { + offset: c.u64()?, + stored: c.u32()?, + decoded: c.u32()?, + checksum: c.u64()?, + encoding: c.u16()?, + compression: c.u16()?, + }; + require(c.u32()? == 0, "nonzero series reference reserved field")?; + Ok(reference) + } + pub(crate) fn unpack(&self, bytes: &[u8]) -> Result> { + require( + bytes.len() == self.stored as usize && codec::checksum(bytes) == self.checksum, + "series payload checksum mismatch", + )?; + codec::decompress(bytes, self.compression, self.decoded as usize) + } +} + +pub(crate) fn pack( + bytes: &[u8], + offset: u64, + encoding: u16, + compression: Compression, + level: i32, + page: bool, +) -> Result<(PayloadReference, Vec)> { + let limit = if page { + PAGE_LIMIT + } else { + codec::COLUMN_LIMIT + }; + require( + !bytes.is_empty() && bytes.len() <= limit, + "series decoded payload exceeds size limit", + )?; + let stored = codec::compress(bytes, compression, level)?; + let reference = PayloadReference { + offset, + stored: u32::try_from(stored.len()) + .map_err(|_| invalid("series stored length overflow"))?, + decoded: bytes.len() as u32, + checksum: codec::checksum(&stored), + encoding, + compression: compression as u16, + }; + reference.validate(offset, reference.end()?, page)?; + Ok((reference, stored)) +} + +#[derive(Debug, Clone)] +pub(crate) struct PageReference { + pub(crate) first: MetricSeriesId, + pub(crate) last: MetricSeriesId, + pub(crate) payload: PayloadReference, +} +impl PageReference { + pub(crate) fn encode(&self, out: &mut Vec) { + put_u32(out, self.first.table_id); + put_u64(out, self.first.tsid); + put_u32(out, self.last.table_id); + put_u64(out, self.last.tsid); + self.payload.encode(out); + } + pub(crate) fn decode(c: &mut Cursor<'_>) -> Result { + let first = MetricSeriesId { + table_id: c.u32()?, + tsid: c.u64()?, + }; + let last = MetricSeriesId { + table_id: c.u32()?, + tsid: c.u64()?, + }; + require(first <= last, "reversed series page bounds")?; + Ok(Self { + first, + last, + payload: PayloadReference::decode(c)?, + }) + } +} + +#[derive(Debug, Clone)] +pub(crate) struct Column { + pub(crate) name: String, + pub(crate) payload: PayloadReference, + pub(crate) null_count: u32, + pub(crate) bounds: Option<(String, String)>, +} +#[derive(Debug, Clone)] +pub(crate) struct Block { + pub(crate) table_id: u32, + pub(crate) first: u64, + pub(crate) last: u64, + pub(crate) rows: u32, + pub(crate) columns: Vec, +} +impl Block { + pub(crate) fn first_id(&self) -> MetricSeriesId { + MetricSeriesId { + table_id: self.table_id, + tsid: self.first, + } + } + pub(crate) fn last_id(&self) -> MetricSeriesId { + MetricSeriesId { + table_id: self.table_id, + tsid: self.last, + } + } + pub(crate) fn encode(&self, out: &mut Vec) { + put_u32(out, self.table_id); + put_u64(out, self.first); + put_u64(out, self.last); + put_u32(out, self.rows); + put_u32(out, self.columns.len() as u32); + for column in &self.columns { + put_string(out, &column.name); + column.payload.encode(out); + put_u32(out, column.null_count); + if let Some((min, max)) = &column.bounds { + put_u32(out, 1); + put_string(out, min); + put_string(out, max); + } else { + put_u32(out, 0); + } + } + } + fn decode( + c: &mut Cursor<'_>, + names: &[String], + payload_end: u64, + previous_end: &mut u64, + ) -> Result { + let table_id = c.u32()?; + let first = c.u64()?; + let last = c.u64()?; + let rows = c.u32()?; + let count = c.u32()? as usize; + require( + rows > 0 + && rows as usize <= codec::COLUMN_LIMIT / 8 + && first <= last + && u64::from(rows - 1) <= last - first, + "invalid series block bounds/count", + )?; + require( + count >= 2 && count <= names.len() && count <= c.remaining() / 48, + "invalid series column count", + )?; + let mut columns = Vec::with_capacity(count); + let mut next_name = 0; + for index in 0..count { + let name = c.string()?; + let position = names[next_name..] + .iter() + .position(|n| *n == name) + .map(|p| p + next_name) + .ok_or_else(|| invalid("unknown or unordered series column"))?; + require( + index >= 2 || position == index, + "missing required series column", + )?; + next_name = position + 1; + let payload = PayloadReference::decode(c)?; + payload.validate(*previous_end, payload_end, false)?; + *previous_end = payload.end()?; + let null_count = c.u32()?; + require(null_count <= rows, "invalid series null count")?; + let bounds = match c.u32()? { + 0 => None, + 1 => { + let min = c.string()?; + let max = c.string()?; + require(min <= max, "reversed string bounds")?; + Some((min, max)) + } + _ => return Err(unsupported("series statistics kind")), + }; + if name == TSID_COLUMN || name == ROW_COUNT_COLUMN { + require( + null_count == 0 && bounds.is_none(), + "invalid integer column statistics", + )?; + } else { + require( + bounds.is_some() == (null_count < rows), + "missing or invalid string statistics", + )?; + } + columns.push(Column { + name, + payload, + null_count, + bounds, + }); + } + require( + columns.iter().take(2).all(|column| { + column.payload.encoding != codec::INTEGER || rows <= column.payload.decoded + }), + "series row count exceeds integer payload lengths", + )?; + Ok(Self { + table_id, + first, + last, + rows, + columns, + }) + } +} + +#[derive(Debug)] +pub(crate) enum Page { + Leaf(Vec), + Internal(Vec), +} +impl Page { + pub(crate) fn encode(&self) -> Vec { + let mut out = Vec::new(); + match self { + Self::Leaf(blocks) => { + put_u32(&mut out, 0); + put_u64(&mut out, blocks.len() as u64); + for block in blocks { + block.encode(&mut out); + } + } + Self::Internal(children) => { + put_u32(&mut out, 1); + put_u64(&mut out, children.len() as u64); + for child in children { + child.encode(&mut out); + } + } + } + out + } + pub(crate) fn decode( + bytes: &[u8], + reference: &PageReference, + depth: u32, + payload_end: u64, + names: &[String], + ) -> Result { + let mut c = Cursor::new(bytes); + let kind = c.u32()?; + let count = c.length()?; + require(count > 0, "empty series metadata page")?; + let (page, first, last) = match (kind, depth) { + (0, 1) => { + require( + count <= c.remaining() / 28, + "series block count exceeds page", + )?; + let mut blocks: Vec = Vec::new(); + let mut end = HEADER_SIZE; + for _ in 0..count { + let block = Block::decode(&mut c, names, payload_end, &mut end)?; + if let Some(previous) = blocks.last() { + require( + previous.last_id() < block.first_id(), + "unordered series blocks", + )?; + } + blocks.push(block); + } + let first = blocks[0].first_id(); + let last = blocks[count - 1].last_id(); + (Self::Leaf(blocks), first, last) + } + (1, 2) => { + require( + bytes.len() <= PAGE_TARGET + && count <= ROOT_CAPACITY + && count <= c.remaining() / 56, + "series root exceeds capacity", + )?; + let mut children: Vec = Vec::with_capacity(count); + let mut end = payload_end; + for _ in 0..count { + let child = PageReference::decode(&mut c)?; + child + .payload + .validate(end, reference.payload.offset, true)?; + end = child.payload.end()?; + if let Some(previous) = children.last() { + require(previous.last < child.first, "unordered series child ranges")?; + } + children.push(child); + } + let first = children[0].first; + let last = children[count - 1].last; + (Self::Internal(children), first, last) + } + _ => return Err(unsupported("series metadata kind/depth")), + }; + c.end()?; + require( + first == reference.first && last == reference.last, + "series page bounds disagree with parent", + )?; + Ok(page) + } +} + +pub(crate) fn header() -> Vec { + let mut out = MAGIC.to_vec(); + put_u32(&mut out, VERSION); + put_u32(&mut out, 0); + out +} +pub(crate) fn validate_header(bytes: &[u8]) -> Result<()> { + let mut c = Cursor::new(bytes); + require(c.take(8)? == MAGIC, "invalid series header magic")?; + if c.u32()? != VERSION { + return Err(unsupported("series file version")); + } + require(c.u32()? == 0, "nonzero series header reserved field")?; + c.end() +} + +pub(crate) struct Trailer { + pub(crate) root: PageReference, + pub(crate) depth: u32, + pub(crate) file_length: u64, + pub(crate) payload_end: u64, +} +impl Trailer { + pub(crate) fn encode(&self) -> Vec { + let mut out = Vec::new(); + self.root.encode(&mut out); + put_u32(&mut out, self.depth); + put_u32(&mut out, VERSION); + put_u64(&mut out, self.file_length); + put_u64(&mut out, self.payload_end); + out.extend(MAGIC); + let checksum = codec::checksum(&out); + put_u64(&mut out, checksum); + out + } + pub(crate) fn decode(bytes: &[u8], file_length: u64) -> Result { + require( + bytes.len() == TRAILER_SIZE as usize, + "truncated series trailer", + )?; + let mut c = Cursor::new(bytes); + let root = PageReference::decode(&mut c)?; + let depth = c.u32()?; + let version = c.u32()?; + if !(1..=2).contains(&depth) || version != VERSION { + return Err(unsupported("series trailer version/depth")); + } + let length = c.u64()?; + let payload_end = c.u64()?; + require( + c.take(8)? == MAGIC && c.u64()? == codec::checksum(&bytes[..88]), + "series trailer checksum/magic mismatch", + )?; + require( + length == file_length + && payload_end >= HEADER_SIZE + && file_length >= HEADER_SIZE + TRAILER_SIZE, + "invalid series file framing", + )?; + root.payload + .validate(payload_end, file_length - TRAILER_SIZE, true)?; + require( + root.payload.end()? == file_length - TRAILER_SIZE, + "series root does not end at trailer", + )?; + Ok(Self { + root, + depth, + file_length, + payload_end, + }) + } +} diff --git a/src/mito2/src/series_index/columnar/reader.rs b/src/mito2/src/series_index/columnar/reader.rs new file mode 100644 index 00000000000..f1f5d388756 --- /dev/null +++ b/src/mito2/src/series_index/columnar/reader.rs @@ -0,0 +1,557 @@ +// Copyright 2023 Greptime Team +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Lazy range reads with per-stream traversal and dictionary predicate evaluation. + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use common_recordbatch::filter::SimpleFilterEvaluator; +use datatypes::arrow::array::{ArrayRef, StringArray, UInt32Array, UInt64Array}; +use datatypes::arrow::datatypes::SchemaRef; +use datatypes::arrow::record_batch::{RecordBatch, RecordBatchOptions}; +use datatypes::value::Value; +use futures::stream::BoxStream; +use object_store::ObjectStore; +use snafu::ResultExt; + +use crate::error::{NewRecordBatchSnafu, OpenDalSnafu, RecordBatchSnafu, Result, SerdeJsonSnafu}; +use crate::series_index::artifact::ArtifactFormat; +use crate::series_index::columnar::codec::{self, Dictionary}; +use crate::series_index::columnar::metadata::{self, Block, Column, Page, PageReference, Trailer}; +use crate::series_index::columnar::{ + MANIFEST_LIMIT, Manifest, VERSION, invalid, require, unsupported, +}; +use crate::series_index::format::{FormatReader, SeriesReadRequest}; +use crate::series_index::{ + MetricSeriesId, ROW_COUNT_COLUMN, SeriesIndexFileHandle, TABLE_ID_COLUMN, TSID_COLUMN, +}; +use crate::sst::parquet::helper::fetch_byte_ranges; + +pub(crate) struct ColumnarReader { + store: ObjectStore, + manifest: Arc, + handle: SeriesIndexFileHandle, +} +impl ColumnarReader { + pub(crate) async fn open(store: ObjectStore, handle: SeriesIndexFileHandle) -> Result { + let entry = handle.entry(); + let artifact = entry + .artifact + .as_ref() + .ok_or_else(|| invalid("missing columnar artifact"))?; + require( + artifact.format == ArtifactFormat::ColumnarSeries + && artifact.generation == entry.index_uuid, + "series artifact identity mismatch", + )?; + let region = handle.file_id().region_id().as_u64(); + let path = format!("{region}/series/{}/manifest.json", entry.index_uuid); + let mut objects = HashMap::with_capacity(artifact.objects.len()); + for object in &artifact.objects { + require( + objects + .insert(object.path.as_str(), object.file_size) + .is_none(), + "duplicate series artifact object", + )?; + } + let length = *objects + .get(path.as_str()) + .ok_or_else(|| invalid("missing series manifest object"))?; + require( + length <= MANIFEST_LIMIT as u64, + "series manifest exceeds size limit", + )?; + let bytes = read_range(&store, &path, 0, length).await?; + let manifest: Manifest = serde_json::from_slice(&bytes).context(SerdeJsonSnafu)?; + if manifest.version != VERSION { + return Err(unsupported("series manifest version")); + } + require( + manifest.format == ArtifactFormat::ColumnarSeries + && manifest.region_id == region + && manifest.generation == entry.index_uuid, + "series manifest identity mismatch", + )?; + let mut coverage = entry.clone(); + coverage.artifact = None; + coverage.file_size = 0; + require( + manifest.coverage == coverage, + "series manifest coverage mismatch", + )?; + let mut names = HashSet::from([ + TABLE_ID_COLUMN.to_owned(), + TSID_COLUMN.to_owned(), + ROW_COUNT_COLUMN.to_owned(), + ]); + let mut ids = HashSet::new(); + for tag in &manifest.tags { + require( + names.insert(tag.name.clone()) && ids.insert(tag.column_id), + "duplicate series tag name/id", + )?; + } + let mut paths = HashSet::from([path]); + let mut file_size = length; + let mut previous = None; + for (index, part) in manifest.files.iter().enumerate() { + require( + index as u64 == u64::from(part.part_id) + && part.first <= part.last + && part.num_series > 0 + && part.file_size >= metadata::HEADER_SIZE + metadata::TRAILER_SIZE, + "invalid series part entry", + )?; + if let Some(last) = previous { + require(last < part.first, "unordered series parts")?; + } + previous = Some(part.last); + let part_path = manifest.part_path(part.part_id); + require( + objects.get(part_path.as_str()) == Some(&part.file_size), + "series part length mismatch", + )?; + paths.insert(part_path); + file_size = file_size + .checked_add(part.file_size) + .ok_or_else(|| invalid("series artifact length overflow"))?; + } + require( + paths.len() == artifact.objects.len() + && artifact + .objects + .iter() + .all(|object| paths.contains(&object.path)), + "series artifact object set mismatch", + )?; + require( + file_size == artifact.file_size && file_size == entry.file_size, + "series artifact size mismatch", + )?; + Ok(Self { + store, + manifest: Arc::new(manifest), + handle, + }) + } +} + +impl FormatReader for ColumnarReader { + fn schema(&self) -> SchemaRef { + self.manifest.schema() + } + + fn read(&self, request: SeriesReadRequest) -> Result>> { + let schema = self.schema(); + let mut projection = Vec::new(); + for name in &request.projection { + projection.push( + schema + .index_of(name) + .map_err(|_| invalid(format!("unknown series projection {name}")))?, + ); + } + for filter in &request.filters { + schema + .index_of(filter.column_name()) + .map_err(|_| invalid(format!("unknown series filter {}", filter.column_name())))?; + } + let projected = Arc::new( + schema + .project(&projection) + .map_err(|e| invalid(format!("series projection: {e}")))?, + ); + let manifest = self.manifest.clone(); + let store = self.store.clone(); + let handle = self.handle.clone(); + let names: Vec<_> = schema + .fields() + .iter() + .skip(1) + .map(|f| f.name().clone()) + .collect(); + Ok(Box::pin(async_stream::try_stream! { + // Retained across every yield, even after the reader object is dropped. + let _handle = handle; + for part in &manifest.files { + if !matches_range(part.first, part.last, &request) { continue; } + let path = manifest.part_path(part.part_id); + let size = part.file_size; + let framing = fetch_byte_ranges(&path, store.clone(), &[0..metadata::HEADER_SIZE, size-metadata::TRAILER_SIZE..size]).await.context(OpenDalSnafu)?; + require( + framing.len() == 2 + && framing[0].len() as u64 == metadata::HEADER_SIZE + && framing[1].len() as u64 == metadata::TRAILER_SIZE, + "series framing length mismatch", + )?; + metadata::validate_header(&framing[0])?; + let trailer = Trailer::decode(&framing[1], size)?; + require(trailer.root.first == part.first && trailer.root.last == part.last, "series part/root bounds mismatch")?; + let root = load_metadata_page(&store, &path, &trailer.root, trailer.depth, trailer.payload_end, &names).await?; + let (mut active, children) = match root { + Page::Leaf(blocks) => (Some(blocks), Vec::new()), + Page::Internal(children) => (None, children), + }; + let mut child_iter = children.into_iter(); + let mut observed_rows = 0u64; + let mut previous_payload_end = metadata::HEADER_SIZE; + loop { + let blocks = if let Some(blocks) = active.take() { blocks } else { + let Some(child) = child_iter.next() else { break; }; + if !matches_range(child.first, child.last, &request) { continue; } + match load_metadata_page(&store, &path, &child, 1, trailer.payload_end, &names).await? { + Page::Leaf(blocks) => blocks, + Page::Internal(_) => Err(unsupported("internal child in depth-two series index"))?, + } + }; + for block in blocks { + observed_rows = observed_rows.checked_add(u64::from(block.rows)).ok_or_else(|| invalid("series part row count overflow"))?; + for column in &block.columns { + column.payload.validate(previous_payload_end, trailer.payload_end, false)?; + previous_payload_end = column.payload.end()?; + } + if !matches_range(block.first_id(), block.last_id(), &request) || !matches_statistics(&block, &request.filters) { continue; } + if let Some(mut output) = read_block(&store, &path, &block, &request, projected.clone()).await? { + while let Some(batch) = output.next_batch()? { yield batch; } + } + } + } + if request.ids.is_none() && request.table_id.is_none() { + require(observed_rows == part.num_series, "series part row count mismatch")?; + } + } + })) + } +} + +fn matches_range(first: MetricSeriesId, last: MetricSeriesId, request: &SeriesReadRequest) -> bool { + let (mut first, mut last) = (first, last); + if let Some(table) = request.table_id { + first = first.max(MetricSeriesId { + table_id: table, + tsid: 0, + }); + last = last.min(MetricSeriesId { + table_id: table, + tsid: u64::MAX, + }); + if first > last { + return false; + } + } + request + .ids + .as_ref() + .is_none_or(|ids| ids.range(first..=last).next().is_some()) +} + +fn matches_statistics(block: &Block, filters: &[SimpleFilterEvaluator]) -> bool { + for filter in filters { + if !filter.is_eq() { + continue; + } + if let Some(Value::String(value)) = filter.literal_value() + && let Some((min, max)) = block + .columns + .iter() + .find(|c| c.name == filter.column_name()) + .and_then(|c| c.bounds.as_ref()) + && (value.as_utf8() < min.as_str() || value.as_utf8() > max.as_str()) + { + return false; + } + } + true +} + +async fn read_range(store: &ObjectStore, path: &str, start: u64, end: u64) -> Result { + let mut buffers = fetch_byte_ranges(path, store.clone(), std::slice::from_ref(&(start..end))) + .await + .context(OpenDalSnafu)?; + let bytes = buffers + .pop() + .ok_or_else(|| invalid("missing series range response"))?; + require( + bytes.len() as u64 == end - start, + "series range length mismatch", + )?; + Ok(bytes) +} + +/// TODO: integrate with Mito's cache using store/region, generation, part, reference, +/// checksum and codec IDs. The immutable path and reference remain available here. +async fn load_metadata_page( + store: &ObjectStore, + path: &str, + reference: &PageReference, + depth: u32, + payload_end: u64, + names: &[String], +) -> Result { + if reference.payload.encoding != 1 { + return Err(unsupported(format!( + "series metadata encoding {}", + reference.payload.encoding + ))); + } + let bytes = read_range( + store, + path, + reference.payload.offset, + reference.payload.end()?, + ) + .await?; + // TODO: benchmark inline versus offloaded codecs for latency, throughput, + // and async-worker responsiveness before adding scheduling overhead. + let decoded = reference.payload.unpack(&bytes)?; + Page::decode(&decoded, reference, depth, payload_end, names) +} + +#[derive(Debug)] +enum Decoded { + Integers(Vec), + Strings(Dictionary), + Table(u32, usize), + Null(usize), +} +impl Decoded { + fn value_bytes(&self, index: usize) -> usize { + match self { + Self::Integers(_) => 8, + Self::Table(_, _) => 4, + Self::Null(_) => 5, + Self::Strings(dict) => { + dict.values[dict.codes[index] as usize] + .as_ref() + .map_or(0, String::len) + + 5 + } + } + } + + fn array(&self, selected: &[usize]) -> ArrayRef { + match self { + Self::Integers(values) => Arc::new(UInt64Array::from_iter_values( + selected.iter().map(|&i| values[i]), + )), + Self::Strings(dict) => Arc::new(StringArray::from_iter( + selected + .iter() + .map(|&i| dict.values[dict.codes[i] as usize].as_deref()), + )), + Self::Table(value, _) => Arc::new(UInt32Array::from(vec![*value; selected.len()])), + Self::Null(_) => Arc::new(StringArray::from(vec![None::<&str>; selected.len()])), + } + } + fn filter(&self, filter: &SimpleFilterEvaluator, selected: &mut Vec) -> Result<()> { + if let Self::Strings(dict) = self { + let values: ArrayRef = Arc::new(StringArray::from_iter( + dict.values.iter().map(|v| v.as_deref()), + )); + let mask = filter.evaluate_array(&values).context(RecordBatchSnafu)?; + selected.retain(|&i| mask.value(dict.codes[i] as usize)); + } else { + let length = match self { + Self::Integers(v) => v.len(), + Self::Null(n) | Self::Table(_, n) => *n, + Self::Strings(_) => 0, + }; + let values = self.array(&(0..length).collect::>()); + let mask = filter.evaluate_array(&values).context(RecordBatchSnafu)?; + selected.retain(|&i| mask.value(i)); + } + Ok(()) + } +} + +/// TODO: share decoded chunks through the existing Mito cache, not a new cache framework. +async fn load_column_chunk( + store: &ObjectStore, + path: &str, + column: &Column, + block: &Block, +) -> Result { + let integer = column.name == TSID_COLUMN || column.name == ROW_COUNT_COLUMN; + let expected = if integer { + codec::INTEGER + } else { + codec::DICTIONARY + }; + if column.payload.encoding != expected { + return Err(unsupported(format!( + "series column encoding {} for {}", + column.payload.encoding, column.name + ))); + } + let bytes = read_range(store, path, column.payload.offset, column.payload.end()?).await?; + let rows = block.rows as usize; + let first = block.first; + let last = block.last; + let bytes = column.payload.unpack(&bytes)?; + if integer { + let values = codec::decode_integers(&bytes, rows)?; + if column.name == TSID_COLUMN { + require( + values.first() == Some(&first) + && values.last() == Some(&last) + && values.windows(2).all(|v| v[0] < v[1]), + "invalid series TSID ordering/bounds", + )?; + } else { + require(values.iter().all(|&v| v > 0), "zero series row count")?; + } + Ok(Decoded::Integers(values)) + } else { + let dictionary = codec::decode_dictionary(&bytes, rows)?; + require( + dictionary.codes.iter().filter(|&&code| code == 0).count() + == column.null_count as usize, + "series dictionary null count mismatch", + )?; + let min = dictionary + .codes + .iter() + .filter_map(|&code| dictionary.values[code as usize].as_ref()) + .min(); + let max = dictionary + .codes + .iter() + .filter_map(|&code| dictionary.values[code as usize].as_ref()) + .max(); + require( + column.bounds.as_ref().map(|(a, b)| (a, b)) == min.zip(max), + "series dictionary bounds mismatch", + )?; + Ok(Decoded::Strings(dictionary)) + } +} + +async fn get_column(store: &ObjectStore, path: &str, block: &Block, name: &str) -> Result { + if name == TABLE_ID_COLUMN { + return Ok(Decoded::Table(block.table_id, block.rows as usize)); + } + match block.columns.iter().find(|c| c.name == name) { + Some(column) => load_column_chunk(store, path, column, block).await, + None => Ok(Decoded::Null(block.rows as usize)), + } +} + +async fn read_block( + store: &ObjectStore, + path: &str, + block: &Block, + request: &SeriesReadRequest, + schema: SchemaRef, +) -> Result> { + let mut decoded = HashMap::new(); + let mut selected: Vec<_> = (0..block.rows as usize).collect(); + if let Some(ids) = &request.ids { + let column = get_column(store, path, block, TSID_COLUMN).await?; + if let Decoded::Integers(values) = &column { + selected.retain(|&i| { + ids.contains(&MetricSeriesId { + table_id: block.table_id, + tsid: values[i], + }) + }); + } + decoded.insert(TSID_COLUMN.to_owned(), Arc::new(column)); + } + for filter in &request.filters { + if selected.is_empty() { + return Ok(None); + } + let name = filter.column_name(); + if !decoded.contains_key(name) { + decoded.insert( + name.to_owned(), + Arc::new(get_column(store, path, block, name).await?), + ); + } + decoded + .get(name) + .ok_or_else(|| invalid("missing decoded filter column"))? + .filter(filter, &mut selected)?; + } + if selected.is_empty() { + return Ok(None); + } + let mut columns = Vec::with_capacity(request.projection.len()); + for name in &request.projection { + if !decoded.contains_key(name) { + decoded.insert( + name.clone(), + Arc::new(get_column(store, path, block, name).await?), + ); + } + columns.push( + decoded + .get(name) + .ok_or_else(|| invalid("missing decoded output column"))? + .clone(), + ); + } + Ok(Some(ProjectedBlock { + columns, + selected, + schema, + position: 0, + })) +} + +/// Dictionary expansion can be much larger than the stored chunk. Bound each +/// materialized batch, allowing one oversized row, without expanding the block. +struct ProjectedBlock { + columns: Vec>, + selected: Vec, + schema: SchemaRef, + position: usize, +} +impl ProjectedBlock { + fn next_batch(&mut self) -> Result> { + const TARGET_BYTES: usize = 8 * 1024 * 1024; + if self.position == self.selected.len() { + return Ok(None); + } + let start = self.position; + let mut bytes = 0usize; + while self.position < self.selected.len() { + let index = self.selected[self.position]; + let row_bytes = self.columns.iter().fold(0usize, |sum, column| { + sum.saturating_add(column.value_bytes(index)) + }); + if self.position > start && bytes.saturating_add(row_bytes) > TARGET_BYTES { + break; + } + bytes = bytes.saturating_add(row_bytes); + self.position += 1; + } + let selected = &self.selected[start..self.position]; + let arrays = self + .columns + .iter() + .map(|column| column.array(selected)) + .collect(); + Ok(Some( + RecordBatch::try_new_with_options( + self.schema.clone(), + arrays, + &RecordBatchOptions::new().with_row_count(Some(selected.len())), + ) + .context(NewRecordBatchSnafu)?, + )) + } +} diff --git a/src/mito2/src/series_index/columnar/tests.rs b/src/mito2/src/series_index/columnar/tests.rs new file mode 100644 index 00000000000..216b3ae186c --- /dev/null +++ b/src/mito2/src/series_index/columnar/tests.rs @@ -0,0 +1,1376 @@ +// Copyright 2023 Greptime Team +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Codec correctness, corruption handling, range selection and artifact lifecycle. + +use std::collections::BTreeSet; +use std::sync::Arc; +use std::time::Duration; + +use common_recordbatch::filter::SimpleFilterEvaluator; +use datafusion_expr::Operator; +use datatypes::arrow::array::{ + Array, ArrayRef, BinaryArray, BinaryDictionaryBuilder, StringArray, UInt8Array, UInt32Array, + UInt64Array, +}; +use datatypes::arrow::datatypes::{DataType, Field, Schema, UInt32Type}; +use datatypes::arrow::record_batch::RecordBatch; +use futures::TryStreamExt; +use object_store::ObjectStore; +use store_api::codec::PrimaryKeyEncoding; +use store_api::metadata::RegionMetadataRef; +use store_api::storage::FileId; + +use super::codec::{self, Compression}; +use super::metadata::{self, PayloadReference, Trailer}; +use super::{ColumnarSeriesFormat, Manifest}; +use crate::series_index::catalog::SeriesIndexEntry; +use crate::series_index::format::{SeriesIndexFormat, SeriesReadRequest}; +use crate::series_index::{ + MetricSeriesId, ROW_COUNT_COLUMN, SeriesIndexFileHandle, TABLE_ID_COLUMN, TSID_COLUMN, + series_index_channel, +}; +use crate::test_util::sst_util::{new_sparse_primary_key, sst_region_metadata_with_encoding}; + +fn metadata() -> RegionMetadataRef { + Arc::new(sst_region_metadata_with_encoding( + PrimaryKeyEncoding::Sparse, + )) +} +fn store() -> ObjectStore { + ObjectStore::new(object_store::services::Memory::default()).unwrap() +} +fn entry() -> SeriesIndexEntry { + SeriesIndexEntry { + artifact: None, + file_size: 0, + index_uuid: FileId::random(), + bucket_start: common_time::Timestamp::new_second(0), + bucket_end: common_time::Timestamp::new_second(100), + source_file_ids: Vec::new(), + min_file_sequence: 1, + max_file_sequence: 2, + compaction_window_secs: 100, + window_sequences: Default::default(), + } +} +fn batch(keys: &[Vec], dictionary: bool, timestamp: i64) -> RecordBatch { + let keys: ArrayRef = if dictionary { + let mut builder = BinaryDictionaryBuilder::::new(); + for key in keys { + builder.append(key).unwrap(); + } + Arc::new(builder.finish()) + } else { + Arc::new(BinaryArray::from_iter_values(keys)) + }; + // A non-timestamp column deliberately proves this writer never decodes timestamps. + let schema = Arc::new(Schema::new(vec![ + Field::new("ts", DataType::Int64, false), + Field::new("__primary_key", keys.data_type().clone(), false), + Field::new("__sequence", DataType::UInt64, false), + Field::new("__op_type", DataType::UInt8, false), + ])); + let n = keys.len(); + RecordBatch::try_new( + schema, + vec![ + Arc::new(datatypes::arrow::array::Int64Array::from(vec![ + timestamp; + n + ])), + keys, + Arc::new(UInt64Array::from(vec![1; n])), + Arc::new(UInt8Array::from(vec![0; n])), + ], + ) + .unwrap() +} +async fn build( + format: &ColumnarSeriesFormat, + store: &ObjectStore, + batches: &[RecordBatch], +) -> SeriesIndexFileHandle { + let meta = metadata(); + let mut entry = entry(); + let mut writer = format + .writer(meta.clone(), store.clone(), &entry) + .await + .unwrap(); + for batch in batches { + writer.write(batch).await.unwrap(); + } + let artifact = writer.finish().await.unwrap(); + entry.file_size = artifact.file_size; + entry.artifact = Some(artifact); + let (purger, _) = series_index_channel(store.clone()); + SeriesIndexFileHandle::new(meta.region_id, entry, purger) +} +async fn manifest(store: &ObjectStore, handle: &SeriesIndexFileHandle) -> Manifest { + let path = handle + .entry() + .artifact + .as_ref() + .unwrap() + .objects + .iter() + .find(|object| object.path.ends_with("manifest.json")) + .unwrap(); + serde_json::from_slice(&store.read(&path.path).await.unwrap().to_bytes()).unwrap() +} +fn request(names: &[&str]) -> SeriesReadRequest { + SeriesReadRequest { + projection: names.iter().map(|n| (*n).to_owned()).collect(), + ..Default::default() + } +} + +#[test] +fn integer_and_dictionary_wire() { + let values = [0, 1, u64::MAX, 0, 1 << 63, 7, 7]; + let bytes = codec::encode_integers(&values); + assert_eq!( + codec::decode_integers(&bytes, values.len()).unwrap(), + values + ); + assert_eq!(codec::encode_integers(&[1, 3, 2]), [2, 4, 1]); + assert!(codec::decode_integers(&[255; 10], 1).is_err()); + assert!(codec::decode_integers(&[0], 2).is_err()); + assert!(codec::decode_integers(&[0, 0], 1).is_err()); + let values = vec![None, Some(""), Some("a"), None, Some("a")]; + let decoded = codec::decode_dictionary(&codec::encode_dictionary(&values).unwrap(), 5).unwrap(); + assert_eq!(decoded.codes, [0, 1, 2, 0, 2]); + assert_eq!( + decoded.values, + [None, Some(String::new()), Some("a".into())] + ); + let mut bytes = codec::encode_dictionary(&values).unwrap(); + *bytes.last_mut().unwrap() = 127; + assert!(codec::decode_dictionary(&bytes, 5).is_err()); + assert!(codec::decode_dictionary(&[255; 32], usize::MAX).is_err()); +} + +#[test] +fn references_beyond_four_gib_and_bounded_decode() { + let offset = (1u64 << 32) + 19; + for compression in [Compression::None, Compression::Zstd] { + let (reference, bytes) = + metadata::pack(&[2, 4, 1], offset, 1, compression, 9, false).unwrap(); + assert_eq!(reference.unpack(&bytes).unwrap(), [2, 4, 1]); + reference + .validate(metadata::HEADER_SIZE, reference.end().unwrap(), false) + .unwrap(); + let mut encoded = Vec::new(); + reference.encode(&mut encoded); + assert_eq!(encoded.len(), 32); + assert_eq!( + PayloadReference::decode(&mut codec::Cursor::new(&encoded)) + .unwrap() + .offset, + offset + ); + let mut invalid = reference; + invalid.offset = u64::MAX; + assert!(invalid.end().is_err()); + invalid = reference; + invalid.decoded = u32::MAX; + assert!(invalid.validate(0, u64::MAX, false).is_err()); + invalid = reference; + invalid.decoded = 1; + assert!(invalid.unpack(&bytes).is_err()); + invalid = reference; + invalid.compression = 42; + assert!(invalid.unpack(&bytes).is_err()); + } + let bomb = codec::compress(&vec![0; 1024 * 1024], Compression::Zstd, 3).unwrap(); + assert!(codec::decompress(&bomb, 1, 16).is_err()); +} + +#[tokio::test] +async fn aggregation_projection_and_independent_reads() { + let store = store(); + let meta = metadata(); + let format = ColumnarSeriesFormat { + block_rows: 2, + ..Default::default() + }; + let a = new_sparse_primary_key(&["", "x"], &meta, 1, 10); + let b = new_sparse_primary_key(&["b", "y"], &meta, 1, 20); + let c = new_sparse_primary_key(&["c", "z"], &meta, 2, 10); + let handle = build( + &format, + &store, + &[ + batch(&[a.clone(), a.clone()], true, 1), + batch(&[a, b, c], false, 999), + ], + ) + .await; + let reader = format.reader(store.clone(), handle.clone()).await.unwrap(); + assert_eq!(reader.schema().fields().len(), 5); + assert!(reader.schema().index_of("__series_min_ts").is_err()); + let req = request(&[TABLE_ID_COLUMN, TSID_COLUMN, ROW_COUNT_COLUMN, "tag_0"]); + let one = reader.read(req.clone()).unwrap(); + let two = reader.read(req).unwrap(); + drop(reader); + let one = one.try_collect::>().await.unwrap(); + let two = two.try_collect::>().await.unwrap(); + assert_eq!(one, two); + assert_eq!(one.iter().map(|b| b.num_rows()).sum::(), 3); + assert_eq!( + one[0] + .column(2) + .as_any() + .downcast_ref::() + .unwrap() + .values(), + &[3, 1] + ); + assert_eq!( + one[1] + .column(0) + .as_any() + .downcast_ref::() + .unwrap() + .value(0), + 2 + ); + let reader = format.reader(store.clone(), handle).await.unwrap(); + let mut req = request(&["tag_0", ROW_COUNT_COLUMN]); + req.filters + .push(SimpleFilterEvaluator::new("tag_0".into(), "b", Operator::Eq).unwrap()); + let batches = reader + .read(req) + .unwrap() + .try_collect::>() + .await + .unwrap(); + assert_eq!(batches.len(), 1); + assert_eq!(batches[0].num_rows(), 1); + assert_eq!( + batches[0] + .column(0) + .as_any() + .downcast_ref::() + .unwrap() + .value(0), + "b" + ); + let mut req = request(&[]); + req.ids = Some(BTreeSet::from([MetricSeriesId { + table_id: 2, + tsid: 10, + }])); + let batches = reader + .read(req) + .unwrap() + .try_collect::>() + .await + .unwrap(); + assert_eq!(batches[0].num_columns(), 0); + assert_eq!(batches[0].num_rows(), 1); + let mut req = request(&[]); + req.ids = Some(BTreeSet::new()); + assert!( + reader + .read(req) + .unwrap() + .try_collect::>() + .await + .unwrap() + .is_empty() + ); + assert!(reader.read(request(&["missing"])).is_err()); + let mut req = request(&[]); + req.filters + .push(SimpleFilterEvaluator::new("__series_min_ts".into(), 1i64, Operator::Eq).unwrap()); + assert!(reader.read(req).is_err()); +} + +#[tokio::test] +async fn inconsistent_object_sizes_are_rejected() { + let store = store(); + let meta = metadata(); + let format = ColumnarSeriesFormat::default(); + let handle = build( + &format, + &store, + &[batch( + &[new_sparse_primary_key(&["a", "x"], &meta, 1, 1)], + false, + 0, + )], + ) + .await; + for case in 0..6 { + let mut entry = handle.entry().clone(); + let artifact = entry.artifact.as_mut().unwrap(); + let reason = match case { + 0 => { + artifact.objects.push(artifact.objects[0].clone()); + "duplicate series artifact object" + } + 1 => { + artifact.objects[0].file_size += 1; + "series part length mismatch" + } + 2 => { + artifact.objects.last_mut().unwrap().file_size = super::MANIFEST_LIMIT as u64 + 1; + "series manifest exceeds size limit" + } + 3 => { + artifact.file_size += 1; + "series artifact size mismatch" + } + 4 => { + entry.file_size += 1; + "series artifact size mismatch" + } + 5 => { + artifact.objects.pop(); + "missing series manifest object" + } + _ => unreachable!(), + }; + let (purger, _) = series_index_channel(store.clone()); + let invalid = SeriesIndexFileHandle::new(meta.region_id, entry, purger); + let error = format.reader(store.clone(), invalid).await.err().unwrap(); + assert!(error.to_string().contains(reason), "case {case}: {error}"); + } +} + +#[tokio::test] +async fn truncated_objects_are_rejected() { + let store = store(); + let meta = metadata(); + let format = ColumnarSeriesFormat::default(); + let handle = build( + &format, + &store, + &[batch( + &[new_sparse_primary_key(&["a", "x"], &meta, 1, 1)], + false, + 0, + )], + ) + .await; + let artifact = handle.entry().artifact.as_ref().unwrap(); + let part = &artifact.objects[0]; + let reader = format.reader(store.clone(), handle.clone()).await.unwrap(); + let bytes = store.read(&part.path).await.unwrap().to_bytes(); + store + .write(&part.path, bytes.slice(..bytes.len() - 1)) + .await + .unwrap(); + assert!( + reader + .read(request(&[TSID_COLUMN])) + .unwrap() + .try_collect::>() + .await + .is_err() + ); + + let manifest = artifact.objects.last().unwrap(); + let bytes = store.read(&manifest.path).await.unwrap().to_bytes(); + store + .write(&manifest.path, bytes.slice(..bytes.len() - 1)) + .await + .unwrap(); + assert!(format.reader(store, handle).await.is_err()); +} + +#[tokio::test] +async fn empty_nullable_and_block_row_options() { + use datatypes::value::ValueRef; + use mito_codec::row_converter::PrimaryKeyCodec; + use mito_codec::row_converter::SparsePrimaryKeyCodec; + use store_api::storage::consts::ReservedColumnId; + let meta = metadata(); + let store = store(); + let format = ColumnarSeriesFormat::default(); + let empty = build(&format, &store, &[]).await; + assert!(manifest(&store, &empty).await.files.is_empty()); + assert_eq!(empty.entry().artifact.as_ref().unwrap().objects.len(), 1); + assert!( + format + .reader(store.clone(), empty) + .await + .unwrap() + .read(request(&[])) + .unwrap() + .try_collect::>() + .await + .unwrap() + .is_empty() + ); + let codec = SparsePrimaryKeyCodec::new(&meta); + let mut key = Vec::new(); + codec + .encode_value_refs( + &[ + (ReservedColumnId::table_id(), ValueRef::UInt32(1)), + (ReservedColumnId::tsid(), ValueRef::UInt64(1)), + (0, ValueRef::String("")), + ], + &mut key, + ) + .unwrap(); + let handle = build(&format, &store, &[batch(&[key], false, 0)]).await; + let reader = format.reader(store.clone(), handle).await.unwrap(); + let batches = reader + .read(request(&["tag_0", "tag_1"])) + .unwrap() + .try_collect::>() + .await + .unwrap(); + assert_eq!(batches[0].column(0).null_count(), 0); + assert_eq!(batches[0].column(1).null_count(), 1); + let mut req = request(&[]); + req.filters + .push(SimpleFilterEvaluator::new("tag_1".into(), "", Operator::Eq).unwrap()); + assert!( + reader + .read(req) + .unwrap() + .try_collect::>() + .await + .unwrap() + .is_empty() + ); + for block_rows in [1, 16384] { + let format = ColumnarSeriesFormat { + block_rows, + compression: Compression::None, + ..Default::default() + }; + let n = if block_rows == 1 { 3 } else { 16385 }; + let keys: Vec<_> = (0..n) + .map(|tsid| new_sparse_primary_key(&["a", "x"], &meta, 1, tsid)) + .collect(); + let handle = build(&format, &store, &[batch(&keys, false, 0)]).await; + let batches = format + .reader(store.clone(), handle) + .await + .unwrap() + .read(request(&[TSID_COLUMN])) + .unwrap() + .try_collect::>() + .await + .unwrap(); + assert_eq!( + batches.iter().map(|b| b.num_rows()).sum::(), + n as usize + ); + assert_eq!(batches[0].num_rows(), block_rows); + } +} + +#[tokio::test] +async fn two_levels_rollover_and_corrupt_framing() { + let store = store(); + let meta = metadata(); + // One descriptor per leaf and two leaves per root force rollover before depth 3. + let format = ColumnarSeriesFormat { + block_rows: 1, + page_bytes: 64, + root_capacity: 2, + compression: Compression::None, + ..Default::default() + }; + let keys: Vec<_> = (0..5) + .map(|tsid| new_sparse_primary_key(&["a", "x"], &meta, 1, tsid)) + .collect(); + let handle = build(&format, &store, &[batch(&keys, false, 0)]).await; + let manifest = manifest(&store, &handle).await; + assert_eq!(manifest.files.len(), 3); + for part in &manifest.files { + let bytes = store + .read(&manifest.part_path(part.part_id)) + .await + .unwrap() + .to_bytes(); + let trailer = Trailer::decode(&bytes[bytes.len() - 96..], bytes.len() as u64).unwrap(); + assert_eq!(trailer.depth, if part.part_id == 2 { 1 } else { 2 }); + for depth in [0u32, 3] { + let mut tail = bytes[bytes.len() - 96..].to_vec(); + tail[56..60].copy_from_slice(&depth.to_le_bytes()); + let sum = codec::checksum(&tail[..88]); + tail[88..].copy_from_slice(&sum.to_le_bytes()); + assert!(Trailer::decode(&tail, bytes.len() as u64).is_err()); + } + for length in 0..96 { + assert!(Trailer::decode(&bytes[bytes.len() - length..], bytes.len() as u64).is_err()); + } + } + let reader = format.reader(store.clone(), handle.clone()).await.unwrap(); + assert_eq!( + reader + .read(request(&[TSID_COLUMN])) + .unwrap() + .try_collect::>() + .await + .unwrap() + .len(), + 5 + ); + let path = manifest.part_path(0); + let mut bytes = store.read(&path).await.unwrap().to_vec(); + bytes[16] ^= 1; + store.write(&path, bytes).await.unwrap(); + assert!( + reader + .read(request(&[TSID_COLUMN])) + .unwrap() + .try_collect::>() + .await + .is_err() + ); + // Corrupt unrequested integer bytes do not cause unrelated projected columns to decode. + assert!( + reader + .read(request(&["tag_0"])) + .unwrap() + .try_collect::>() + .await + .is_ok() + ); +} + +#[tokio::test] +async fn explicit_abort_drop_and_failed_finish_cleanup() { + let store = store(); + let meta = metadata(); + let format = ColumnarSeriesFormat { + block_rows: 1, + part_bytes: 1, + ..Default::default() + }; + let keys: Vec<_> = (0..3) + .map(|tsid| new_sparse_primary_key(&["a", "x"], &meta, 1, tsid)) + .collect(); + for drop_writer in [false, true] { + let entry = entry(); + let prefix = format!("{}/series/{}/", meta.region_id.as_u64(), entry.index_uuid); + let mut writer = format + .writer(meta.clone(), store.clone(), &entry) + .await + .unwrap(); + writer.write(&batch(&keys, false, 0)).await.unwrap(); + assert!( + store + .exists(&format!("{prefix}part-0.series")) + .await + .unwrap() + ); + if drop_writer { + drop(writer); + } else { + writer.abort().await.unwrap(); + } + tokio::time::timeout(Duration::from_secs(5), async { + loop { + if store + .list_with(&prefix) + .recursive(true) + .await + .unwrap() + .iter() + .all(|e| e.metadata().is_dir()) + { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + } + let entry = entry(); + let prefix = format!("{}/series/{}/", meta.region_id.as_u64(), entry.index_uuid); + let mut writer = format.writer(meta, store.clone(), &entry).await.unwrap(); + writer.write(&batch(&keys, false, 0)).await.unwrap(); + assert!(writer.write(&batch(&keys, false, 0)).await.is_err()); + assert!(writer.finish().await.is_err()); + assert!( + store + .list_with(&prefix) + .recursive(true) + .await + .unwrap() + .iter() + .all(|e| e.metadata().is_dir()) + ); +} + +#[tokio::test] +async fn selective_io_and_pinned_stream_retirement() { + use object_store::layers::mock::MockLayerBuilder; + use std::sync::Mutex; + let store = store(); + let meta = metadata(); + let format = ColumnarSeriesFormat { + page_bytes: 64, + ..Default::default() + }; + let a = new_sparse_primary_key(&["a", "x"], &meta, 7, 10); + let b = new_sparse_primary_key(&["", "x"], &meta, 7, 11); + let c = new_sparse_primary_key(&["é", "x"], &meta, 9, 5); + let built = build( + &format, + &store, + &[batch(&[a.clone(), a, b.clone(), b.clone(), b, c], false, 0)], + ) + .await; + let (purger, mut purges) = series_index_channel(store.clone()); + let handle = SeriesIndexFileHandle::new(meta.region_id, built.entry().clone(), purger); + drop(built); + let reads = Arc::new(Mutex::new(Vec::new())); + let observed = reads.clone(); + let layer = MockLayerBuilder::default() + .reader_factory(Arc::new(move |path, _, inner| { + Box::new(RecordingRead { + inner, + path: path.to_owned(), + reads: observed.clone(), + }) + })) + .build() + .unwrap(); + let reader = format + .reader(store.layer(layer), handle.clone()) + .await + .unwrap(); + let all = reader + .read(request(&[ + TABLE_ID_COLUMN, + TSID_COLUMN, + ROW_COUNT_COLUMN, + "tag_0", + "tag_1", + ])) + .unwrap() + .try_collect::>() + .await + .unwrap(); + assert_eq!(all.len(), 2); + assert_eq!( + all[0] + .column(1) + .as_any() + .downcast_ref::() + .unwrap() + .values(), + &[10, 11] + ); + assert_eq!( + all[0] + .column(2) + .as_any() + .downcast_ref::() + .unwrap() + .values(), + &[2, 3] + ); + assert_eq!( + all[0] + .column(3) + .as_any() + .downcast_ref::() + .unwrap(), + &StringArray::from(vec![Some("a"), Some("")]) + ); + assert_eq!( + all[1] + .column(3) + .as_any() + .downcast_ref::() + .unwrap() + .value(0), + "é" + ); + assert_eq!(all[0].column(4).null_count(), 0); + reads.lock().unwrap().clear(); + let mut req = request(&["tag_0"]); + req.table_id = Some(9); + let stream = reader.read(req).unwrap(); + handle.mark_deleted(); + drop(handle); + drop(reader); + assert!(purges.try_recv().is_err()); + let result = stream.try_collect::>().await.unwrap(); + assert_eq!(result.len(), 1); + assert!(purges.try_recv().is_ok()); + // Framing, root, one leaf, and one tag payload. No TSID/count chunks or other leaf. + let reads = reads.lock().unwrap(); + assert_eq!( + reads.iter().filter(|(p, _)| p.ends_with(".series")).count(), + 4 + ); +} + +#[tokio::test] +async fn real_format_reconciliation_and_fallback() { + use crate::config::MitoConfig; + use crate::series_index::format::SeriesFormats; + use crate::series_index::maintenance::reconcile_series_indexes; + use crate::test_util::TestEnv; + use store_api::region_engine::RegionEngine; + let mut env = TestEnv::with_prefix("columnar-reconcile").await; + let timestamps = [1000, 2000, 3000, 4000, 101000, 102000, 103000, 104000]; + let (engine, region) = crate::series_index::tests::prepare_region_with_config( + &mut env, + ×tamps, + MitoConfig { + experimental_enable_series_index: true, + experimental_series_scan_v2: true, + experimental_series_index_maintenance_interval: Duration::from_secs(3600), + min_compaction_interval: Duration::from_secs(3600), + ..Default::default() + }, + ) + .await; + let format = Arc::new(ColumnarSeriesFormat::default()); + *region.series_index_version_control.formats.write().unwrap() = Arc::new(SeriesFormats { + index: Some(format.clone()), + ..Default::default() + }); + let store = region.series_index_store.clone().unwrap(); + let (purger, mut purges) = series_index_channel(store.clone()); + let reconcile = || { + reconcile_series_indexes( + 0, + store.clone(), + region.clone(), + Duration::from_secs(100), + 0, + purger.clone(), + false, + true, + ) + }; + assert_eq!(reconcile().await.unwrap().built_series, 2); + let snapshot = region.series_index_version(); + for handle in snapshot.series_indexes.values() { + let reader = format.reader(store.clone(), handle.clone()).await.unwrap(); + let batches = reader + .read(request(&[ROW_COUNT_COLUMN])) + .unwrap() + .try_collect::>() + .await + .unwrap(); + assert_eq!(batches.iter().map(|b| b.num_rows()).sum::(), 1); + } + assert_eq!(reconcile().await.unwrap().built_series, 0); + let reopened = + crate::series_index::catalog::load_version_control(&store, region.region_id, &purger).await; + assert_eq!(reopened.current().series_indexes.len(), 2); + let scanner = engine + .scanner( + region.region_id, + store_api::storage::ScanRequest { + distribution: Some(store_api::storage::TimeSeriesDistribution::PerSeries), + ..Default::default() + }, + ) + .await + .unwrap(); + let batches = scanner + .scan() + .await + .unwrap() + .try_collect::>() + .await + .unwrap(); + assert_eq!( + batches.iter().map(|b| b.num_rows()).sum::(), + timestamps.len() + ); + let catalog_path = crate::series_index::catalog::series_catalog_path(region.region_id); + let old_catalog = store.read(&catalog_path).await.unwrap().to_bytes(); + let old = snapshot.series_indexes.values().next().unwrap(); + store + .delete(&old.entry().artifact.as_ref().unwrap().objects[0].path) + .await + .unwrap(); + let layer = object_store::layers::mock::MockLayerBuilder::default() + .writer_factory(Arc::new(|path, _, inner| { + if path.ends_with("series-index.json") { + Box::new(FailedOutput { + inner, + fail_write: false, + }) + } else { + inner + } + })) + .build() + .unwrap(); + assert!( + reconcile_series_indexes( + 0, + store.clone().layer(layer), + region.clone(), + Duration::from_secs(100), + 0, + purger.clone(), + false, + true + ) + .await + .is_err() + ); + assert!(Arc::ptr_eq(&snapshot, ®ion.series_index_version())); + assert_eq!( + old_catalog, + store.read(&catalog_path).await.unwrap().to_bytes() + ); + let cleanup = purges.try_recv().unwrap().objects.unwrap(); + assert_eq!(cleanup.len(), 2); + assert!(cleanup.iter().all(|path| { + !old.entry() + .artifact + .as_ref() + .unwrap() + .objects + .iter() + .any(|object| &object.path == path) + })); + engine.stop().await.unwrap(); +} + +struct FailedOutput { + inner: object_store::layers::mock::oio::Writer, + fail_write: bool, +} +impl object_store::layers::mock::Write for FailedOutput { + async fn write(&mut self, buffer: object_store::Buffer) -> object_store::Result<()> { + if self.fail_write { + return Err(object_store::Error::new( + object_store::ErrorKind::Unexpected, + "injected series write failure", + )); + } + self.inner.write(buffer).await + } + async fn close(&mut self) -> object_store::Result { + Err(object_store::Error::new( + object_store::ErrorKind::Unexpected, + "injected series close failure", + )) + } + async fn abort(&mut self) -> object_store::Result<()> { + self.inner.abort().await + } +} + +#[tokio::test] +async fn opendal_write_close_and_manifest_failures() { + use object_store::layers::mock::MockLayerBuilder; + let meta = metadata(); + let keys: Vec<_> = (0..3) + .map(|tsid| new_sparse_primary_key(&["a", "x"], &meta, 1, tsid)) + .collect(); + for (manifest_only, fail_write) in [(false, true), (false, false), (true, false)] { + let store = store(); + let layer = MockLayerBuilder::default() + .writer_factory(Arc::new(move |path, _, inner| { + if !manifest_only || path.ends_with("manifest.json") { + Box::new(FailedOutput { inner, fail_write }) + } else { + inner + } + })) + .build() + .unwrap(); + let entry = entry(); + let prefix = format!("{}/series/{}/", meta.region_id.as_u64(), entry.index_uuid); + let format = ColumnarSeriesFormat { + block_rows: 1, + ..Default::default() + }; + let mut writer = format + .writer(meta.clone(), store.clone().layer(layer), &entry) + .await + .unwrap(); + let _ = writer.write(&batch(&keys, false, 0)).await; + assert!(writer.finish().await.is_err()); + assert!( + store + .list_with(&prefix) + .recursive(true) + .await + .unwrap() + .iter() + .all(|e| e.metadata().is_dir()) + ); + } +} + +#[tokio::test] +async fn filesystem_drop_cleans_only_owned_atomic_output() { + let directory = common_test_util::temp_dir::create_temp_dir("columnar-files"); + let root = directory.path().to_str().unwrap(); + let temporary = directory.path().join("tmp"); + let store = ObjectStore::new( + object_store::services::Fs::default() + .root(root) + .atomic_write_dir(temporary.to_str().unwrap()), + ) + .unwrap(); + let meta = metadata(); + // Exceed OpenDAL's 8 MiB buffer so both physical temporary files exist. + let label = "x".repeat(16 * 1024); + let keys: Vec<_> = (0..1025) + .map(|tsid| new_sparse_primary_key(&[&format!("{tsid:04}{label}"), "x"], &meta, 1, tsid)) + .collect(); + let format = ColumnarSeriesFormat { + block_rows: 1024, + compression: Compression::None, + ..Default::default() + }; + let mut first = format + .writer(meta.clone(), store.clone(), &entry()) + .await + .unwrap(); + let mut second = format.writer(meta, store.clone(), &entry()).await.unwrap(); + first.write(&batch(&keys, false, 0)).await.unwrap(); + second.write(&batch(&keys, false, 0)).await.unwrap(); + tokio::time::timeout(Duration::from_secs(5), async { + loop { + if std::fs::read_dir(&temporary).unwrap().count() == 2 { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + drop(first); + tokio::time::timeout(Duration::from_secs(5), async { + loop { + if std::fs::read_dir(&temporary).unwrap().count() == 1 { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + let artifact = second.finish().await.unwrap(); + for object in artifact.objects { + assert!(store.exists(&object.path).await.unwrap()); + } + assert_eq!(std::fs::read_dir(&temporary).unwrap().count(), 0); +} + +#[tokio::test] +async fn unknown_column_codec_can_only_be_skipped() { + let store = store(); + let meta = metadata(); + let format = ColumnarSeriesFormat { + compression: Compression::None, + ..Default::default() + }; + let handle = build( + &format, + &store, + &[batch( + &[new_sparse_primary_key(&["a", "x"], &meta, 1, 1)], + false, + 0, + )], + ) + .await; + let manifest = manifest(&store, &handle).await; + let path = manifest.part_path(0); + let mut bytes = store.read(&path).await.unwrap().to_vec(); + let trailer_start = bytes.len() - 96; + let mut trailer = Trailer::decode(&bytes[trailer_start..], bytes.len() as u64).unwrap(); + assert_eq!(trailer.depth, 1); + // Leaf prefix + block prefix + TSID name + encoding's offset in PayloadReference. + let encoding = trailer.root.payload.offset as usize + 12 + 28 + 8 + TSID_COLUMN.len() + 24; + bytes[encoding..encoding + 2].copy_from_slice(&99u16.to_le_bytes()); + trailer.root.payload.checksum = + codec::checksum(&bytes[trailer.root.payload.offset as usize..trailer_start]); + bytes[trailer_start..].copy_from_slice(&trailer.encode()); + store.write(&path, bytes).await.unwrap(); + let reader = format.reader(store, handle).await.unwrap(); + assert!( + reader + .read(request(&["tag_0"])) + .unwrap() + .try_collect::>() + .await + .is_ok() + ); + let error = reader + .read(request(&[TSID_COLUMN])) + .unwrap() + .try_collect::>() + .await + .unwrap_err(); + assert!(error.to_string().contains("encoding 99"), "{error}"); +} + +#[tokio::test] +async fn malformed_manifest_and_timestamp_independence() { + let store = store(); + let meta = metadata(); + let format = ColumnarSeriesFormat::default(); + let keys = vec![new_sparse_primary_key(&["a", "x"], &meta, 1, 1)]; + let first = build(&format, &store, &[batch(&keys, false, -999)]).await; + let second = build(&format, &store, &[batch(&keys, true, 999)]).await; + let mut manifest = manifest(&store, &first).await; + let other = self::manifest(&store, &second).await; + assert_eq!( + store.read(&manifest.part_path(0)).await.unwrap().to_bytes(), + store.read(&other.part_path(0)).await.unwrap().to_bytes() + ); + manifest.files[0].part_id = 1; + store + .write(&manifest.path(), serde_json::to_vec(&manifest).unwrap()) + .await + .unwrap(); + assert!(format.reader(store.clone(), first).await.is_err()); + let mut entry = second.entry().clone(); + entry + .artifact + .as_mut() + .unwrap() + .objects + .push(crate::series_index::artifact::ArtifactObject { + path: "unowned".into(), + file_size: 0, + }); + let (purger, _) = series_index_channel(store.clone()); + let handle = SeriesIndexFileHandle::new(meta.region_id, entry, purger); + assert!(format.reader(store, handle).await.is_err()); +} + +struct PausedClose { + inner: object_store::layers::mock::oio::Writer, + closing: Arc, +} +impl object_store::layers::mock::Write for PausedClose { + async fn write(&mut self, buffer: object_store::Buffer) -> object_store::Result<()> { + self.inner.write(buffer).await + } + async fn close(&mut self) -> object_store::Result { + self.closing.notify_one(); + futures::future::pending().await + } + async fn abort(&mut self) -> object_store::Result<()> { + self.inner.abort().await + } +} +#[tokio::test] +async fn cancelled_finish_aborts_live_writer() { + use object_store::layers::mock::MockLayerBuilder; + let store = store(); + let meta = metadata(); + let entry = entry(); + let prefix = format!("{}/series/{}/", meta.region_id.as_u64(), entry.index_uuid); + let closing = Arc::new(tokio::sync::Notify::new()); + let observed = closing.clone(); + let layer = MockLayerBuilder::default() + .writer_factory(Arc::new(move |_, _, inner| { + Box::new(PausedClose { + inner, + closing: observed.clone(), + }) + })) + .build() + .unwrap(); + let format = ColumnarSeriesFormat::default(); + let mut writer = format + .writer(meta.clone(), store.clone().layer(layer), &entry) + .await + .unwrap(); + writer + .write(&batch( + &[new_sparse_primary_key(&["a", "x"], &meta, 1, 1)], + false, + 0, + )) + .await + .unwrap(); + let task = tokio::spawn(async move { writer.finish().await }); + tokio::time::timeout(Duration::from_secs(5), closing.notified()) + .await + .unwrap(); + task.abort(); + assert!(task.await.unwrap_err().is_cancelled()); + tokio::time::timeout(Duration::from_secs(5), async { + loop { + if store + .list_with(&prefix) + .recursive(true) + .await + .unwrap() + .iter() + .all(|e| e.metadata().is_dir()) + { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); +} + +#[tokio::test] +async fn opendal_reads_sparse_part_beyond_four_gib() { + use super::metadata::Page; + use std::io::{Seek, SeekFrom, Write}; + let directory = common_test_util::temp_dir::create_temp_dir("columnar-files"); + let store = ObjectStore::new( + object_store::services::Fs::default().root(directory.path().to_str().unwrap()), + ) + .unwrap(); + let meta = metadata(); + let format = ColumnarSeriesFormat { + compression: Compression::None, + ..Default::default() + }; + let handle = build( + &format, + &store, + &[batch( + &[new_sparse_primary_key(&["a", "x"], &meta, 1, 1)], + false, + 0, + )], + ) + .await; + let mut manifest = manifest(&store, &handle).await; + let path = manifest.part_path(0); + let bytes = store.read(&path).await.unwrap().to_bytes(); + let mut trailer = Trailer::decode(&bytes[bytes.len() - 96..], bytes.len() as u64).unwrap(); + let names: Vec<_> = manifest + .schema() + .fields() + .iter() + .skip(1) + .map(|f| f.name().clone()) + .collect(); + let root_bytes = trailer + .root + .payload + .unpack(&bytes[trailer.root.payload.offset as usize..bytes.len() - 96]) + .unwrap(); + let Page::Leaf(mut blocks) = + Page::decode(&root_bytes, &trailer.root, 1, trailer.payload_end, &names).unwrap() + else { + panic!("expected leaf"); + }; + let shift = 1u64 << 32; + for block in &mut blocks { + for column in &mut block.columns { + column.payload.offset += shift; + } + } + let leaf = Page::Leaf(blocks).encode(); + trailer.root.payload.offset += shift; + trailer.root.payload.checksum = codec::checksum(&leaf); + let old_payload_end = trailer.payload_end as usize; + trailer.payload_end += shift; + trailer.file_length += shift; + // A sparse hole uses no 4 GiB allocation. All reads below go through OpenDAL. + let mut file = std::fs::File::create(directory.path().join(&path)).unwrap(); + file.write_all(&bytes[..16]).unwrap(); + file.seek(SeekFrom::Start(shift + 16)).unwrap(); + file.write_all(&bytes[16..old_payload_end]).unwrap(); + file.write_all(&leaf).unwrap(); + file.write_all(&trailer.encode()).unwrap(); + drop(file); + manifest.files[0].file_size += shift; + let manifest_bytes = serde_json::to_vec(&manifest).unwrap(); + let mut entry = handle.entry().clone(); + entry.file_size = manifest_bytes.len() as u64 + manifest.files[0].file_size; + let artifact = entry.artifact.as_mut().unwrap(); + artifact.file_size = entry.file_size; + for object in &mut artifact.objects { + object.file_size = if object.path == manifest.path() { + manifest_bytes.len() as u64 + } else { + manifest.files[0].file_size + }; + } + store.write(&manifest.path(), manifest_bytes).await.unwrap(); + let (purger, _) = series_index_channel(store.clone()); + let handle = SeriesIndexFileHandle::new(meta.region_id, entry, purger); + let batches = format + .reader(store, handle) + .await + .unwrap() + .read(request(&[TSID_COLUMN, "tag_0"])) + .unwrap() + .try_collect::>() + .await + .unwrap(); + assert_eq!( + batches[0] + .column(0) + .as_any() + .downcast_ref::() + .unwrap() + .value(0), + 1 + ); + assert_eq!( + batches[0] + .column(1) + .as_any() + .downcast_ref::() + .unwrap() + .value(0), + "a" + ); +} + +struct RecordingRead { + inner: object_store::layers::mock::oio::Reader, + path: String, + reads: Arc>>, +} +impl object_store::layers::mock::Read for RecordingRead { + async fn open( + &self, + range: object_store::layers::mock::BytesRange, + ) -> object_store::Result<( + object_store::layers::mock::RpRead, + Box, + )> { + self.reads.lock().unwrap().push((self.path.clone(), range)); + self.inner.open(range).await + } + async fn read( + &self, + range: object_store::layers::mock::BytesRange, + ) -> object_store::Result<(object_store::layers::mock::RpRead, object_store::Buffer)> { + self.reads.lock().unwrap().push((self.path.clone(), range)); + self.inner.read(range).await + } +} + +#[tokio::test] +async fn dictionary_expansion_is_batched_and_empty_string_filter_is_exact() { + let store = store(); + let meta = metadata(); + let label = "x".repeat(1024); + let format = ColumnarSeriesFormat { + block_rows: 16384, + ..Default::default() + }; + let keys: Vec<_> = (0..16384) + .map(|tsid| new_sparse_primary_key(&[&label, ""], &meta, 1, tsid)) + .collect(); + let handle = build(&format, &store, &[batch(&keys, false, 0)]).await; + let reader = format.reader(store, handle).await.unwrap(); + let batches = reader + .read(request(&["tag_0"])) + .unwrap() + .try_collect::>() + .await + .unwrap(); + assert!(batches.len() > 1); + assert_eq!(batches.iter().map(|b| b.num_rows()).sum::(), 16384); + assert!( + batches + .iter() + .all(|b| b.num_rows() * 1029 <= 8 * 1024 * 1024) + ); + // Empty strings remain non-null and match equality exactly. + let mut req = request(&[]); + req.filters + .push(SimpleFilterEvaluator::new("tag_1".into(), "", Operator::Eq).unwrap()); + assert_eq!( + reader + .read(req) + .unwrap() + .try_collect::>() + .await + .unwrap() + .iter() + .map(|b| b.num_rows()) + .sum::(), + 16384 + ); +} + +#[tokio::test] +async fn large_repeated_tags_across_small_batches() { + let store = store(); + let meta = metadata(); + let format = ColumnarSeriesFormat::default(); + let label = "x".repeat(1024 * 1024); + let mut entry = entry(); + let mut writer = format + .writer(meta.clone(), store.clone(), &entry) + .await + .unwrap(); + for tsid in 0..20 { + let key = new_sparse_primary_key(&[&label, "x"], &meta, 1, tsid); + // Duplicate series cross batch boundaries and must still aggregate correctly. + for _ in 0..2 { + writer + .write(&batch(std::slice::from_ref(&key), false, 0)) + .await + .unwrap(); + } + } + let artifact = writer.finish().await.unwrap(); + entry.file_size = artifact.file_size; + entry.artifact = Some(artifact); + let (purger, _) = series_index_channel(store.clone()); + let handle = SeriesIndexFileHandle::new(meta.region_id, entry, purger); + let batches = format + .reader(store.clone(), handle) + .await + .unwrap() + .read(request(&[TSID_COLUMN, ROW_COUNT_COLUMN, "tag_0"])) + .unwrap() + .try_collect::>() + .await + .unwrap(); + assert!(batches.len() >= 3); + assert_eq!(batches.iter().map(|b| b.num_rows()).sum::(), 20); + let mut expected_tsid = 0; + for batch in batches { + assert!(batch.num_rows() <= 7); + let labels = batch + .column(2) + .as_any() + .downcast_ref::() + .unwrap(); + assert!(labels.iter().all(|value| value == Some(label.as_str()))); + let ids = batch + .column(0) + .as_any() + .downcast_ref::() + .unwrap(); + let counts = batch + .column(1) + .as_any() + .downcast_ref::() + .unwrap(); + for (id, count) in ids.values().iter().zip(counts.values()) { + assert_eq!(*id, expected_tsid); + assert_eq!(*count, 2); + expected_tsid += 1; + } + } +} diff --git a/src/mito2/src/series_index/columnar/writer.rs b/src/mito2/src/series_index/columnar/writer.rs new file mode 100644 index 00000000000..72156ff139c --- /dev/null +++ b/src/mito2/src/series_index/columnar/writer.rs @@ -0,0 +1,614 @@ +// Copyright 2023 Greptime Team +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Streaming aggregation and owned OpenDAL outputs. + +use async_trait::async_trait; +use bytes::Bytes; +use datatypes::arrow::array::{Array, BinaryArray, DictionaryArray}; +use datatypes::arrow::datatypes::UInt32Type; +use datatypes::arrow::record_batch::RecordBatch; +use mito_codec::row_converter::SparseOffsetsCache; +use object_store::{ObjectStore, Writer}; +use snafu::{IntoError, ResultExt}; +use store_api::metadata::RegionMetadataRef; + +use crate::error::{JoinSnafu, OpenDalSnafu, Result, SerdeJsonSnafu}; +use crate::series_index::artifact::{ArtifactFormat, ArtifactMetadata, ArtifactObject}; +use crate::series_index::catalog::SeriesIndexEntry; +use crate::series_index::columnar::codec::{self, encode_dictionary, encode_integers}; +use crate::series_index::columnar::metadata::{ + self, Block, Column, Page, PageReference, Trailer, pack, +}; +use crate::series_index::columnar::{ + ColumnarSeriesFormat, MANIFEST_LIMIT, Manifest, Part, Tag, VERSION, invalid, require, +}; +use crate::series_index::format::FormatWriter; +use crate::series_index::writer::{decode_series_identity, tag_columns, validate_metadata}; +use crate::series_index::{MetricSeriesId, ROW_COUNT_COLUMN, TSID_COLUMN}; +use crate::sst::{DEFAULT_WRITE_BUFFER_SIZE, DEFAULT_WRITE_CONCURRENCY}; + +/// Maximum owned row bytes per block; a single larger row is flushed on its own. +const BLOCK_BYTE_BUDGET: usize = 8 * 1024 * 1024; + +/// Drop owns the live OpenDAL writer, including its private atomic-write temporary file. +struct LiveFile { + writer: Option, + store: ObjectStore, + path: String, +} +impl Drop for LiveFile { + fn drop(&mut self) { + if let Some(mut writer) = self.writer.take() { + let store = self.store.clone(); + let path = self.path.clone(); + common_runtime::spawn_compact(async move { + if let Err(error) = writer.abort().await { + common_telemetry::warn!(error; "Aborting series output {path}"); + } + if let Err(error) = store.delete(&path).await { + common_telemetry::warn!(error; "Deleting series output {path}"); + } + }); + } + } +} + +struct Outputs { + store: ObjectStore, + active: Option, + paths: Vec, + committed: bool, +} +impl Outputs { + async fn open(&mut self, path: String) -> Result<()> { + require(self.active.is_none(), "series output already open")?; + self.paths.push(path.clone()); + let store = self.store.clone(); + // The task owns opening: cancellation drops the returned LiveFile and aborts it. + self.active = Some( + common_runtime::spawn_compact(async move { + let writer = store + .writer_with(&path) + .chunk(DEFAULT_WRITE_BUFFER_SIZE.as_bytes() as usize) + .concurrent(DEFAULT_WRITE_CONCURRENCY) + .await + .context(OpenDalSnafu)?; + Ok::<_, crate::error::Error>(LiveFile { + writer: Some(writer), + store, + path, + }) + }) + .await + .context(JoinSnafu)??, + ); + Ok(()) + } + async fn write(&mut self, bytes: Vec) -> Result<()> { + self.active + .as_mut() + .and_then(|f| f.writer.as_mut()) + .ok_or_else(|| invalid("series output is not open"))? + .write(Bytes::from(bytes)) + .await + .context(OpenDalSnafu)?; + Ok(()) + } + async fn close(&mut self) -> Result<()> { + let file = self + .active + .as_mut() + .ok_or_else(|| invalid("series output is not open"))?; + file.writer + .as_mut() + .ok_or_else(|| invalid("series output is closed"))? + .close() + .await + .context(OpenDalSnafu)?; + file.writer = None; + self.active = None; + Ok(()) + } + async fn cleanup(&mut self) -> Result<()> { + let mut first_error = None; + if let Some(file) = self.active.as_mut() + && let Some(writer) = file.writer.as_mut() + { + match writer.abort().await { + Ok(()) => file.writer = None, + Err(error) => first_error = Some(OpenDalSnafu.into_error(error)), + } + } + self.active = None; + for path in &self.paths { + if let Err(error) = self.store.delete(path).await { + first_error.get_or_insert_with(|| OpenDalSnafu.into_error(error)); + } + } + if let Some(error) = first_error { + Err(error) + } else { + self.committed = true; + Ok(()) + } + } +} +impl Drop for Outputs { + fn drop(&mut self) { + if !self.committed { + let mut owned = Self { + store: self.store.clone(), + active: self.active.take(), + paths: std::mem::take(&mut self.paths), + committed: true, + }; + common_runtime::spawn_compact(async move { + if let Err(error) = owned.cleanup().await { + common_telemetry::warn!(error; "Cleaning unpublished series artifact"); + } + }); + } + } +} + +#[derive(Debug)] +struct Row { + id: MetricSeriesId, + count: u64, + tags: Vec>, +} + +pub(crate) struct ColumnarWriter { + options: ColumnarSeriesFormat, + manifest: Manifest, + outputs: Outputs, + tags: Vec<(u32, String)>, + offsets: SparseOffsetsCache, + buffer: Vec, + last_key: Vec, + current: Option, + rows: Vec, + row_bytes: usize, + leaves: Vec>, + leaf_bytes: usize, + descriptor_bytes: usize, + position: u64, + part_rows: u64, + failed: bool, +} +impl ColumnarWriter { + pub(crate) fn new( + options: ColumnarSeriesFormat, + metadata: RegionMetadataRef, + store: ObjectStore, + entry: &SeriesIndexEntry, + ) -> Result { + validate_metadata(&metadata)?; + require( + options.block_rows > 0 + && options.block_rows <= codec::COLUMN_LIMIT / 10 + && options.root_capacity > 0 + && options.root_capacity <= metadata::ROOT_CAPACITY + && (12..=metadata::PAGE_TARGET).contains(&options.page_bytes), + "invalid columnar writer options", + )?; + let tags = tag_columns(&metadata); + let mut coverage = entry.clone(); + coverage.file_size = 0; + coverage.artifact = None; + let manifest = Manifest { + format: ArtifactFormat::ColumnarSeries, + version: VERSION, + region_id: metadata.region_id.as_u64(), + generation: entry.index_uuid, + tags: tags + .iter() + .map(|(id, name)| Tag { + column_id: *id, + name: name.clone(), + }) + .collect(), + coverage, + files: Vec::new(), + }; + Ok(Self { + options, + manifest, + outputs: Outputs { + store, + active: None, + paths: Vec::new(), + committed: false, + }, + tags, + offsets: SparseOffsetsCache::new(), + buffer: Vec::new(), + last_key: Vec::new(), + current: None, + rows: Vec::new(), + row_bytes: 0, + leaves: Vec::new(), + leaf_bytes: 12, + descriptor_bytes: 0, + position: 0, + part_rows: 0, + failed: false, + }) + } + + async fn push_current(&mut self) -> Result<()> { + if let Some(row) = self.current.take() { + let row_bytes = std::mem::size_of::() + + row.tags.capacity() * std::mem::size_of::>() + + row + .tags + .iter() + .flatten() + .map(String::capacity) + .sum::(); + if self.row_bytes.saturating_add(row_bytes) > BLOCK_BYTE_BUDGET + || self + .rows + .first() + .is_some_and(|first| first.id.table_id != row.id.table_id) + { + self.flush_block().await?; + } + self.row_bytes += row_bytes; + self.rows.push(row); + if self.rows.len() >= self.options.block_rows || self.row_bytes >= BLOCK_BYTE_BUDGET { + self.flush_block().await?; + } + } + Ok(()) + } + + async fn append_key(&mut self, key: &[u8]) -> Result<()> { + if key == self.last_key && self.current.is_some() { + if let Some(row) = self.current.as_mut() { + row.count = row + .count + .checked_add(1) + .ok_or_else(|| invalid("series row count overflow"))?; + } + return Ok(()); + } + let (id, tags) = + decode_series_identity(key, &self.tags, &mut self.offsets, &mut self.buffer)?; + if let Some(row) = self.current.as_mut() { + require(id >= row.id, "series identifiers are not sorted")?; + if id == row.id { + row.count = row + .count + .checked_add(1) + .ok_or_else(|| invalid("series row count overflow"))?; + self.last_key.clear(); + self.last_key.extend(key); + return Ok(()); + } + } + self.push_current().await?; + self.current = Some(Row { id, count: 1, tags }); + self.last_key.clear(); + self.last_key.extend(key); + Ok(()) + } + + async fn write_batch(&mut self, batch: &RecordBatch) -> Result<()> { + require(batch.num_columns() >= 4, "series input is not a flat batch")?; + let pk = batch.column(crate::sst::parquet::flat_format::primary_key_column_index( + batch.num_columns(), + )); + if let Some(keys) = pk.as_any().downcast_ref::() { + require(keys.null_count() == 0, "null series primary key")?; + for key in keys.iter().flatten() { + self.append_key(key).await?; + } + } else if let Some(keys) = pk.as_any().downcast_ref::>() { + require(keys.null_count() == 0, "null series primary key")?; + let values = keys + .values() + .as_any() + .downcast_ref::() + .ok_or_else(|| invalid("series dictionary values must be binary"))?; + for key in keys.keys().values() { + require( + (*key as usize) < values.len() && !values.is_null(*key as usize), + "invalid series dictionary primary key", + )?; + self.append_key(values.value(*key as usize)).await?; + } + } else { + return Err(invalid( + "series primary key must be binary or UInt32 dictionary", + )); + } + Ok(()) + } + + async fn flush_block(&mut self) -> Result<()> { + if self.rows.is_empty() { + return Ok(()); + } + let rows = std::mem::take(&mut self.rows); + self.row_bytes = 0; + // TODO: benchmark inline versus offloaded codecs for latency, throughput, + // and async-worker responsiveness before adding scheduling overhead. + let (mut block, chunks) = encode_block(rows, &self.tags, &self.options)?; + let mut descriptor = Vec::new(); + block.encode(&mut descriptor); + require( + descriptor.len() + 12 <= metadata::PAGE_LIMIT, + "series descriptor exceeds leaf limit", + )?; + let needs_leaf = + !self.leaves.is_empty() && self.leaf_bytes + descriptor.len() > self.options.page_bytes; + if needs_leaf && self.leaves.len() == self.options.root_capacity { + self.finish_part().await?; + } + if self.outputs.active.is_none() { + let id = u32::try_from(self.manifest.files.len()) + .map_err(|_| invalid("too many series parts"))?; + self.outputs.open(self.manifest.part_path(id)).await?; + self.outputs.write(metadata::header()).await?; + self.position = metadata::HEADER_SIZE; + } + for (column, chunk) in block.columns.iter_mut().zip(chunks) { + column.payload.offset = self.position; + self.position = column.payload.end()?; + self.outputs.write(chunk).await?; + } + if self.leaves.is_empty() || self.leaf_bytes + descriptor.len() > self.options.page_bytes { + self.leaves.push(Vec::new()); + self.leaf_bytes = 12; + } + self.leaf_bytes += descriptor.len(); + self.descriptor_bytes += descriptor.len(); + self.part_rows += u64::from(block.rows); + self.leaves + .last_mut() + .ok_or_else(|| invalid("missing series leaf"))? + .push(block); + if self.position >= self.options.part_bytes + || self.descriptor_bytes >= self.options.metadata_bytes + { + self.finish_part().await?; + } + Ok(()) + } + + async fn write_page( + &mut self, + page: Page, + first: MetricSeriesId, + last: MetricSeriesId, + ) -> Result { + let position = self.position; + let (payload, bytes) = pack( + &page.encode(), + position, + 1, + self.options.compression, + self.options.zstd_level, + true, + )?; + self.position = payload.end()?; + self.outputs.write(bytes).await?; + Ok(PageReference { + first, + last, + payload, + }) + } + + async fn finish_part(&mut self) -> Result<()> { + if self.leaves.is_empty() { + return Ok(()); + } + let payload_end = self.position; + let mut children = Vec::new(); + for leaf in std::mem::take(&mut self.leaves) { + let first = leaf + .first() + .ok_or_else(|| invalid("empty series leaf"))? + .first_id(); + let last = leaf + .last() + .ok_or_else(|| invalid("empty series leaf"))? + .last_id(); + children.push(self.write_page(Page::Leaf(leaf), first, last).await?); + } + let first = children[0].first; + let last = children[children.len() - 1].last; + let depth = if children.len() == 1 { 1 } else { 2 }; + let root = if depth == 1 { + children.remove(0) + } else { + self.write_page(Page::Internal(children), first, last) + .await? + }; + let file_length = self + .position + .checked_add(metadata::TRAILER_SIZE) + .ok_or_else(|| invalid("series file length overflow"))?; + self.outputs + .write( + Trailer { + root, + depth, + file_length, + payload_end, + } + .encode(), + ) + .await?; + self.outputs.close().await?; + self.manifest.files.push(Part { + part_id: u32::try_from(self.manifest.files.len()) + .map_err(|_| invalid("too many series parts"))?, + file_size: file_length, + num_series: self.part_rows, + first, + last, + }); + self.position = 0; + self.part_rows = 0; + self.descriptor_bytes = 0; + self.leaf_bytes = 12; + Ok(()) + } + + async fn complete(&mut self) -> Result { + require(!self.failed, "series writer failed previously")?; + self.push_current().await?; + self.flush_block().await?; + self.finish_part().await?; + let bytes = serde_json::to_vec(&self.manifest).context(SerdeJsonSnafu)?; + require( + bytes.len() <= MANIFEST_LIMIT, + "series manifest exceeds size limit", + )?; + let mut file_size = bytes.len() as u64; + let manifest_size = file_size; + let mut objects = Vec::with_capacity(self.manifest.files.len() + 1); + for part in &self.manifest.files { + objects.push(ArtifactObject { + path: self.manifest.part_path(part.part_id), + file_size: part.file_size, + }); + file_size = file_size + .checked_add(part.file_size) + .ok_or_else(|| invalid("series artifact length overflow"))?; + } + objects.push(ArtifactObject { + path: self.manifest.path(), + file_size: manifest_size, + }); + self.outputs.open(self.manifest.path()).await?; + self.outputs.write(bytes).await?; + self.outputs.close().await?; + self.outputs.committed = true; + Ok(ArtifactMetadata { + format: ArtifactFormat::ColumnarSeries, + generation: self.manifest.generation, + objects, + file_size, + }) + } +} + +#[async_trait] +impl FormatWriter for ColumnarWriter { + async fn write(&mut self, batch: &RecordBatch) -> Result<()> { + require(!self.failed, "series writer failed previously")?; + // A cancelled write must not allow a later finish to publish partial input. + self.failed = true; + let result = self.write_batch(batch).await; + if result.is_ok() { + self.failed = false; + } + result + } + async fn finish(mut self: Box) -> Result { + let result = self.complete().await; + if result.is_err() + && let Err(error) = self.outputs.cleanup().await + { + common_telemetry::warn!(error; "Cleaning failed series build"); + } + result + } + async fn abort(mut self: Box) -> Result<()> { + self.outputs.cleanup().await + } +} + +fn encode_block( + rows: Vec, + tags: &[(u32, String)], + options: &ColumnarSeriesFormat, +) -> Result<(Block, Vec>)> { + let first = rows + .first() + .ok_or_else(|| invalid("empty series block"))? + .id; + let last = rows.last().ok_or_else(|| invalid("empty series block"))?.id; + let mut block = Block { + table_id: first.table_id, + first: first.tsid, + last: last.tsid, + rows: rows.len() as u32, + columns: Vec::new(), + }; + let mut chunks = Vec::new(); + for (name, values) in [ + ( + TSID_COLUMN, + rows.iter().map(|r| r.id.tsid).collect::>(), + ), + (ROW_COUNT_COLUMN, rows.iter().map(|r| r.count).collect()), + ] { + let (payload, bytes) = pack( + &encode_integers(&values), + 0, + codec::INTEGER, + options.compression, + options.zstd_level, + false, + )?; + block.columns.push(Column { + name: name.into(), + payload, + null_count: 0, + bounds: None, + }); + chunks.push(bytes); + } + for (index, (_, name)) in tags.iter().enumerate() { + let values: Vec<_> = rows.iter().map(|row| row.tags[index].as_deref()).collect(); + let null_count = values.iter().filter(|v| v.is_none()).count() as u32; + if null_count == block.rows { + continue; + } + let min = values + .iter() + .flatten() + .min() + .copied() + .ok_or_else(|| invalid("missing tag minimum"))?; + let max = values + .iter() + .flatten() + .max() + .copied() + .ok_or_else(|| invalid("missing tag maximum"))?; + let (payload, bytes) = pack( + &encode_dictionary(&values)?, + 0, + codec::DICTIONARY, + options.compression, + options.zstd_level, + false, + )?; + block.columns.push(Column { + name: name.clone(), + payload, + null_count, + bounds: Some((min.to_owned(), max.to_owned())), + }); + chunks.push(bytes); + } + Ok((block, chunks)) +} diff --git a/src/mito2/src/series_index/format.rs b/src/mito2/src/series_index/format.rs index b59933f719a..43a8ff39d26 100644 --- a/src/mito2/src/series_index/format.rs +++ b/src/mito2/src/series_index/format.rs @@ -14,11 +14,15 @@ //! Storage-format boundaries. Encodings stay outside reconciliation and source scheduling. +use std::collections::BTreeSet; use std::fmt::Debug; use std::sync::Arc; use async_trait::async_trait; +use common_recordbatch::filter::SimpleFilterEvaluator; +use datatypes::arrow::datatypes::SchemaRef; use datatypes::arrow::record_batch::RecordBatch; +use futures::stream::BoxStream; use object_store::ObjectStore; use store_api::metadata::RegionMetadataRef; use store_api::storage::FileId; @@ -26,6 +30,7 @@ use store_api::storage::FileId; use crate::error::Result; use crate::series_index::artifact::{ArtifactFormat, ArtifactMetadata}; use crate::series_index::catalog::SeriesIndexEntry; +use crate::series_index::{MetricSeriesId, SeriesIndexFileHandle}; /// Writers own partial outputs until finish succeeds; abort removes every partial object. /// A failed finish must clean up its output before returning; implementations also own @@ -37,10 +42,43 @@ pub(crate) trait FormatWriter: Send { async fn abort(self: Box) -> Result<()>; } -/// Builds series-index artifacts; read interfaces are deferred. +/// A read has independent traversal state and pins its immutable artifact. +#[allow( + dead_code, + reason = "Query adapter will consume the format reader separately." +)] +#[derive(Debug, Clone, Default)] +pub(crate) struct SeriesReadRequest { + pub(crate) projection: Vec, + pub(crate) table_id: Option, + pub(crate) ids: Option>, + pub(crate) filters: Vec, +} + +#[allow( + dead_code, + reason = "Query adapter will consume the format reader separately." +)] +pub(crate) trait FormatReader: Send + Sync { + fn schema(&self) -> SchemaRef; + fn read(&self, request: SeriesReadRequest) -> Result>>; +} + +/// Builds and opens immutable series-index artifacts. #[async_trait] pub(crate) trait SeriesIndexFormat: Debug + Send + Sync { fn format(&self) -> ArtifactFormat; + #[allow(dead_code, reason = "Query adapter is deferred.")] + async fn reader( + &self, + _store: ObjectStore, + _handle: SeriesIndexFileHandle, + ) -> Result> { + crate::error::UnsupportedOperationSnafu { + err_msg: "This series format has no reader", + } + .fail() + } async fn writer( &self, metadata: RegionMetadataRef, diff --git a/src/mito2/src/series_index/format_tests.rs b/src/mito2/src/series_index/format_tests.rs index a7e0c86069d..0fa2661c93e 100644 --- a/src/mito2/src/series_index/format_tests.rs +++ b/src/mito2/src/series_index/format_tests.rs @@ -30,7 +30,7 @@ use store_api::storage::FileId; use crate::config::MitoConfig; use crate::error::{OpenDalSnafu, Result, UnexpectedSnafu}; -use crate::series_index::artifact::{ArtifactFormat, ArtifactMetadata}; +use crate::series_index::artifact::{ArtifactFormat, ArtifactMetadata, ArtifactObject}; use crate::series_index::catalog::{SeriesIndexEntry, load_version_control}; use crate::series_index::format::{FormatWriter, SamplesFormat, SeriesFormats, SeriesIndexFormat}; use crate::series_index::maintenance::reconcile_series_indexes; @@ -64,8 +64,11 @@ impl FormatWriter for FakeWriter { Ok(self.artifact) } async fn abort(self: Box) -> Result<()> { - for path in &self.artifact.objects { - self.store.delete(path).await.context(OpenDalSnafu)?; + for object in &self.artifact.objects { + self.store + .delete(&object.path) + .await + .context(OpenDalSnafu)?; } Ok(()) } @@ -92,7 +95,10 @@ impl SamplesFormat for FakeSamples { artifact: ArtifactMetadata { format: self.format(), generation, - objects, + objects: objects + .into_iter() + .map(|path| ArtifactObject { path, file_size: 4 }) + .collect(), file_size: 8, }, fail: self.fail.clone(), @@ -156,7 +162,7 @@ async fn format_skeleton_build_reopen_and_retire() { // Rebuilding a missing sidecar replaces its generation but retains old readers' files. let old = snapshot.samples.values().next().unwrap().clone(); store - .delete(&old.entry().artifact.objects[0]) + .delete(&old.entry().artifact.objects[0].path) .await .unwrap(); let stats = reconcile_series_indexes( @@ -244,7 +250,7 @@ impl SeriesIndexFormat for FakeIndex { artifact: ArtifactMetadata { format: self.format(), generation: entry.index_uuid, - objects: vec![path], + objects: vec![ArtifactObject { path, file_size: 4 }], file_size: 4, }, fail: Arc::new(AtomicBool::new(false)), @@ -321,7 +327,7 @@ async fn format_index_rebuild_is_scoped_and_scans_fall_back_to_ssts() { let missing = before.series_indexes.values().next().unwrap(); store - .delete(&missing.entry().artifact.as_ref().unwrap().objects[0]) + .delete(&missing.entry().artifact.as_ref().unwrap().objects[0].path) .await .unwrap(); assert_eq!(1, reconcile().await.unwrap().built_series); @@ -397,7 +403,7 @@ async fn format_catalog_failure_preserves_published_artifacts() { let catalog = store.read(&catalog_path).await.unwrap().to_bytes(); let old = before.samples.values().next().unwrap(); store - .delete(&old.entry().artifact.objects[0]) + .delete(&old.entry().artifact.objects[0].path) .await .unwrap(); let layer = MockLayerBuilder::default() @@ -427,11 +433,13 @@ async fn format_catalog_failure_preserves_published_artifacts() { assert_eq!(catalog, store.read(&catalog_path).await.unwrap().to_bytes()); let cleanup = purges.try_recv().unwrap().objects.unwrap(); assert_eq!(2, cleanup.len()); - assert!( - cleanup + assert!(cleanup.iter().all(|path| { + !old.entry() + .artifact + .objects .iter() - .all(|path| !old.entry().artifact.objects.contains(path)) - ); + .any(|object| &object.path == path) + })); assert!(purges.try_recv().is_err()); let reopened = load_version_control(&store, region.region_id, &purger).await; assert_eq!( diff --git a/src/mito2/src/series_index/maintenance.rs b/src/mito2/src/series_index/maintenance.rs index 232e17ac266..1e777a2aa56 100644 --- a/src/mito2/src/series_index/maintenance.rs +++ b/src/mito2/src/series_index/maintenance.rs @@ -235,7 +235,7 @@ async fn build_index_version( if reusable && let Some(artifact) = &entry.artifact { for object in &artifact.objects { reusable &= store - .exists(object) + .exists(&object.path) .await .context(crate::error::OpenDalSnafu)?; } @@ -288,9 +288,9 @@ async fn build_index_version( let reusable = if let Some(handle) = samples.get(&id) { let artifact = &handle.entry().artifact; let mut exists = artifact.format == backend.format(); - for path in &artifact.objects { + for object in &artifact.objects { exists &= store - .exists(path) + .exists(&object.path) .await .context(crate::error::OpenDalSnafu)?; } diff --git a/src/mito2/src/series_index/version.rs b/src/mito2/src/series_index/version.rs index 7d819a0df55..d5212e85420 100644 --- a/src/mito2/src/series_index/version.rs +++ b/src/mito2/src/series_index/version.rs @@ -83,8 +83,14 @@ impl Drop for SeriesIndexFileHandleInner { fn drop(&mut self) { if self.deleted.load(Ordering::Acquire) { if let Some(artifact) = &self.entry.artifact { - self.purger - .purge_objects(self.file_id, artifact.objects.clone()); + self.purger.purge_objects( + self.file_id, + artifact + .objects + .iter() + .map(|object| object.path.clone()) + .collect(), + ); } else { self.purger.purge(PurgeRequest { file_id: self.file_id, diff --git a/src/mito2/src/series_index/writer.rs b/src/mito2/src/series_index/writer.rs index 0863b3b8519..28999d8409b 100644 --- a/src/mito2/src/series_index/writer.rs +++ b/src/mito2/src/series_index/writer.rs @@ -477,7 +477,7 @@ fn time_index_unit(metadata: &RegionMetadataRef) -> Result { .unit()) } -fn validate_metadata(metadata: &RegionMetadataRef) -> Result<()> { +pub(crate) fn validate_metadata(metadata: &RegionMetadataRef) -> Result<()> { for column in &metadata.column_metadatas { ensure!( !matches!( @@ -540,7 +540,7 @@ fn validate_metadata(metadata: &RegionMetadataRef) -> Result<()> { Ok(()) } -fn tag_columns(metadata: &RegionMetadataRef) -> Vec<(ColumnId, String)> { +pub(crate) fn tag_columns(metadata: &RegionMetadataRef) -> Vec<(ColumnId, String)> { metadata .primary_key_columns() .filter(|column| !is_reserved_column(column.column_id)) @@ -595,16 +595,9 @@ fn decode_primary_key( offsets: &mut SparseOffsetsCache, buf: &mut Vec, ) -> Result { - let mut view = SparsePrimaryKeyView::new(primary_key, offsets).context(DecodeSnafu)?; - - let table_id = view.table_id(); - let tsid = view.tsid(); - - let mut tags = Vec::with_capacity(tag_columns.len()); - for (column_id, _) in tag_columns { - let value = view.label(*column_id, buf).context(DecodeSnafu)?; - tags.push(value.map(str::to_owned)); - } + let (id, tags) = decode_series_identity(primary_key, tag_columns, offsets, buf)?; + let table_id = id.table_id; + let tsid = id.tsid; Ok(SeriesIndexRow { min_ts, @@ -616,6 +609,29 @@ fn decode_primary_key( }) } +/// Shared sparse-key extraction, independent of timestamp aggregation. +pub(crate) fn decode_series_identity( + primary_key: &[u8], + tag_columns: &[(ColumnId, String)], + offsets: &mut SparseOffsetsCache, + buf: &mut Vec, +) -> Result<(crate::series_index::MetricSeriesId, Vec>)> { + let mut view = SparsePrimaryKeyView::new(primary_key, offsets).context(DecodeSnafu)?; + let id = crate::series_index::MetricSeriesId { + table_id: view.table_id(), + tsid: view.tsid(), + }; + let mut tags = Vec::with_capacity(tag_columns.len()); + for (column_id, _) in tag_columns { + tags.push( + view.label(*column_id, buf) + .context(DecodeSnafu)? + .map(str::to_owned), + ); + } + Ok((id, tags)) +} + /// Builds a timestamp array in `unit` from raw i64 values. fn ts_array(timestamps: impl Iterator, unit: TimeUnit) -> ArrayRef { match unit { From 14f0ead84c9e2c10444b016e61d532a159b0fec0 Mon Sep 17 00:00:00 2001 From: discord9 Date: Thu, 22 Oct 2026 17:27:44 +0800 Subject: [PATCH 17/18] feat(promql): execute constant scalar broadcasts in the native executor (#9859) Execute compiler-emitted literal Scalar/vector arithmetic, filtering and bool comparisons, and unary negation in NativeExecutor. Reuse existing ScalarOperand and map kernels while pulling only the vector side; preserve sparse/missing values, correct labels, and series closure. Reuse existing reservations, metrics and merge handling. Track name-dropping collisions, merge disjoint collapsed series before a downstream Window, and retain no vector stream in the broadcast. Reject unsupported computed-scalar operands at construction. Add real compiler-to-executor and direct executor regressions for both operand directions, folded bool constants, exact 21-point results, streaming/collecting Window lifecycle, nonclosing empty filters, final closure, bounded retention and reservation release. No duplicate compiler lowering, dynamic scalar/scalar-scalar execution, Aggregate runtime or public Fused entrypoint wiring. Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> --- src/promql/src/native/execute.rs | 550 ++++++++++-- src/promql/src/native/execute/tests.rs | 843 ++++++++++++++++++- src/query/src/promql/native_compile/tests.rs | 429 +++++++++- 3 files changed, 1755 insertions(+), 67 deletions(-) diff --git a/src/promql/src/native/execute.rs b/src/promql/src/native/execute.rs index 7684ee2aa2b..d5470816004 100644 --- a/src/promql/src/native/execute.rs +++ b/src/promql/src/native/execute.rs @@ -36,19 +36,29 @@ //! as one chunk addressed on its own grid. Nothing is decided for a series before it ends, which is //! the price of the kernel taking one axis rather than a chunk of one. //! -//! A vector–vector binary node is the one kind that reads two inputs: it retains the chunks of both +//! A vector–vector binary node is the only kind that reads two inputs: it retains the chunks of both //! operands and runs the matching kernel once the input has ended, because the operation pairs two //! whole vectors point by point and nothing is decided for a point until both sides are complete. //! The chunks of one logical series are assembled into the single series the kernel matches before it //! runs, so a series that arrived in many chunks is one candidate, not one per chunk. //! +//! A constant scalar is a value node rather than a stream: it has no input and hands over no chunk, +//! because what it produces is the one value it was bound to and not a series. A binary node with a +//! scalar operand therefore reads one input — the vector side — and applies the operation to each +//! chunk it is handed as it arrives: the scalar is read off the node's binding rather than pulled, +//! and no chunk of the vector is retained, because one vector value pairs with one constant and +//! nothing is decided by what comes after it. That broadcast is a different mode from the vector +//! matching above and keeps neither operand: what it hands out is the chunk it transformed. +//! //! A node kind this runtime does not execute yet is rejected when the execution is built, naming -//! the kind and its position. An unsupported tree therefore never starts, rather than answering -//! half a query or panicking halfway through one. +//! the kind and its position. That is an aggregate, and a binary node whose two operands are +//! neither two evaluated vectors nor one evaluated vector and one constant. An unsupported tree +//! therefore never starts, rather than answering half a query or panicking halfway through one. //! //! A node that rewrites labels — a window drops the metric name and the empty-valued labels, a map -//! drops the name — can turn two distinct input series into one output label set. That pair is -//! legal when the two hold values at disjoint positions, which is exactly what +//! drops the name, and a binary node drops it for arithmetic and for a `bool` comparison — can turn +//! two distinct input series into one output label set. That pair is legal when the two hold values +//! at disjoint positions, which is exactly what //! [`crate::grid_series::merge_same_labelset`] merges: `is_last` closes the source series whose //! data the chunk carries, not the label set it shares with another series, so a second series may //! fill the positions the first one left empty. The node that rewrote the labels keeps which @@ -93,13 +103,19 @@ use crate::grid_series::{ GridSample, GridSeriesBuilder, LabelSet, label_set, merge_same_labelset, owned, }; use crate::instant_eval::select_instants; -use crate::map::{MathOp, drop_metric_name, map_math}; +use crate::map::{ + ArithmeticOp, Comparison, ComparisonOp, MathOp, ScalarOperand, drop_metric_name, + map_arithmetic, map_compare, map_math, +}; use crate::native::batch::EvaluatedChunk; use crate::native::metrics::{NodeMetrics, NodePath}; -use crate::native::plan::{BoundInstant, NativeExpr, NativeNode, NativeTree}; +use crate::native::plan::{ + BoundInstant, BoundScalar, NativeExpr, NativeNode, NativeTree, NativeType, +}; use crate::range_eval::RangeEvaluator; use crate::vector_match::{ - BinaryMatching, binary_vectors_with_reservation, validate_binary_operand, + BinaryMatching, BinaryOp, Cardinality, FillValues, MatchingLabels, + binary_vectors_with_reservation, validate_binary_operand, }; #[cfg(test)] @@ -371,13 +387,21 @@ fn buffer_bytes(buffer: &Buffer) -> usize { enum NodeTag { /// Forwards the chunks it is given. Input, + /// A constant scalar: the value its consumer broadcasts, and no chunk of its own. + /// + /// It is a value node and not a stream. Its output type is [`NativeType::Scalar`], and the + /// runtime produces evaluated chunks only, so the node hands nothing over: what its consumer + /// reads is the constant bound to it, which is why it has no input and no run-time state. A + /// `pull` of it answers `None` for exactly that reason, and that is not a stall to fix. + Scalar, /// Buffers one series and selects the last sample at or before each of its grid points. Instant, /// Feeds its input to its own [`RangeEvaluator`]. Window, /// Applies point-wise operations to its input chunks. Map, - /// Matches two evaluated vectors and applies one operation to every matched pair of series. + /// Matches two evaluated vectors and applies one operation to every matched pair of series, or + /// broadcasts one constant over the chunks of one evaluated vector. Binary, } @@ -386,6 +410,7 @@ impl NodeTag { fn describe(self) -> &'static str { match self { Self::Input => "input", + Self::Scalar => "scalar", Self::Instant => "instant selection", Self::Window => "window", Self::Map => "map", @@ -1725,6 +1750,86 @@ impl BinaryRuntime { } } +/// The operation a scalar broadcast applies, with the side the constant sits on already resolved. +#[derive(Clone, Copy, Debug)] +enum BroadcastOp { + /// Float arithmetic between the vector and the constant. + Arithmetic(ArithmeticOp), + /// A comparison: `Filter` keeps the positions that passed, `Bool` reports `1`/`0` for them. + Compare(Comparison), +} + +/// What a binary node holds when one operand is a constant: the operation, its scalar operand and +/// the layout of the vector side. +/// +/// A broadcast applies to each chunk as it arrives; unlike [`BinaryRuntime`], it does not retain +/// both operands for whole-vector matching. +struct BroadcastRuntime { + /// The scalar and its position in the operation. + operand: ScalarOperand, + /// The operation applied to every value of the vector. + op: BroadcastOp, + /// The layout of the vector side: the fields a chunk of it carries, on the grid it is addressed + /// on. A chunk of the vector side is checked against it by the kernel itself. + sample_schema: Arc, +} + +impl BroadcastRuntime { + /// Whether the operation rewrites the label set of what it hands out. + /// + /// Arithmetic and a `bool` comparison drop the metric name, so two input series can become one + /// label set under this node and what it hands out is recorded in an [`Emitted`] table. A + /// filtering comparison keeps the label set it read, so it keeps none. The answer is read off + /// [`Self::op`] rather than stored beside it, so an operation and its label policy cannot + /// disagree. + fn drops_labels(&self) -> bool { + match self.op { + BroadcastOp::Arithmetic(_) | BroadcastOp::Compare(Comparison::Bool(_)) => true, + BroadcastOp::Compare(Comparison::Filter(_)) => false, + } + } + + /// The label set a chunk of the vector side's `metric` becomes under this operation. + /// + /// It is what a series closes under when the operation emptied every position: the labels say + /// which series closed, and they are the labels the operation produces, not the ones it read. + fn output_metric(&self, metric: &MetricName) -> Result { + if self.drops_labels() { + drop_metric_name(metric) + } else { + Ok(metric.clone()) + } + } + + /// Applies the operation to one chunk of the vector side. + /// + /// `None` means the operation emptied every position and the chunk does not close its series: + /// the input may still decide more, so nothing is handed on. A chunk that does close its series + /// is answered even then — with a series carrying no row — because a consumer tells "nothing + /// more here" from "no more input" by that chunk alone. + fn apply(&self, chunk: &EvaluatedChunk) -> Result> { + let output = match self.op { + BroadcastOp::Arithmetic(op) => Some(map_arithmetic( + &self.sample_schema, + &chunk.series, + op, + self.operand, + )?), + BroadcastOp::Compare(comparison) => { + map_compare(&self.sample_schema, &chunk.series, comparison, self.operand)? + } + }; + match output { + Some(output) => Ok(Some(output)), + None if chunk.is_last => { + let metric = self.output_metric(&chunk.series.metric)?; + no_row_series(&self.sample_schema, &chunk.series, metric).map(Some) + } + None => Ok(None), + } + } +} + /// The buffered chunks as the one source axis they make up: their timestamps in arrival order. /// /// The axis type validates the result as strictly increasing, which is also what refuses a series @@ -1885,8 +1990,17 @@ struct NodeRuntime { window: Option, /// What a map node applies. map: Option, - /// What a binary node holds. + /// What a binary node holds when it matches two vectors: both operands, until the input ends. + /// + /// `None` for every other node, and for a binary node that broadcasts a constant instead: that + /// one holds no operand at all and is described by [`NodeRuntime::broadcast`]. binary: Option, + /// What a binary node holds when one operand is a constant: the operation and the layout of the + /// vector side. + /// + /// The two modes are exclusive. A broadcast transforms each chunk of the vector side as it + /// arrives and retains none of them, so what it holds is its own configuration and not data. + broadcast: Option, /// The layout the caller's chunks of an input node are checked against. /// /// A tree with a binary node reads two inputs, and each of them is bound to its own layout, so @@ -2021,6 +2135,64 @@ impl NodeRuntime { Ok(output) } + /// Broadcasts one chunk of the vector side with the constant this node was bound to. + /// + /// `None` means the operation emptied every position and the chunk does not close its series: + /// there is nothing to hand on, and the vector side may still decide more. Nothing of the chunk + /// is retained — the operation pairs one value with one constant — so what this node holds + /// afterwards is the label sets it has handed out and nothing else. + fn broadcast(&mut self, path: &NodePath, chunk: NativeChunk) -> Result> { + let NativeChunk::Evaluated(chunk) = chunk else { + return Err(DataFusionError::Execution(format!( + "the binary node at {path} broadcasts over an evaluated vector, not raw samples" + ))); + }; + let (positions, closing) = (chunk.series.time.len(), chunk.is_last); + // The chunk is held for as long as this node works on it. + self.buffer.grow(evaluated_bytes(&chunk))?; + let metrics = Arc::clone(&self.buffer.metrics); + let outcome = { + // The timer covers this node's own work only: it is dropped before the driver polls + // the vector side again. + let _timer = metrics.timer(); + match self.broadcast.as_ref() { + Some(broadcast) => broadcast.apply(&chunk), + None => Err(no_broadcast(path)), + } + }; + metrics.record_input(1, positions); + if closing { + // A series closes exactly once, on the chunk carrying `is_last`: the broadcast kept it + // on every chunk it handed up, so the series it read is closed here. + metrics.record_completed_series(1); + } + // An error leaves the reservation to the driver, which releases every node when it stops. + let output = outcome?; + let output = match output { + Some(series) => Some(EvaluatedChunk::try_new(series, closing)?), + None => None, + }; + if let Some(output) = &output { + metrics.record_grid_points(output.series.time.len()); + metrics.record_output(1, valid_values(output)); + // Only an operation that rewrites the label set is where two input series can become + // one, and only that one keeps the table `track` records into. A filtering comparison + // keeps the label set it read, so recording it would refuse two input series that + // share it without this node having made them share it. + if self + .broadcast + .as_ref() + .is_some_and(BroadcastRuntime::drops_labels) + { + self.track(path, output)?; + } + } + // The chunk is gone and the label sets this node has handed out are what is left. + let emitted = self.emitted_bytes(); + self.buffer.resize(emitted)?; + Ok(output) + } + /// Feeds one chunk to this node's window. /// /// A window that collects its input keeps the whole series of every chunk it is handed and @@ -2354,6 +2526,7 @@ impl NodeRuntime { self.window = None; self.instant = None; self.binary = None; + self.broadcast = None; self.source_schema = None; self.emitted = None; self.merge = None; @@ -2439,14 +2612,17 @@ impl NativeExecutionState { NativeExpr::Window { .. } => NodeTag::Window, NativeExpr::Map { .. } => NodeTag::Map, // A binary node is built here and narrowed below: legality is a wider set of operand - // types than the kernel takes, so the shape is checked where the operands' layouts - // are read. + // types than the kernels take, so which of its two modes it is -- a match between + // two vectors, or a constant broadcast over one -- is decided where the operands' + // layouts are read. NativeExpr::Binary { .. } => NodeTag::Binary, - // The kinds below are legal in a bound tree, and `validate` stays the authority on - // that; this runtime simply does not execute them yet. Rejected before a single - // chunk is read: a tree the runtime cannot execute must not start, because a - // partial answer would be one the caller never asked for. - NativeExpr::Scalar(_) => return Err(not_implemented("Scalar", &path)), + // A scalar is a value node: it produces the constant it was bound to, hands over no + // chunk and holds no state, so it is built like any other node and pulled like none. + NativeExpr::Scalar(_) => NodeTag::Scalar, + // An aggregate is legal in a bound tree, and `validate` stays the authority on that; + // this runtime simply does not execute it yet. Rejected before a single chunk is + // read: a tree the runtime cannot execute must not start, because a partial answer + // would be one the caller never asked for. NativeExpr::Aggregate { .. } => return Err(not_implemented("Aggregate", &path)), }; let buffer = NodeBuffer::new(&path, handles, &pool); @@ -2457,6 +2633,7 @@ impl NativeExecutionState { window: None, map: None, binary: None, + broadcast: None, source_schema: None, emitted: None, merge: None, @@ -2520,11 +2697,12 @@ impl NativeExecutionState { right, matching, } => { - // The one shape this runtime executes is two evaluated vectors: a scalar side is - // a broadcast the kernel does not take, and a raw side has no positions to be - // matched at. Both are rejected as this node, before the pass reaches the - // operand that produced them, so the position named is the one that asked for - // them. + // A binary node has two modes here, and the operands' layouts are what tell + // them apart. Two evaluated vectors are matched by label set and retain both + // operands; one evaluated vector and a constant is a broadcast, which pairs + // each vector value with the constant as the chunk carrying it arrives. All + // that is left is refused as this node, before the pass reaches the operands + // that produced them, so the position named is the one that asked for them. match (left.output.samples(), right.output.samples()) { (Some(left), Some(right)) if left.eval_grid.is_some() && right.eval_grid.is_some() => @@ -2536,14 +2714,27 @@ impl NativeExecutionState { [Arc::clone(left), Arc::clone(right)], )); } - _ => return Err(not_implemented("Binary", &path)), + _ => { + let broadcast = broadcast_runtime(&path, left, right, matching)?; + if broadcast.drops_labels() { + // The operation rewrites the label set it hands out, so this node + // is one where two input series can become one: it keeps the table + // that refuses a second value at a position the first one filled. + runtime.emitted = Some(Emitted::default()); + } + runtime.broadcast = Some(broadcast); + } } - // A binary operation matches by label set, so a result label set may be - // produced at points that do not collide; the kernel groups and merges them - // itself, which is why this node keeps no ledger of what it handed out. + // A binary operation over two vectors matches by label set, so a result label + // set may be produced at points that do not collide; the kernel groups and + // merges them itself, which is why that mode keeps no ledger of what it handed + // out. A broadcast that keeps the labels it reads is not where two series + // become one either, so it keeps none; the one that drops them does, above. } NativeExpr::Scalar(_) | NativeExpr::Aggregate { .. } => { - // Rejected above, where the pass has the position to name. + // A scalar holds nothing: what its consumer reads is the constant the node was + // bound to, which is on the tree rather than in this state. The aggregate is + // rejected above, where the pass has the position to name. } } // The inputs this node reads, in the order the tree declares them: a unary node's one @@ -2575,6 +2766,16 @@ impl NativeExecutionState { runtimes.push(runtime); } + // A caller names a node by the position the metrics give it, and a chunk it queues is bound + // to the input that position names, so every position is indexed once the pass has given the + // whole tree one. The reverse pass below reads it as well, because an operand is not always + // the node the spec's `input` names: a broadcast's vector side may be its right operand. + let by_path: HashMap = paths + .iter() + .enumerate() + .map(|(index, path)| (path.clone(), index)) + .collect(); + // One reverse pass sets where a merge has to happen, from the input upwards: the pass above // walked the root first, so a node's input is the node after it and has been decided by the // time the node itself is. @@ -2615,14 +2816,40 @@ impl NativeExecutionState { holds_shared[index] = input_holds_shared; } NodeTag::Map => holds_shared[index] = true, - NodeTag::Binary => { - // The kernel matches its operands by label set and files one result row per - // label set and point, merging the rows of one set whatever piece they came - // from: what it hands out is one series per label set, so nothing above has - // to join pieces of a series. Its own operands are read whole -- it retains - // them until the input ends -- so its flag is false whatever theirs are. + NodeTag::Scalar => { + // A scalar produces one value and no labels, so it is not a stream of series + // and nothing above it can read two of them under one label set. The flag + // is false rather than inherited: the node has no input at all. holds_shared[index] = false; } + NodeTag::Binary => { + // A vector match files one result row per label set and point, merging the + // rows of one set whatever piece they came from: what it hands out is one + // series per label set, so nothing above has to join pieces of a series. Its + // own operands are read whole -- it retains them until the input ends -- so + // its flag is false whatever theirs are. + // + // A broadcast hands each input chunk up transformed instead, so it merges + // nothing: what its output can hold under one label set is what its vector + // side held, plus the pair its own label rewrite can create. One that drops + // the metric name -- arithmetic and a `bool` comparison -- is therefore where + // two input series become one label set, and one that keeps the name only + // inherits the pair its input already had. The flag of the *vector* side is + // the one that matters: the constant is a node with no input at all. + holds_shared[index] = match runtimes[index].broadcast.as_ref() { + Some(broadcast) => { + let vector = match broadcast.operand { + ScalarOperand::Right(_) => nodes[index].input, + ScalarOperand::Left(_) => { + by_path.get(&paths[index].right()).copied() + } + }; + broadcast.drops_labels() + || vector.is_some_and(|vector| holds_shared[vector]) + } + None => false, + }; + } NodeTag::Window => { let (drops_metric_name, input_schema) = match expr { NativeExpr::Window { input, op } => ( @@ -2674,14 +2901,6 @@ impl NativeExecutionState { } } - // A caller names a node by the position the metrics give it, and a chunk it queues is bound - // to the input that position names, so every position is indexed once the pass has given the - // whole tree one. - let by_path: HashMap = paths - .iter() - .enumerate() - .map(|(index, path)| (path.clone(), index)) - .collect(); // Every input node is where a caller's chunks can enter, and each of them is named by its // own position when a caller queues a chunk. A tree of unary nodes has one input; a binary // node adds one per operand, so a tree of nested binary nodes has as many as it has leaves. @@ -2850,8 +3069,9 @@ impl NativeExecutionState { /// The driver owns the state and nothing else: every decision a node makes was bound before the /// tree existed, so a pull only reads the position and runs the bound operation. /// -/// A window directly over a source evaluates its chunks as they arrive, and a map at the root hands -/// its chunks on as it produces them: both stream. A window whose input can hold two series under +/// A window directly over a source evaluates its chunks as they arrive, a map at the root hands +/// its chunks on as it produces them, and a binary node over a constant transforms each chunk of +/// the vector side as it arrives: all three stream. A window whose input can hold two series under /// one canonical label set collects that input instead, merges each set once and evaluates it, so /// it reports at [`Self::finish`] rather than as its input arrives. pub struct NativeExecutor { @@ -2932,9 +3152,11 @@ impl NativeExecutor { /// /// A window and a map take whole chunks from their input, so nothing beyond the chunk in hand /// is materialised on their edge -- except below a window that collects its input, which holds - /// that input until [`Self::finish`]. A binary node is the other exception: it pairs two whole - /// vectors point by point, so it retains every chunk each operand hands over and reports `None` - /// until [`Self::finish`] ends the input, which is when it computes its result. + /// that input until [`Self::finish`]. A binary node that matches two vectors is the other + /// exception: it pairs two whole vectors point by point, so it retains every chunk each operand + /// hands over and reports `None` until [`Self::finish`] ends the input, which is when it + /// computes its result. A binary node that broadcasts a constant is not one: it pairs each + /// value of the vector side with the constant and hands the transformed chunk up as it arrives. /// /// A chunk's `is_last` says the series whose data it carries ended. It does not say its label /// set is finished: two source series whose labels were rewritten into one set are two pieces @@ -3103,9 +3325,10 @@ impl NativeExecutor { /// Whether the node at `index` has produced everything it ever will. /// /// A pull that finds nothing does not answer it: a collecting window reports nothing until it - /// is flushed, and a binary node reports nothing until both nodes it reads from are finished. - /// Each node therefore answers from its own state and from its children's, which is what keeps - /// a binary node from computing on an operand a window below it has not handed over yet. + /// is flushed, and a binary node that matches two vectors reports nothing until both nodes it + /// reads from are finished. Each node therefore answers from its own state and from its + /// children's, which is what keeps a binary node from computing on an operand a window below it + /// has not handed over yet. fn settled(&self, index: usize) -> bool { let runtime = &self.state.runtimes[index]; if !runtime.queued.is_empty() || runtime.merge.is_some() { @@ -3113,10 +3336,23 @@ impl NativeExecutor { } match self.state.nodes[index].tag { NodeTag::Input => true, + // A scalar produces its value without reading anything, so it is settled from the + // start: there is no chunk it owes its consumer. + NodeTag::Scalar => true, NodeTag::Map | NodeTag::Window | NodeTag::Instant => self.state.nodes[index] .input .is_none_or(|child| self.settled(child)), NodeTag::Binary => { + // A broadcast owes only what its vector side has not handed over yet: it answers + // each chunk as it reads it and keeps nothing, so it is settled exactly when that + // side is. A vector match is settled once its kernel ran and both operands are. + if let Some(broadcast) = &runtime.broadcast { + let vector = match broadcast.operand { + ScalarOperand::Right(_) => self.state.nodes[index].input, + ScalarOperand::Left(_) => self.state.right_input(index).ok(), + }; + return vector.is_some_and(|vector| self.settled(vector)); + } runtime .binary .as_ref() @@ -3212,6 +3448,10 @@ impl NativeExecutor { let input = self.state.nodes[index].input; match tag { NodeTag::Input => self.state.runtimes[index].pop_source(), + // A scalar owes its consumer no chunk: it produces one value and no series, and the + // value is the constant on the tree, which the consumer reads off its own binding. A + // pull of it is therefore empty by construction and says nothing has stalled. + NodeTag::Scalar => Ok(None), NodeTag::Map => { let child = input.ok_or_else(|| missing_input(tag))?; // An operation that empties every position yields no chunk, and the map asks its @@ -3262,6 +3502,13 @@ impl NativeExecutor { // one is the input a spec of two fields would have had to carry. let left = input.ok_or_else(|| missing_input(tag))?; let right = self.state.right_input(index)?; + // A broadcast reads the vector side only: the constant is on the node's binding, so + // there is no second stream to pull and no chunk to retain. Each chunk is + // transformed as it arrives and handed up, which is what keeps a broadcast over a + // whole vector from costing the vector. + if self.state.runtimes[index].broadcast.is_some() { + return self.broadcast_node(index, left, right); + } // Both operands are read to the end of what they hand over: before the input ends a // binary node only retains the chunks, because a pair is not decided until both // sides are whole, and after it ends this is the drain that empties the two. @@ -3286,6 +3533,45 @@ impl NativeExecutor { } } + /// Pulls the vector side of the broadcast at `index` until it hands over a chunk, and returns + /// that chunk transformed. + /// + /// Only the vector side is pulled: the scalar side is the constant the node was bound to. The + /// chunk is transformed inside this call and nothing of it is kept, so what a broadcast holds + /// does not grow with the vector it reads. + fn broadcast_node( + &mut self, + index: usize, + left: usize, + right: usize, + ) -> Result> { + let vector = { + let broadcast = self.state.runtimes[index] + .broadcast + .as_ref() + .ok_or_else(|| no_broadcast(&self.state.paths[index]))?; + match broadcast.operand { + ScalarOperand::Right(_) => left, + ScalarOperand::Left(_) => right, + } + }; + // An operation that empties every position yields no chunk, and the node asks its vector + // side again rather than reporting a decision it did not make. + loop { + let Some(chunk) = self.pull(vector)? else { + // Nothing is left in the vector side. Before the input ends that is a stall rather + // than an end, and so is a vector side still holding collected input: the node has + // answered everything it can and owes whatever that side has not produced yet. + return Ok(None); + }; + let path = &self.state.paths[index]; + let runtime = &mut self.state.runtimes[index]; + if let Some(output) = runtime.broadcast(path, chunk)? { + return Ok(Some(NativeChunk::Evaluated(output))); + } + } + } + /// Maps one chunk the node at `index` pulled from its input. fn map_node(&mut self, index: usize, chunk: NativeChunk) -> Result> { let NativeChunk::Evaluated(chunk) = chunk else { @@ -3424,7 +3710,10 @@ fn map_chunk( // holds no value now, so there is no row to hand over. None => { return if chunk.is_last { - let empty = no_row_series(schema, &chunk.series)?; + // A map drops the metric name, so the empty series closes under the labels the + // map produces. + let metric = drop_metric_name(&chunk.series.metric)?; + let empty = no_row_series(schema, &chunk.series, metric)?; EvaluatedChunk::try_new(empty, true).map(Some) } else { Ok(None) @@ -3435,11 +3724,13 @@ fn map_chunk( EvaluatedChunk::try_new(series, chunk.is_last).map(Some) } -/// A series carrying the identity of `series` and no row at all. +/// A series carrying `metric` and no row at all. /// /// It is what a closing chunk holds when the operation emptied its input: the labels say which -/// series closed, and the axis is empty because no position holds a value. -fn no_row_series(schema: &SeriesSchema, series: &Series) -> Result { +/// series closed, and the axis is empty because no position holds a value. `metric` is the identity +/// the operation produces — a map drops the name, a filtering comparison keeps it — so the caller +/// says which, rather than this function deciding for it. +fn no_row_series(schema: &SeriesSchema, series: &Series, metric: MetricName) -> Result { let grid = series.time.grid().ok_or_else(|| { DataFusionError::Execution("a mapped chunk must be addressed on a grid".to_string()) })?; @@ -3447,7 +3738,6 @@ fn no_row_series(schema: &SeriesSchema, series: &Series) -> Result { .map(|_| Samples::from_float(Float64Array::from(Vec::::new()))) .collect(); let fields = SeriesFields::try_from_vec(fields).map_err(series_error)?; - let metric = drop_metric_name(&series.metric)?; let at = series.time.last_position().map_or(0, |last| last + 1); let time = GridRange::try_new(Arc::clone(grid), at, 0).map_err(series_error)?; Series::try_new(schema, metric, TimeAxis::Grid(time), fields).map_err(series_error) @@ -3483,7 +3773,9 @@ fn evaluated_values(chunk: &NativeChunk) -> usize { /// The rejection of a node kind this runtime does not execute yet. /// /// It names the kind and the node's position: the position is what the metrics and `EXPLAIN` call -/// the same node, so a reader can find it in the tree. +/// the same node, so a reader can find it in the tree. An aggregate is the one kind left here: a +/// scalar executes as a value node, and a binary node executes both as a vector match and as a +/// constant broadcast. fn not_implemented(kind: &str, path: &NodePath) -> DataFusionError { DataFusionError::NotImplemented(format!( "the native runtime does not execute {kind} nodes yet: {kind} at {path}" @@ -3576,6 +3868,154 @@ fn no_binary(path: &NodePath) -> DataFusionError { DataFusionError::Execution(format!("the node at {path} holds no binary operation")) } +/// The error for a node asked to broadcast without the broadcast its kind holds. +fn no_broadcast(path: &NodePath) -> DataFusionError { + DataFusionError::Execution(format!("the node at {path} holds no scalar broadcast")) +} + +/// The rejection of a scalar broadcast whose matching is not the one a constant can take. +/// +/// A constant has no labels, so there is nothing to match it against: Prometheus does not consult +/// the matching modifiers of a vector–scalar operation at all. A tree that carries them therefore +/// describes an operation that is not the one it would compute, which is refused rather than run +/// as if the modifiers were absent. +fn matched_scalar(path: &NodePath) -> DataFusionError { + DataFusionError::NotImplemented(format!( + "the native runtime does not execute a scalar broadcast whose constant carries matching \ + modifiers: a constant has no labels to match, so the operation this Binary at {path} \ + describes is not the one it would compute" + )) +} + +/// The rejection of a binary node whose operands are neither two evaluated vectors nor one +/// evaluated vector and one constant. +/// +/// `operands` says which shape arrived and why that shape has no path here, so a tree a later +/// stage compiles is refused for a reason a reader can act on. The kind and the position are named +/// the way every other rejection in this runtime names them. +fn unsupported_binary(path: &NodePath, operands: &str) -> DataFusionError { + DataFusionError::NotImplemented(format!( + "the native runtime does not execute this Binary node: it reads {operands}, and this \ + runtime reads two evaluated vectors or one evaluated vector and one constant, at {path}" + )) +} + +/// The broadcast a binary node with one constant operand is, read off its binding. +/// +/// The two sides say which mode this is: one evaluated vector and one constant is a broadcast. The +/// constant comes from the scalar node's own binding — the value the node produces — and the +/// operation from the matching the compiler bound; [`ScalarOperand`] preserves the constant's side. +/// +/// Of the rejection arms in the operand-type match below, only the two-constant one is a shape a +/// tree that `validate` accepts can put here; the others are backstops that keep the match +/// exhaustive. That is not the wide safety margin it looks like: [`NativeNode::validate`] refuses a +/// binary operand that produces neither evaluated values nor a scalar before any of this runs, so a +/// raw side or an aggregate-state side can never reach this function at all. The refusals after the +/// match — a constant operand that is not a literal scalar node, and a constant carrying matching +/// modifiers — are reachable through a validated tree. +fn broadcast_runtime( + path: &NodePath, + left: &NativeNode, + right: &NativeNode, + matching: &BinaryMatching, +) -> Result { + let (scalar_operand, vector, constant) = match (&left.output, &right.output) { + (NativeType::Samples(vector), NativeType::Scalar(_)) if vector.eval_grid.is_some() => ( + ScalarOperand::Right as fn(f64) -> ScalarOperand, + vector, + right, + ), + (NativeType::Scalar(_), NativeType::Samples(vector)) if vector.eval_grid.is_some() => ( + ScalarOperand::Left as fn(f64) -> ScalarOperand, + vector, + left, + ), + // The two-constant pair is reachable through the state builder, which is what this arm + // guards: `validate` accepts a binary node of two constants (it binds them to a scalar), and + // a compiler that could not fold a scalar–scalar operation would emit one. No constant has + // labels or fields to broadcast over, so there is no vector to apply the operation to. The + // executor test `an_unimplemented_node_is_reported_not_panicked` builds this pair directly. + (NativeType::Scalar(_), NativeType::Scalar(_)) => { + return Err(unsupported_binary( + path, + "two constants, which broadcast over no vector", + )); + } + // Raw samples or aggregate state: `validate` already refused either as a binary operand, so + // this arm is the exhaustiveness backstop rather than a reachable path. + (_, _) => { + return Err(unsupported_binary( + path, + "an operand that is neither evaluated samples nor a constant, which no validated \ + tree binds as a binary operand", + )); + } + }; + let NativeExpr::Scalar(BoundScalar { value }) = &constant.expr else { + // NotImplemented rather than Execution: this is a shape the runtime does not support, not a + // tree whose values are wrong. A caller that falls back to another planner on NotImplemented + // then still can, which an Execution would deny it. + return Err(DataFusionError::NotImplemented(format!( + "the native runtime does not execute the Binary node at {path}, which broadcasts an \ + operand that is not a literal scalar: the constant side has to be a Scalar node, and a \ + computed constant such as `1 < bool 2` is not one until it is folded" + ))); + }; + // A constant has no labels, so the modifiers a vector–vector operation would use have no + // meaning here: a tree that carries them describes another operation than the one it computes. + if matching.labels != MatchingLabels::Ignoring(Vec::new()) + || matching.cardinality != Cardinality::OneToOne + || matching.fill != FillValues::default() + { + return Err(matched_scalar(path)); + } + Ok(BroadcastRuntime { + operand: scalar_operand(*value), + op: broadcast_op(path, matching)?, + sample_schema: Arc::clone(vector), + }) +} + +/// The operation a binary node's `matching` describes. +/// +/// A comparison is a filter unless it was bound as `bool`, and a filter keeps the label set it +/// read while every other operation drops the metric name. Arithmetic has no `bool` form, so one +/// bound that way describes a node the compiler does not produce and is refused. +fn broadcast_op(path: &NodePath, matching: &BinaryMatching) -> Result { + let arithmetic = |op: ArithmeticOp| { + if matching.return_bool { + Err(DataFusionError::Execution(format!( + "the binary node at {path} carries `bool` on an arithmetic operator, which has no \ + bool form" + ))) + } else { + Ok(BroadcastOp::Arithmetic(op)) + } + }; + let comparison = |op: ComparisonOp| { + if matching.return_bool { + Ok(BroadcastOp::Compare(Comparison::Bool(op))) + } else { + Ok(BroadcastOp::Compare(Comparison::Filter(op))) + } + }; + match matching.op { + BinaryOp::Add => arithmetic(ArithmeticOp::Add), + BinaryOp::Sub => arithmetic(ArithmeticOp::Subtract), + BinaryOp::Mul => arithmetic(ArithmeticOp::Multiply), + BinaryOp::Div => arithmetic(ArithmeticOp::Divide), + BinaryOp::Mod => arithmetic(ArithmeticOp::Modulo), + BinaryOp::Pow => arithmetic(ArithmeticOp::Power), + BinaryOp::Atan2 => arithmetic(ArithmeticOp::Atan2), + BinaryOp::Eq => comparison(ComparisonOp::Equal), + BinaryOp::Ne => comparison(ComparisonOp::NotEqual), + BinaryOp::Gt => comparison(ComparisonOp::Greater), + BinaryOp::Lt => comparison(ComparisonOp::Less), + BinaryOp::Ge => comparison(ComparisonOp::GreaterEqual), + BinaryOp::Le => comparison(ComparisonOp::LessEqual), + } +} + /// The error for a reservation the platform cannot express. /// /// The binary node reserves its retained operands and the kernel's live workspace together, and diff --git a/src/promql/src/native/execute/tests.rs b/src/promql/src/native/execute/tests.rs index f31cf86be64..11403b2d957 100644 --- a/src/promql/src/native/execute/tests.rs +++ b/src/promql/src/native/execute/tests.rs @@ -482,6 +482,7 @@ fn source_runtime(pool: &Arc) -> NodeRuntime { window: None, map: None, binary: None, + broadcast: None, source_schema: None, emitted: None, merge: None, @@ -501,6 +502,7 @@ fn binary_runtime(pool: &Arc, schema: &Arc) -> Nod Arc::new(binary_matching(BinaryOp::Add)), [Arc::clone(schema), Arc::clone(schema)], )), + broadcast: None, source_schema: None, emitted: None, merge: None, @@ -1076,12 +1078,15 @@ fn a_downstream_window_emits_before_its_input_series_ends() { } /// Every node kind the runtime does not execute is rejected, naming the kind and its position. +/// +/// A scalar is not one of them any more: it is a value node, and a binary node over a constant is +/// executed as a broadcast. The binary shapes that stay refused are the ones no kernel here takes, +/// and the aggregate is still a kind this runtime does not run at all. #[test] fn an_unimplemented_node_is_reported_not_panicked() { // One grid shared by the nodes that validate by allocation identity. let statement = || grid(0, 120_000, 2); - let scalar_tree = scalar(1.0, statement()); let aggregate_tree = { let out = statement(); NativeNode { @@ -1096,18 +1101,30 @@ fn an_unimplemented_node_is_reported_not_panicked() { output: NativeType::Samples(evaluated_schema(out)), } }; - let binary_tree = { + // Two constants make a scalar, which is a legal node and a shape no binary here broadcasts + // over: there is no vector to pair either of them with. + let two_scalars_tree = { + let out = statement(); + NativeNode { + expr: NativeExpr::Binary { + left: Box::new(scalar(1.0, Arc::clone(&out))), + right: Box::new(scalar(2.0, Arc::clone(&out))), + matching: binary_matching(BinaryOp::Add), + }, + output: NativeType::Scalar(out), + } + }; + // A constant carries no labels, so the matching modifiers of a vector–scalar operation have no + // meaning: the operation this tree names is not the one it would compute. + let matched_scalar_tree = { let out = statement(); NativeNode { expr: NativeExpr::Binary { left: Box::new(window(input(), Arc::clone(&out))), right: Box::new(scalar(1.0, Arc::clone(&out))), matching: BinaryMatching { - op: BinaryOp::Add, - return_bool: false, - labels: MatchingLabels::Ignoring(Vec::new()), - cardinality: Cardinality::OneToOne, - fill: FillValues::default(), + labels: MatchingLabels::On(vec!["job".into()]), + ..binary_matching(BinaryOp::Mul) }, }, output: NativeType::Samples(evaluated_schema(out)), @@ -1115,11 +1132,16 @@ fn an_unimplemented_node_is_reported_not_panicked() { }; let cases = [ - ("Scalar", "root", scalar_tree), - ("Aggregate", "root", aggregate_tree), - ("Binary", "root", binary_tree), + ( + "Aggregate", + "root", + "does not execute Aggregate", + aggregate_tree, + ), + ("Binary", "root", "two constants", two_scalars_tree), + ("Binary", "root", "matching modifiers", matched_scalar_tree), ]; - for (kind, path, root) in cases { + for (kind, path, reason, root) in cases { // Legality stays `validate`'s: the plan accepts these trees, the runtime does not run them. let tree = NativeTree::try_new(root).expect("the bound tree is legal"); let error = match NativeExecutionState::try_new( @@ -1138,6 +1160,7 @@ fn an_unimplemented_node_is_reported_not_panicked() { let message = error.to_string(); assert!(message.contains(kind), "{kind}: {message}"); assert!(message.contains(&format!("at {path}")), "{message}"); + assert!(message.contains(reason), "{reason}: {message}"); } } @@ -6417,3 +6440,801 @@ fn an_all_missing_match_group_fits_its_pool() { assert_eq!(pool.reserved(), 0); assert_eq!(execution.state().reserved_bytes(), 0); } + +// --------------------------------------------------------------------------- +// Scalar broadcasts, driven through `NativeExecutor`. +// --------------------------------------------------------------------------- + +/// A binary node over `left` and `right` where one side is a constant, bound the way the compiler +/// binds a broadcast: the output is the vector side's fields on the statement's grid. +fn broadcast_of(left: NativeNode, right: NativeNode, matching: BinaryMatching) -> NativeNode { + let vector = left + .output + .samples() + .or_else(|| right.output.samples()) + .expect("a broadcast reads one evaluated vector") + .clone(); + NativeNode { + expr: NativeExpr::Binary { + left: Box::new(left), + right: Box::new(right), + matching, + }, + output: NativeType::Samples(Arc::new(SeriesSchema { + fields: vector.fields.clone(), + eval_grid: vector.eval_grid.clone(), + })), + } +} + +/// `vector constant`, on the grid the vector is evaluated on. +fn broadcast_right( + vector: NativeNode, + constant: f64, + op: BinaryOp, + return_bool: bool, +) -> NativeNode { + let grid = vector + .output + .grid() + .expect("a broadcast reads a grid") + .clone(); + let matching = BinaryMatching { + return_bool, + ..binary_matching(op) + }; + broadcast_of(vector, scalar(constant, grid), matching) +} + +/// `constant vector`, on the grid the vector is evaluated on. +fn broadcast_left( + constant: f64, + vector: NativeNode, + op: BinaryOp, + return_bool: bool, +) -> NativeNode { + let grid = vector + .output + .grid() + .expect("a broadcast reads a grid") + .clone(); + let matching = BinaryMatching { + return_bool, + ..binary_matching(op) + }; + broadcast_of(scalar(constant, grid), vector, matching) +} + +/// The metric name a chunk carries, or `None` when the operation dropped it. +fn name_of(chunk: &EvaluatedChunk) -> Option<&str> { + chunk.series.metric.name() +} + +/// A constant on the right multiplies every value of the vector and drops the metric name. +/// +/// The chunk arrives as it is pushed, before the input ends: a broadcast answers a chunk from that +/// chunk alone, so nothing waits for the vector to be complete. +#[test] +fn a_constant_on_the_right_multiplies_every_value() { + let grid = grid(0, 60_000, 4); + let schema = evaluated_schema(Arc::clone(&grid)); + let tree = NativeTree::try_new(broadcast_right( + evaluated_input(Arc::clone(&grid)), + 100.0, + BinaryOp::Mul, + false, + )) + .unwrap(); + let mut execution = executor(&tree, pool(), &ExecutionPlanMetricsSet::new()); + execution + .push_evaluated(evaluated_chunk(&schema, 0, vec![1.0, 2.0, 3.0, 4.0], true)) + .unwrap(); + let streamed = execution + .next_chunk() + .unwrap() + .expect("a broadcast answers the chunk it read without waiting for the end of the input"); + assert!(streamed.is_last, "the closing the chunk carried is kept"); + assert_eq!( + sample_values(&streamed), + vec![Some(100.0), Some(200.0), Some(300.0), Some(400.0)] + ); + assert!( + name_of(&streamed).is_none(), + "arithmetic drops the metric name, and the chunk still carries it" + ); + + let out = run(&mut execution); + assert!( + out.is_empty(), + "the chunk was already handed over, so the end of the input owes nothing" + ); + assert_eq!(execution.state().reserved_bytes(), 0); +} + +/// The side a constant is on decides which operand the vector's value is. +/// +/// Division is not commutative, so `x / 100` and `100 / x` answering different values is what says +/// the operand direction survived the broadcast. +#[test] +fn a_broadcast_keeps_the_operand_direction() { + let grid = grid(0, 60_000, 3); + let schema = evaluated_schema(Arc::clone(&grid)); + let values = || vec![100.0, 200.0, 400.0]; + let run_one = |root: NativeNode| -> Vec> { + let tree = NativeTree::try_new(root).unwrap(); + let mut execution = executor(&tree, pool(), &ExecutionPlanMetricsSet::new()); + execution + .push_evaluated(evaluated_chunk(&schema, 0, values(), true)) + .unwrap(); + let out = run(&mut execution); + assert_eq!(out.len(), 1, "one chunk in, one chunk out"); + sample_values(&out[0]) + }; + assert_eq!( + run_one(broadcast_right( + evaluated_input(Arc::clone(&grid)), + 100.0, + BinaryOp::Div, + false, + )), + vec![Some(1.0), Some(2.0), Some(4.0)], + "x / 100 divides the vector's value" + ); + assert_eq!( + run_one(broadcast_left( + 100.0, + evaluated_input(Arc::clone(&grid)), + BinaryOp::Div, + false, + )), + vec![Some(1.0), Some(0.5), Some(0.25)], + "100 / x divides the constant by the vector's value" + ); + assert_eq!( + run_one(broadcast_right( + evaluated_input(Arc::clone(&grid)), + 100.0, + BinaryOp::Sub, + false, + )), + vec![Some(0.0), Some(100.0), Some(300.0)], + "x - 100 subtracts from the vector's value" + ); + assert_eq!( + run_one(broadcast_left( + 100.0, + evaluated_input(Arc::clone(&grid)), + BinaryOp::Sub, + false, + )), + vec![Some(0.0), Some(-100.0), Some(-300.0)], + "100 - x subtracts the vector's value" + ); +} + +/// A `bool` comparison reports `1`/`0` at every position and drops the metric name. +#[test] +fn a_bool_comparison_reports_one_and_zero() { + let grid = grid(0, 60_000, 3); + let schema = evaluated_schema(Arc::clone(&grid)); + let tree = NativeTree::try_new(broadcast_right( + evaluated_input(Arc::clone(&grid)), + 0.0, + BinaryOp::Gt, + true, + )) + .unwrap(); + let mut execution = executor(&tree, pool(), &ExecutionPlanMetricsSet::new()); + execution + .push_evaluated(evaluated_chunk(&schema, 0, vec![1.0, -1.0, 0.0], true)) + .unwrap(); + let out = run(&mut execution); + assert_eq!( + addressed(&out), + vec![(0, vec![Some(1.0), Some(0.0), Some(0.0)])], + "`x > bool 0` answers 1 or 0 at every position it read" + ); + assert!( + name_of(&out[0]).is_none(), + "a bool comparison drops the metric name" + ); +} + +/// A filtering comparison keeps only the positions that passed, and keeps the label set it read. +#[test] +fn a_filtering_comparison_keeps_the_positions_that_passed() { + let grid = grid(0, 60_000, 3); + let schema = evaluated_schema(Arc::clone(&grid)); + let tree = NativeTree::try_new(broadcast_right( + evaluated_input(Arc::clone(&grid)), + 0.0, + BinaryOp::Gt, + false, + )) + .unwrap(); + let mut execution = executor(&tree, pool(), &ExecutionPlanMetricsSet::new()); + execution + .push_evaluated(evaluated_chunk(&schema, 0, vec![1.0, -1.0, 0.0], true)) + .unwrap(); + let out = run(&mut execution); + assert_eq!( + decided(&out), + vec![(0, Some(1.0))], + "only the position that passed is handed over" + ); + assert_eq!( + name_of(&out[0]), + Some("requests"), + "a filter keeps the label set it read" + ); +} + +/// `-1 * x` is the negation shape: a constant on the left, multiplying every value. +#[test] +fn a_constant_of_minus_one_on_the_left_negates_the_vector() { + let grid = grid(0, 60_000, 3); + let schema = evaluated_schema(Arc::clone(&grid)); + let tree = NativeTree::try_new(broadcast_left( + -1.0, + evaluated_input(Arc::clone(&grid)), + BinaryOp::Mul, + false, + )) + .unwrap(); + let mut execution = executor(&tree, pool(), &ExecutionPlanMetricsSet::new()); + execution + .push_evaluated(evaluated_chunk(&schema, 0, vec![1.0, -2.0, 5.0], true)) + .unwrap(); + let out = run(&mut execution); + assert_eq!( + addressed(&out), + vec![(0, vec![Some(-1.0), Some(2.0), Some(-5.0)])], + "-1 * x negates every value of the vector" + ); +} + +/// A position the vector held no value at stays missing, and a position it skipped stays skipped. +/// +/// The chunk that is not last is answered as it arrives: a broadcast transforms a chunk and hands +/// it up, so `is_last` is the only thing that says where a series ends. +#[test] +fn a_broadcast_preserves_missing_values_and_skipped_positions() { + let grid = grid(0, 60_000, 6); + let schema = evaluated_schema(Arc::clone(&grid)); + let tree = NativeTree::try_new(broadcast_right( + evaluated_input(Arc::clone(&grid)), + 2.0, + BinaryOp::Mul, + false, + )) + .unwrap(); + let mut execution = executor(&tree, pool(), &ExecutionPlanMetricsSet::new()); + + // Position 0 holds a value and position 1 holds none: the axis covers both, so the difference + // is the field's validity rather than the axis's rows. + execution + .push_evaluated(evaluated_chunk_of( + &schema, + metric(), + 0, + vec![Some(3.0), None], + false, + )) + .unwrap(); + let first = execution + .next_chunk() + .unwrap() + .expect("a broadcast answers a chunk that does not close its series"); + assert!(!first.is_last, "the chunk that did not close is not closed"); + assert_eq!( + decided(&[first]), + vec![(0, Some(6.0)), (1, None)], + "the positions are unchanged and the missing one stayed missing" + ); + + // The last chunk sits at position 5: positions 2 to 4 are skipped, not empty values. + execution + .push_evaluated(evaluated_chunk_of( + &schema, + metric(), + 5, + vec![Some(4.0)], + true, + )) + .unwrap(); + let second = execution + .next_chunk() + .unwrap() + .expect("the closing chunk is answered as it arrives"); + assert!(second.is_last); + assert_eq!(decided(&[second]), vec![(5, Some(8.0))]); + assert!( + execution.finish().unwrap().is_empty(), + "both chunks were handed over as they arrived, so the end of the input owes nothing" + ); + assert_eq!(execution.state().reserved_bytes(), 0); +} + +/// An empty non-closing filter result does not end a broadcast before a later chunk passes. +#[test] +fn a_filter_empty_nonclosing_chunk_does_not_end_the_broadcast() { + let grid = grid(0, 60_000, 5); + let schema = evaluated_schema(Arc::clone(&grid)); + let tree = NativeTree::try_new(broadcast_right( + evaluated_input(Arc::clone(&grid)), + 0.0, + BinaryOp::Gt, + false, + )) + .unwrap(); + let mut execution = executor(&tree, pool(), &ExecutionPlanMetricsSet::new()); + + execution + .push_evaluated(evaluated_chunk(&schema, 0, vec![-1.0, -2.0], false)) + .unwrap(); + assert!( + execution.next_chunk().unwrap().is_none(), + "a non-closing chunk with no passing positions emits no output but does not end the series" + ); + + execution + .push_evaluated(evaluated_chunk(&schema, 2, vec![3.0, -4.0, 5.0], true)) + .unwrap(); + let passing = execution + .next_chunk() + .unwrap() + .expect("the broadcast remains live and hands over a later passing chunk"); + assert!( + passing.is_last, + "only the later input chunk closes the series" + ); + assert_eq!( + decided(std::slice::from_ref(&passing)), + vec![(2, Some(3.0)), (4, Some(5.0))] + ); + assert_eq!( + name_of(&passing), + Some("requests"), + "the filtering comparison preserves the input labels" + ); + assert!( + execution.finish().unwrap().is_empty(), + "the sole closing chunk was handed over exactly once" + ); +} + +/// A filter that passes nothing on the chunk that closes a series still closes it. +/// The chunk it hands over holds no row and carries the label set the operation produces, which is +/// the only way the consumer downstream learns that the series ended rather than that no input has +/// arrived yet. +#[test] +fn a_filter_that_passes_nothing_still_closes_the_series() { + let grid = grid(0, 60_000, 3); + let schema = evaluated_schema(Arc::clone(&grid)); + + // A filtering comparison keeps the label set it read, so the closing chunk carries it. + let tree = NativeTree::try_new(broadcast_right( + evaluated_input(Arc::clone(&grid)), + 0.0, + BinaryOp::Gt, + false, + )) + .unwrap(); + let mut execution = executor(&tree, pool(), &ExecutionPlanMetricsSet::new()); + execution + .push_evaluated(evaluated_chunk(&schema, 0, vec![-1.0, -2.0], false)) + .unwrap(); + assert!( + execution.next_chunk().unwrap().is_none(), + "a chunk that passed nothing and closed nothing has nothing to hand over" + ); + execution + .push_evaluated(evaluated_chunk(&schema, 2, vec![-3.0], true)) + .unwrap(); + let out = run(&mut execution); + assert_eq!( + out.len(), + 1, + "the closing chunk is handed over even though it holds no value" + ); + assert!(out[0].is_last, "the empty chunk closes the series"); + assert_eq!( + out[0].series.time.len(), + 0, + "a filter that passed nothing hands over no row" + ); + assert_eq!( + name_of(&out[0]), + Some("requests"), + "a filter keeps the labels it read, so the closing chunk carries them" + ); + assert_eq!( + series_start(&out[0]), + 3, + "the empty chunk closes at the end" + ); + + // A `bool` comparison drops the name, so its empty closing chunk carries the dropped one. + let bool_tree = NativeTree::try_new(broadcast_right( + evaluated_input(Arc::clone(&grid)), + 0.0, + BinaryOp::Gt, + true, + )) + .unwrap(); + let mut execution = executor(&bool_tree, pool(), &ExecutionPlanMetricsSet::new()); + execution + .push_evaluated(evaluated_chunk_of( + &schema, + metric(), + 0, + vec![None; 3], + true, + )) + .unwrap(); + let out = run(&mut execution); + assert_eq!( + out.len(), + 1, + "the closing chunk of an all-missing input arrives" + ); + assert!(out[0].is_last); + assert_eq!(out[0].series.time.len(), 0); + assert!( + name_of(&out[0]).is_none(), + "a bool comparison drops the metric name, and the empty closing chunk carries that" + ); + assert_eq!(execution.state().reserved_bytes(), 0); +} + +/// A broadcast holds the chunk it is working on and nothing more, however long the vector is. +/// +/// The same series arrives in one chunk and in many: what the node reserves has to be the chunk in +/// hand and the label sets it handed out, not the vector it read, or a broadcast over a long series +/// would cost the whole series. +#[test] +fn a_broadcast_does_not_retain_the_vector_it_reads() { + // The most the node ever reserved, over the same series delivered in `count` chunks. + let peak = + |count: usize| -> usize { + let grid = grid(0, 60_000, count as u64); + let schema = evaluated_schema(Arc::clone(&grid)); + let tree = NativeTree::try_new(broadcast_right( + evaluated_input(Arc::clone(&grid)), + 2.0, + BinaryOp::Mul, + false, + )) + .unwrap(); + let pool = bounded_pool(32 << 10); + let mut execution = executor(&tree, Arc::clone(&pool), &ExecutionPlanMetricsSet::new()); + let mut peak = 0; + for index in 0..count { + execution + .push_evaluated(evaluated_chunk( + &schema, + index, + vec![index as f64], + index + 1 == count, + )) + .unwrap_or_else(|error| { + panic!("chunk {index} of {count} did not fit: {error}"); + }); + // The consumer drops every chunk: a reader that streams the vector never holds it, and + // neither may the node. + drop(execution.next_chunk().unwrap_or_else(|error| { + panic!("chunk {index} of {count} did not fit: {error}") + })); + let held = execution.state().reserved_bytes(); + assert_eq!( + held, + pool.reserved(), + "chunk {index} of {count}: the state and the pool disagree on what is held" + ); + assert!( + held <= (8 << 10), + "chunk {index} of {count} holds {held} bytes of one float and one label set" + ); + peak = peak.max(held); + } + // The series ends, and the node holds nothing afterwards. + drop(run(&mut execution)); + assert_eq!(execution.state().reserved_bytes(), 0); + assert_eq!(pool.reserved(), 0); + peak + }; + assert_eq!( + peak(64), + peak(1024), + "the reservation grew with the number of chunks the same vector arrived in" + ); +} + +/// A window above a broadcast that drops the metric name merges what that broadcast collapses. +/// +/// The multiplication rewrites the label set, so two input series with different names and one set +/// of tags become one label set under it. A window above therefore has to collect its input and +/// merge the pieces before it evaluates: reporting each piece on its own answers two series where +/// Prometheus answers one, and the second piece's positions are exactly the ones the first left +/// empty. `holds_shared` is what tells the window to collect, so a window that streams here is the +/// regression this test exists for. +#[test] +fn a_window_above_a_broadcast_merges_the_series_it_collapses() { + let grid = grid(0, 60_000, 4); + let schema = evaluated_schema(Arc::clone(&grid)); + let tree = NativeTree::try_new(window_with( + broadcast_right( + evaluated_input(Arc::clone(&grid)), + 2.0, + BinaryOp::Mul, + false, + ), + RangeFunction::LastOverTime, + 60_000, + Some(Arc::clone(&grid)), + Arc::clone(&grid), + )) + .unwrap(); + let set = ExecutionPlanMetricsSet::new(); + let mut execution = executor(&tree, pool(), &set); + // Two series that only differ by name, each holding the half of the grid the other leaves empty. + execution + .push_evaluated(evaluated_chunk_of( + &schema, + named_metric("requests", "x"), + 0, + vec![Some(1.0), Some(2.0)], + true, + )) + .unwrap(); + execution + .push_evaluated(evaluated_chunk_of( + &schema, + named_metric("requests_total", "x"), + 2, + vec![Some(3.0), Some(4.0)], + true, + )) + .unwrap(); + + let out = run(&mut execution); + assert_eq!( + decided(&out), + vec![ + (0, Some(2.0)), + (1, Some(4.0)), + (2, Some(6.0)), + (3, Some(8.0)) + ], + "the window did not merge the two pieces the broadcast collapsed onto one label set" + ); + assert_eq!( + out.iter().filter(|chunk| chunk.is_last).count(), + 1, + "the merged series closed more or less than once" + ); + // The node counts the source series it collected, not the merged one: two fragments arrived + // with `is_last`, so two source series ended. What proves the merge happened is the single + // closing chunk above -- a window that streamed would have closed each piece on its own. + assert_eq!( + count(&set, &NodePath::root(), NodeMetric::COMPLETED_SERIES), + 2, + "the window did not see both source series end" + ); + assert_eq!(execution.state().reserved_bytes(), 0); +} + +/// A broadcast hands one chunk over for every chunk it reads, and closes the series once. +/// +/// The metrics are what says the chunk-per-chunk behaviour held: one input batch per chunk read, one +/// output batch per chunk handed over, and one closed series for the series that ended. +#[test] +fn a_broadcast_reports_one_chunk_in_for_each_chunk_out() { + let grid = grid(0, 60_000, 4); + let schema = evaluated_schema(Arc::clone(&grid)); + let tree = NativeTree::try_new(broadcast_right( + evaluated_input(Arc::clone(&grid)), + 2.0, + BinaryOp::Mul, + false, + )) + .unwrap(); + let set = ExecutionPlanMetricsSet::new(); + let mut execution = executor(&tree, pool(), &set); + execution + .push_evaluated(evaluated_chunk(&schema, 0, vec![1.0, 2.0], false)) + .unwrap(); + execution + .push_evaluated(evaluated_chunk(&schema, 2, vec![3.0, 4.0], true)) + .unwrap(); + let out = run(&mut execution); + assert_eq!( + addressed(&out), + vec![ + (0, vec![Some(2.0), Some(4.0)]), + (2, vec![Some(6.0), Some(8.0)]) + ], + "each chunk is answered as it arrived, at the positions it held" + ); + assert_eq!( + count(&set, &NodePath::root(), NodeMetric::INPUT_BATCHES), + 2, + "the node read two chunks" + ); + assert_eq!( + count(&set, &NodePath::root(), NodeMetric::OUTPUT_BATCHES), + 2, + "the node handed two chunks over" + ); + assert_eq!( + count(&set, &NodePath::root(), NodeMetric::COMPLETED_SERIES), + 1, + "the series closed once, on the chunk that said so" + ); + // The scalar side is a node of the tree and reads nothing: it has no input at all. + assert_eq!( + count(&set, &NodePath::root().right(), NodeMetric::INPUT_BATCHES), + 0, + "the constant was pulled as a stream" + ); +} + +/// A broadcast forwards the settled pieces of a streaming window and its eventual close. +#[test] +fn a_broadcast_over_a_streaming_window_hands_over_the_points_it_settles() { + let inner_grid = grid(0, 60_000, 5); + let outer_grid = grid(0, 120_000, 3); + let inner_schema = evaluated_schema(Arc::clone(&inner_grid)); + let tree = NativeTree::try_new(broadcast_right( + window_with( + evaluated_input(Arc::clone(&inner_grid)), + RangeFunction::LastOverTime, + 240_000, + Some(Arc::clone(&inner_grid)), + outer_grid, + ), + 100.0, + BinaryOp::Mul, + false, + )) + .unwrap(); + let set = ExecutionPlanMetricsSet::new(); + let pool = pool(); + let mut execution = executor(&tree, Arc::clone(&pool), &set); + execution + .push_evaluated(evaluated_chunk( + &inner_schema, + 0, + vec![1.0, 2.0, 3.0], + false, + )) + .unwrap(); + + let mut out = Vec::new(); + while let Some(chunk) = execution.next_chunk().unwrap() { + out.push(chunk); + } + assert_eq!(decided(&out), vec![(0, Some(100.0)), (1, Some(300.0))]); + assert!(out.iter().all(|chunk| !chunk.is_last)); + assert_eq!( + count(&set, &NodePath::root(), NodeMetric::COMPLETED_SERIES), + 0 + ); + + execution + .push_evaluated(evaluated_chunk(&inner_schema, 3, vec![4.0, 5.0], true)) + .unwrap(); + while let Some(chunk) = execution.next_chunk().unwrap() { + out.push(chunk); + } + out.extend(execution.finish().unwrap()); + assert_eq!( + decided(&out), + vec![(0, Some(100.0)), (1, Some(300.0)), (2, Some(500.0))] + ); + assert_eq!(out.iter().filter(|chunk| chunk.is_last).count(), 1); + assert!(out.last().unwrap().is_last); + assert!(out.iter().all(|chunk| name_of(chunk).is_none())); + assert_eq!(execution.state().reserved_bytes(), 0); + assert_eq!(pool.reserved(), 0); +} + +/// A broadcast waits for a collecting window to flush before answering its series. +#[test] +fn a_broadcast_above_a_collecting_window_waits_for_its_flush() { + let grid = grid(0, 60_000, 1); + let schema = evaluated_schema(Arc::clone(&grid)); + let tree = NativeTree::try_new(broadcast_right( + window_with( + map(evaluated_input(Arc::clone(&grid)), vec![MathOp::Abs]), + RangeFunction::LastOverTime, + 60_000, + Some(Arc::clone(&grid)), + Arc::clone(&grid), + ), + 10.0, + BinaryOp::Mul, + false, + )) + .unwrap(); + let pool = pool(); + let mut execution = executor(&tree, Arc::clone(&pool), &ExecutionPlanMetricsSet::new()); + execution + .push_evaluated_at( + &NodePath::root().left().input().input(), + evaluated_chunk_of(&schema, metric(), 0, vec![Some(-1.0)], true), + ) + .unwrap(); + + assert!(execution.next_chunk().unwrap().is_none()); + let out = run(&mut execution); + assert_eq!(decided(&out), vec![(0, Some(10.0))]); + assert_eq!(out.iter().filter(|chunk| chunk.is_last).count(), 1); + assert_eq!(execution.state().reserved_bytes(), 0); + assert_eq!(pool.reserved(), 0); +} + +/// A broadcast rejects distinct input names that become one output label set at occupied points. +#[test] +fn a_broadcast_refuses_two_series_that_share_a_label_set_and_releases_the_reservation() { + let grid = grid(0, 60_000, 2); + let schema = evaluated_schema(Arc::clone(&grid)); + let tree = NativeTree::try_new(broadcast_right( + evaluated_input(Arc::clone(&grid)), + 100.0, + BinaryOp::Mul, + false, + )) + .unwrap(); + let pool = pool(); + let mut execution = executor(&tree, Arc::clone(&pool), &ExecutionPlanMetricsSet::new()); + let first_metric = + MetricName::try_new(Some("requests".to_string().into()), Vec::new()).unwrap(); + execution + .push_evaluated(evaluated_chunk_of( + &schema, + first_metric, + 0, + vec![Some(1.0), Some(2.0)], + true, + )) + .unwrap(); + let first = execution + .next_chunk() + .unwrap() + .expect("first series output"); + assert_eq!( + decided(std::slice::from_ref(&first)), + vec![(0, Some(100.0)), (1, Some(200.0))] + ); + + let second_metric = + MetricName::try_new(Some("requests_total".to_string().into()), Vec::new()).unwrap(); + execution + .push_evaluated(evaluated_chunk_of( + &schema, + second_metric, + 0, + vec![Some(3.0), Some(4.0)], + true, + )) + .unwrap(); + let error = execution + .next_chunk() + .expect_err("the output label set and occupied positions conflict"); + let message = error.to_string(); + assert!( + message.contains("that both hold a value of field"), + "expected the collision the node produces, got: {message:?}" + ); + assert_eq!( + pool.reserved(), + 0, + "error retained a pool reservation: {message}" + ); + assert_eq!( + execution.state().reserved_bytes(), + 0, + "error retained execution bytes: {message}" + ); +} diff --git a/src/query/src/promql/native_compile/tests.rs b/src/query/src/promql/native_compile/tests.rs index 76697ce8633..be4e3bc0d9a 100644 --- a/src/query/src/promql/native_compile/tests.rs +++ b/src/query/src/promql/native_compile/tests.rs @@ -16,9 +16,17 @@ use std::sync::Arc; use std::time::{Duration, UNIX_EPOCH}; use datafusion::error::DataFusionError; +use datafusion::execution::memory_pool::{GreedyMemoryPool, MemoryPool}; +use datafusion::physical_plan::metrics::ExecutionPlanMetricsSet; +use datatypes::arrow::array::Float64Array; +use datatypes::arrow::buffer::{Buffer, ScalarBuffer}; +use datatypes::series::MetricName; use datatypes::series::SeriesOrdering; -use datatypes::series::samples::{FieldSpec, SeriesSchema}; +use datatypes::series::raw::{RawSeriesChunk, RawTimestamps}; +use datatypes::series::samples::{FieldSpec, SampleRef, Samples, SeriesFields, SeriesSchema}; +use promql::evaluated::EvaluatedChunk; use promql::map::MathOp; +use promql::native::execute::{NativeExecutionState, NativeExecutor}; use promql::native::plan::{ AggregateKind, AggregateStage, BoundAggregate, BoundInstant, BoundMap, BoundWindow, Grouping, NativeExpr, NativeNode, NativeTree, NativeType, @@ -2354,6 +2362,425 @@ fn the_batch_acceptance_queries_compile() { } } +/// One raw chunk of the metric `x`, as the caller binds it from a table. +/// +/// `values` is one sample per timestamp, and a `None` is a position whose sample is missing rather +/// than a value: the two are not the same thing, and a broadcast has to keep the difference. +fn raw_x(timestamps: &[i64], values: Vec>, is_last: bool) -> RawSeriesChunk { + let metric = MetricName::try_new(Some("x".into()), vec![]).expect("a metric name"); + RawSeriesChunk::try_new( + &raw_schema(), + Buffer::from_slice_ref(b"series-a"), + Some(metric), + RawTimestamps::try_new(ScalarBuffer::from(timestamps.to_vec())).expect("timestamps"), + SeriesFields::one(Samples::from_float(Float64Array::from(values))), + is_last, + ) + .expect("a raw chunk") +} + +/// `x`'s samples at every grid point of [`eval_stmt`]'s grid, holding `1..=21`. +fn samples_at_every_grid_point() -> (Vec, Vec>) { + let timestamps = (0..21).map(|index| index * 5_000).collect(); + let values = (1..=21).map(|value| Some(f64::from(value))).collect(); + (timestamps, values) +} + +/// Compiles `query`, runs it over one raw series pushed as `chunks`, and returns every chunk the +/// root handed over. +/// +/// This is the real path: the tree comes from the compiler rather than being built by hand here, so +/// a binding the compiler got wrong cannot be hidden by a test that restates it. +fn execute_compiled(query: &str, chunks: Vec) -> Vec { + let tree = compile(&eval_stmt(query), raw_schema(), SeriesOrdering::KeyThenTime) + .unwrap_or_else(|error| panic!("`{query}` was refused: {error}")); + let pool: Arc = Arc::new(GreedyMemoryPool::new(1 << 20)); + let mut execution = NativeExecutor::new( + NativeExecutionState::try_new(&tree, pool, &ExecutionPlanMetricsSet::new(), 0) + .unwrap_or_else(|error| panic!("`{query}` was not executable: {error}")), + ); + for chunk in chunks { + execution + .push_raw(chunk) + .unwrap_or_else(|error| panic!("`{query}` refused its input: {error}")); + } + let mut out = Vec::new(); + while let Some(chunk) = execution + .next_chunk() + .unwrap_or_else(|error| panic!("`{query}` failed while running: {error}")) + { + out.push(chunk); + } + out.extend( + execution + .finish() + .unwrap_or_else(|error| panic!("`{query}` failed while closing: {error}")), + ); + out +} + +/// The `(grid position, value)` rows `chunks` hold, concatenated in the order they arrived. +/// +/// A position a chunk holds no value at is not a row at all, so what the function reports is the +/// positions that carry something: an operation that turned a value into a hole would show up as a +/// missing entry here rather than as a `None`. +fn rows(chunks: &[EvaluatedChunk]) -> Vec<(usize, f64)> { + let mut rows = Vec::new(); + for chunk in chunks { + let mut field = chunk.series.fields.as_slice()[0].iter(); + for (position, _) in chunk.series.time.positions() { + match field.next().expect("one sample per row") { + SampleRef::Float(value) => rows.push((position, value)), + SampleRef::Missing => {} + SampleRef::Histogram(_) => panic!("a scalar broadcast produced a histogram"), + } + } + } + rows +} + +/// The label set the executed chunks carry, or `None` when they carry the empty one. +fn executed_name(chunks: &[EvaluatedChunk]) -> Option { + chunks + .first() + .and_then(|chunk| chunk.series.metric.name().map(|name| name.to_string())) +} + +/// A constant on the right of a multiplication reaches the executor and multiplies every value. +/// +/// The tree is `Binary(Mul, Instant, Scalar(100))`, which the compiler binds and the runtime runs as +/// a broadcast: the chunk `x` produces is transformed as it arrives, and the answer keeps `x`'s +/// positions. +#[test] +fn a_compiled_constant_multiplies_the_vector_it_is_paired_with() { + let (timestamps, values) = samples_at_every_grid_point(); + let out = execute_compiled("x * 100", vec![raw_x(×tamps, values, true)]); + + let expected: Vec<(usize, f64)> = (0..21) + .map(|index| (index, (index as f64 + 1.0) * 100.0)) + .collect(); + assert_eq!( + rows(&out), + expected, + "`x * 100` did not multiply every value" + ); + // Arithmetic drops the metric name, as Prometheus does. + assert_eq!(executed_name(&out), None, "`x * 100` kept the metric name"); +} + +/// A constant on the left keeps the operand order: `100 / x` is not `x / 100`. +#[test] +fn a_compiled_constant_on_the_left_keeps_the_operand_order() { + let (timestamps, values) = samples_at_every_grid_point(); + let divided = rows(&execute_compiled( + "100 / x", + vec![raw_x(×tamps, values.clone(), true)], + )); + let expected: Vec<(usize, f64)> = (0..21) + .map(|index| (index, 100.0 / (index as f64 + 1.0))) + .collect(); + assert_eq!(divided, expected, "`100 / x` did not divide by the vector"); + + // The other order is a different query, and it produces different numbers: a runtime that + // swapped the operands would answer this one with the first one's values. + let other = rows(&execute_compiled( + "x / 100", + vec![raw_x(×tamps, values, true)], + )); + assert_ne!(divided, other, "`100 / x` and `x / 100` agree"); +} + +/// `x > bool 0` reports one per value that passes, rather than filtering the vector. +#[test] +fn a_compiled_bool_comparison_reports_one_and_zero() { + let (timestamps, _) = samples_at_every_grid_point(); + // Half the values are negative, so the comparison decides both ways. + let values = (1..=21) + .map(|value| Some(f64::from(value) - 10.0)) + .collect(); + let out = execute_compiled("x > bool 0", vec![raw_x(×tamps, values, true)]); + + let expected: Vec<(usize, f64)> = (0..21) + .map(|index| { + let value = index as f64 + 1.0 - 10.0; + (index, f64::from(u8::from(value > 0.0))) + }) + .collect(); + assert_eq!(rows(&out), expected, "`x > bool 0` did not report 0/1"); + assert_eq!( + executed_name(&out), + None, + "`x > bool 0` kept the metric name" + ); +} + +/// Comparisons with constant operands are folded before the executor runs the vector operation. +/// These queries exercise the compiled tree through the native executor, including scalar broadcast, +/// unary negation, comparison results, label dropping, and the one closing output chunk. +#[test] +fn a_compiled_bool_constant_comparison_runs_with_vector_arithmetic() { + let (timestamps, _) = samples_at_every_grid_point(); + let values: Vec> = [-2.0, 0.0, 2.0] + .into_iter() + .cycle() + .take(timestamps.len()) + .map(Some) + .collect(); + let cases = [ + ("x * (1 < bool 2)", [-2.0, 0.0, 2.0]), + ("x * (1 > bool 2)", [0.0, 0.0, 0.0]), + ("-(1 < bool 2) + x", [-3.0, -1.0, 1.0]), + ("x > bool (1 == bool 1)", [0.0, 0.0, 1.0]), + ]; + + for (query, pattern) in cases { + let out = execute_compiled(query, vec![raw_x(×tamps, values.clone(), true)]); + let expected: Vec<(usize, f64)> = pattern + .into_iter() + .cycle() + .take(timestamps.len()) + .enumerate() + .collect(); + assert_eq!(rows(&out), expected, "`{query}` returned wrong rows"); + assert_eq!(executed_name(&out), None, "`{query}` kept the metric name"); + assert_eq!(out.len(), 1, "`{query}` did not produce one output chunk"); + assert!(out[0].is_last, "`{query}`'s output did not close"); + } +} + +/// `x > 0` filters, so it keeps the labels it read and drops the positions that failed. +#[test] +fn a_compiled_filtering_comparison_keeps_the_values_that_passed() { + let (timestamps, _) = samples_at_every_grid_point(); + let values = (1..=21) + .map(|value| Some(f64::from(value) - 10.0)) + .collect(); + let out = execute_compiled("x > 0", vec![raw_x(×tamps, values, true)]); + + let expected: Vec<(usize, f64)> = (0..21) + .filter_map(|index| { + let value = index as f64 + 1.0 - 10.0; + (value > 0.0).then_some((index, value)) + }) + .collect(); + assert_eq!(rows(&out), expected, "`x > 0` did not filter the vector"); + assert_eq!( + executed_name(&out), + Some("x".to_string()), + "a filtering comparison keeps the labels it read" + ); +} + +/// The negation the compiler binds is the multiplication it means, and it runs that way. +#[test] +fn a_compiled_negation_negates_every_value() { + let (timestamps, values) = samples_at_every_grid_point(); + let out = execute_compiled("-x", vec![raw_x(×tamps, values, true)]); + let expected: Vec<(usize, f64)> = (0..21) + .map(|index| (index, -(index as f64 + 1.0))) + .collect(); + assert_eq!(rows(&out), expected, "`-x` did not negate every value"); +} + +/// A negation over a window negates the window result and drops its metric name. +#[test] +fn a_compiled_negation_of_a_window_negates_the_window_result() { + let (timestamps, values) = samples_at_every_grid_point(); + let input = || vec![raw_x(×tamps, values.clone(), true)]; + + let baseline = rows(&execute_compiled("rate(x[5m])", input())); + assert!( + !baseline.is_empty(), + "the window answered nothing, so this case would prove nothing" + ); + + let out_chunks = execute_compiled("-rate(x[5m])", input()); + let out = rows(&out_chunks); + let expected: Vec<(usize, f64)> = baseline + .iter() + .map(|(position, value)| (*position, -*value)) + .collect(); + assert_eq!( + out, expected, + "`-rate(x[5m])` did not negate the window's answer" + ); + // `rate(x[5m]) * 100` anchors the first result at 0.5, so the rate alone is 0.005 -- the same + // window `(5 + 2.5 + 0) / 5 / 300s` -- and negation flips only its sign. + assert_eq!( + out.first(), + Some(&(1, -0.005)), + "the first negated rate was not the sign-flipped anchor" + ); + assert_eq!( + executed_name(&out_chunks), + None, + "arithmetic drops the metric name" + ); +} + +/// A filtering comparison with a constant on the left keeps the written operand order. +#[test] +fn a_compiled_constant_on_the_left_of_a_comparison_keeps_the_operand_order() { + let (timestamps, values) = samples_at_every_grid_point(); + // This compiles ScalarOperand::Left through the real compiler, not a hand-built tree. + let left = execute_compiled("10 < x", vec![raw_x(×tamps, values.clone(), true)]); + let left_rows = rows(&left); + let expected: Vec<(usize, f64)> = (0..21) + .filter_map(|index| { + let value = index as f64 + 1.0; + (value > 10.0).then_some((index, value)) + }) + .collect(); + assert_eq!( + left_rows, expected, + "`10 < x` did not keep values greater than 10" + ); + assert_eq!( + executed_name(&left), + Some("x".to_string()), + "a filtering comparison keeps the labels it read" + ); + + let right = rows(&execute_compiled( + "x < 10", + vec![raw_x(×tamps, values, true)], + )); + let complementary: Vec<(usize, f64)> = (0..21) + .filter_map(|index| { + let value = index as f64 + 1.0; + (value < 10.0).then_some((index, value)) + }) + .collect(); + assert_eq!( + right, complementary, + "`x < 10` did not keep values below 10" + ); + assert_ne!( + left_rows, right, + "the two operand orders produced the same rows" + ); + assert!(!left_rows.is_empty(), "`10 < x` produced no rows"); + assert!(!right.is_empty(), "`x < 10` produced no rows"); +} + +/// The broadcast composes with a window: `rate(x[5m]) * 100` runs the evaluator and multiplies its +/// answer, rather than replacing it or multiplying the input. +/// +/// The rate itself is not re-derived here. `rate` extrapolates a counter's slope to the whole +/// window, so hand-computing its value would be this test restating a kernel it does not own; the +/// baseline is instead what the same executor answers for the very same input without the +/// multiplication, which is an oracle the broadcast cannot influence. +#[test] +fn a_compiled_broadcast_over_a_window_multiplies_the_window_result() { + let (timestamps, values) = samples_at_every_grid_point(); + let input = || vec![raw_x(×tamps, values.clone(), true)]; + + let baseline = rows(&execute_compiled("rate(x[5m])", input())); + assert!( + !baseline.is_empty(), + "the window answered nothing, so this case would prove nothing" + ); + + let out = rows(&execute_compiled("rate(x[5m]) * 100", input())); + let expected: Vec<(usize, f64)> = baseline + .iter() + .map(|(position, value)| (*position, value * 100.0)) + .collect(); + assert_eq!( + out, expected, + "`rate(x[5m]) * 100` is not the window's answer multiplied by 100" + ); + + // One absolute anchor, so agreement with a baseline that itself regressed cannot pass. A + // counter rising by one every five seconds, over the window `(-295s, 5s]` that holds two + // samples: the second is the evaluation point, so nothing is extrapolated at the end, while the + // 295s of window before the first sample is far past the 5.5s threshold and is replaced by half + // the 5s average interval. `(5 + 2.5 + 0) / 5 / 300s = 0.005`, and `* 100` is 0.5. + assert_eq!( + out.first(), + Some(&(1, 0.5)), + "the first rate was not the extrapolated one the kernel computes" + ); +} + +/// A missing sample and a skipped position stay absent through the broadcast: it pairs values with +/// a constant and never makes one up. +#[test] +fn a_compiled_broadcast_preserves_missing_values_and_skipped_positions() { + // Everything but the sample at 10s and the position at 20s: the first is a position that holds + // a missing sample, the second a position with no sample at all. + let mut timestamps = Vec::new(); + let mut values = Vec::new(); + for index in 0..21 { + if index == 4 { + continue; + } + timestamps.push(index * 5_000); + values.push((index != 2).then_some(index as f64 + 1.0)); + } + + let out = execute_compiled("x * 100", vec![raw_x(×tamps, values, true)]); + let expected: Vec<(usize, f64)> = (0..21) + .filter(|index| *index != 2 && *index != 4) + .map(|index| (index, (index as f64 + 1.0) * 100.0)) + .collect(); + assert_eq!( + rows(&out), + expected, + "the broadcast changed which positions hold a value" + ); + // And the two holes are holes, not zeros: a neighbour is present at both sides of each one. + let positions: Vec = rows(&out) + .into_iter() + .map(|(position, _)| position) + .collect(); + assert!(!positions.contains(&2), "a missing sample became a value"); + assert!(!positions.contains(&4), "a skipped position became a value"); +} + +/// A series delivered in two raw chunks is answered as one series: the rows are right, and the +/// answer closes exactly once. +/// +/// This is not a streaming proof, and cannot be one: the compiler puts an [`NativeExpr::Instant`] +/// under the binary, and a selection reads a whole axis before it decides anything, so it buffers +/// both chunks and answers the series once. What the one output chunk demonstrates is therefore +/// finality rather than incrementality. The executor-level proof of a broadcast's streaming is +/// `src/promql/src/native/execute/tests.rs::a_broadcast_does_not_retain_the_vector_it_reads`, which +/// drives an evaluated source directly: a chunk is answered per chunk read and the reservation +/// stays bounded however long the series is. +#[test] +fn a_compiled_broadcast_answers_a_series_delivered_in_two_chunks() { + let timestamps: Vec = (0..4).map(|index| index * 5_000).collect(); + let first = raw_x(×tamps[..2], vec![Some(1.0), Some(2.0)], false); + let last = raw_x(×tamps[2..], vec![Some(3.0), Some(4.0)], true); + + let out = execute_compiled("x * 100", vec![first, last]); + let expected: Vec<(usize, f64)> = (0..4) + .map(|index| (index, (index as f64 + 1.0) * 100.0)) + .collect(); + assert_eq!( + rows(&out), + expected, + "`x * 100` did not transform every chunk" + ); + // One output chunk, because the selection below it answers a whole series at once: the two raw + // chunks are one series to the selection, so the broadcast is handed one chunk and hands one up. + assert_eq!( + out.len(), + 1, + "the selection answers a whole series at once, so the broadcast saw one chunk" + ); + assert_eq!( + out.iter().filter(|chunk| chunk.is_last).count(), + 1, + "the series closed more or less than once" + ); + assert!( + out.last().expect("an answer").is_last, + "the closing chunk of the input did not close the output" + ); +} + /// `predict_linear` reads its horizon after its range selector, so the number the query wrote after /// the selector is the horizon the kernel extrapolates to. The value is asserted apart from the /// window, because a compiler that bound the horizon on the wrong side of the call would still From f067b252da9097353a898c41523f8e9c84d60b1f Mon Sep 17 00:00:00 2001 From: Dennis Zhuang Date: Thu, 22 Oct 2026 18:31:31 +0800 Subject: [PATCH 18/18] fix(promql): read the grid length from its spec now that timestamps are deferred Signed-off-by: Dennis Zhuang --- src/promql/src/native/execute.rs | 7 +++---- src/promql/src/select/tests.rs | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/promql/src/native/execute.rs b/src/promql/src/native/execute.rs index 0251bcc2861..7046d675377 100644 --- a/src/promql/src/native/execute.rs +++ b/src/promql/src/native/execute.rs @@ -1948,10 +1948,9 @@ fn vector_workspace_bytes(op: &BoundVectorOp, schema: &SeriesSchema, series: &[S }); } BoundVectorOp::Absent { .. } => { - let points = schema - .eval_grid - .as_ref() - .map_or(0, |grid| grid.timestamps().len()); + let points = schema.eval_grid.as_ref().map_or(0, |grid| { + usize::try_from(grid.spec().len).unwrap_or(usize::MAX) + }); return points.saturating_mul(fields.saturating_mul(1 + std::mem::size_of::())); } BoundVectorOp::TopK { .. } diff --git a/src/promql/src/select/tests.rs b/src/promql/src/select/tests.rs index b2492aba577..c8d9d7e2366 100644 --- a/src/promql/src/select/tests.rs +++ b/src/promql/src/select/tests.rs @@ -310,7 +310,7 @@ fn sort_drops_histogram_samples() { )))], ) .unwrap(); - if schema.eval_grid.as_ref().unwrap().timestamps().len() > 1 { + if schema.eval_grid.as_ref().unwrap().spec().len > 1 { builder .push_samples(1, &[Some(GridSample::Float(2.0))]) .unwrap();