diff --git a/src/query/src/promql/error.rs b/src/query/src/promql/error.rs index 3b24d33612d..121b5e81a83 100644 --- a/src/query/src/promql/error.rs +++ b/src/query/src/promql/error.rs @@ -142,6 +142,16 @@ pub enum Error { location: Location, }, + #[snafu(display( + "The subquery child window is outside the representable millisecond range: {}", + timestamp + ))] + SubqueryTimestampOutOfRange { + timestamp: String, + #[snafu(implicit)] + location: Location, + }, + #[snafu(display("Cannot find column {col}"))] ColumnNotFound { col: String, @@ -244,6 +254,7 @@ impl ErrorExt for Error { | ExpectRangeSelector { .. } | ZeroRangeSelector { .. } | InvalidTimeRange { .. } + | SubqueryTimestampOutOfRange { .. } | ColumnNotFound { .. } | FunctionInvalidArgument { .. } | UnsupportedVectorMatch { .. } diff --git a/src/query/src/promql/planner.rs b/src/query/src/promql/planner.rs index 07faf3b53dd..2d52cfb83f7 100644 --- a/src/query/src/promql/planner.rs +++ b/src/query/src/promql/planner.rs @@ -48,7 +48,7 @@ use datafusion::functions_window::row_number::RowNumber; use datafusion::logical_expr::expr::{Alias, ScalarFunction, WindowFunction}; use datafusion::logical_expr::expr_rewriter::normalize_cols; use datafusion::logical_expr::{ - BinaryExpr, Cast, Extension, LogicalPlan, LogicalPlanBuilder, Operator, + BinaryExpr, Cast, EmptyRelation, Extension, LogicalPlan, LogicalPlanBuilder, Operator, ScalarUDF as ScalarUdfDef, WindowFrame, WindowFunctionDefinition, }; use datafusion::prelude as df_prelude; @@ -107,9 +107,10 @@ use crate::promql::error::{ CatalogSnafu, ColumnNotFoundSnafu, DataFusionPlanningSnafu, ExpectRangeSelectorSnafu, FunctionInvalidArgumentSnafu, InvalidDestinationLabelNameSnafu, InvalidRegularExpressionSnafu, InvalidTimeRangeSnafu, MultiFieldsNotSupportedSnafu, MultipleMetricMatchersSnafu, - MultipleVectorSnafu, NoMetricMatcherSnafu, Result, TableNameNotFoundSnafu, - TimeIndexNotFoundSnafu, UnexpectedPlanExprSnafu, UnexpectedTokenSnafu, UnknownTableSnafu, - UnsupportedExprSnafu, UnsupportedMatcherOpSnafu, ValueNotFoundSnafu, ZeroRangeSelectorSnafu, + MultipleVectorSnafu, NoMetricMatcherSnafu, Result, SubqueryTimestampOutOfRangeSnafu, + TableNameNotFoundSnafu, TimeIndexNotFoundSnafu, UnexpectedPlanExprSnafu, UnexpectedTokenSnafu, + UnknownTableSnafu, UnsupportedExprSnafu, UnsupportedMatcherOpSnafu, ValueNotFoundSnafu, + ZeroRangeSelectorSnafu, }; use crate::query_engine::QueryEngineState; @@ -410,9 +411,7 @@ impl PromPlanner { .. } = subquery_expr; - // Prometheus evaluates the inner expression over `(start - offset - range, end - offset]` - // (`subqueryTimeRange`). Shift the inner window back here; `RangeManipulate` maps the - // samples forward again by the same offset. + // Shift the child window back; `RangeManipulate` restores the outer timeline. let offset_ms = match offset { Some(Offset::Pos(duration)) => duration.as_millis() as Millisecond, Some(Offset::Neg(duration)) => -(duration.as_millis() as Millisecond), @@ -423,14 +422,42 @@ impl PromPlanner { if let Some(step) = step { self.ctx.interval = step.as_millis() as _; } + ensure!(self.ctx.interval > 0, ZeroRangeSelectorSnafu); let current_start = self.ctx.start; let current_end = self.ctx.end; - self.ctx.start -= offset_ms + range.as_millis() as i64 - self.ctx.interval; - self.ctx.end -= offset_ms; + // Reject an invalid parent window rather than treating it as empty. + ensure!( + current_start <= current_end, + InvalidTimeRangeSnafu { + start: current_start, + end: current_end, + } + ); + // An empty child needs only its schema; plan it over the valid caller window. + let child_window = Self::subquery_child_window( + current_start, + current_end, + current_interval, + offset_ms, + range.as_millis() as Millisecond, + self.ctx.interval, + )?; + if let Some((child_start, child_end)) = child_window { + self.ctx.start = child_start; + self.ctx.end = child_end; + } let input = self.prom_expr_to_plan(expr, query_engine_state).await?; self.ctx.interval = current_interval; self.ctx.start = current_start; self.ctx.end = current_end; + // Return no rows with the child schema so enclosing fallbacks still work. + let input = match child_window { + Some(_) => input, + None => LogicalPlan::EmptyRelation(EmptyRelation { + produce_one_row: false, + schema: input.schema().clone(), + }), + }; ensure!(!range.is_zero(), ZeroRangeSelectorSnafu); let range_ms = range.as_millis() as _; @@ -1656,6 +1683,47 @@ impl PromPlanner { } } + /// Returns the first absolute step multiple strictly after `start - offset - range` + /// through the last parent evaluation instant minus `offset`. Requires a positive step. + /// Returns `None` for an empty window; an unrepresentable nonempty bound is an error. + fn subquery_child_window( + start: Millisecond, + end: Millisecond, + interval: Millisecond, + offset: Millisecond, + range: Millisecond, + step: Millisecond, + ) -> Result> { + let window_start = i128::from(start) - i128::from(offset) - i128::from(range); + let grid_start = (window_start.div_euclid(i128::from(step)) + 1) * i128::from(step); + let grid_end = if interval > 0 { + let steps = (i128::from(end) - i128::from(start)) / i128::from(interval); + i128::from(start) + steps * i128::from(interval) + } else { + i128::from(end) + } - i128::from(offset); + // Check emptiness before converting bounds to i64. + if grid_start > grid_end { + return Ok(None); + } + let bound = |name: &str, value: i128| { + i64::try_from(value).map_err(|_| { + SubqueryTimestampOutOfRangeSnafu { + timestamp: format!( + "subquery child window {name} = {value} ms (start = {start} ms, \ + end = {end} ms, interval = {interval} ms, offset = {offset} ms, \ + range = {range} ms, step = {step} ms)" + ), + } + .build() + }) + }; + Ok(Some(( + bound("grid start", grid_start)?, + bound("grid end", grid_end)?, + ))) + } + /// The columns that identify one series, which is the series key expected by the PromQL plan /// nodes that hold exactly one series per input batch. fn series_key_columns(&self) -> Vec { diff --git a/src/query/src/promql/planner/test.rs b/src/query/src/promql/planner/test.rs index 215acb85cf9..642255532bd 100644 --- a/src/query/src/promql/planner/test.rs +++ b/src/query/src/promql/planner/test.rs @@ -5134,6 +5134,605 @@ async fn count_over_time_subquery_with_offset() { indie_query_plan_compare(query, expected).await; } +/// Time-varying samples on and off the subquery step grid expose incorrect window folding. +async fn build_subquery_offset_probe_provider() -> DfTableSourceProvider { + let catalog_list = MemoryCatalogManager::with_default_setup(); + let probe_rows = (0..=6_i64) + .map(|step| (step * 10_000, (step + 1) as f64)) + .collect::>(); + let offgrid_rows = (0..6_i64) + .map(|step| (step * 10_000 + 5_000, (step + 1) as f64)) + .collect::>(); + + for (table_id, name, rows) in [ + (7_001_u32, "subquery_offset_probe", &probe_rows), + (7_002_u32, "subquery_offset_offgrid", &offgrid_rows), + ] { + let schema = Arc::new(Schema::new(vec![ + ColumnSchema::new( + "host".to_string(), + ConcreteDataType::string_datatype(), + false, + ), + ColumnSchema::new( + "timestamp".to_string(), + ConcreteDataType::timestamp_millisecond_datatype(), + false, + ) + .with_time_index(true), + ColumnSchema::new( + "val".to_string(), + ConcreteDataType::float64_datatype(), + true, + ), + ])); + let batch = RecordBatch::try_new( + schema.arrow_schema().clone(), + vec![ + Arc::new(StringArray::from(vec!["a"; rows.len()])), + Arc::new(TimestampMillisecondArray::from_iter_values( + rows.iter().map(|(ts, _)| *ts), + )), + Arc::new(Float64Array::from_iter_values( + rows.iter().map(|(_, value)| *value), + )), + ], + ) + .unwrap(); + let backing = GreptimeMemTable::new_with_catalog( + name, + GreptimeRecordBatch::from_df_record_batch(schema.clone(), batch), + table_id, + DEFAULT_CATALOG_NAME.to_string(), + DEFAULT_SCHEMA_NAME.to_string(), + ); + let table_meta = TableMetaBuilder::empty() + .schema(schema) + .primary_key_indices(vec![0]) + .value_indices(vec![2]) + .next_column_id(3) + .build() + .unwrap(); + let table_info = Arc::new( + TableInfoBuilder::default() + .table_id(table_id) + .name(name) + .meta(table_meta) + .build() + .unwrap(), + ); + let table = Arc::new(Table::new( + table_info, + FilterPushDownType::Unsupported, + backing.data_source(), + )); + assert!( + catalog_list + .register_table_sync(RegisterTableRequest { + catalog: DEFAULT_CATALOG_NAME.to_string(), + schema: DEFAULT_SCHEMA_NAME.to_string(), + table_name: name.to_string(), + table_id, + table, + }) + .is_ok() + ); + } + + DfTableSourceProvider::new( + catalog_list, + false, + QueryContext::arc(), + DummyDecoder::arc(), + false, + ) +} + +/// Plans on a seconds-based evaluation grid. +async fn plan_subquery_offset_probe( + query: &str, + start: u64, + end: u64, + step: u64, +) -> Result { + let eval_stmt = EvalStmt { + expr: parser::parse(query).unwrap(), + start: UNIX_EPOCH + Duration::from_secs(start), + end: UNIX_EPOCH + Duration::from_secs(end), + interval: Duration::from_secs(step), + lookback_delta: Duration::from_secs(300), + }; + PromPlanner::stmt_to_plan( + build_subquery_offset_probe_provider().await, + &eval_stmt, + &build_query_engine_state(), + ) + .await +} + +/// Evaluates seconds-based bounds and returns sorted `(timestamp_ms, value)` rows. +async fn run_subquery_offset_probe( + query: &str, + start: u64, + end: u64, + step: u64, +) -> Vec<(i64, f64)> { + let state = build_query_engine_state(); + let plan = plan_subquery_offset_probe(query, start, end, step) + .await + .unwrap(); + + let (_, batches) = execute(plan, &state).await; + let mut rows = Vec::new(); + for batch in &batches { + let timestamp_index = batch + .schema() + .fields() + .iter() + .position(|field| matches!(field.data_type(), ArrowDataType::Timestamp(..))) + .expect("no timestamp column"); + let value_index = batch + .schema() + .fields() + .iter() + .position(|field| field.data_type() == &ArrowDataType::Float64) + .expect("no Float64 value column"); + let timestamps = batch + .column(timestamp_index) + .as_any() + .downcast_ref::() + .expect("timestamp column is not a millisecond timestamp"); + let values = batch + .column(value_index) + .as_any() + .downcast_ref::() + .expect("value column is not Float64"); + rows.extend( + timestamps + .iter() + .zip(values.iter()) + .map(|(timestamp, value)| (timestamp.unwrap(), value.unwrap())), + ); + } + rows.sort_by_key(|(timestamp, _)| *timestamp); + rows +} + +#[tokio::test] +async fn subquery_own_offset_shifts_the_inner_window_but_keeps_evaluation_timestamps() { + assert_eq!( + run_subquery_offset_probe( + "sum_over_time(subquery_offset_probe[20s:10s] offset 30s)", + 45, + 45, + 1 + ) + .await, + vec![(45_000, 3.0)], + ); + assert_eq!( + run_subquery_offset_probe("sum_over_time(subquery_offset_probe[20s:10s])", 15, 15, 1).await, + vec![(15_000, 3.0)], + ); + + assert_eq!( + run_subquery_offset_probe( + "sum_over_time(subquery_offset_probe[20s:10s] offset 30s)", + 30, + 60, + 30 + ) + .await, + vec![(30_000, 1.0), (60_000, 7.0)], + ); + assert_eq!( + run_subquery_offset_probe("sum_over_time(subquery_offset_probe[20s:10s])", 0, 30, 30).await, + vec![(0, 1.0), (30_000, 7.0)], + ); + + // Negative offsets look ahead while retaining outer timestamps. + assert_eq!( + run_subquery_offset_probe( + "sum_over_time(subquery_offset_probe[20s:10s] offset -30s)", + 0, + 30, + 30 + ) + .await, + vec![(0, 7.0), (30_000, 13.0)], + ); + assert_eq!( + run_subquery_offset_probe("sum_over_time(subquery_offset_probe[20s:10s])", 30, 60, 30) + .await, + vec![(30_000, 7.0), (60_000, 13.0)], + ); +} + +/// Expectations match Prometheus v3.14.0. +#[tokio::test] +async fn subquery_inner_points_are_anchored_on_absolute_step_multiples() { + assert_eq!( + run_subquery_offset_probe("sum_over_time(subquery_offset_offgrid[20s:10s])", 45, 45, 1) + .await, + vec![(45_000, 7.0)], + ); + + // An off-grid offset distinguishes absolute anchoring from re-phasing. + assert_eq!( + run_subquery_offset_probe( + "sum_over_time(subquery_offset_offgrid[20s:10s] offset 5s)", + 30, + 30, + 1 + ) + .await, + vec![(30_000, 3.0)], + ); + assert_eq!( + run_subquery_offset_probe( + "sum_over_time(subquery_offset_offgrid[20s:10s] offset 5s)", + 45, + 45, + 1 + ) + .await, + vec![(45_000, 7.0)], + ); + + // The window starts before the first sample. + assert_eq!( + run_subquery_offset_probe( + "sum_over_time(subquery_offset_offgrid[20s:10s] offset 15s)", + 30, + 30, + 1 + ) + .await, + vec![(30_000, 1.0)], + ); + + assert_eq!( + run_subquery_offset_probe( + "sum_over_time(subquery_offset_offgrid[20s:10s] offset -5s)", + 15, + 15, + 1 + ) + .await, + vec![(15_000, 3.0)], + ); + + // The negative grid start exercises Euclidean alignment. + assert_eq!( + run_subquery_offset_probe("sum_over_time(subquery_offset_offgrid[40s:10s])", 15, 15, 1) + .await, + vec![(15_000, 1.0)], + ); + + // Multi-step evaluation starts off the subquery grid. + assert_eq!( + run_subquery_offset_probe( + "sum_over_time(subquery_offset_offgrid[20s:10s])", + 35, + 65, + 30 + ) + .await, + vec![(35_000, 5.0), (65_000, 11.0)], + ); + + // Child points reach no sample at the first evaluation. + assert_eq!( + run_subquery_offset_probe( + "sum_over_time(subquery_offset_offgrid[20s:10s] offset 30s)", + 30, + 60, + 30 + ) + .await, + vec![(60_000, 5.0)], + ); +} + +#[test] +fn subquery_child_window_is_the_child_grid_window() { + let child_window = PromPlanner::subquery_child_window; + // Exclusive lower bound; upper bound is the last parent evaluation step. + assert_eq!( + child_window(30_000, 60_000, 10_000, 0, 20_000, 10_000).unwrap(), + Some((20_000, 60_000)) + ); + assert_eq!( + child_window(45_000, 60_000, 10_000, 0, 20_000, 10_000).unwrap(), + Some((30_000, 55_000)) + ); + assert_eq!( + child_window(25_000, 60_000, 10_000, 0, 20_000, 10_000).unwrap(), + Some((10_000, 55_000)) + ); + assert_eq!( + child_window(35_000, 65_000, 30_000, 0, 20_000, 10_000).unwrap(), + Some((20_000, 65_000)) + ); + assert_eq!( + child_window(0, 55_000, 30_000, 0, 20_000, 10_000).unwrap(), + Some((-10_000, 30_000)) + ); + // A nonempty child window can contribute no parent rows. + assert_eq!( + child_window(35_000, 65_000, 10_000, 0, 5_000, 10_000).unwrap(), + Some((40_000, 65_000)) + ); + // The first child point is past the parent’s last evaluation. + assert_eq!( + child_window(35_000, 44_000, 10_000, 0, 5_000, 10_000).unwrap(), + None + ); + assert_eq!( + child_window(30_000, 30_000, 1_000, 5_000, 20_000, 10_000).unwrap(), + Some((10_000, 25_000)) + ); + assert_eq!( + child_window(15_000, 15_000, 1_000, -5_000, 20_000, 10_000).unwrap(), + Some((10_000, 20_000)) + ); + // Boundaries before the epoch floor towards minus infinity. + assert_eq!( + child_window(-15_000, -15_000, 1_000, 0, 20_000, 10_000).unwrap(), + Some((-30_000, -15_000)) + ); + assert_eq!( + child_window(-45_000, -45_000, 1_000, -10_000, 20_000, 10_000).unwrap(), + Some((-50_000, -35_000)) + ); + // A grid start before the first sample is not clamped to it. + assert_eq!( + child_window(0, 0, 1_000, 0, 20_000, 10_000).unwrap(), + Some((-10_000, 0)) + ); + // A zero parent interval leaves the upper bound unaligned. + assert_eq!( + child_window(0, 55_000, 0, 0, 20_000, 10_000).unwrap(), + Some((-10_000, 55_000)) + ); + + // Only the derived start overflows. + let err = child_window(i64::MIN + 1_000, i64::MIN + 1_000, 1_000, 0, 2_000, 10).unwrap_err(); + assert!( + matches!( + &err, + crate::promql::error::Error::SubqueryTimestampOutOfRange { timestamp, .. } + if timestamp.contains("grid start") && timestamp.contains("-9223372036854776800") + ), + "{err}" + ); + assert_eq!(err.status_code(), StatusCode::InvalidArguments, "{err}"); + // Only the derived end overflows. + let err = child_window(i64::MAX - 1_000, i64::MAX - 1_000, 1, -2_000, 3_000, 10).unwrap_err(); + assert!( + matches!( + &err, + crate::promql::error::Error::SubqueryTimestampOutOfRange { timestamp, .. } + if timestamp.contains("grid end") && timestamp.contains("9223372036854776807") + ), + "{err}" + ); + assert_eq!(err.status_code(), StatusCode::InvalidArguments, "{err}"); + // The wide window is nonempty even though its start overflows. + let err = child_window(i64::MIN, i64::MAX, 10_000, i64::MAX, i64::MAX, 1_000).unwrap_err(); + assert!( + matches!( + &err, + crate::promql::error::Error::SubqueryTimestampOutOfRange { timestamp, .. } + if timestamp.contains("grid start") + ), + "{err}" + ); + // Both derived bounds overflow; report the exact start without clipping. + let err = child_window(i64::MAX - 1_000, i64::MAX - 1_000, 1, i64::MIN, 10, 1).unwrap_err(); + assert!( + matches!( + &err, + crate::promql::error::Error::SubqueryTimestampOutOfRange { timestamp, .. } + if timestamp.contains("grid start") + && timestamp.contains("18446744073709550606") + ), + "{err}" + ); + assert_eq!(err.status_code(), StatusCode::InvalidArguments, "{err}"); + // Check emptiness before converting overflowing bounds. + assert_eq!( + child_window(i64::MAX, i64::MAX, 1_000, i64::MIN, 0, 1_000).unwrap(), + None + ); +} + +#[tokio::test] +async fn subquery_child_window_without_a_step_reports_no_row() { + // The first child point is after the parent’s only evaluation. + assert_eq!( + run_subquery_offset_probe("sum_over_time(subquery_offset_offgrid[5s:10s])", 35, 44, 10) + .await, + vec![], + ); + assert_eq!( + run_subquery_offset_probe( + "sum_over_time(subquery_offset_offgrid[5s:10s]) or vector(1)", + 35, + 44, + 10 + ) + .await, + vec![(35_000, 1.0)], + ); + // Multi-step: every step of the grid reports no child point and falls back. + assert_eq!( + run_subquery_offset_probe( + "sum_over_time(subquery_offset_offgrid[5s:10s]) or vector(1)", + 35, + 65, + 10 + ) + .await, + vec![(35_000, 1.0), (45_000, 1.0), (55_000, 1.0), (65_000, 1.0)], + ); + // A nonempty child grid can yield no parent rows; a longer range does. + assert_eq!( + run_subquery_offset_probe("sum_over_time(subquery_offset_offgrid[5s:10s])", 35, 65, 10) + .await, + vec![], + ); + assert_eq!( + run_subquery_offset_probe( + "sum_over_time(subquery_offset_offgrid[20s:10s])", + 35, + 65, + 10 + ) + .await, + vec![(35_000, 5.0), (45_000, 7.0), (55_000, 9.0), (65_000, 11.0)], + ); + assert_eq!( + run_subquery_offset_probe( + "sum_over_time(subquery_offset_offgrid[20s:10s] offset 5s)", + 35, + 65, + 10 + ) + .await, + vec![(35_000, 5.0), (45_000, 7.0), (55_000, 9.0), (65_000, 11.0)], + ); + // A negative offset makes the short window reach a child point. + assert_eq!( + run_subquery_offset_probe( + "sum_over_time(subquery_offset_offgrid[5s:10s] offset -5s)", + 35, + 44, + 10 + ) + .await, + vec![(35_000, 4.0)], + ); + + // An empty outer window suppresses inner results, but not an outer fallback. + assert_eq!( + run_subquery_offset_probe( + "sum_over_time((sum_over_time(subquery_offset_offgrid[5s:10s]))[5s:10s])", + 35, + 44, + 10 + ) + .await, + vec![], + ); + assert_eq!( + run_subquery_offset_probe( + "sum_over_time((sum_over_time(subquery_offset_offgrid[5s:10s]))[5s:10s]) or vector(1)", + 35, + 44, + 10 + ) + .await, + vec![(35_000, 1.0)], + ); + assert_eq!( + run_subquery_offset_probe( + "sum_over_time((sum_over_time(subquery_offset_offgrid[5s:10s]) or vector(1))[5s:10s])", + 35, + 44, + 10 + ) + .await, + vec![], + ); +} + +#[tokio::test] +async fn subquery_rejects_invalid_range_and_zero_step() { + let err = + plan_subquery_offset_probe("sum_over_time(subquery_offset_offgrid[5s:10s])", 44, 35, 10) + .await + .unwrap_err(); + assert!( + matches!( + &err, + crate::promql::error::Error::InvalidTimeRange { + start: 44_000, + end: 35_000, + .. + } + ), + "{err}" + ); + let err = plan_subquery_offset_probe( + "sum_over_time((sum_over_time(subquery_offset_offgrid[5s:10s]))[5s:10s])", + 44, + 35, + 10, + ) + .await + .unwrap_err(); + assert_eq!(err.status_code(), StatusCode::InvalidArguments, "{err}"); + + // `[5s:0]` parses to zero; planning rejects it. + let err = plan_subquery_offset_probe("sum_over_time(subquery_offset_offgrid[5s:0])", 60, 60, 1) + .await + .unwrap_err(); + assert!( + matches!(&err, crate::promql::error::Error::ZeroRangeSelector { .. }), + "{err}" + ); + // An omitted child step inherits the zero parent step. + let err = plan_subquery_offset_probe("sum_over_time(subquery_offset_offgrid[5s:])", 60, 60, 0) + .await + .unwrap_err(); + assert!( + matches!(&err, crate::promql::error::Error::ZeroRangeSelector { .. }), + "{err}" + ); +} + +/// Rejects nonempty child windows whose derived millisecond bounds are unrepresentable. +#[tokio::test] +async fn subquery_rejects_a_child_window_out_of_the_representable_range() { + // Offset is i64::MAX milliseconds; only the derived start overflows. + let err = plan_subquery_offset_probe( + "sum_over_time(subquery_offset_offgrid[21366776s:10s] offset 292471208y21366775s807ms)", + 0, + 60, + 10, + ) + .await + .unwrap_err(); + assert!( + matches!( + &err, + crate::promql::error::Error::SubqueryTimestampOutOfRange { timestamp, .. } + if timestamp.contains("grid start = -9223372058221550000") + ), + "{err}" + ); + assert_eq!(err.status_code(), StatusCode::InvalidArguments, "{err}"); + // Offset is -i64::MAX milliseconds; only the derived end overflows. + let err = plan_subquery_offset_probe( + "sum_over_time(subquery_offset_offgrid[20s:10s] offset -292471208y21366775s807ms)", + 0, + 60, + 10, + ) + .await + .unwrap_err(); + assert!( + matches!( + &err, + crate::promql::error::Error::SubqueryTimestampOutOfRange { timestamp, .. } + if timestamp.contains("grid end = 9223372036854835807") + ), + "{err}" + ); + assert_eq!(err.status_code(), StatusCode::InvalidArguments, "{err}"); +} + #[tokio::test] async fn test_hash_join() { let mut eval_stmt = EvalStmt { diff --git a/tests/cases/standalone/common/promql/subquery.result b/tests/cases/standalone/common/promql/subquery.result index 61f09258a41..1be8073a541 100644 --- a/tests/cases/standalone/common/promql/subquery.result +++ b/tests/cases/standalone/common/promql/subquery.result @@ -159,7 +159,174 @@ tql eval (30, 30, '1s') predict_linear(subquery_offset_total[20s:10s] offset -30 | 1970-01-01T00:00:30 | 4.0 | a | +---------------------+----------------------------------------------+------+ +-- Offset results match the shifted evaluation while retaining outer timestamps. +-- Verified against Prometheus v3.14.0. +tql eval (30, 60, '30s') sum_over_time(subquery_offset_total[20s:10s] offset 30s); + ++---------------------+----------------------------------+------+ +| ts | prom_sum_over_time(ts_range,val) | host | ++---------------------+----------------------------------+------+ +| 1970-01-01T00:00:30 | 1.0 | a | +| 1970-01-01T00:01:00 | 7.0 | a | ++---------------------+----------------------------------+------+ + +tql eval (0, 30, '30s') sum_over_time(subquery_offset_total[20s:10s]); + ++---------------------+----------------------------------+------+ +| ts | prom_sum_over_time(ts_range,val) | host | ++---------------------+----------------------------------+------+ +| 1970-01-01T00:00:00 | 1.0 | a | +| 1970-01-01T00:00:30 | 7.0 | a | ++---------------------+----------------------------------+------+ + drop table subquery_offset_total; Affected Rows: 0 +-- Off-grid samples expose absolute step anchoring. +create table subquery_offset_offgrid ( + ts timestamp time index, + host string primary key, + val double, +); + +Affected Rows: 0 + +insert into subquery_offset_offgrid values + (5000, 'a', 1), + (15000, 'a', 2), + (25000, 'a', 3), + (35000, 'a', 4), + (45000, 'a', 5), + (55000, 'a', 6); + +Affected Rows: 6 + +tql eval (30, 30, '1s') sum_over_time(subquery_offset_offgrid[20s:10s] offset 5s); + ++---------------------+----------------------------------+------+ +| ts | prom_sum_over_time(ts_range,val) | host | ++---------------------+----------------------------------+------+ +| 1970-01-01T00:00:30 | 3.0 | a | ++---------------------+----------------------------------+------+ + +-- Offset moves the window without re-phasing its step points. +tql eval (30, 30, '1s') sum_over_time(subquery_offset_offgrid[20s:10s]); + ++---------------------+----------------------------------+------+ +| ts | prom_sum_over_time(ts_range,val) | host | ++---------------------+----------------------------------+------+ +| 1970-01-01T00:00:30 | 5.0 | a | ++---------------------+----------------------------------+------+ + +-- The window starts before the first sample. +tql eval (30, 30, '1s') sum_over_time(subquery_offset_offgrid[20s:10s] offset 15s); + ++---------------------+----------------------------------+------+ +| ts | prom_sum_over_time(ts_range,val) | host | ++---------------------+----------------------------------+------+ +| 1970-01-01T00:00:30 | 1.0 | a | ++---------------------+----------------------------------+------+ + +tql eval (15, 15, '1s') sum_over_time(subquery_offset_offgrid[20s:10s] offset -5s); + ++---------------------+----------------------------------+------+ +| ts | prom_sum_over_time(ts_range,val) | host | ++---------------------+----------------------------------+------+ +| 1970-01-01T00:00:15 | 3.0 | a | ++---------------------+----------------------------------+------+ + +-- Multi-step evaluation uses the non-aligned parent timeline. +tql eval (35, 65, '30s') sum_over_time(subquery_offset_offgrid[20s:10s]); + ++---------------------+----------------------------------+------+ +| ts | prom_sum_over_time(ts_range,val) | host | ++---------------------+----------------------------------+------+ +| 1970-01-01T00:00:35 | 5.0 | a | +| 1970-01-01T00:01:05 | 11.0 | a | ++---------------------+----------------------------------+------+ + +-- An evaluation instant whose window reaches no sample has no row (30s), unlike 60s -> 2 + 3 +tql eval (30, 60, '30s') sum_over_time(subquery_offset_offgrid[20s:10s] offset 30s); + ++---------------------+----------------------------------+------+ +| ts | prom_sum_over_time(ts_range,val) | host | ++---------------------+----------------------------------+------+ +| 1970-01-01T00:01:00 | 5.0 | a | ++---------------------+----------------------------------+------+ + +-- No child point yields no row; the enclosing fallback still fires. +tql eval (35, 44, '10s') sum_over_time(subquery_offset_offgrid[5s:10s]); + ++----+----------------------------------+------+ +| ts | prom_sum_over_time(ts_range,val) | host | ++----+----------------------------------+------+ ++----+----------------------------------+------+ + +tql eval (35, 44, '10s') sum_over_time(subquery_offset_offgrid[5s:10s]) or vector(1); + ++---------------------+------+----------------------------------+ +| ts | host | prom_sum_over_time(ts_range,val) | ++---------------------+------+----------------------------------+ +| 1970-01-01T00:00:35 | | 1.0 | ++---------------------+------+----------------------------------+ + +-- A negative offset makes the short window reach a child point. +tql eval (35, 44, '10s') sum_over_time(subquery_offset_offgrid[5s:10s] offset -5s); + ++---------------------+----------------------------------+------+ +| ts | prom_sum_over_time(ts_range,val) | host | ++---------------------+----------------------------------+------+ +| 1970-01-01T00:00:35 | 4.0 | a | ++---------------------+----------------------------------+------+ + +-- An empty outer window suppresses inner results, but not an outer fallback. +tql eval (35, 44, '10s') sum_over_time((sum_over_time(subquery_offset_offgrid[5s:10s]))[5s:10s]); + ++----+---------------------------------------------------------------+------+ +| ts | prom_sum_over_time(ts_range,prom_sum_over_time(ts_range,val)) | host | ++----+---------------------------------------------------------------+------+ ++----+---------------------------------------------------------------+------+ + +tql eval (35, 44, '10s') sum_over_time((sum_over_time(subquery_offset_offgrid[5s:10s]))[5s:10s]) or vector(1); + ++---------------------+------+---------------------------------------------------------------+ +| ts | host | prom_sum_over_time(ts_range,prom_sum_over_time(ts_range,val)) | ++---------------------+------+---------------------------------------------------------------+ +| 1970-01-01T00:00:35 | | 1.0 | ++---------------------+------+---------------------------------------------------------------+ + +-- An aligned instant has a child point in the same short range. +tql eval (30, 30, '1s') sum_over_time(subquery_offset_offgrid[5s:10s]); + ++---------------------+----------------------------------+------+ +| ts | prom_sum_over_time(ts_range,val) | host | ++---------------------+----------------------------------+------+ +| 1970-01-01T00:00:30 | 3.0 | a | ++---------------------+----------------------------------+------+ + +-- A longer range covers child steps on the off-grid parent timeline. +tql eval (35, 65, '10s') sum_over_time(subquery_offset_offgrid[20s:10s]); + ++---------------------+----------------------------------+------+ +| ts | prom_sum_over_time(ts_range,val) | host | ++---------------------+----------------------------------+------+ +| 1970-01-01T00:00:35 | 5.0 | a | +| 1970-01-01T00:00:45 | 7.0 | a | +| 1970-01-01T00:00:55 | 9.0 | a | +| 1970-01-01T00:01:05 | 11.0 | a | ++---------------------+----------------------------------+------+ + +-- Zero step parses here but is invalid for planning. +tql eval (60, 60, '1s') sum_over_time(subquery_offset_offgrid[5s:0]); + +Error: 1004(InvalidArguments), Zero range in range selector + +tql eval (44, 35, '10s') sum_over_time(subquery_offset_offgrid[5s:10s]); + +Error: 1004(InvalidArguments), The end time must be greater than start time, start: 44000, end: 35000 + +drop table subquery_offset_offgrid; + +Affected Rows: 0 + diff --git a/tests/cases/standalone/common/promql/subquery.sql b/tests/cases/standalone/common/promql/subquery.sql index 598aa91a06e..c44e5163b8d 100644 --- a/tests/cases/standalone/common/promql/subquery.sql +++ b/tests/cases/standalone/common/promql/subquery.sql @@ -65,4 +65,67 @@ tql eval (60, 60, '1s') predict_linear(subquery_offset_total[20s:10s] offset 30s tql eval (30, 30, '1s') predict_linear(subquery_offset_total[20s:10s] offset -30s, 0); +-- Offset results match the shifted evaluation while retaining outer timestamps. +-- Verified against Prometheus v3.14.0. +tql eval (30, 60, '30s') sum_over_time(subquery_offset_total[20s:10s] offset 30s); + +tql eval (0, 30, '30s') sum_over_time(subquery_offset_total[20s:10s]); + drop table subquery_offset_total; + +-- Off-grid samples expose absolute step anchoring. +create table subquery_offset_offgrid ( + ts timestamp time index, + host string primary key, + val double, +); + +insert into subquery_offset_offgrid values + (5000, 'a', 1), + (15000, 'a', 2), + (25000, 'a', 3), + (35000, 'a', 4), + (45000, 'a', 5), + (55000, 'a', 6); + +tql eval (30, 30, '1s') sum_over_time(subquery_offset_offgrid[20s:10s] offset 5s); + +-- Offset moves the window without re-phasing its step points. +tql eval (30, 30, '1s') sum_over_time(subquery_offset_offgrid[20s:10s]); + +-- The window starts before the first sample. +tql eval (30, 30, '1s') sum_over_time(subquery_offset_offgrid[20s:10s] offset 15s); + +tql eval (15, 15, '1s') sum_over_time(subquery_offset_offgrid[20s:10s] offset -5s); + +-- Multi-step evaluation uses the non-aligned parent timeline. +tql eval (35, 65, '30s') sum_over_time(subquery_offset_offgrid[20s:10s]); + +-- An evaluation instant whose window reaches no sample has no row (30s), unlike 60s -> 2 + 3 +tql eval (30, 60, '30s') sum_over_time(subquery_offset_offgrid[20s:10s] offset 30s); + +-- No child point yields no row; the enclosing fallback still fires. +tql eval (35, 44, '10s') sum_over_time(subquery_offset_offgrid[5s:10s]); + +tql eval (35, 44, '10s') sum_over_time(subquery_offset_offgrid[5s:10s]) or vector(1); + +-- A negative offset makes the short window reach a child point. +tql eval (35, 44, '10s') sum_over_time(subquery_offset_offgrid[5s:10s] offset -5s); + +-- An empty outer window suppresses inner results, but not an outer fallback. +tql eval (35, 44, '10s') sum_over_time((sum_over_time(subquery_offset_offgrid[5s:10s]))[5s:10s]); + +tql eval (35, 44, '10s') sum_over_time((sum_over_time(subquery_offset_offgrid[5s:10s]))[5s:10s]) or vector(1); + +-- An aligned instant has a child point in the same short range. +tql eval (30, 30, '1s') sum_over_time(subquery_offset_offgrid[5s:10s]); + +-- A longer range covers child steps on the off-grid parent timeline. +tql eval (35, 65, '10s') sum_over_time(subquery_offset_offgrid[20s:10s]); + +-- Zero step parses here but is invalid for planning. +tql eval (60, 60, '1s') sum_over_time(subquery_offset_offgrid[5s:0]); + +tql eval (44, 35, '10s') sum_over_time(subquery_offset_offgrid[5s:10s]); + +drop table subquery_offset_offgrid;