perf(promql): reuse sliding min and max candidates

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
This commit is contained in:
discord9
2026-09-10 12:01:51 +08:00
parent a7ce7486e1
commit e4a835144e
4 changed files with 776 additions and 20 deletions
+147 -1
View File
@@ -37,7 +37,8 @@ use datatypes::arrow::datatypes::{DataType, Field};
use futures::StreamExt;
use promql::extension_plan::RangeManipulate;
use promql::functions::{
Changes, Delta, IDelta, Increase, PredictLinear, QuantileOverTime, Rate, Resets, SumOverTime,
Changes, Delta, IDelta, Increase, MaxOverTime, MinOverTime, PredictLinear, QuantileOverTime,
Rate, Resets, SumOverTime,
};
use promql::range_array::RangeArray;
@@ -228,6 +229,19 @@ fn make_edge_count_input_with_ranges(
]
}
fn make_extrema_input_with_ranges(values: Vec<f64>, ranges: Vec<(u32, u32)>) -> Vec<ColumnarValue> {
let timestamps = Arc::new(TimestampMillisecondArray::from_iter_values(
(0..values.len()).map(|index| index as i64 * 1_000),
));
let values = Arc::new(Float64Array::from(values));
let timestamp_ranges = RangeArray::from_ranges(timestamps, ranges.clone()).unwrap();
let value_ranges = RangeArray::from_ranges(values, ranges).unwrap();
vec![
ColumnarValue::Array(Arc::new(timestamp_ranges.into_dict())),
ColumnarValue::Array(Arc::new(value_ranges.into_dict())),
]
}
fn make_quantile_input(num_points: usize, window_size: u32) -> Vec<ColumnarValue> {
let (ts_range, val_range, _) =
build_sliding_ranges(num_points, window_size, build_default_values(num_points), 0);
@@ -338,6 +352,46 @@ fn assert_edge_count_output(
assert_eq!(actual, expected);
}
fn extrema_oracle(
values: &[f64],
ranges: &[(u32, u32)],
is_better: impl Fn(f64, f64) -> bool,
) -> Vec<Option<f64>> {
ranges
.iter()
.map(|(offset, length)| {
let window = &values[*offset as usize..(*offset + *length) as usize];
let mut extrema = *window.first()?;
for value in &window[1..] {
if is_better(*value, extrema) || extrema.is_nan() {
extrema = *value;
}
}
Some(extrema)
})
.collect()
}
fn assert_extrema_output(
udf: &datafusion::logical_expr::ScalarUDF,
prepared: &PreparedUdfCall,
expected: &[Option<f64>],
) {
let output = invoke_prepared_output(udf, prepared);
let ColumnarValue::Array(output) = output else {
panic!("extrema range UDF must return an array");
};
let output = output.as_any().downcast_ref::<Float64Array>().unwrap();
assert_eq!(output.len(), expected.len());
for (actual, expected) in output.iter().zip(expected) {
match (actual, expected) {
(Some(actual), Some(expected)) => assert_eq!(actual.to_bits(), expected.to_bits()),
(None, None) => {}
(actual, expected) => panic!("expected {expected:?}, got {actual:?}"),
}
}
}
fn bench_range_functions(c: &mut Criterion) {
let mut group = c.benchmark_group("range_fn");
@@ -552,6 +606,97 @@ fn bench_delta_rate_comparison(c: &mut Criterion) {
group.finish();
}
fn bench_extrema_functions(c: &mut Criterion) {
let mut group = c.benchmark_group("extrema_fn");
let num_points = 4_096;
let values = build_gauge_values(num_points);
let min_udf = MinOverTime::scalar_udf();
let max_udf = MaxOverTime::scalar_udf();
let mut backwards_ranges = (0..=num_points - 20)
.step_by(5)
.map(|offset| (offset as u32, 20))
.collect::<Vec<_>>();
backwards_ranges.extend((0..=512).step_by(5).map(|offset| (offset as u32, 20)));
// The last two controls use explicit ranges instead of a regular window/step sweep.
let cases = vec![
(
"w4_step1",
(0..=num_points - 4)
.map(|offset| (offset as u32, 4))
.collect::<Vec<_>>(),
),
(
"w20_step1",
(0..=num_points - 20)
.map(|offset| (offset as u32, 20))
.collect::<Vec<_>>(),
),
(
"w20_step5",
(0..=num_points - 20)
.step_by(5)
.map(|offset| (offset as u32, 20))
.collect::<Vec<_>>(),
),
(
"w240_step1",
(0..=num_points - 240)
.map(|offset| (offset as u32, 240))
.collect::<Vec<_>>(),
),
(
"w240_step240",
(0..=num_points - 240)
.step_by(240)
.map(|offset| (offset as u32, 240))
.collect::<Vec<_>>(),
),
("backwards_reset_rebuild_w20_step5", backwards_ranges),
(
"low_coverage_full_backing_w4",
vec![
(0, 4),
(512, 4),
(1_024, 4),
(1_536, 4),
(2_048, 4),
(2_560, 4),
(3_584, 4),
(4_092, 4),
],
),
];
let functions: [(
&str,
&datafusion::logical_expr::ScalarUDF,
fn(f64, f64) -> bool,
); 2] = [
("min_over_time", &min_udf, |value, extrema| value < extrema),
("max_over_time", &max_udf, |value, extrema| value > extrema),
];
for (case_name, ranges) in cases {
let prepared = PreparedUdfCall::new(make_extrema_input_with_ranges(
values.clone(),
ranges.clone(),
));
for (function_name, udf, is_better) in functions {
assert_extrema_output(udf, &prepared, &extrema_oracle(&values, &ranges, is_better));
group.bench_with_input(
BenchmarkId::new(
format!("{function_name}_{case_name}"),
format!("N{num_points}"),
),
&(),
|b, _| b.iter(|| invoke_prepared(udf, &prepared)),
);
}
}
group.finish();
}
fn bench_edge_count_functions(c: &mut Criterion) {
let mut group = c.benchmark_group("edge_count_fn");
let num_points = 4_096;
@@ -882,5 +1027,6 @@ criterion_group!(
bench_range_functions,
bench_delta_rate_comparison,
bench_edge_count_functions,
bench_extrema_functions,
bench_range_manipulate_wall_time
);
+521 -17
View File
@@ -12,6 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use std::collections::VecDeque;
use std::sync::Arc;
use common_macro::range_fn;
@@ -21,10 +22,10 @@ use datafusion::logical_expr::{ScalarUDF, Volatility};
use datafusion::physical_plan::ColumnarValue;
use datatypes::arrow::array::Array;
use datatypes::arrow::compute;
use datatypes::arrow::datatypes::DataType;
use datatypes::arrow::datatypes::{DataType, TimeUnit};
use crate::functions::{compensated_sum_inc, extract_array};
use crate::range_array::RangeArray;
use crate::functions::{compensated_sum_inc, extract_array, extract_range_dict};
use crate::range_array::{RangeArray, unpack};
/// The average value of all points in the specified interval.
#[range_fn(
@@ -37,12 +38,219 @@ pub fn avg_over_time(_: &TimestampMillisecondArray, values: &Float64Array) -> Op
}
/// The minimum value of all points in the specified interval.
#[range_fn(
name = MinOverTime,
ret = Float64Array,
display_name = prom_min_over_time
)]
pub fn min_over_time(_: &TimestampMillisecondArray, values: &Float64Array) -> Option<f64> {
#[derive(Debug)]
pub struct MinOverTime {}
impl MinOverTime {
pub const fn name() -> &'static str {
"prom_min_over_time"
}
pub fn scalar_udf() -> ScalarUDF {
datafusion_expr::create_udf(
Self::name(),
Self::input_type(),
Self::return_type(),
Volatility::Volatile,
Arc::new(Self::calc) as _,
)
}
fn input_type() -> Vec<DataType> {
min_max_input_type()
}
fn return_type() -> DataType {
Float64Array::new_null(0).data_type().clone()
}
fn calc(input: &[ColumnarValue]) -> Result<ColumnarValue, DataFusionError> {
min_max_over_time_batch(input, true, Self::name())
}
}
/// The maximum value of all points in the specified interval.
#[derive(Debug)]
pub struct MaxOverTime {}
impl MaxOverTime {
pub const fn name() -> &'static str {
"prom_max_over_time"
}
pub fn scalar_udf() -> ScalarUDF {
datafusion_expr::create_udf(
Self::name(),
Self::input_type(),
Self::return_type(),
Volatility::Volatile,
Arc::new(Self::calc) as _,
)
}
fn input_type() -> Vec<DataType> {
min_max_input_type()
}
fn return_type() -> DataType {
Float64Array::new_null(0).data_type().clone()
}
fn calc(input: &[ColumnarValue]) -> Result<ColumnarValue, DataFusionError> {
min_max_over_time_batch(input, false, Self::name())
}
}
fn min_max_input_type() -> Vec<DataType> {
vec![
RangeArray::convert_data_type(DataType::Timestamp(TimeUnit::Millisecond, None)),
RangeArray::convert_data_type(DataType::Float64),
]
}
fn min_max_over_time_batch(
input: &[ColumnarValue],
is_min: bool,
func_name: &str,
) -> Result<ColumnarValue, DataFusionError> {
if input.len() != 2 {
return Err(DataFusionError::Execution(format!(
"{func_name}: expected 2 inputs, found {}",
input.len()
)));
}
let timestamps = extract_range_dict(
&input[0],
func_name,
"timestamp range vector",
&DataType::Timestamp(TimeUnit::Millisecond, None),
)?;
let values = extract_range_dict(
&input[1],
func_name,
"value range vector",
&DataType::Float64,
)?;
let timestamp_keys = timestamps.keys().values();
let value_keys = values.keys().values();
if timestamp_keys.len() != value_keys.len() {
return Err(DataFusionError::Execution(format!(
"{func_name}: timestamp and value ranges should have the same number of windows, found {} and {}",
timestamp_keys.len(),
value_keys.len()
)));
}
let values = values
.values()
.as_any()
.downcast_ref::<Float64Array>()
.ok_or_else(|| {
DataFusionError::Execution(format!(
"{func_name}: expect value range vector values of type Float64"
))
})?;
let mut extrema = VecDeque::new();
let mut latest_nan = None;
let mut previous_window = None;
let mut result = Vec::with_capacity(value_keys.len());
for index in 0..value_keys.len() {
let (_, timestamp_length) = unpack(timestamp_keys[index]);
let (value_offset, value_length) = unpack(value_keys[index]);
if timestamp_length != value_length {
return Err(DataFusionError::Execution(format!(
"{func_name}: timestamp and value ranges have different lengths at window {index}: {timestamp_length} and {value_length}"
)));
}
let start = value_offset as usize;
let end = start + value_length as usize;
let append_start = match previous_window {
Some((previous_start, previous_end))
if start >= previous_start && end >= previous_end =>
{
while extrema
.front()
.is_some_and(|&value_index| value_index < start)
{
extrema.pop_front();
}
if latest_nan.is_some_and(|value_index| value_index < start) {
latest_nan = None;
}
previous_end.max(start)
}
Some(_) | None => {
extrema.clear();
latest_nan = None;
start
}
};
append_min_max_values(
values,
append_start,
end,
is_min,
&mut extrema,
&mut latest_nan,
);
result.push(
extrema
.front()
.map(|&value_index| values.value(value_index))
.or_else(|| latest_nan.map(|value_index| values.value(value_index))),
);
previous_window = Some((start, end));
}
Ok(ColumnarValue::Array(Arc::new(Float64Array::from_iter(
result,
))))
}
fn append_min_max_values(
values: &Float64Array,
start: usize,
end: usize,
is_min: bool,
extrema: &mut VecDeque<usize>,
latest_nan: &mut Option<usize>,
) {
for index in start..end {
if values.is_null(index) {
continue;
}
let value = values.value(index);
if value.is_nan() {
// Keep the latest payload while no non-NaN value is in the window.
*latest_nan = Some(index);
continue;
}
while let Some(&tail_index) = extrema.back() {
let tail_value = values.value(tail_index);
// Strict comparison retains the first equal value, including signed zero.
if if is_min {
value < tail_value
} else {
value > tail_value
} {
extrema.pop_back();
} else {
break;
}
}
extrema.push_back(index);
}
}
#[cfg(test)]
fn min_over_time(_: &TimestampMillisecondArray, values: &Float64Array) -> Option<f64> {
let mut valid_values = values.iter().flatten();
let mut min = valid_values.next()?;
for value in valid_values {
@@ -53,13 +261,8 @@ pub fn min_over_time(_: &TimestampMillisecondArray, values: &Float64Array) -> Op
Some(min)
}
/// The maximum value of all points in the specified interval.
#[range_fn(
name = MaxOverTime,
ret = Float64Array,
display_name = prom_max_over_time
)]
pub fn max_over_time(_: &TimestampMillisecondArray, values: &Float64Array) -> Option<f64> {
#[cfg(test)]
fn max_over_time(_: &TimestampMillisecondArray, values: &Float64Array) -> Option<f64> {
let mut valid_values = values.iter().flatten();
let mut max = valid_values.next()?;
for value in valid_values {
@@ -195,7 +398,308 @@ pub fn stddev_over_time(_: &TimestampMillisecondArray, values: &Float64Array) ->
#[cfg(test)]
mod test {
use super::*;
use crate::functions::test_util::simple_range_udf_runner;
use crate::functions::test_util::{invoke_range_udf, simple_range_udf_runner};
fn assert_option_bits(actual: &[Option<f64>], expected: &[Option<f64>]) {
assert_eq!(actual.len(), expected.len());
for (actual, expected) in actual.iter().zip(expected) {
match (actual, expected) {
(Some(actual), Some(expected)) => assert_eq!(actual.to_bits(), expected.to_bits()),
(None, None) => {}
(actual, expected) => panic!("expected {expected:?}, got {actual:?}"),
}
}
}
fn old_min_max_over_windows(
values: &Float64Array,
ranges: &[(u32, u32)],
is_min: bool,
) -> Vec<Option<f64>> {
ranges
.iter()
.map(|&(offset, length)| {
let values = values.slice(offset as usize, length as usize);
let values = values.as_any().downcast_ref::<Float64Array>().unwrap();
let timestamps = TimestampMillisecondArray::new_null(values.len());
if is_min {
min_over_time(&timestamps, values)
} else {
max_over_time(&timestamps, values)
}
})
.collect()
}
fn run_min_max_udf(
udf: ScalarUDF,
timestamps: RangeArray,
values: RangeArray,
) -> Vec<Option<f64>> {
let result = invoke_range_udf(udf, timestamps, values).unwrap();
extract_array(&result)
.unwrap()
.as_any()
.downcast_ref::<Float64Array>()
.unwrap()
.iter()
.collect()
}
fn slice_range_array(range: RangeArray, offset: usize, length: usize) -> RangeArray {
RangeArray::try_new(range.into_dict().slice(offset, length).to_data().into()).unwrap()
}
fn sliced_float_values(values: Vec<Option<f64>>) -> Float64Array {
let mut backing = vec![Some(1234.0)];
backing.extend(values);
let length = backing.len() - 1;
let backing = Float64Array::from(backing);
backing
.slice(1, length)
.as_any()
.downcast_ref::<Float64Array>()
.unwrap()
.clone()
}
fn min_max_range_inputs(
timestamps: &TimestampMillisecondArray,
values: &Float64Array,
timestamp_ranges: &[(u32, u32)],
value_ranges: &[(u32, u32)],
) -> (RangeArray, RangeArray) {
let timestamp_prefix = [(0, 1)];
let value_prefix = [(0, 1)];
let timestamps = RangeArray::from_ranges(
Arc::new(timestamps.clone()),
timestamp_prefix
.into_iter()
.chain(timestamp_ranges.iter().copied()),
)
.unwrap();
let values = RangeArray::from_ranges(
Arc::new(values.clone()),
value_prefix.into_iter().chain(value_ranges.iter().copied()),
)
.unwrap();
(
slice_range_array(timestamps, 1, timestamp_ranges.len()),
slice_range_array(values, 1, value_ranges.len()),
)
}
fn assert_min_max_udfs_match_oracle(
timestamp_values: &TimestampMillisecondArray,
all_values: &Float64Array,
timestamp_ranges: &[(u32, u32)],
value_ranges: &[(u32, u32)],
) {
let expected_min = old_min_max_over_windows(all_values, value_ranges, true);
let expected_max = old_min_max_over_windows(all_values, value_ranges, false);
let (timestamps, values) =
min_max_range_inputs(timestamp_values, all_values, timestamp_ranges, value_ranges);
assert_option_bits(
&run_min_max_udf(MinOverTime::scalar_udf(), timestamps, values),
&expected_min,
);
let (timestamps, values) =
min_max_range_inputs(timestamp_values, all_values, timestamp_ranges, value_ranges);
assert_option_bits(
&run_min_max_udf(MaxOverTime::scalar_udf(), timestamps, values),
&expected_max,
);
}
#[test]
fn min_max_over_time_batch_preserves_bits_across_irregular_windows() {
let first_nan = f64::from_bits(0x7ff8_0000_0000_00a1);
let second_nan = f64::from_bits(0x7ff8_0000_0000_00b2);
let third_nan = f64::from_bits(0x7ff8_0000_0000_00c3);
let values = sliced_float_values(vec![
None,
Some(first_nan),
Some(-0.0),
Some(0.0),
Some(2.0),
Some(2.0),
Some(f64::INFINITY),
Some(f64::NEG_INFINITY),
Some(second_nan),
None,
Some(3.0),
Some(third_nan),
]);
let timestamps = TimestampMillisecondArray::from_iter((0..16).map(Some));
let value_ranges = [
(0, 0),
(0, 2),
(0, 4),
(1, 4),
(4, 4),
(4, 4),
(8, 2),
(5, 2),
(3, 3),
(11, 1),
];
let timestamp_ranges = [
(8, 0),
(7, 2),
(6, 4),
(5, 4),
(4, 4),
(4, 4),
(3, 2),
(2, 2),
(1, 3),
(0, 1),
];
assert_min_max_udfs_match_oracle(&timestamps, &values, &timestamp_ranges, &value_ranges);
}
#[test]
fn min_max_over_time_batch_preserves_first_signed_zero_in_both_orders() {
let timestamps = TimestampMillisecondArray::from(vec![0, 1, 2, 3]);
for (values, expected) in [
(Float64Array::from(vec![-0.0, 0.0]), -0.0),
(Float64Array::from(vec![0.0, -0.0]), 0.0),
] {
let (timestamp_ranges, value_ranges) =
min_max_range_inputs(&timestamps, &values, &[(2, 2)], &[(0, 2)]);
assert_option_bits(
&run_min_max_udf(MinOverTime::scalar_udf(), timestamp_ranges, value_ranges),
&[Some(expected)],
);
let (timestamp_ranges, value_ranges) =
min_max_range_inputs(&timestamps, &values, &[(2, 2)], &[(0, 2)]);
assert_option_bits(
&run_min_max_udf(MaxOverTime::scalar_udf(), timestamp_ranges, value_ranges),
&[Some(expected)],
);
}
}
#[test]
fn min_max_over_time_batch_rebuilds_after_right_bound_retreat() {
let timestamps = TimestampMillisecondArray::from(vec![0, 1, 2]);
let ranges = [(0, 3), (0, 2)];
assert_min_max_udfs_match_oracle(
&timestamps,
&Float64Array::from(vec![3.0, 2.0, 1.0]),
&ranges,
&ranges,
);
assert_min_max_udfs_match_oracle(
&timestamps,
&Float64Array::from(vec![1.0, 2.0, 3.0]),
&ranges,
&ranges,
);
}
#[test]
fn min_max_over_time_batch_returns_null_after_expiring_last_nan() {
let nan = f64::from_bits(0x7ff8_0000_0000_00d4);
let timestamps = TimestampMillisecondArray::from(vec![0, 1]);
let values = Float64Array::from(vec![Some(nan), None]);
let ranges = [(0, 1), (1, 1)];
let (timestamp_ranges, value_ranges) =
min_max_range_inputs(&timestamps, &values, &ranges, &ranges);
assert_option_bits(
&run_min_max_udf(MinOverTime::scalar_udf(), timestamp_ranges, value_ranges),
&[Some(nan), None],
);
let (timestamp_ranges, value_ranges) =
min_max_range_inputs(&timestamps, &values, &ranges, &ranges);
assert_option_bits(
&run_min_max_udf(MaxOverTime::scalar_udf(), timestamp_ranges, value_ranges),
&[Some(nan), None],
);
}
#[test]
fn min_max_over_time_batch_matches_scalar_oracle_for_random_windows() {
let values = sliced_float_values(
(0..64)
.map(|index| match index % 11 {
0 => None,
1 => Some(f64::from_bits(0x7ff8_0000_0000_0100 + index)),
2 => Some(-0.0),
3 => Some(0.0),
4 => Some(f64::INFINITY),
5 => Some(f64::NEG_INFINITY),
_ => Some((index as f64 * 17.0).sin()),
})
.collect(),
);
let timestamps = TimestampMillisecondArray::from_iter((0..128).map(Some));
let mut seed = 0x5eed_u64;
let mut value_ranges = Vec::new();
let mut timestamp_ranges = Vec::new();
for _ in 0..256 {
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
let start = (seed as usize) % values.len();
let length = ((seed >> 32) as usize) % (values.len() - start + 1);
value_ranges.push((start as u32, length as u32));
timestamp_ranges.push(((values.len() - start) as u32, length as u32));
}
assert_min_max_udfs_match_oracle(&timestamps, &values, &timestamp_ranges, &value_ranges);
}
#[test]
fn min_max_over_time_batch_validates_inputs() {
let error = MinOverTime::calc(&[]).unwrap_err();
assert!(error.to_string().contains("expected 2 inputs, found 0"));
let timestamps =
RangeArray::from_ranges(Arc::new(TimestampMillisecondArray::from(vec![0])), [(0, 1)])
.unwrap();
let error = MaxOverTime::calc(&[
ColumnarValue::Array(Arc::new(timestamps.into_dict())),
ColumnarValue::Array(Arc::new(datatypes::arrow::array::Int64Array::from(vec![1]))),
])
.unwrap_err();
assert!(
error
.to_string()
.contains("expect value range vector as DictionaryArray<Int64>")
);
let null_keys = datatypes::arrow::array::Int64Array::from_iter([Some(0), None]);
let null_key_dict = datatypes::arrow::array::DictionaryArray::<
datatypes::arrow::datatypes::Int64Type,
>::try_new(
null_keys,
Arc::new(TimestampMillisecondArray::from(vec![0, 1])),
)
.unwrap();
let error = MinOverTime::calc(&[
ColumnarValue::Array(Arc::new(null_key_dict)),
ColumnarValue::Array(Arc::new(datatypes::arrow::array::Int64Array::from(vec![1]))),
])
.unwrap_err();
assert!(error.to_string().contains("Empty range is not expected"));
}
#[test]
fn min_max_over_time_batch_rejects_mismatched_windows() {
let timestamps = Arc::new(TimestampMillisecondArray::from(vec![0, 1, 2]));
let values = Arc::new(Float64Array::from(vec![1.0, 2.0, 3.0]));
let timestamp_ranges = RangeArray::from_ranges(timestamps.clone(), [(0, 1)]).unwrap();
let value_ranges = RangeArray::from_ranges(values.clone(), [(0, 1), (1, 1)]).unwrap();
let error = invoke_range_udf(MinOverTime::scalar_udf(), timestamp_ranges, value_ranges)
.unwrap_err();
assert!(error.to_string().contains("same number of windows"));
let timestamp_ranges = RangeArray::from_ranges(timestamps, [(0, 1)]).unwrap();
let value_ranges = RangeArray::from_ranges(values, [(1, 2)]).unwrap();
let error = invoke_range_udf(MaxOverTime::scalar_udf(), timestamp_ranges, value_ranges)
.unwrap_err();
assert!(error.to_string().contains("different lengths at window 0"));
}
fn assert_over_time_value(actual: Option<f64>, expected: Option<f64>) {
match (actual, expected) {
@@ -185,3 +185,27 @@ tql eval (60, 60, '1s') max_over_time(data[2m]);
tql eval (60, 60, '1s') last_over_time(data[2m]);
drop table data;
-- Sliding min/max regression: overlapping 30s windows expire extrema at the
-- left boundary, retain equal extrema, and end with a sparse empty window.
create table moving_extrema (ts timestamp(3) time index, val double, series string primary key);
insert into moving_extrema values
(0, 5::double, 'moving'),
(10000, 1::double, 'moving'),
(20000, 4::double, 'moving'),
(30000, 4::double, 'moving'),
(40000, 2::double, 'moving'),
(60000, 3::double, 'moving');
-- eval range from 20s to 100s min_over_time(moving_extrema[30s]); 90s and 100s are empty.
-- {series="moving"} 1 1 2 2 2 3 3
-- SQLNESS SORT_RESULT 2 1
tql eval (20, 100, '10s') min_over_time(moving_extrema[30s]);
-- eval range from 20s to 100s max_over_time(moving_extrema[30s]); 90s and 100s are empty.
-- {series="moving"} 5 4 4 4 4 3 3
-- SQLNESS SORT_RESULT 2 1
tql eval (20, 100, '10s') max_over_time(moving_extrema[30s]);
drop table moving_extrema;
@@ -2,8 +2,8 @@
#
# 128 series × 780 timestamps at 15s cadence = 99,840 rows. Data starts one
# hour before evaluation; 481 evaluations span two hours. `count_over_time` is
# boundary-sensitive, while `sum_over_time` intentionally includes kernel scan
# work and the plain selector controls unrelated path variance.
# boundary-sensitive. Min/max cover sliding extrema, while `sum_over_time`
# intentionally includes kernel scan work and the plain selector controls unrelated path variance.
[case]
name = "promql_range_boundary"
@@ -99,6 +99,88 @@ iterations = 9
[scenario.queries.thresholds]
max_candidate_latency_regression_pct = 20
# Sliding extrema cover small, representative, and wide overlapping windows.
[[scenario.queries]]
name = "min_over_time_1m"
kind = "tql"
query = "TQL ANALYZE VERBOSE (1704070800, 1704078000, '15s') min_over_time(promql_range_boundary{host=~'host.*'}[1m])"
warmup = 3
iterations = 9
[scenario.queries.thresholds]
max_candidate_latency_regression_pct = 20
[[scenario.queries]]
name = "max_over_time_1m"
kind = "tql"
query = "TQL ANALYZE VERBOSE (1704070800, 1704078000, '15s') max_over_time(promql_range_boundary{host=~'host.*'}[1m])"
warmup = 3
iterations = 9
[scenario.queries.thresholds]
max_candidate_latency_regression_pct = 20
[[scenario.queries]]
name = "min_over_time_5m"
kind = "tql"
query = "TQL ANALYZE VERBOSE (1704070800, 1704078000, '15s') min_over_time(promql_range_boundary{host=~'host.*'}[5m])"
warmup = 3
iterations = 9
[scenario.queries.thresholds]
max_candidate_latency_regression_pct = 20
[[scenario.queries]]
name = "max_over_time_5m"
kind = "tql"
query = "TQL ANALYZE VERBOSE (1704070800, 1704078000, '15s') max_over_time(promql_range_boundary{host=~'host.*'}[5m])"
warmup = 3
iterations = 9
[scenario.queries.thresholds]
max_candidate_latency_regression_pct = 20
[[scenario.queries]]
name = "min_over_time_1h"
kind = "tql"
query = "TQL ANALYZE VERBOSE (1704070800, 1704078000, '15s') min_over_time(promql_range_boundary{host=~'host.*'}[1h])"
warmup = 3
iterations = 9
[scenario.queries.thresholds]
max_candidate_latency_regression_pct = 20
[[scenario.queries]]
name = "max_over_time_1h"
kind = "tql"
query = "TQL ANALYZE VERBOSE (1704070800, 1704078000, '15s') max_over_time(promql_range_boundary{host=~'host.*'}[1h])"
warmup = 3
iterations = 9
[scenario.queries.thresholds]
max_candidate_latency_regression_pct = 20
# A bounded control with a one-hour evaluation step avoids overlapping windows.
[[scenario.queries]]
name = "min_over_time_1h_nonoverlap_control"
kind = "tql"
query = "TQL ANALYZE VERBOSE (1704070800, 1704078000, '1h') min_over_time(promql_range_boundary{host=~'host.*'}[1h])"
warmup = 3
iterations = 9
[scenario.queries.thresholds]
max_candidate_latency_regression_pct = 20
[[scenario.queries]]
name = "max_over_time_1h_nonoverlap_control"
kind = "tql"
query = "TQL ANALYZE VERBOSE (1704070800, 1704078000, '1h') max_over_time(promql_range_boundary{host=~'host.*'}[1h])"
warmup = 3
iterations = 9
[scenario.queries.thresholds]
max_candidate_latency_regression_pct = 20
# Edge-scan kernel candidates: the Phase 3 implementation replaces repeated
# per-window scans with exact edge-prefix counts.
[[scenario.queries]]