mirror of
https://github.com/GreptimeTeam/greptimedb.git
synced 2026-11-14 06:46:56 +00:00
chore: merge narthex into feat/narthex-cross-table
Signed-off-by: Dennis Zhuang <killme2008@gmail.com> # Conflicts: # src/promql/src/extension_plan/fused_series.rs # src/promql/src/extension_plan/fused_series/tests.rs
This commit is contained in:
@@ -27,7 +27,7 @@ use std::io::Cursor;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use common_error::ext::{BoxedError, ErrorExt};
|
||||
use common_error::ext::{BoxedError, ErrorExt, PlainError};
|
||||
use common_error::status_code::StatusCode;
|
||||
use common_recordbatch::DfSendableRecordBatchStream;
|
||||
use datatypes::arrow::array::{
|
||||
@@ -68,8 +68,31 @@ fn labels(host: &str) -> SeriesLabels {
|
||||
|
||||
/// One validated evaluated chunk: `values` on the runs `spans` of the shared test grid.
|
||||
fn series(spans: &[(usize, usize)], values: Vec<Option<f64>>, metric: SeriesLabels) -> Series {
|
||||
series_on(&evaluated_schema(), spans, values, metric)
|
||||
}
|
||||
|
||||
/// An evaluated schema large enough for multi-batch lifecycle fixtures.
|
||||
fn large_evaluated_schema() -> Arc<SeriesSchema> {
|
||||
Arc::new(SeriesSchema {
|
||||
fields: evaluated_schema().fields.clone(),
|
||||
eval_grid: Some(Arc::new(
|
||||
datatypes::series::time::EvalGrid::try_new(
|
||||
datatypes::series::time::GridSpec::try_new(0, 1000, 1600).unwrap(),
|
||||
)
|
||||
.unwrap(),
|
||||
)),
|
||||
})
|
||||
}
|
||||
|
||||
/// One validated evaluated chunk on the supplied schema's grid.
|
||||
fn series_on(
|
||||
schema: &SeriesSchema,
|
||||
spans: &[(usize, usize)],
|
||||
values: Vec<Option<f64>>,
|
||||
metric: SeriesLabels,
|
||||
) -> Series {
|
||||
let time = TimeAxis::from_grid_spans(
|
||||
evaluated_grid(),
|
||||
schema.eval_grid.as_ref().unwrap().clone(),
|
||||
spans
|
||||
.iter()
|
||||
.map(|(start, len)| GridSpan::new(*start, *len))
|
||||
@@ -77,7 +100,7 @@ fn series(spans: &[(usize, usize)], values: Vec<Option<f64>>, metric: SeriesLabe
|
||||
)
|
||||
.unwrap();
|
||||
let fields = SeriesFields::one(Samples::from_float(Float64Array::from(values)));
|
||||
Series::try_new(&evaluated_schema(), metric, time, fields).unwrap()
|
||||
Series::try_new(schema, metric, time, fields).unwrap()
|
||||
}
|
||||
|
||||
/// An evaluated producer stream over the supplied chunks and their finality.
|
||||
@@ -87,6 +110,32 @@ fn source(items: Vec<(Series, bool)>) -> SendableEvaluatedSeriesStream {
|
||||
))
|
||||
}
|
||||
|
||||
/// A counted source whose guard records when the packer releases its upstream stream.
|
||||
fn observed_source(
|
||||
items: Vec<Result<(Series, bool), BoxedError>>,
|
||||
polls: Arc<std::sync::atomic::AtomicUsize>,
|
||||
dropped: Arc<std::sync::atomic::AtomicBool>,
|
||||
) -> SendableEvaluatedSeriesStream {
|
||||
struct DropGuard(Arc<std::sync::atomic::AtomicBool>);
|
||||
impl Drop for DropGuard {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(true, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
Box::pin(futures_util::stream::unfold(
|
||||
(items.into_iter(), DropGuard(dropped)),
|
||||
move |(mut items, guard)| {
|
||||
let polls = polls.clone();
|
||||
async move {
|
||||
polls.fetch_add(1, Ordering::SeqCst);
|
||||
let item = items.next()?;
|
||||
Some((item, (items, guard)))
|
||||
}
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
/// Packs the supplied chunks, keeping the stream so a test can observe its errors.
|
||||
fn pack(items: Vec<(Series, bool)>) -> DfSendableRecordBatchStream {
|
||||
pack_evaluated_series(source(items), evaluated_schema()).unwrap()
|
||||
@@ -330,6 +379,102 @@ fn single_position_spans(start: usize, count: usize) -> Vec<(usize, usize)> {
|
||||
(0..count).map(|offset| (start + offset, 1)).collect()
|
||||
}
|
||||
|
||||
/// The evaluated packer drains one large sparse chunk in bounded batches without re-polling upstream.
|
||||
#[tokio::test]
|
||||
async fn evaluated_pack_drains_large_sparse_chunk_before_polling_again() {
|
||||
let schema = large_evaluated_schema();
|
||||
let positions: Vec<usize> = (0..1600).step_by(2).collect();
|
||||
let spans: Vec<(usize, usize)> = positions.iter().map(|position| (*position, 1)).collect();
|
||||
let expected: Vec<Option<f64>> = (0..positions.len())
|
||||
.map(|index| Some(index as f64))
|
||||
.collect();
|
||||
let chunk = series_on(&schema, &spans, expected.clone(), labels("large"));
|
||||
let polls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let dropped = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let upstream = observed_source(vec![Ok((chunk, true))], polls.clone(), dropped.clone());
|
||||
let mut stream = pack_evaluated_series(upstream, schema.clone()).unwrap();
|
||||
let mut batches = Vec::new();
|
||||
let mut batch = stream.next().await.unwrap().unwrap();
|
||||
assert_eq!(batch.num_rows(), SeriesFrameBuilder::DEFAULT_MAX_ROWS);
|
||||
batches.push(batch);
|
||||
assert_eq!(polls.load(Ordering::SeqCst), 1);
|
||||
for _ in 0..2 {
|
||||
batch = stream.next().await.unwrap().unwrap();
|
||||
assert_eq!(batch.num_rows(), SeriesFrameBuilder::DEFAULT_MAX_ROWS);
|
||||
batches.push(batch);
|
||||
assert_eq!(polls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
while let Some(batch) = stream.next().await {
|
||||
batches.push(batch.unwrap());
|
||||
}
|
||||
assert!(batches.len() > 1);
|
||||
assert!(dropped.load(Ordering::SeqCst));
|
||||
assert!(stream.next().await.is_none());
|
||||
assert!(stream.next().await.is_none());
|
||||
let declared = batches[0].schema();
|
||||
let (source, _, _) = df_source(batches, declared);
|
||||
let rows = drain(unpack_evaluated_series(source, Some(schema)).unwrap()).await;
|
||||
let (actual_positions, actual_values) = flat(&rows);
|
||||
assert_eq!(actual_positions, positions);
|
||||
assert_float_payload_eq(&actual_values, &expected, "large sparse values");
|
||||
assert_eq!(rows.iter().filter(|(_, last)| *last).count(), 1);
|
||||
assert!(rows.last().unwrap().1);
|
||||
}
|
||||
|
||||
/// Dropping a partially drained output releases both upstream and buffered label ownership.
|
||||
#[tokio::test]
|
||||
async fn evaluated_pack_drop_releases_unfinished_large_chunk() {
|
||||
let identity: Arc<str> = Arc::from("drop-aware");
|
||||
let weak = Arc::downgrade(&identity);
|
||||
let metric = SeriesLabels::try_new(Some(identity.clone()), vec![]).unwrap();
|
||||
let schema = large_evaluated_schema();
|
||||
let positions: Vec<usize> = (0..1600).step_by(2).collect();
|
||||
let spans: Vec<(usize, usize)> = positions.iter().map(|position| (*position, 1)).collect();
|
||||
let chunk = series_on(&schema, &spans, vec![Some(1.0); spans.len()], metric);
|
||||
drop(identity);
|
||||
let polls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let dropped = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let upstream = observed_source(vec![Ok((chunk, true))], polls, dropped.clone());
|
||||
let mut stream = pack_evaluated_series(upstream, schema).unwrap();
|
||||
assert!(stream.next().await.unwrap().unwrap().num_rows() > 0);
|
||||
assert!(!dropped.load(Ordering::SeqCst), "output is unfinished");
|
||||
assert!(weak.upgrade().is_some(), "buffered output retains labels");
|
||||
drop(stream);
|
||||
assert!(dropped.load(Ordering::SeqCst));
|
||||
assert!(
|
||||
weak.upgrade().is_none(),
|
||||
"drop releases buffered label owner"
|
||||
);
|
||||
}
|
||||
|
||||
/// A source error discards buffered success rather than publishing it before the error.
|
||||
#[tokio::test]
|
||||
async fn evaluated_pack_error_discards_buffer_and_releases_source() {
|
||||
let identity: Arc<str> = Arc::from("error-owner");
|
||||
let weak = Arc::downgrade(&identity);
|
||||
let metric = SeriesLabels::try_new(Some(identity.clone()), vec![]).unwrap();
|
||||
let chunk = series(&[(0, 1)], vec![Some(1.0)], metric);
|
||||
drop(identity);
|
||||
let error = BoxedError::new(PlainError::new(
|
||||
"source failed".into(),
|
||||
StatusCode::Unsupported,
|
||||
));
|
||||
let polls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let dropped = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let upstream = observed_source(
|
||||
vec![Ok((chunk, false)), Err(error)],
|
||||
polls.clone(),
|
||||
dropped.clone(),
|
||||
);
|
||||
let mut stream = pack_evaluated_series(upstream, evaluated_schema()).unwrap();
|
||||
assert!(stream.next().await.unwrap().is_err());
|
||||
assert!(dropped.load(Ordering::SeqCst));
|
||||
assert_eq!(polls.load(Ordering::SeqCst), 2);
|
||||
assert!(weak.upgrade().is_none());
|
||||
assert!(stream.next().await.is_none());
|
||||
assert!(stream.next().await.is_none());
|
||||
}
|
||||
|
||||
/// Rows split across batches keep their order and finality: every position appears once and in
|
||||
/// order, and only the last row of the source's final chunk closes the chain.
|
||||
#[tokio::test]
|
||||
@@ -411,6 +556,75 @@ async fn consecutive_chains_with_equal_labels_each_close_independently() {
|
||||
assert_eq!(values(&rows[1].0), vec![Some(3.0), Some(4.0)]);
|
||||
}
|
||||
|
||||
/// Malformed chunks are rejected before buffered rows escape, and terminal failures release the
|
||||
/// producer and any label owners held by its chunks.
|
||||
#[tokio::test]
|
||||
async fn evaluated_pack_rejects_malformed_sequences_before_publishing() {
|
||||
let cases = [
|
||||
(
|
||||
"identity changed",
|
||||
vec![
|
||||
(series(&[(0, 1)], vec![Some(1.0)], labels("first")), false),
|
||||
(series(&[(1, 1)], vec![Some(2.0)], labels("other")), true),
|
||||
],
|
||||
"identity changed",
|
||||
),
|
||||
(
|
||||
"overlap",
|
||||
vec![
|
||||
(
|
||||
series(&[(1, 2)], vec![Some(1.0), Some(2.0)], labels("same")),
|
||||
false,
|
||||
),
|
||||
(series(&[(2, 1)], vec![Some(3.0)], labels("same")), true),
|
||||
],
|
||||
"positions are not increasing",
|
||||
),
|
||||
(
|
||||
"backward",
|
||||
vec![
|
||||
(series(&[(3, 1)], vec![Some(1.0)], labels("same")), false),
|
||||
(series(&[(1, 1)], vec![Some(2.0)], labels("same")), true),
|
||||
],
|
||||
"positions are not increasing",
|
||||
),
|
||||
];
|
||||
for (name, mut items, expected_error) in cases {
|
||||
let owner: Arc<str> = Arc::from("tracked-label-owner");
|
||||
let weak = Arc::downgrade(&owner);
|
||||
if name != "identity changed" {
|
||||
let metric_name = items[0].0.metric.name().map(Arc::<str>::from);
|
||||
let tracked = SeriesLabels::try_new(
|
||||
metric_name,
|
||||
vec![Label {
|
||||
name: Arc::from("tracked"),
|
||||
value: owner.clone(),
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
for (chunk, _) in &mut items {
|
||||
chunk.metric = tracked.clone();
|
||||
}
|
||||
}
|
||||
drop(owner);
|
||||
let polls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let dropped = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let upstream = observed_source(items.into_iter().map(Ok).collect(), polls, dropped.clone());
|
||||
let mut stream = pack_evaluated_series(upstream, evaluated_schema()).unwrap();
|
||||
let error = stream.next().await.unwrap().unwrap_err();
|
||||
assert!(
|
||||
error.to_string().contains(expected_error),
|
||||
"{name}: {error}"
|
||||
);
|
||||
assert!(dropped.load(Ordering::SeqCst), "{name}: source dropped");
|
||||
if name != "identity changed" {
|
||||
assert!(weak.upgrade().is_none(), "{name}: label owner released");
|
||||
}
|
||||
assert!(stream.next().await.is_none(), "{name}: stable EOF");
|
||||
assert!(stream.next().await.is_none(), "{name}: repeated EOF");
|
||||
}
|
||||
}
|
||||
|
||||
/// A chain still open at EOF is an error on both sides: a producer that never closed a series cannot
|
||||
/// finish, and a receiver cannot pretend the stream ended cleanly.
|
||||
#[tokio::test]
|
||||
|
||||
@@ -187,7 +187,8 @@ impl AggregateFinalize {
|
||||
/// its binding: its `Utf8` columns are the labels it writes, and its `Float64` columns name the
|
||||
/// merged fields by position, since the merge folds the cut aggregate's fields in the order the
|
||||
/// statement declares them. Building rejects an input that is not state, a suffix the runtime
|
||||
/// does not execute and a schema [`classify_output`] cannot convert.
|
||||
/// does not execute (the supported suffix operations are maps, aggregates, TopK and
|
||||
/// constant-sided binary operations) and a schema [`classify_output`] cannot convert.
|
||||
pub fn to_execution_plan(
|
||||
&self,
|
||||
input: Arc<dyn ExecutionPlan>,
|
||||
@@ -346,12 +347,18 @@ struct Split {
|
||||
|
||||
/// Splits the innermost aggregate out of `node`.
|
||||
///
|
||||
/// `Ok(None)` is a subtree holding no aggregate. Only a map, an aggregate and a binary operation with
|
||||
/// a constant on one side may sit above the cut: a map is point-wise and so is the same operation over
|
||||
/// merged values, an aggregate runs over the complete merge, and a constant is broadcast over whatever
|
||||
/// the other side answers - while an instant selection, a window, a vector-wide operation or a binary
|
||||
/// operation between two vectors would read evaluated values, the whole vector or a matched pair,
|
||||
/// shapes the merge does not produce.
|
||||
/// `Ok(None)` is a subtree holding no aggregate. The currently verified suffix operations are maps,
|
||||
/// aggregates, TopK/BottomK and binary operations with a constant on one side. TopK/BottomK run once
|
||||
/// in the suffix over the fully merged aggregate series, per evaluation timestamp, and never in the
|
||||
/// partition-local producer. A map is point-wise and an aggregate runs over the complete merge.
|
||||
/// LimitK is declined because it selects the first `k` series in input order
|
||||
/// ([`crate::select::limitk`]), while this merge does not preserve the storage input order. Other
|
||||
/// vector operations have not yet been verified/supported by this split.
|
||||
///
|
||||
/// An instant selection or window above the cut cannot be applied to the merged values under the
|
||||
/// tree's current binding: the cut output is evaluated, whereas these nodes consume raw samples.
|
||||
/// A binary operation between two vectors would require matching the cut's merged output against a
|
||||
/// vector sibling, but this merge only gathers the cut's state.
|
||||
fn split(node: &NativeNode, fields: &[FieldSpec], time_index: &str) -> DfResult<Option<Split>> {
|
||||
match &node.expr {
|
||||
NativeExpr::Input(_) | NativeExpr::Scalar(_) => Ok(None),
|
||||
@@ -412,6 +419,22 @@ fn split(node: &NativeNode, fields: &[FieldSpec], time_index: &str) -> DfResult<
|
||||
}
|
||||
}
|
||||
}
|
||||
NativeExpr::Vector {
|
||||
input,
|
||||
op: op @ crate::native::plan::BoundVectorOp::TopK { .. },
|
||||
} => {
|
||||
let Some(mut inner) = split(input, fields, time_index)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
inner.suffix = NativeNode {
|
||||
expr: NativeExpr::Vector {
|
||||
input: Box::new(inner.suffix),
|
||||
op: op.clone(),
|
||||
},
|
||||
output: node.output.clone(),
|
||||
};
|
||||
Ok(Some(inner))
|
||||
}
|
||||
NativeExpr::Instant { input, .. }
|
||||
| NativeExpr::Window { input, .. }
|
||||
| NativeExpr::Timestamp { input }
|
||||
@@ -620,9 +643,9 @@ const STRING_CAPACITY: usize = 1024;
|
||||
/// first item rather than a row the error invalidates after it. What the answer holds is charged to
|
||||
/// that stream, which is the only owner it has: the plan answers once.
|
||||
///
|
||||
/// The cut is the innermost aggregate of a tree of inputs, maps and aggregates, so the suffix the
|
||||
/// runtime is asked to build is one of those; [`ExecutionPlan::execute`] builds its state and is
|
||||
/// where a shape the runtime cannot run is refused.
|
||||
/// The suffix above the cut is built from the aggregate's output and may contain maps, aggregates,
|
||||
/// TopK/BottomK or a binary operation with a constant on one side. [`ExecutionPlan::execute`]
|
||||
/// builds its state and is where a shape the runtime cannot run is refused.
|
||||
///
|
||||
/// The plain state-batch input is temporary. The final design consumes series frames directly
|
||||
/// from FusedSeries instead of decoding and rebuilding this intermediate state-row layout.
|
||||
|
||||
@@ -24,6 +24,7 @@ use datafusion::common::DFSchema;
|
||||
use datatypes::series::samples::FieldSpec;
|
||||
use datatypes::series::time::{EvalGrid, GridSpec};
|
||||
|
||||
use crate::native::plan::BoundVectorOp;
|
||||
use crate::vector_match::{BinaryMatching, BinaryOp, Cardinality, FillValues, MatchingLabels};
|
||||
|
||||
/// The statement's grid, which every evaluated chunk is addressed on.
|
||||
@@ -337,14 +338,86 @@ fn a_constant_operand_keeps_the_cut_below_it() {
|
||||
"the merged values enter where the aggregate was: {}",
|
||||
node.suffix
|
||||
);
|
||||
assert!(!node.suffix.to_string().contains("Window"));
|
||||
}
|
||||
}
|
||||
|
||||
fn vector_over(input: NativeNode, op: BoundVectorOp) -> NativeNode {
|
||||
let output = input.output.clone();
|
||||
NativeNode {
|
||||
expr: NativeExpr::Vector {
|
||||
input: Box::new(input),
|
||||
op,
|
||||
},
|
||||
output,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topk_and_bottomk_run_after_the_aggregate_cut() {
|
||||
for top in [true, false] {
|
||||
let aggregate = complete(AggregateKind::Sum, by(&["k0"]))(window());
|
||||
let root = vector_over(
|
||||
aggregate,
|
||||
BoundVectorOp::TopK {
|
||||
k: 10.0,
|
||||
top,
|
||||
group: by(&[]),
|
||||
},
|
||||
);
|
||||
let tree = NativeTree::try_new(root).unwrap();
|
||||
assert!(AggregateFinalize::can_split(&tree, &raw_fields(), "ts"));
|
||||
let node = finalize(tree.root().clone(), output_schema(&["sum(v0)"], &["k0"])).unwrap();
|
||||
let NativeExpr::Vector {
|
||||
input,
|
||||
op: BoundVectorOp::TopK {
|
||||
top: actual_top, ..
|
||||
},
|
||||
} = &node.suffix.root().expr
|
||||
else {
|
||||
panic!("{}", node.suffix);
|
||||
};
|
||||
assert_eq!(top, *actual_top);
|
||||
assert!(
|
||||
!node.suffix.to_string().contains("Window"),
|
||||
"{}",
|
||||
node.suffix
|
||||
matches!(&input.expr, NativeExpr::Input(bound) if bound.source_index == 0 && bound.output.samples().is_some_and(|schema| schema.fields.len() == 1 && schema.fields[0].name.as_ref() == "v0" && schema.eval_grid.as_ref().is_some_and(|actual| actual.spec() == grid().spec())))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn limitk_above_the_cut_is_declined_because_merge_does_not_preserve_input_order() {
|
||||
let error = finalize(
|
||||
vector_over(
|
||||
complete(AggregateKind::Sum, by(&["k0"]))(window()),
|
||||
BoundVectorOp::LimitK {
|
||||
k: 1.0,
|
||||
group: by(&[]),
|
||||
},
|
||||
),
|
||||
output_schema(&["sum(v0)"], &["k0"]),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(error, DataFusionError::NotImplemented(_)),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topk_without_aggregate_is_not_split() {
|
||||
let tree = NativeTree::try_new(vector_over(
|
||||
window(),
|
||||
BoundVectorOp::TopK {
|
||||
k: 10.0,
|
||||
top: true,
|
||||
group: by(&[]),
|
||||
},
|
||||
))
|
||||
.unwrap();
|
||||
assert!(!AggregateFinalize::can_split(&tree, &raw_fields(), "ts"));
|
||||
assert!(split(tree.root(), &raw_fields(), "ts").unwrap().is_none());
|
||||
}
|
||||
|
||||
/// A binary operation above the cut is only the operation over merged values when its other side is a
|
||||
/// constant: two aggregates are two cuts, a vector sibling would have to be matched with the merged
|
||||
/// values by label, and a prefix that pairs two vectors reads the whole vector rather than one series.
|
||||
|
||||
@@ -45,7 +45,8 @@ use std::sync::{Arc, Mutex};
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
use common_query::series_frame::{
|
||||
ensure_frame_matches_label_source, pack_evaluated_series, unpack_raw_series,
|
||||
SendableEvaluatedSeriesStream, ensure_frame_matches_label_source, pack_evaluated_series,
|
||||
unpack_raw_series,
|
||||
};
|
||||
use datafusion::arrow::array::{ArrayRef, Float64Array, StringArray, TimestampMillisecondArray};
|
||||
use datafusion::arrow::buffer::ScalarBuffer;
|
||||
@@ -66,7 +67,6 @@ use datafusion::physical_plan::{
|
||||
Partitioning, PlanProperties, RecordBatchStream, SendableRecordBatchStream,
|
||||
};
|
||||
use datatypes::series::SeriesOrdering;
|
||||
use datatypes::series::frame::{FrameLabelMode, SeriesFrameBuilder, SeriesFrameCursor};
|
||||
use datatypes::series::raw::RawSeriesChunk;
|
||||
use datatypes::series::samples::{DenseSamplesRef, FieldSpec, SampleRef, Samples, SeriesSchema};
|
||||
use datatypes::series::time::EvalGrid;
|
||||
@@ -270,13 +270,15 @@ impl FusedSeries {
|
||||
"FusedSeries SeriesFrame output does not yet support multiple sources".into(),
|
||||
));
|
||||
}
|
||||
let output_schema = if format == FusedOutputFormat::SeriesFrame {
|
||||
Arc::new(datafusion::common::DFSchema::try_from(
|
||||
series_frame_output_schema(evaluated_output(&tree)?.as_ref())?,
|
||||
)?)
|
||||
} else {
|
||||
output_schema
|
||||
};
|
||||
if format == FusedOutputFormat::SeriesFrame {
|
||||
let expected = series_frame_output_schema(evaluated_output(&tree)?.as_ref())?;
|
||||
if output_schema.as_arrow() != expected.as_ref() {
|
||||
return Err(DataFusionError::Plan(
|
||||
"FusedSeries SeriesFrame output schema does not match the evaluated frame layout"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
promql,
|
||||
tree,
|
||||
@@ -1245,27 +1247,7 @@ impl ExecutionPlan for FusedSeriesExec {
|
||||
// values rather than state: partial state is a third output the format does not reach.
|
||||
let frame_mode =
|
||||
self.partial_fold.is_none() && self.format == FusedOutputFormat::SeriesFrame;
|
||||
let eval_schema = if frame_mode {
|
||||
Some(evaluated_output(&self.tree)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let frame_builder = eval_schema
|
||||
.as_ref()
|
||||
.map(|schema| {
|
||||
SeriesFrameBuilder::try_new(
|
||||
schema,
|
||||
FrameLabelMode::Explicit,
|
||||
None,
|
||||
SeriesFrameBuilder::DEFAULT_MAX_ROWS,
|
||||
SeriesFrameBuilder::DEFAULT_MAX_BYTES,
|
||||
)
|
||||
.map_err(|error| {
|
||||
DataFusionError::Plan(format!("cannot create evaluated frame builder: {error}"))
|
||||
})
|
||||
})
|
||||
.transpose()?;
|
||||
Ok(Box::pin(FusedSeriesStream {
|
||||
let stream = FusedSeriesStream {
|
||||
inputs,
|
||||
executor: Some(executor),
|
||||
input_ids,
|
||||
@@ -1273,13 +1255,19 @@ impl ExecutionPlan for FusedSeriesExec {
|
||||
tags: Arc::clone(&self.tags),
|
||||
output_schema: self.output_schema.clone(),
|
||||
partial_fold,
|
||||
frame_builder,
|
||||
eval_schema,
|
||||
frame_cursor: None,
|
||||
frame_mode,
|
||||
_export_budget: Arc::clone(&export_budget),
|
||||
ready: VecDeque::new(),
|
||||
done: false,
|
||||
}))
|
||||
};
|
||||
if frame_mode {
|
||||
let schema = evaluated_output(&self.tree)?;
|
||||
let source: SendableEvaluatedSeriesStream =
|
||||
Box::pin(FusedEvaluatedStream { source: stream });
|
||||
Ok(pack_evaluated_series(source, schema)?)
|
||||
} else {
|
||||
Ok(Box::pin(stream))
|
||||
}
|
||||
}
|
||||
|
||||
fn metrics(&self) -> Option<MetricsSet> {
|
||||
@@ -1396,10 +1384,8 @@ pub struct FusedSeriesStream {
|
||||
output_schema: SchemaRef,
|
||||
/// Per-partition aggregate state fold, if this stream exports a partial result.
|
||||
partial_fold: Option<PartialFold>,
|
||||
frame_builder: Option<SeriesFrameBuilder>,
|
||||
eval_schema: Option<Arc<SeriesSchema>>,
|
||||
/// The evaluated chunk whose frame is still being appended across downstream polls.
|
||||
frame_cursor: Option<SeriesFrameCursor>,
|
||||
/// Whether evaluated chunks are emitted as series frames rather than converted to rows.
|
||||
frame_mode: bool,
|
||||
/// Keeps exported state batches charged for as long as this stream holds them.
|
||||
_export_budget: Arc<Mutex<Option<MemoryReservation>>>,
|
||||
/// The batches the executor settled since the last poll of the input.
|
||||
@@ -1423,7 +1409,7 @@ impl FusedSeriesStream {
|
||||
})? {
|
||||
executor.push_raw(*input_id, chunk.clone())?;
|
||||
}
|
||||
if self.frame_builder.is_some() {
|
||||
if self.frame_mode {
|
||||
Ok(())
|
||||
} else {
|
||||
let mut settled = Vec::new();
|
||||
@@ -1431,7 +1417,7 @@ impl FusedSeriesStream {
|
||||
settled.push(chunk);
|
||||
}
|
||||
for chunk in settled {
|
||||
self.convert(chunk)?;
|
||||
self.convert_rows(chunk)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1481,8 +1467,6 @@ impl FusedSeriesStream {
|
||||
self.done = true;
|
||||
self.executor = None;
|
||||
self.partial_fold = None;
|
||||
self.frame_builder = None;
|
||||
self.frame_cursor = None;
|
||||
self.inputs = None;
|
||||
// This reservation belongs only to this execution. Release it even if the consumer keeps
|
||||
// the failed stream and the plan alive.
|
||||
@@ -1494,62 +1478,19 @@ impl FusedSeriesStream {
|
||||
self.ready.clear();
|
||||
}
|
||||
|
||||
/// Converts one settled chunk into the node's output: query rows, an evaluated series frame,
|
||||
/// or partition-local state for a later aggregate merge.
|
||||
fn convert(&mut self, chunk: EvaluatedChunk) -> DataFusionResult<()> {
|
||||
/// Converts one settled chunk into a row batch. Evaluated frames leave this driver as chunks.
|
||||
fn convert_rows(&mut self, chunk: EvaluatedChunk) -> DataFusionResult<()> {
|
||||
if let Some(fold) = self.partial_fold.as_mut() {
|
||||
fold.update(chunk)?;
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(schema) = self.eval_schema.as_ref()
|
||||
&& self.frame_builder.is_some()
|
||||
{
|
||||
self.frame_cursor = Some(
|
||||
SeriesFrameCursor::try_new_evaluated(schema, chunk.series, chunk.is_last).map_err(
|
||||
|error| {
|
||||
DataFusionError::Execution(format!("cannot frame evaluated chunk: {error}"))
|
||||
},
|
||||
)?,
|
||||
);
|
||||
} else {
|
||||
let batch = series_to_record_batch(
|
||||
&self.classification,
|
||||
&self.tags,
|
||||
&self.output_schema,
|
||||
&chunk,
|
||||
)?;
|
||||
self.ready.push_back(batch);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Appends only until the current evaluated chunk yields one batch.
|
||||
fn advance_frame_cursor(&mut self) -> DataFusionResult<Option<RecordBatch>> {
|
||||
let (Some(cursor), Some(builder)) =
|
||||
(self.frame_cursor.as_mut(), self.frame_builder.as_mut())
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
match cursor.append_to(builder).map_err(|error| {
|
||||
DataFusionError::Execution(format!("cannot append evaluated frame: {error}"))
|
||||
})? {
|
||||
Some(batch) => Ok(Some(batch.with_schema(self.output_schema.clone())?)),
|
||||
None => {
|
||||
self.frame_cursor = None;
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn flush_frames(&mut self) -> DataFusionResult<()> {
|
||||
if let Some(builder) = self.frame_builder.as_mut()
|
||||
&& let Some(batch) = builder.flush().map_err(|error| {
|
||||
DataFusionError::Execution(format!("cannot flush evaluated frame: {error}"))
|
||||
})?
|
||||
{
|
||||
self.ready
|
||||
.push_back(batch.with_schema(self.output_schema.clone())?);
|
||||
}
|
||||
let batch = series_to_record_batch(
|
||||
&self.classification,
|
||||
&self.tags,
|
||||
&self.output_schema,
|
||||
&chunk,
|
||||
)?;
|
||||
self.ready.push_back(batch);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1562,6 +1503,87 @@ impl FusedSeriesStream {
|
||||
}
|
||||
}
|
||||
|
||||
enum FusedOutput {
|
||||
Rows(RecordBatch),
|
||||
Evaluated(EvaluatedChunk),
|
||||
}
|
||||
|
||||
impl FusedSeriesStream {
|
||||
fn poll_output(&mut self, cx: &mut Context<'_>) -> Poll<Option<DataFusionResult<FusedOutput>>> {
|
||||
for _ in 0..POLL_BUDGET {
|
||||
if let Some(batch) = self.ready.pop_front() {
|
||||
return Poll::Ready(Some(Ok(FusedOutput::Rows(batch))));
|
||||
}
|
||||
if self.frame_mode && !self.done {
|
||||
match self
|
||||
.executor
|
||||
.as_mut()
|
||||
.map_or(Ok(None), NativeExecutor::next_chunk)
|
||||
{
|
||||
Ok(Some(chunk)) => return Poll::Ready(Some(Ok(FusedOutput::Evaluated(chunk)))),
|
||||
Ok(None) if self.inputs.is_none() => self.executor = None,
|
||||
Ok(None) => {}
|
||||
Err(error) => {
|
||||
self.fail();
|
||||
return Poll::Ready(Some(Err(error)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.done {
|
||||
return Poll::Ready(None);
|
||||
}
|
||||
let Some(inputs) = self.inputs.as_mut() else {
|
||||
match self.pull_one() {
|
||||
Ok(Some(chunk)) if self.frame_mode => {
|
||||
return Poll::Ready(Some(Ok(FusedOutput::Evaluated(chunk))));
|
||||
}
|
||||
Ok(Some(chunk)) => {
|
||||
if let Err(error) = self.convert_rows(chunk) {
|
||||
self.fail();
|
||||
return Poll::Ready(Some(Err(error)));
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
if let Err(error) = self.finish_fold() {
|
||||
self.fail();
|
||||
return Poll::Ready(Some(Err(error)));
|
||||
}
|
||||
self.done = true;
|
||||
continue;
|
||||
}
|
||||
Err(error) => {
|
||||
self.fail();
|
||||
return Poll::Ready(Some(Err(error)));
|
||||
}
|
||||
}
|
||||
continue;
|
||||
};
|
||||
match Pin::new(inputs).poll_next(cx) {
|
||||
Poll::Pending => return Poll::Pending,
|
||||
Poll::Ready(None) => {
|
||||
self.inputs = None;
|
||||
if let Err(error) = self.close_input() {
|
||||
self.fail();
|
||||
return Poll::Ready(Some(Err(error)));
|
||||
}
|
||||
}
|
||||
Poll::Ready(Some(Err(error))) => {
|
||||
self.fail();
|
||||
return Poll::Ready(Some(Err(DataFusionError::External(Box::new(error)))));
|
||||
}
|
||||
Poll::Ready(Some(Ok((source, chunk)))) => {
|
||||
if let Err(error) = self.push(source, chunk) {
|
||||
self.fail();
|
||||
return Poll::Ready(Some(Err(error)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cx.waker().wake_by_ref();
|
||||
Poll::Pending
|
||||
}
|
||||
}
|
||||
|
||||
impl RecordBatchStream for FusedSeriesStream {
|
||||
fn schema(&self) -> SchemaRef {
|
||||
self.output_schema.clone()
|
||||
@@ -1571,110 +1593,47 @@ impl RecordBatchStream for FusedSeriesStream {
|
||||
impl Stream for FusedSeriesStream {
|
||||
type Item = DataFusionResult<RecordBatch>;
|
||||
|
||||
fn poll_next(
|
||||
mut self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
) -> Poll<Option<DataFusionResult<RecordBatch>>> {
|
||||
let this = &mut *self;
|
||||
// A batch that is already settled is handed over before anything is read, so a bounded
|
||||
// poll never delays one that is ready behind more input.
|
||||
for _ in 0..POLL_BUDGET {
|
||||
if this.frame_cursor.is_some() {
|
||||
match this.advance_frame_cursor() {
|
||||
Ok(Some(batch)) => return Poll::Ready(Some(Ok(batch))),
|
||||
Ok(None) => {}
|
||||
Err(error) => {
|
||||
this.fail();
|
||||
return Poll::Ready(Some(Err(error)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(batch) = this.ready.pop_front() {
|
||||
return Poll::Ready(Some(Ok(batch)));
|
||||
}
|
||||
if !this.done && this.frame_builder.is_some() && this.frame_cursor.is_none() {
|
||||
let next = this
|
||||
.executor
|
||||
.as_mut()
|
||||
.map_or(Ok(None), NativeExecutor::next_chunk);
|
||||
match next {
|
||||
Ok(Some(chunk)) => {
|
||||
if let Err(error) = this.convert(chunk) {
|
||||
this.fail();
|
||||
return Poll::Ready(Some(Err(error)));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Ok(None) => {
|
||||
if this.inputs.is_none() {
|
||||
this.executor = None;
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
this.fail();
|
||||
return Poll::Ready(Some(Err(error)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if this.done {
|
||||
return Poll::Ready(None);
|
||||
}
|
||||
let Some(inputs) = this.inputs.as_mut() else {
|
||||
match this.pull_one() {
|
||||
Ok(Some(chunk)) => {
|
||||
if let Err(error) = this.convert(chunk) {
|
||||
this.fail();
|
||||
return Poll::Ready(Some(Err(error)));
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
if let Err(error) = this.finish_fold().and_then(|()| this.flush_frames()) {
|
||||
this.fail();
|
||||
return Poll::Ready(Some(Err(error)));
|
||||
}
|
||||
this.done = true;
|
||||
continue;
|
||||
}
|
||||
Err(error) => {
|
||||
this.fail();
|
||||
return Poll::Ready(Some(Err(error)));
|
||||
}
|
||||
}
|
||||
continue;
|
||||
};
|
||||
match Pin::new(inputs).poll_next(cx) {
|
||||
Poll::Pending => {
|
||||
if let Err(error) = this.flush_frames() {
|
||||
this.fail();
|
||||
return Poll::Ready(Some(Err(error)));
|
||||
}
|
||||
if let Some(batch) = this.ready.pop_front() {
|
||||
return Poll::Ready(Some(Ok(batch)));
|
||||
}
|
||||
return Poll::Pending;
|
||||
}
|
||||
Poll::Ready(None) => {
|
||||
this.inputs = None;
|
||||
if let Err(error) = this.close_input() {
|
||||
this.fail();
|
||||
return Poll::Ready(Some(Err(error)));
|
||||
}
|
||||
}
|
||||
Poll::Ready(Some(Err(error))) => {
|
||||
this.fail();
|
||||
return Poll::Ready(Some(Err(DataFusionError::External(Box::new(error)))));
|
||||
}
|
||||
Poll::Ready(Some(Ok((source, chunk)))) => {
|
||||
if let Err(error) = this.push(source, chunk) {
|
||||
this.fail();
|
||||
return Poll::Ready(Some(Err(error)));
|
||||
}
|
||||
}
|
||||
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
||||
match self.poll_output(cx) {
|
||||
Poll::Ready(Some(Ok(FusedOutput::Rows(batch)))) => Poll::Ready(Some(Ok(batch))),
|
||||
Poll::Ready(Some(Ok(FusedOutput::Evaluated(_)))) => {
|
||||
self.fail();
|
||||
Poll::Ready(Some(Err(DataFusionError::Internal(
|
||||
"evaluated chunk reached the row stream adapter".into(),
|
||||
))))
|
||||
}
|
||||
Poll::Ready(Some(Err(error))) => Poll::Ready(Some(Err(error))),
|
||||
Poll::Ready(None) => Poll::Ready(None),
|
||||
Poll::Pending => Poll::Pending,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct FusedEvaluatedStream {
|
||||
source: FusedSeriesStream,
|
||||
}
|
||||
|
||||
impl Stream for FusedEvaluatedStream {
|
||||
type Item = Result<(datatypes::series::samples::Series, bool), common_error::ext::BoxedError>;
|
||||
|
||||
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
||||
match Pin::new(&mut self.source).poll_output(cx) {
|
||||
Poll::Ready(Some(Ok(FusedOutput::Evaluated(chunk)))) => {
|
||||
Poll::Ready(Some(Ok((chunk.series, chunk.is_last))))
|
||||
}
|
||||
Poll::Ready(Some(Ok(FusedOutput::Rows(_)))) => {
|
||||
self.source.fail();
|
||||
Poll::Ready(Some(Err(common_error::ext::BoxedError::new(
|
||||
common_query::error::Error::from(DataFusionError::Internal(
|
||||
"row batch reached the evaluated stream adapter".into(),
|
||||
)),
|
||||
))))
|
||||
}
|
||||
Poll::Ready(Some(Err(error))) => Poll::Ready(Some(Err(
|
||||
common_error::ext::BoxedError::new(common_query::error::Error::from(error)),
|
||||
))),
|
||||
Poll::Ready(None) => Poll::Ready(None),
|
||||
Poll::Pending => Poll::Pending,
|
||||
}
|
||||
// The budget ran out on input that settled nothing. Everything read so far is kept, so the
|
||||
// next poll continues where this one stopped; waking first is what asks for that poll.
|
||||
cx.waker().wake_by_ref();
|
||||
Poll::Pending
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
use common_query::series_frame::{unpack_evaluated_series, unpack_raw_series};
|
||||
use common_query::series_frame::unpack_evaluated_series;
|
||||
use datafusion::arrow::array::{Array, Float64Array, StringArray, TimestampMillisecondArray};
|
||||
use datafusion::arrow::buffer::{Buffer, ScalarBuffer};
|
||||
use datafusion::arrow::datatypes::{DataType, Field, Schema, SchemaRef, TimeUnit};
|
||||
@@ -48,9 +48,7 @@ use datafusion::datasource::memory::MemorySourceConfig;
|
||||
use datafusion::datasource::source::DataSourceExec;
|
||||
use datafusion::error::{DataFusionError, Result as DataFusionResult};
|
||||
use datafusion::execution::context::TaskContext;
|
||||
use datafusion::execution::memory_pool::{
|
||||
GreedyMemoryPool, MemoryConsumer, MemoryPool, UnboundedMemoryPool,
|
||||
};
|
||||
use datafusion::execution::memory_pool::{GreedyMemoryPool, MemoryPool, UnboundedMemoryPool};
|
||||
use datafusion::execution::runtime_env::RuntimeEnvBuilder;
|
||||
use datafusion::logical_expr::{EmptyRelation, Expr, LogicalPlan, UserDefinedLogicalNodeCore};
|
||||
use datafusion::physical_expr::{EquivalenceProperties, PhysicalExpr};
|
||||
@@ -69,25 +67,24 @@ use datafusion::prelude::SessionContext;
|
||||
use datatypes::prelude::ConcreteDataType;
|
||||
use datatypes::series::frame::{
|
||||
FrameContent, FrameLabelMode, LABEL_DECODE_VERSION, LabelDecodeColumn, LabelDecodeEncoding,
|
||||
LabelDecodeSpec, LabelDecodeTag, SeriesFrameBuilder, SeriesFrameDeclaration,
|
||||
pack_raw_series_chunk, raw_series_frame_schema, series_frame_schema,
|
||||
LabelDecodeSpec, LabelDecodeTag, SeriesFrameDeclaration, pack_raw_series_chunk,
|
||||
raw_series_frame_schema, series_frame_schema,
|
||||
};
|
||||
use datatypes::series::raw::{RawSeriesChunk, RawTimestamps};
|
||||
use datatypes::series::samples::{FieldSpec, Samples, Series, SeriesFields, SeriesSchema};
|
||||
use datatypes::series::time::{EvalGrid, GridSpan, GridSpec, TimeAxis};
|
||||
use datatypes::series::{Label, SeriesLabels, SeriesOrdering};
|
||||
use futures::{Stream, StreamExt, TryStreamExt};
|
||||
use store_api::region_engine::{RawLabelSource, SendableRawSeriesStream};
|
||||
use store_api::region_engine::RawLabelSource;
|
||||
|
||||
mod frame_output;
|
||||
|
||||
use super::{
|
||||
FusedOutputFormat, FusedSeries, FusedSeriesBinding, FusedSeriesStream, OutputColumn,
|
||||
POLL_BUDGET, series_to_record_batch,
|
||||
FusedOutputFormat, FusedSeries, FusedSeriesBinding, OutputColumn, POLL_BUDGET,
|
||||
series_to_record_batch,
|
||||
};
|
||||
use crate::evaluated::EvaluatedChunk;
|
||||
use crate::extension_plan::aggregate_exchange::state_schema;
|
||||
use crate::extension_plan::series_gather::SeriesGather;
|
||||
use crate::native::execute::{InputId, NativeExecutionState, NativeExecutor};
|
||||
use crate::native::plan::{
|
||||
AggregateKind, AggregateStage, BoundAggregate, BoundInput, BoundInstant, BoundScalar,
|
||||
@@ -535,6 +532,12 @@ fn keyed_frame_schema_of(schema: &SeriesSchema) -> SchemaRef {
|
||||
.expect("key labels are a frame")
|
||||
}
|
||||
|
||||
fn canonical_frame_schema(tree: &NativeTree) -> datafusion::common::DFSchemaRef {
|
||||
let evaluated = super::evaluated_output(tree).unwrap();
|
||||
let schema = super::series_frame_output_schema(evaluated.as_ref()).unwrap();
|
||||
Arc::new(datafusion::common::DFSchema::try_from(schema).unwrap())
|
||||
}
|
||||
|
||||
/// The columns this node produces: the field, the tags and the grid timestamps.
|
||||
fn output_schema() -> SchemaRef {
|
||||
Arc::new(Schema::new(vec![
|
||||
@@ -2573,221 +2576,6 @@ async fn a_ready_batch_is_returned_before_later_input_is_read() {
|
||||
assert!(total > 1, "the fixture has to outlast the first answer");
|
||||
}
|
||||
|
||||
fn direct_frame_stream(
|
||||
schema: Arc<SeriesSchema>,
|
||||
input: Option<SendableRawSeriesStream>,
|
||||
) -> FusedSeriesStream {
|
||||
let output_schema = series_frame_schema(&schema, FrameLabelMode::Explicit, None).unwrap();
|
||||
let builder = SeriesFrameBuilder::try_new(
|
||||
&schema,
|
||||
FrameLabelMode::Explicit,
|
||||
None,
|
||||
SeriesFrameBuilder::DEFAULT_MAX_ROWS,
|
||||
SeriesFrameBuilder::DEFAULT_MAX_BYTES,
|
||||
)
|
||||
.unwrap();
|
||||
FusedSeriesStream {
|
||||
inputs: input.map(|input| {
|
||||
SeriesGather::new(
|
||||
vec![(0, input)],
|
||||
MemoryConsumer::new("test")
|
||||
.register(&(Arc::new(UnboundedMemoryPool::default()) as Arc<dyn MemoryPool>)),
|
||||
)
|
||||
}),
|
||||
executor: None,
|
||||
input_ids: Vec::new(),
|
||||
classification: Arc::from([]),
|
||||
tags: Arc::from([]),
|
||||
output_schema,
|
||||
partial_fold: None,
|
||||
frame_builder: Some(builder),
|
||||
eval_schema: Some(schema),
|
||||
frame_cursor: None,
|
||||
_export_budget: Arc::new(Mutex::new(None)),
|
||||
ready: VecDeque::new(),
|
||||
done: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn sparse_eval_chunk(rows: usize, value: f64) -> (Arc<SeriesSchema>, EvaluatedChunk) {
|
||||
let grid =
|
||||
Arc::new(EvalGrid::try_new(GridSpec::try_new(0, 1, (rows * 2) as u64).unwrap()).unwrap());
|
||||
let schema = Arc::new(SeriesSchema {
|
||||
fields: Arc::from([FieldSpec { name: VALUE.into() }]),
|
||||
eval_grid: Some(Arc::clone(&grid)),
|
||||
});
|
||||
let axis =
|
||||
TimeAxis::from_grid_spans(grid, (0..rows).map(|i| GridSpan::new(i * 2, 1)).collect())
|
||||
.unwrap();
|
||||
let chunk = EvaluatedChunk::try_new(
|
||||
Series {
|
||||
metric: SeriesLabels::try_new(Some(Arc::from(METRIC)), Vec::new()).unwrap(),
|
||||
time: axis,
|
||||
fields: SeriesFields::one(Samples::from_float(Float64Array::from(vec![value; rows]))),
|
||||
},
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
(schema, chunk)
|
||||
}
|
||||
|
||||
/// A large evaluated chunk is encoded over successive polls rather than queued as all of its
|
||||
/// physical frame batches at once.
|
||||
#[tokio::test]
|
||||
async fn a_large_evaluated_chunk_keeps_a_cursor_between_polls() {
|
||||
const ROWS: usize = 800;
|
||||
let value = f64::from_bits(0x4009_21fb_5444_2d18);
|
||||
let (schema, chunk) = sparse_eval_chunk(ROWS, value);
|
||||
let mut stream = direct_frame_stream(Arc::clone(&schema), None);
|
||||
stream
|
||||
.convert(chunk)
|
||||
.expect("the evaluated chunk becomes a cursor");
|
||||
assert!(
|
||||
stream.ready.is_empty(),
|
||||
"conversion must not eagerly queue frame batches"
|
||||
);
|
||||
assert!(
|
||||
stream.frame_cursor.is_some(),
|
||||
"unencoded rows remain owned by the cursor"
|
||||
);
|
||||
|
||||
let first = stream.next().await.unwrap().unwrap();
|
||||
assert!(first.num_rows() <= SeriesFrameBuilder::DEFAULT_MAX_ROWS);
|
||||
assert!(
|
||||
stream.frame_cursor.is_some(),
|
||||
"the first batch cannot drain 800 rows"
|
||||
);
|
||||
assert!(stream.ready.is_empty());
|
||||
|
||||
let mut batches = vec![first];
|
||||
while let Some(batch) = stream.next().await {
|
||||
batches.push(batch.unwrap());
|
||||
}
|
||||
assert!(batches.len() > 1);
|
||||
let decoded = unpack_evaluated_series(
|
||||
Box::pin(RecordBatchStreamAdapter::new(
|
||||
batches[0].schema(),
|
||||
futures::stream::iter(batches.into_iter().map(Ok)),
|
||||
)),
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
.try_collect::<Vec<_>>()
|
||||
.await
|
||||
.unwrap();
|
||||
let mut decoded_rows = Vec::new();
|
||||
let mut closing_chunks = 0;
|
||||
for (series, last) in &decoded {
|
||||
if *last {
|
||||
closing_chunks += 1;
|
||||
}
|
||||
assert_eq!(Some(METRIC), series.metric.name());
|
||||
for (position, row) in series.time.positions() {
|
||||
let value = match series.fields.as_slice()[0].sample(row) {
|
||||
datatypes::series::samples::SampleRef::Float(value) => value,
|
||||
_ => panic!("the decoded field remains a present Float64"),
|
||||
};
|
||||
decoded_rows.push((position, value.to_bits()));
|
||||
}
|
||||
}
|
||||
decoded_rows.sort_unstable_by_key(|(position, _)| *position);
|
||||
assert_eq!(1, closing_chunks, "the chunk closes exactly once");
|
||||
assert_eq!(
|
||||
(0..ROWS)
|
||||
.map(|i| (i * 2, 0x4009_21fb_5444_2d18))
|
||||
.collect::<Vec<_>>(),
|
||||
decoded_rows,
|
||||
"every sparse position carries the exact expected float bits",
|
||||
);
|
||||
|
||||
assert!(stream.next().await.is_none(), "EOF remains stable");
|
||||
assert!(stream.next().await.is_none(), "repeated EOF remains stable");
|
||||
}
|
||||
|
||||
/// The cursor and its input are both released when the consumer drops an unfinished frame.
|
||||
#[tokio::test]
|
||||
async fn dropping_a_partly_emitted_frame_releases_its_input() {
|
||||
const ROWS: usize = 800;
|
||||
let (schema, mut chunk) = sparse_eval_chunk(ROWS, 3.0);
|
||||
let name = Arc::<str>::from(METRIC);
|
||||
let name_weak = Arc::downgrade(&name);
|
||||
chunk
|
||||
.series
|
||||
.metric
|
||||
.set("__name__", Arc::clone(&name))
|
||||
.unwrap();
|
||||
drop(name);
|
||||
let raw = raw_schema();
|
||||
let raw_chunk = series_chunk("cursor-source", &[(0, 1.0)], true);
|
||||
let raw_frame = frame_of(&raw, &raw_chunk);
|
||||
let dropped = Arc::new(AtomicBool::new(false));
|
||||
let raw_input = Box::pin(StagedStream::new(frame_schema(), vec![raw_frame]).dropped(&dropped));
|
||||
let input = unpack_raw_series(raw_input, Some(Arc::clone(&raw))).unwrap();
|
||||
let mut stream = direct_frame_stream(Arc::clone(&schema), Some(input));
|
||||
stream.convert(chunk).unwrap();
|
||||
assert!(stream.next().await.unwrap().unwrap().num_rows() > 0);
|
||||
assert!(stream.frame_cursor.is_some());
|
||||
assert!(
|
||||
name_weak.upgrade().is_some(),
|
||||
"the cursor retains its unencoded label identity"
|
||||
);
|
||||
assert!(!dropped.load(Ordering::SeqCst));
|
||||
assert!(stream.inputs.is_some());
|
||||
drop(stream);
|
||||
assert!(dropped.load(Ordering::SeqCst));
|
||||
assert!(
|
||||
name_weak.upgrade().is_none(),
|
||||
"dropping the cursor releases its label identity"
|
||||
);
|
||||
}
|
||||
|
||||
/// An upstream error clears rows buffered in the frame builder before any batch is delivered.
|
||||
#[tokio::test]
|
||||
async fn frame_upstream_error_discards_buffered_rows_and_terminates() {
|
||||
let (schema, chunk) = sparse_eval_chunk(2, 5.0);
|
||||
let raw = raw_schema();
|
||||
let dropped = Arc::new(AtomicBool::new(false));
|
||||
let input = unpack_raw_series(
|
||||
Box::pin(
|
||||
StagedStream::new(frame_schema(), Vec::new())
|
||||
.error("frame input failed")
|
||||
.dropped(&dropped),
|
||||
),
|
||||
Some(Arc::clone(&raw)),
|
||||
)
|
||||
.unwrap();
|
||||
let mut stream = direct_frame_stream(schema, Some(input));
|
||||
stream.convert(chunk).unwrap();
|
||||
assert!(stream.advance_frame_cursor().unwrap().is_none());
|
||||
assert!(
|
||||
stream.frame_cursor.is_none(),
|
||||
"small rows sit buffered in the builder"
|
||||
);
|
||||
assert!(stream.ready.is_empty());
|
||||
|
||||
let error = stream
|
||||
.next()
|
||||
.await
|
||||
.expect("the upstream error is yielded")
|
||||
.expect_err("buffered frame rows cannot hide the source error");
|
||||
assert!(error.to_string().contains("frame input failed"));
|
||||
assert!(
|
||||
stream.next().await.is_none(),
|
||||
"the failed stream terminates once"
|
||||
);
|
||||
assert!(
|
||||
stream.next().await.is_none(),
|
||||
"EOF after failure remains stable"
|
||||
);
|
||||
assert!(
|
||||
dropped.load(Ordering::SeqCst),
|
||||
"failure releases the source"
|
||||
);
|
||||
assert!(stream.frame_builder.is_none());
|
||||
assert!(stream.frame_cursor.is_none());
|
||||
assert!(stream.ready.is_empty());
|
||||
}
|
||||
|
||||
/// A chunk whose rows form several runs is converted row by row from the rows it carries.
|
||||
///
|
||||
/// A sparse result is one chunk holding two runs with a hole between them
|
||||
@@ -4033,7 +3821,7 @@ async fn series_frame_output_uses_evaluated_codec_schema() {
|
||||
tree.clone(),
|
||||
binding(&tree),
|
||||
vec![input_plan(frame_schema())],
|
||||
output_schema().to_dfschema_ref().unwrap(),
|
||||
canonical_frame_schema(&tree),
|
||||
FusedOutputFormat::SeriesFrame,
|
||||
)
|
||||
.expect("evaluated samples have a frame schema");
|
||||
@@ -4045,6 +3833,16 @@ async fn series_frame_output_uses_evaluated_codec_schema() {
|
||||
.all(|field| field.name() != "ts"),
|
||||
"frame schema comes from the codec rather than the row output"
|
||||
);
|
||||
let wrong_schema = FusedSeries::try_new_with_format(
|
||||
Arc::from(PROMQL),
|
||||
tree.clone(),
|
||||
binding(&tree),
|
||||
vec![input_plan(frame_schema())],
|
||||
output_schema().to_dfschema_ref().unwrap(),
|
||||
FusedOutputFormat::SeriesFrame,
|
||||
)
|
||||
.expect_err("the caller's incorrect output schema is rejected");
|
||||
assert!(wrong_schema.to_string().contains("schema does not match"));
|
||||
assert!(format!("{frame_node:?}").contains("FusedSeries"));
|
||||
let exec = plan_of(frame_node, memory_exec(one_series_frames())).unwrap();
|
||||
assert_eq!(1, exec.properties().partitioning.partition_count());
|
||||
@@ -4065,7 +3863,7 @@ fn series_frame_rejects_multiple_sources_during_planning() {
|
||||
tree.clone(),
|
||||
binding(&tree),
|
||||
vec![input_plan(frame_schema()), input_plan(frame_schema())],
|
||||
binary_output_schema().to_dfschema_ref().unwrap(),
|
||||
canonical_frame_schema(&tree),
|
||||
FusedOutputFormat::SeriesFrame,
|
||||
)
|
||||
.unwrap_err();
|
||||
@@ -4090,7 +3888,7 @@ async fn series_frame_preserves_four_input_partitions() {
|
||||
tree.clone(),
|
||||
binding(&tree),
|
||||
vec![input_plan(frame_schema())],
|
||||
output_schema().to_dfschema_ref().unwrap(),
|
||||
canonical_frame_schema(&tree),
|
||||
FusedOutputFormat::SeriesFrame,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -4141,7 +3939,7 @@ async fn series_frame_rate_matches_plain_rows_bitwise() {
|
||||
tree.clone(),
|
||||
binding(&tree),
|
||||
vec![input_plan(frame_schema())],
|
||||
output_schema().to_dfschema_ref().unwrap(),
|
||||
canonical_frame_schema(&tree),
|
||||
FusedOutputFormat::SeriesFrame,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -4226,7 +4024,7 @@ fn the_explain_text_names_the_frame_format_only() {
|
||||
rows.tree().clone(),
|
||||
rows.binding().clone(),
|
||||
rows.inputs().into_iter().cloned().collect(),
|
||||
Arc::clone(rows.schema()),
|
||||
canonical_frame_schema(rows.tree()),
|
||||
FusedOutputFormat::SeriesFrame,
|
||||
)
|
||||
.expect("a rate tree is evaluated on a grid");
|
||||
@@ -4261,7 +4059,7 @@ fn the_logical_schema_follows_the_output_format() {
|
||||
rows.tree().clone(),
|
||||
rows.binding().clone(),
|
||||
rows.inputs().into_iter().cloned().collect(),
|
||||
Arc::clone(rows.schema()),
|
||||
canonical_frame_schema(rows.tree()),
|
||||
FusedOutputFormat::SeriesFrame,
|
||||
)
|
||||
.expect("a rate tree is evaluated on a grid");
|
||||
@@ -4290,7 +4088,7 @@ fn rebuilding_a_frame_node_keeps_its_output_format() {
|
||||
rows.tree().clone(),
|
||||
rows.binding().clone(),
|
||||
rows.inputs().into_iter().cloned().collect(),
|
||||
Arc::clone(rows.schema()),
|
||||
canonical_frame_schema(rows.tree()),
|
||||
FusedOutputFormat::SeriesFrame,
|
||||
)
|
||||
.expect("a rate tree is evaluated on a grid");
|
||||
@@ -4381,7 +4179,7 @@ async fn a_pending_input_does_not_hold_the_rows_a_frame_buffered() {
|
||||
tree.clone(),
|
||||
binding(&tree),
|
||||
vec![input_plan(schema)],
|
||||
output_schema().to_dfschema_ref().unwrap(),
|
||||
canonical_frame_schema(&tree),
|
||||
FusedOutputFormat::SeriesFrame,
|
||||
)
|
||||
.expect("a rate tree is evaluated on a grid");
|
||||
@@ -4462,6 +4260,56 @@ async fn a_pending_input_does_not_hold_the_rows_a_frame_buffered() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Errors from a fused frame input terminate once and release its upstream immediately.
|
||||
#[tokio::test]
|
||||
async fn fused_frame_input_error_terminates_and_releases_input() {
|
||||
let schema = frame_schema();
|
||||
let partial = frame(&series_chunk(
|
||||
"web-1",
|
||||
&[(GRID_START + RANGE_MS + SAMPLE_STEP, 1.0)],
|
||||
false,
|
||||
));
|
||||
let dropped = Arc::new(AtomicBool::new(false));
|
||||
let staged_schema = Arc::clone(&schema);
|
||||
let staged_dropped = Arc::clone(&dropped);
|
||||
let source = staged(Arc::clone(&schema), move || {
|
||||
Box::pin(
|
||||
StagedStream::new(Arc::clone(&staged_schema), vec![partial.clone()])
|
||||
.error("fused frame input failed")
|
||||
.dropped(&staged_dropped),
|
||||
)
|
||||
});
|
||||
let tree = rate_tree();
|
||||
let node = FusedSeries::try_new_with_format(
|
||||
Arc::from(PROMQL),
|
||||
tree.clone(),
|
||||
binding(&tree),
|
||||
vec![input_plan(schema)],
|
||||
canonical_frame_schema(&tree),
|
||||
FusedOutputFormat::SeriesFrame,
|
||||
)
|
||||
.unwrap();
|
||||
let exec = plan_of(node, source).unwrap();
|
||||
let mut output = exec
|
||||
.execute(0, SessionContext::default().task_ctx())
|
||||
.unwrap();
|
||||
let error = match output.next().await {
|
||||
Some(Err(error)) => error,
|
||||
Some(Ok(_)) => panic!("unexpected output before the upstream failure"),
|
||||
None => panic!("the upstream failure was swallowed"),
|
||||
};
|
||||
assert!(error.to_string().contains("fused frame input failed"));
|
||||
assert!(
|
||||
dropped.load(Ordering::SeqCst),
|
||||
"the failed adapter releases input"
|
||||
);
|
||||
assert!(output.next().await.is_none(), "failure is yielded once");
|
||||
assert!(
|
||||
output.next().await.is_none(),
|
||||
"EOF remains stable after failure"
|
||||
);
|
||||
}
|
||||
|
||||
/// A closed chain that holds no value still reaches the consumer of a frame.
|
||||
///
|
||||
/// The last sample sits past the grid, so no evaluation point has a sample in its window and the
|
||||
@@ -4487,7 +4335,7 @@ async fn a_frame_carries_the_closing_chunk_a_row_table_drops() {
|
||||
tree.clone(),
|
||||
binding(&tree),
|
||||
vec![input_plan(frame_schema())],
|
||||
output_schema().to_dfschema_ref().unwrap(),
|
||||
canonical_frame_schema(&tree),
|
||||
FusedOutputFormat::SeriesFrame,
|
||||
)
|
||||
.expect("a rate tree is evaluated on a grid");
|
||||
@@ -4572,7 +4420,7 @@ async fn two_chains_that_close_on_one_label_set_each_keep_their_frame() {
|
||||
tree.clone(),
|
||||
binding(&tree),
|
||||
vec![input_plan(frame_schema())],
|
||||
output_schema().to_dfschema_ref().unwrap(),
|
||||
canonical_frame_schema(&tree),
|
||||
FusedOutputFormat::SeriesFrame,
|
||||
)
|
||||
.expect("a rate tree is evaluated on a grid");
|
||||
|
||||
@@ -162,7 +162,11 @@ async fn sparse_instant_frame_preserves_positions_nulls_and_nan_across_formats()
|
||||
tree.clone(),
|
||||
binding.clone(),
|
||||
vec![input_plan(frame_schema_of(&schema))],
|
||||
two_field_output_schema().to_dfschema_ref().unwrap(),
|
||||
if format == FusedOutputFormat::SeriesFrame {
|
||||
canonical_frame_schema(&tree)
|
||||
} else {
|
||||
two_field_output_schema().to_dfschema_ref().unwrap()
|
||||
},
|
||||
format,
|
||||
)
|
||||
.expect("instant tree has the declared frame output")
|
||||
@@ -217,7 +221,11 @@ async fn abs_rate_frame_matches_plain_rows_with_exact_values_and_labels() {
|
||||
tree.clone(),
|
||||
binding.clone(),
|
||||
vec![input_plan(frame_schema())],
|
||||
output_schema().to_dfschema_ref().unwrap(),
|
||||
if format == FusedOutputFormat::SeriesFrame {
|
||||
canonical_frame_schema(&tree)
|
||||
} else {
|
||||
output_schema().to_dfschema_ref().unwrap()
|
||||
},
|
||||
format,
|
||||
)
|
||||
.unwrap()
|
||||
@@ -317,7 +325,11 @@ async fn rewritten_identical_labels_keep_disjoint_chains_in_one_encoder() {
|
||||
tree.clone(),
|
||||
binding.clone(),
|
||||
vec![input_plan(frame_schema())],
|
||||
output_schema().to_dfschema_ref().unwrap(),
|
||||
if format == FusedOutputFormat::SeriesFrame {
|
||||
canonical_frame_schema(&tree)
|
||||
} else {
|
||||
output_schema().to_dfschema_ref().unwrap()
|
||||
},
|
||||
format,
|
||||
)
|
||||
.unwrap()
|
||||
|
||||
@@ -287,8 +287,9 @@ impl DistPlannerAnalyzer {
|
||||
let plan = plan.transform_with_subqueries(&|plan| {
|
||||
Self::inspect_plan_with_subquery(plan, in_process_region_transport)
|
||||
})?;
|
||||
let mut rewriter = PlanRewriter::new(&plan.data, in_process_region_transport);
|
||||
let result = plan.data.rewrite(&mut rewriter)?.data;
|
||||
let plan = rewrite_hybrid_binary_operands(plan.data, in_process_region_transport)?;
|
||||
let mut rewriter = PlanRewriter::new(&plan, in_process_region_transport);
|
||||
let result = plan.rewrite(&mut rewriter)?.data;
|
||||
Self::assign_merge_scan_remote_dyn_filter_producer_ids(result)
|
||||
}
|
||||
|
||||
@@ -508,6 +509,56 @@ struct PlanRewriter {
|
||||
new_child_plan: Option<LogicalPlan>,
|
||||
}
|
||||
|
||||
fn native_operand_wrapper_child(plan: &LogicalPlan) -> Option<&LogicalPlan> {
|
||||
match plan {
|
||||
LogicalPlan::SubqueryAlias(alias) => Some(alias.input.as_ref()),
|
||||
LogicalPlan::Projection(projection) => Some(projection.input.as_ref()),
|
||||
LogicalPlan::Filter(filter) => Some(filter.input.as_ref()),
|
||||
LogicalPlan::Window(window) => Some(window.input.as_ref()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn native_operand_fused(plan: &LogicalPlan) -> Option<&LogicalPlan> {
|
||||
let mut node = plan;
|
||||
while let Some(child) = native_operand_wrapper_child(node) {
|
||||
node = child;
|
||||
}
|
||||
(is_fused_series_plan(node) && matches!(node.inputs().as_slice(), [LogicalPlan::TableScan(_)]))
|
||||
.then_some(node)
|
||||
}
|
||||
|
||||
fn is_native_operand_subtree(plan: &LogicalPlan) -> bool {
|
||||
native_operand_fused(plan).is_some()
|
||||
}
|
||||
|
||||
fn rewrite_hybrid_binary_operands(plan: LogicalPlan, transport: bool) -> DfResult<LogicalPlan> {
|
||||
plan.transform_up(|plan| {
|
||||
let LogicalPlan::Join(join) = &plan else {
|
||||
return Ok(Transformed::no(plan));
|
||||
};
|
||||
if !is_native_operand_subtree(&join.left) || !is_native_operand_subtree(&join.right) {
|
||||
return Ok(Transformed::no(plan));
|
||||
}
|
||||
fn rewrite_side(side: &LogicalPlan, transport: bool) -> DfResult<LogicalPlan> {
|
||||
if let Some(child) = native_operand_wrapper_child(side) {
|
||||
let rewritten_child = rewrite_side(child, transport)?;
|
||||
return side
|
||||
.clone()
|
||||
.with_new_exprs(side.expressions(), vec![rewritten_child]);
|
||||
}
|
||||
let mut rw = PlanRewriter::new(side, transport);
|
||||
Ok(side.clone().rewrite(&mut rw)?.data)
|
||||
}
|
||||
let left = rewrite_side(&join.left, transport)?;
|
||||
let right = rewrite_side(&join.right, transport)?;
|
||||
let mut rebuilt = LogicalPlan::Join(join.clone());
|
||||
rebuilt = rebuilt.with_new_exprs(rebuilt.expressions(), vec![left, right])?;
|
||||
Ok(Transformed::yes(rebuilt))
|
||||
})
|
||||
.map(|transformed| transformed.data)
|
||||
}
|
||||
|
||||
fn is_admitted_pair_plan(plan: &LogicalPlan) -> bool {
|
||||
let LogicalPlan::Extension(extension) = plan else {
|
||||
return false;
|
||||
|
||||
@@ -16,6 +16,7 @@ use std::collections::{BTreeSet, HashSet};
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::promql::native_compile::{compile, statement_grid};
|
||||
use arrow::datatypes::{DataType, IntervalDayTime, TimeUnit};
|
||||
use common_catalog::consts::{DEFAULT_CATALOG_NAME, DEFAULT_SCHEMA_NAME, MITO_ENGINE};
|
||||
use common_error::ext::BoxedError;
|
||||
@@ -43,10 +44,14 @@ use datafusion_functions::datetime::expr_fn::now;
|
||||
use datafusion_functions::unicode::expr_fn::substring;
|
||||
use datatypes::data_type::ConcreteDataType;
|
||||
use datatypes::schema::{ColumnSchema, SchemaBuilder, SchemaRef};
|
||||
use datatypes::series::SeriesOrdering;
|
||||
use datatypes::series::samples::{FieldSpec, SeriesSchema};
|
||||
use futures::Stream;
|
||||
use futures::task::{Context, Poll};
|
||||
use pretty_assertions::assert_eq;
|
||||
use promql::extension_plan::{HistogramFold, HistogramFoldOperation};
|
||||
use promql::extension_plan::{
|
||||
FusedSeries, FusedSeriesBinding, HistogramFold, HistogramFoldOperation,
|
||||
};
|
||||
use regex::Regex;
|
||||
use store_api::data_source::DataSource;
|
||||
use store_api::storage::ScanRequest;
|
||||
@@ -322,6 +327,134 @@ impl Stream for EmptyStream {
|
||||
}
|
||||
}
|
||||
|
||||
fn hybrid_fused(scan: LogicalPlan, metric: &str) -> LogicalPlan {
|
||||
let stmt = promql_parser::parser::EvalStmt {
|
||||
expr: promql_parser::parser::parse(metric).unwrap(),
|
||||
start: std::time::UNIX_EPOCH,
|
||||
end: std::time::UNIX_EPOCH + std::time::Duration::from_secs(10),
|
||||
interval: std::time::Duration::from_secs(1),
|
||||
lookback_delta: std::time::Duration::from_secs(1),
|
||||
};
|
||||
let series = Arc::new(SeriesSchema {
|
||||
fields: Arc::from([FieldSpec { name: "v0".into() }]),
|
||||
eval_grid: None,
|
||||
});
|
||||
let tree = compile(&stmt, series, SeriesOrdering::KeyThenTime).unwrap();
|
||||
let fused = FusedSeries::try_new(
|
||||
Arc::from(metric),
|
||||
tree,
|
||||
FusedSeriesBinding {
|
||||
grid: statement_grid(&stmt).unwrap(),
|
||||
time_index: "ts".into(),
|
||||
tags: vec![],
|
||||
metric_name: Some(Arc::from(metric)),
|
||||
},
|
||||
vec![scan],
|
||||
Arc::new(
|
||||
datafusion_common::DFSchema::try_from(arrow::datatypes::Schema::new(vec![
|
||||
arrow::datatypes::Field::new("v0", DataType::Float64, true),
|
||||
]))
|
||||
.unwrap(),
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
LogicalPlan::Extension(Extension {
|
||||
node: Arc::new(fused),
|
||||
})
|
||||
}
|
||||
|
||||
fn count_merge_scans(plan: &LogicalPlan) -> usize {
|
||||
usize::from(
|
||||
matches!(plan, LogicalPlan::Extension(extension) if extension.node.as_any().is::<MergeScanLogicalPlan>()),
|
||||
) + plan
|
||||
.inputs()
|
||||
.iter()
|
||||
.map(|input| count_merge_scans(input))
|
||||
.sum::<usize>()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hybrid_binary_join_rewrites_each_native_operand_boundary() {
|
||||
let left_table = TestTable::table_with_name(0, "left_metric".into());
|
||||
let right_table = TestTable::table_with_name(1, "right_metric".into());
|
||||
let scan = |table: TableRef| {
|
||||
LogicalPlanBuilder::scan(
|
||||
table.table_info().name.clone(),
|
||||
Arc::new(DefaultTableSource::new(Arc::new(
|
||||
DfTableProviderAdapter::new(table),
|
||||
))),
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
.build()
|
||||
.unwrap()
|
||||
};
|
||||
let left = LogicalPlanBuilder::from(hybrid_fused(scan(left_table), "left_metric"))
|
||||
.alias(TableReference::bare("left_metric"))
|
||||
.unwrap()
|
||||
.build()
|
||||
.unwrap();
|
||||
let right = LogicalPlanBuilder::from(hybrid_fused(scan(right_table), "right_metric"))
|
||||
.alias(TableReference::bare("right_metric"))
|
||||
.unwrap()
|
||||
.build()
|
||||
.unwrap();
|
||||
let plan = LogicalPlanBuilder::from(left)
|
||||
.join(
|
||||
LogicalPlanBuilder::from(right).build().unwrap(),
|
||||
JoinType::Inner,
|
||||
(vec![Column::from_name("v0")], vec![Column::from_name("v0")]),
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
.build()
|
||||
.unwrap();
|
||||
let rewritten = DistPlannerAnalyzer::new(true).try_push_down(plan).unwrap();
|
||||
let LogicalPlan::Join(join) = &rewritten else {
|
||||
panic!("Join must remain frontend: {rewritten}");
|
||||
};
|
||||
for (side, expected_table) in [(&join.left, "left_metric"), (&join.right, "right_metric")] {
|
||||
assert_eq!(1, count_merge_scans(side), "{rewritten}");
|
||||
assert_no_fused_series_outside_merge_scan(side);
|
||||
|
||||
let merge_scan = find_merge_scan(side).expect("each join operand has a MergeScan");
|
||||
let LogicalPlan::Extension(extension) = merge_scan.input() else {
|
||||
panic!("MergeScan remote input must be a FusedSeries root");
|
||||
};
|
||||
let fused = extension
|
||||
.node
|
||||
.as_any()
|
||||
.downcast_ref::<FusedSeries>()
|
||||
.expect("MergeScan remote input must be a FusedSeries root");
|
||||
assert_eq!(expected_table, fused.promql());
|
||||
assert_eq!(1, fused.children().len());
|
||||
let [LogicalPlan::TableScan(table_scan)] = fused.children() else {
|
||||
panic!("FusedSeries operand must directly consume a TableScan: {fused:?}");
|
||||
};
|
||||
assert_eq!(expected_table, table_scan.table_name.to_string());
|
||||
}
|
||||
assert_eq!(
|
||||
2,
|
||||
count_merge_scans(&rewritten),
|
||||
"Join must be outside both MergeScans: {rewritten}"
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_no_fused_series_outside_merge_scan(plan: &LogicalPlan) {
|
||||
if let LogicalPlan::Extension(extension) = plan {
|
||||
if extension.node.as_any().is::<MergeScanLogicalPlan>() {
|
||||
return;
|
||||
}
|
||||
assert!(
|
||||
!extension.node.as_any().is::<FusedSeries>(),
|
||||
"FusedSeries must be contained in a MergeScan remote input: {plan}"
|
||||
);
|
||||
}
|
||||
for input in plan.inputs() {
|
||||
assert_no_fused_series_outside_merge_scan(input);
|
||||
}
|
||||
}
|
||||
|
||||
fn find_merge_scan(plan: &LogicalPlan) -> Option<&MergeScanLogicalPlan> {
|
||||
if let LogicalPlan::Extension(extension) = plan
|
||||
&& let Some(merge_scan) = extension
|
||||
|
||||
@@ -586,7 +586,11 @@ impl PromPlanner {
|
||||
// schema.
|
||||
self.ctx.start = end;
|
||||
self.ctx.end = end;
|
||||
let plan = self.prom_expr_to_plan(expr, query_engine_state).await?;
|
||||
let native_fused = self.native_fused;
|
||||
self.native_fused = false;
|
||||
let plan_result = self.prom_expr_to_plan(expr, query_engine_state).await;
|
||||
self.native_fused = native_fused;
|
||||
let plan = plan_result?;
|
||||
return Ok(LogicalPlan::EmptyRelation(EmptyRelation {
|
||||
produce_one_row: false,
|
||||
schema: plan.schema().clone(),
|
||||
@@ -594,7 +598,11 @@ impl PromPlanner {
|
||||
};
|
||||
self.ctx.start = start;
|
||||
self.ctx.end = end;
|
||||
self.prom_expr_to_plan(expr, query_engine_state).await
|
||||
let native_fused = self.native_fused;
|
||||
self.native_fused = false;
|
||||
let plan_result = self.prom_expr_to_plan(expr, query_engine_state).await;
|
||||
self.native_fused = native_fused;
|
||||
plan_result
|
||||
}
|
||||
|
||||
async fn prom_subquery_expr_to_plan(
|
||||
@@ -646,10 +654,14 @@ impl PromPlanner {
|
||||
self.ctx.start = child_start;
|
||||
self.ctx.end = child_end;
|
||||
}
|
||||
let input = self.prom_expr_to_plan(expr, query_engine_state).await?;
|
||||
let native_fused = self.native_fused;
|
||||
self.native_fused = false;
|
||||
let input_result = self.prom_expr_to_plan(expr, query_engine_state).await;
|
||||
self.native_fused = native_fused;
|
||||
self.ctx.interval = current_interval;
|
||||
self.ctx.start = current_start;
|
||||
self.ctx.end = current_end;
|
||||
let input = input_result?;
|
||||
// Return no rows with the child schema so enclosing fallbacks still work.
|
||||
let input = match child_window {
|
||||
Some(_) => input,
|
||||
@@ -1365,32 +1377,57 @@ impl PromPlanner {
|
||||
}
|
||||
// both are columns. join them on time index
|
||||
(None, None) => {
|
||||
let mut left_input = self.prom_expr_to_plan(lhs, query_engine_state).await?;
|
||||
let left_field_columns = self.ctx.field_columns.clone();
|
||||
let left_time_index_column = self.ctx.time_index_column.clone();
|
||||
let native_pair =
|
||||
self.native_fused && native::hybrid_binary_admitted(*op, modifier, lhs, rhs);
|
||||
let native = if native_pair {
|
||||
match (
|
||||
self.try_plan_native_operand(lhs, query_engine_state)
|
||||
.await?,
|
||||
self.try_plan_native_operand(rhs, query_engine_state)
|
||||
.await?,
|
||||
) {
|
||||
(Some(left), Some(right)) => Some((left, right)),
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let used_native = native.is_some();
|
||||
let (mut left_input, mut left_context, mut right_input, mut right_context) =
|
||||
if let Some(((left, left_ctx), (right, right_ctx))) = native {
|
||||
(left, left_ctx, right, right_ctx)
|
||||
} else {
|
||||
let left = self.prom_expr_to_plan(lhs, query_engine_state).await?;
|
||||
let left_ctx = self.ctx.clone();
|
||||
let right = self.prom_expr_to_plan(rhs, query_engine_state).await?;
|
||||
let right_ctx = self.ctx.clone();
|
||||
(left, left_ctx, right, right_ctx)
|
||||
};
|
||||
let left_field_columns = left_context.field_columns.clone();
|
||||
let left_time_index_column = left_context.time_index_column.clone();
|
||||
self.ctx = left_context.clone();
|
||||
let mut left_table_ref = self
|
||||
.table_ref()
|
||||
.unwrap_or_else(|_| TableReference::bare(""));
|
||||
let mut left_context = self.ctx.clone();
|
||||
|
||||
let mut right_input = self.prom_expr_to_plan(rhs, query_engine_state).await?;
|
||||
let right_field_columns = self.ctx.field_columns.clone();
|
||||
let right_time_index_column = self.ctx.time_index_column.clone();
|
||||
let right_field_columns = right_context.field_columns.clone();
|
||||
let right_time_index_column = right_context.time_index_column.clone();
|
||||
self.ctx = right_context.clone();
|
||||
let mut right_table_ref = self
|
||||
.table_ref()
|
||||
.unwrap_or_else(|_| TableReference::bare(""));
|
||||
let mut right_context = self.ctx.clone();
|
||||
|
||||
// Both operands are planned first because only the planned contexts tell a tag
|
||||
// from a value field. The rewrite merely adds matchers to a selector, so the
|
||||
// table reference, time index and field columns captured above stay valid.
|
||||
if let Some(rewritten) = matching_filters::propagate(
|
||||
binary_expr,
|
||||
&left_context.tag_columns,
|
||||
&right_context.tag_columns,
|
||||
&left_context.aggregation_field_labels,
|
||||
&right_context.aggregation_field_labels,
|
||||
) {
|
||||
if !used_native
|
||||
&& let Some(rewritten) = matching_filters::propagate(
|
||||
binary_expr,
|
||||
&left_context.tag_columns,
|
||||
&right_context.tag_columns,
|
||||
&left_context.aggregation_field_labels,
|
||||
&right_context.aggregation_field_labels,
|
||||
)
|
||||
{
|
||||
// A copied matcher belongs to the scan, not to the operand's identity:
|
||||
// `absent()` turns `selector_matcher` into the labels it reports, so the
|
||||
// re-planned context keeps the matchers the operand was written with.
|
||||
|
||||
@@ -40,7 +40,7 @@ use common_query::prelude::OTLP_AGGREGATION_TEMPORALITY_LABEL;
|
||||
use datafusion::arrow::datatypes::{DataType as ArrowDataType, Field, TimeUnit as ArrowTimeUnit};
|
||||
use datafusion::common::{DFSchema, DFSchemaRef};
|
||||
use datafusion::error::DataFusionError;
|
||||
use datafusion::logical_expr::{Extension, LogicalPlan, TableScanBuilder};
|
||||
use datafusion::logical_expr::{Extension, LogicalPlan, LogicalPlanBuilder, TableScanBuilder};
|
||||
use datafusion::prelude::Expr as DfExpr;
|
||||
use datafusion_expr::Expr;
|
||||
use datatypes::series::SeriesOrdering;
|
||||
@@ -51,7 +51,8 @@ use promql::vector_match::{Cardinality, MatchingLabels, SetOp};
|
||||
use promql_parser::label::Matchers;
|
||||
use promql_parser::parser::value::ValueType;
|
||||
use promql_parser::parser::{
|
||||
EvalStmt, Expr as PromExpr, MatrixSelector, ParenExpr, VectorSelector,
|
||||
BinModifier, EvalStmt, Expr as PromExpr, LabelModifier, MatrixSelector, ParenExpr,
|
||||
VectorMatchCardinality, VectorSelector, token,
|
||||
};
|
||||
use session::context::QueryContextRef;
|
||||
use snafu::ResultExt;
|
||||
@@ -109,7 +110,70 @@ type PreparedInput = (
|
||||
Vec<String>,
|
||||
);
|
||||
|
||||
pub(crate) fn hybrid_binary_admitted(
|
||||
op: token::TokenType,
|
||||
modifier: &Option<BinModifier>,
|
||||
lhs: &PromExpr,
|
||||
rhs: &PromExpr,
|
||||
) -> bool {
|
||||
let supported_op = matches!(
|
||||
op.id(),
|
||||
token::T_ADD
|
||||
| token::T_SUB
|
||||
| token::T_MUL
|
||||
| token::T_DIV
|
||||
| token::T_MOD
|
||||
| token::T_POW
|
||||
| token::T_ATAN2
|
||||
);
|
||||
let supported_matching = match modifier {
|
||||
None => true,
|
||||
Some(modifier) => {
|
||||
matches!(&modifier.card, VectorMatchCardinality::OneToOne)
|
||||
&& (matches!(&modifier.matching, Some(LabelModifier::Include(labels)) if !labels.labels.is_empty())
|
||||
|| matches!(&modifier.matching, Some(LabelModifier::Exclude(labels)) if labels.labels.is_empty()))
|
||||
}
|
||||
};
|
||||
supported_op
|
||||
&& supported_matching
|
||||
&& lhs.value_type() == ValueType::Vector
|
||||
&& rhs.value_type() == ValueType::Vector
|
||||
&& admitted_selector(lhs).is_some()
|
||||
&& admitted_selector(rhs).is_some()
|
||||
}
|
||||
|
||||
impl PromPlanner {
|
||||
pub(super) async fn try_plan_native_operand(
|
||||
&mut self,
|
||||
expr: &PromExpr,
|
||||
query_engine_state: &QueryEngineState,
|
||||
) -> Result<Option<(LogicalPlan, super::PromPlannerContext)>> {
|
||||
if admitted_selector(expr).is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
let saved = self.ctx.clone();
|
||||
let (plan, operand_ctx) = match self
|
||||
.try_plan_native_fused_with_context(expr, query_engine_state)
|
||||
.await?
|
||||
{
|
||||
Some((plan, Some(operand_ctx))) => (plan, operand_ctx),
|
||||
_ => {
|
||||
self.ctx = saved;
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
self.ctx = operand_ctx.clone();
|
||||
let table_ref = self.table_ref();
|
||||
self.ctx = saved;
|
||||
let table_ref = table_ref?;
|
||||
let plan = LogicalPlanBuilder::from(plan)
|
||||
.alias(table_ref)
|
||||
.context(DataFusionPlanningSnafu)?
|
||||
.build()
|
||||
.context(DataFusionPlanningSnafu)?;
|
||||
Ok(Some((plan, operand_ctx)))
|
||||
}
|
||||
|
||||
/// Plans `prom_expr` as one native node, or returns `None` to keep the existing plan.
|
||||
///
|
||||
/// `query_engine_state` is only read to build the *existing* plan of an aggregate query, whose
|
||||
@@ -133,6 +197,17 @@ impl PromPlanner {
|
||||
prom_expr: &PromExpr,
|
||||
query_engine_state: &QueryEngineState,
|
||||
) -> Result<Option<LogicalPlan>> {
|
||||
Ok(self
|
||||
.try_plan_native_fused_with_context(prom_expr, query_engine_state)
|
||||
.await?
|
||||
.map(|(plan, _)| plan))
|
||||
}
|
||||
|
||||
async fn try_plan_native_fused_with_context(
|
||||
&mut self,
|
||||
prom_expr: &PromExpr,
|
||||
query_engine_state: &QueryEngineState,
|
||||
) -> Result<Option<(LogicalPlan, Option<super::PromPlannerContext>)>> {
|
||||
if !self.native_fused {
|
||||
return Ok(None);
|
||||
}
|
||||
@@ -188,13 +263,17 @@ impl PromPlanner {
|
||||
output_schema,
|
||||
)
|
||||
.context(DataFusionPlanningSnafu)?;
|
||||
return Ok(Some(LogicalPlan::Extension(Extension {
|
||||
node: Arc::new(node),
|
||||
})));
|
||||
return Ok(Some((
|
||||
LogicalPlan::Extension(Extension {
|
||||
node: Arc::new(node),
|
||||
}),
|
||||
None,
|
||||
)));
|
||||
}
|
||||
let original_context = self.ctx.clone();
|
||||
let preparation = async {
|
||||
let mut prepared: Vec<PreparedInput> = Vec::new();
|
||||
let mut single_input_context = None;
|
||||
for selector in &selectors {
|
||||
self.ctx = original_context.clone();
|
||||
// These matchers are alternatives the one scan below the node cannot represent, so the
|
||||
@@ -210,6 +289,9 @@ impl PromPlanner {
|
||||
if self.setup_context().await?.is_some() {
|
||||
return Ok(None);
|
||||
}
|
||||
if selectors.len() == 1 {
|
||||
single_input_context = Some(self.ctx.clone());
|
||||
}
|
||||
let (Some(metric), Some(time)) = (
|
||||
self.ctx.table_name.clone(),
|
||||
self.ctx.time_index_column.clone(),
|
||||
@@ -360,11 +442,11 @@ impl PromPlanner {
|
||||
}
|
||||
prepared.push((input, series_schema, Some(Arc::from(metric)), time, tags));
|
||||
}
|
||||
Ok(Some(prepared))
|
||||
Ok(Some((prepared, single_input_context)))
|
||||
}
|
||||
.await;
|
||||
self.ctx = original_context;
|
||||
let Some(mut prepared) = preparation? else {
|
||||
let Some((mut prepared, single_input_context)) = preparation? else {
|
||||
return Ok(None);
|
||||
};
|
||||
// Two selectors over one table share one region, so the node runs there over both scans.
|
||||
@@ -536,9 +618,12 @@ impl PromPlanner {
|
||||
Err(DataFusionError::NotImplemented(_)) => return Ok(None),
|
||||
Err(error) => return Err(error).context(DataFusionPlanningSnafu),
|
||||
};
|
||||
return Ok(Some(LogicalPlan::Extension(Extension {
|
||||
node: Arc::new(node),
|
||||
})));
|
||||
return Ok(Some((
|
||||
LogicalPlan::Extension(Extension {
|
||||
node: Arc::new(node),
|
||||
}),
|
||||
single_input_context,
|
||||
)));
|
||||
}
|
||||
let tree = compiled.tree;
|
||||
let root_fields = tree
|
||||
@@ -593,9 +678,12 @@ impl PromPlanner {
|
||||
output_schema,
|
||||
)
|
||||
.context(DataFusionPlanningSnafu)?;
|
||||
Ok(Some(LogicalPlan::Extension(Extension {
|
||||
node: Arc::new(node),
|
||||
})))
|
||||
Ok(Some((
|
||||
LogicalPlan::Extension(Extension {
|
||||
node: Arc::new(node),
|
||||
}),
|
||||
single_input_context,
|
||||
)))
|
||||
}
|
||||
|
||||
/// The statement being planned, rebuilt from the evaluation numbers the planner holds.
|
||||
@@ -972,8 +1060,8 @@ mod tests {
|
||||
|
||||
use super::{
|
||||
NATIVE_PROMQL_EXTENSION_KEY, admitted_selector, admitted_single_selector,
|
||||
collect_selectors, fused_output_tags, independent_scan_pair, native_promql_requested,
|
||||
scan_time_filter,
|
||||
collect_selectors, fused_output_tags, hybrid_binary_admitted, independent_scan_pair,
|
||||
native_promql_requested, scan_time_filter,
|
||||
};
|
||||
|
||||
fn compiled_test_tree(query: &str) -> NativeTree {
|
||||
@@ -1146,6 +1234,32 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn hybrid_admission(query: &str) -> bool {
|
||||
let expression = promql_parser::parser::parse(query).unwrap();
|
||||
let promql_parser::parser::Expr::Binary(binary) = expression else {
|
||||
panic!("expected binary expression: {query}");
|
||||
};
|
||||
hybrid_binary_admitted(binary.op, &binary.modifier, &binary.lhs, &binary.rhs)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hybrid_binary_admission_is_narrow() {
|
||||
for query in ["A / B", "rate(A[1m]) / rate(B[1m])"] {
|
||||
assert!(hybrid_admission(query), "{query}");
|
||||
}
|
||||
assert!(hybrid_admission("A + on(job) B"));
|
||||
for query in [
|
||||
"A and B",
|
||||
"A + on() B",
|
||||
"A + on(job) group_left B",
|
||||
"sum(A) / B",
|
||||
"100 / B",
|
||||
"A / (B + C)",
|
||||
] {
|
||||
assert!(!hybrid_admission(query), "{query}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn millisecond_scan_bounds_preserve_open_closed_and_maximum_edges() {
|
||||
let inclusive = scan_time_filter(0, 10, true, "ts").to_string();
|
||||
|
||||
@@ -2662,16 +2662,26 @@ async fn native_fused_cross_table_pair_reads_each_table_through_its_own_source()
|
||||
}
|
||||
}
|
||||
|
||||
/// Two selectors over tables split into regions keep the existing plan, across tables as on one.
|
||||
/// Two selectors over tables split into regions are not one two-input node, across tables as on
|
||||
/// one; the hybrid route may still plan each operand natively under the existing join.
|
||||
#[tokio::test]
|
||||
async fn native_fused_cross_table_pair_on_partitioned_tables_falls_back() {
|
||||
fn has_pair_node(plan: &LogicalPlan) -> bool {
|
||||
if let LogicalPlan::Extension(Extension { node }) = plan
|
||||
&& let Some(fused) = node.as_any().downcast_ref::<FusedSeries>()
|
||||
&& fused.inputs().len() > 1
|
||||
{
|
||||
return true;
|
||||
}
|
||||
plan.inputs().into_iter().any(has_pair_node)
|
||||
}
|
||||
let plan = build_native_fused_plan_with_provider(
|
||||
"some_metric / other_metric",
|
||||
true,
|
||||
native_two_table_provider(vec![0]).await,
|
||||
)
|
||||
.await;
|
||||
assert!(find_fused_series(&plan).is_none(), "{plan}");
|
||||
assert!(!has_pair_node(&plan), "{plan}");
|
||||
}
|
||||
|
||||
/// `some_metric` as [`native_two_table_provider`] builds it, and `other_metric` whose tags are
|
||||
|
||||
@@ -67,23 +67,32 @@
|
||||
//! existing plan. [`check_fallback_case`] pins that the computed scalars do, and that a fold over a
|
||||
//! second source and a subquery keep it too.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fmt;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use common_base::Plugins;
|
||||
use common_query::OutputData;
|
||||
use common_recordbatch::RecordBatches;
|
||||
use common_recordbatch::util::collect_batches;
|
||||
use datafusion::physical_plan::ExecutionPlan;
|
||||
use datafusion::common::tree_node::TreeNodeRecursion;
|
||||
use datafusion::execution::TaskContext;
|
||||
use datafusion::physical_expr::PhysicalExpr;
|
||||
use datafusion::physical_plan::coalesce_partitions::CoalescePartitionsExec;
|
||||
use datafusion::physical_plan::coop::CooperativeExec;
|
||||
use datafusion::physical_plan::displayable;
|
||||
use datafusion::physical_plan::metrics::MetricValue;
|
||||
use datafusion::physical_plan::projection::ProjectionExec;
|
||||
use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
|
||||
use datafusion::physical_plan::{
|
||||
DisplayAs, DisplayFormatType, ExecutionPlan, PlanProperties, SendableRecordBatchStream,
|
||||
};
|
||||
use datatypes::arrow::array::{Array, ArrayRef, Float64Array, Int64Array, StringArray};
|
||||
use datatypes::arrow::compute::cast;
|
||||
use datatypes::arrow::datatypes::DataType;
|
||||
use datatypes::arrow::record_batch::RecordBatch;
|
||||
use frontend::instance::Instance;
|
||||
use futures::StreamExt;
|
||||
use query::datafusion::QUERY_PARALLELISM_HINT;
|
||||
use query::dist_plan::MergeScanExec;
|
||||
use query::options::QueryOptions;
|
||||
@@ -116,8 +125,17 @@ const EVAL_MILLIS: i64 = EVAL_SECS * 1000;
|
||||
const GAUGE_TABLE: &str = "native_agg_gauge";
|
||||
/// The table the `rate` queries read: the same five series as [`GAUGE_TABLE`], holding a counter.
|
||||
const COUNTER_TABLE: &str = "native_agg_counter";
|
||||
/// The right operand of the hybrid binary cases. It has one region but a partition column, so a
|
||||
/// pair reading it is declined by the two-scan native route and planned by the hybrid one.
|
||||
const COUNTER_RHS_TABLE: &str = "native_agg_counter_rhs";
|
||||
/// The table the range-window smoke test reads.
|
||||
const RANGE_TABLE: &str = "native_agg_range";
|
||||
const HYBRID_MATCH_LEFT: &str = "native_hybrid_match_left";
|
||||
/// Partitioned like [`COUNTER_RHS_TABLE`], so the hybrid route plans a pair reading it.
|
||||
const HYBRID_MATCH_RIGHT: &str = "native_hybrid_match_right";
|
||||
/// The same rows as [`HYBRID_MATCH_RIGHT`] without a partition column, so the two-scan route plans
|
||||
/// a pair reading it.
|
||||
const HYBRID_MATCH_RIGHT_UNPARTITIONED: &str = "native_hybrid_match_right_unpartitioned";
|
||||
/// The tolerance of the value comparisons: relative, with an absolute floor.
|
||||
const FLOAT_TOLERANCE: f64 = 1e-10;
|
||||
|
||||
@@ -182,6 +200,8 @@ const COUNTER_EAST_REGION: &str = "us-east-1";
|
||||
#[derive(Default)]
|
||||
struct NativePlanObserver {
|
||||
seen: Mutex<Vec<Arc<dyn ExecutionPlan>>>,
|
||||
record_original_streams: bool,
|
||||
partial_batches: Arc<Mutex<Vec<(usize, RecordBatch)>>>,
|
||||
}
|
||||
|
||||
impl NativePlanObserver {
|
||||
@@ -194,6 +214,10 @@ impl NativePlanObserver {
|
||||
fn since(&self, mark: usize) -> Vec<Arc<dyn ExecutionPlan>> {
|
||||
self.seen.lock().unwrap()[mark..].to_vec()
|
||||
}
|
||||
|
||||
fn partial_batches(&self) -> Vec<(usize, RecordBatch)> {
|
||||
std::mem::take(&mut *self.partial_batches.lock().unwrap())
|
||||
}
|
||||
}
|
||||
|
||||
impl PhysicalPlanWrapper for NativePlanObserver {
|
||||
@@ -202,8 +226,84 @@ impl PhysicalPlanWrapper for NativePlanObserver {
|
||||
origin: Arc<dyn ExecutionPlan>,
|
||||
_ctx: QueryContextRef,
|
||||
) -> Arc<dyn ExecutionPlan> {
|
||||
self.seen.lock().unwrap().push(Arc::clone(&origin));
|
||||
origin
|
||||
if origin.name() == FUSED_EXEC_NAME && self.record_original_streams {
|
||||
let recorder = Arc::new(PartialStateRecorder {
|
||||
input: Arc::clone(&origin),
|
||||
properties: Arc::clone(origin.properties()),
|
||||
batches: Arc::clone(&self.partial_batches),
|
||||
});
|
||||
self.seen
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(Arc::clone(&recorder) as Arc<dyn ExecutionPlan>);
|
||||
recorder
|
||||
} else {
|
||||
self.seen.lock().unwrap().push(Arc::clone(&origin));
|
||||
origin
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Transparent test wrapper that records the fused producer's original partition streams.
|
||||
#[derive(Debug)]
|
||||
struct PartialStateRecorder {
|
||||
input: Arc<dyn ExecutionPlan>,
|
||||
properties: Arc<PlanProperties>,
|
||||
batches: Arc<Mutex<Vec<(usize, RecordBatch)>>>,
|
||||
}
|
||||
|
||||
impl DisplayAs for PartialStateRecorder {
|
||||
fn fmt_as(&self, format: DisplayFormatType, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
self.input.fmt_as(format, f)
|
||||
}
|
||||
}
|
||||
|
||||
impl ExecutionPlan for PartialStateRecorder {
|
||||
fn name(&self) -> &str {
|
||||
"PartialStateRecorder"
|
||||
}
|
||||
fn properties(&self) -> &Arc<PlanProperties> {
|
||||
&self.properties
|
||||
}
|
||||
fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
|
||||
vec![&self.input]
|
||||
}
|
||||
fn apply_expressions(
|
||||
&self,
|
||||
f: &mut dyn FnMut(&Arc<dyn PhysicalExpr>) -> datafusion::common::Result<TreeNodeRecursion>,
|
||||
) -> datafusion::common::Result<TreeNodeRecursion> {
|
||||
self.input.apply_expressions(f)
|
||||
}
|
||||
fn with_new_children(
|
||||
self: Arc<Self>,
|
||||
mut children: Vec<Arc<dyn ExecutionPlan>>,
|
||||
) -> datafusion::common::Result<Arc<dyn ExecutionPlan>> {
|
||||
if children.len() != 1 {
|
||||
return Err(datafusion::error::DataFusionError::Internal(
|
||||
"PartialStateRecorder expects one child".into(),
|
||||
));
|
||||
}
|
||||
Ok(Arc::new(Self {
|
||||
input: children.remove(0),
|
||||
properties: Arc::clone(&self.properties),
|
||||
batches: Arc::clone(&self.batches),
|
||||
}))
|
||||
}
|
||||
fn execute(
|
||||
&self,
|
||||
partition: usize,
|
||||
context: Arc<TaskContext>,
|
||||
) -> datafusion::common::Result<SendableRecordBatchStream> {
|
||||
let stream = self.input.execute(partition, context)?;
|
||||
let batches = Arc::clone(&self.batches);
|
||||
let schema = stream.schema();
|
||||
let recorded = stream.map(move |result| {
|
||||
if let Ok(batch) = &result {
|
||||
batches.lock().unwrap().push((partition, batch.clone()));
|
||||
}
|
||||
result
|
||||
});
|
||||
Ok(Box::pin(RecordBatchStreamAdapter::new(schema, recorded)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -434,6 +534,463 @@ async fn run_query(
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn native_hybrid_binary_05_06_08() {
|
||||
common_telemetry::init_default_ut_logging();
|
||||
let observer = Arc::new(NativePlanObserver::default());
|
||||
let plugins = Plugins::new();
|
||||
plugins.insert(Arc::clone(&observer) as PhysicalPlanWrapperRef);
|
||||
let standalone = GreptimeDbStandaloneBuilder::new("native_hybrid_binary")
|
||||
.with_query_options(QueryOptions {
|
||||
experimental_native_promql: true,
|
||||
..Default::default()
|
||||
})
|
||||
.with_plugin(plugins)
|
||||
.build()
|
||||
.await;
|
||||
let instance = Arc::clone(standalone.fe_instance());
|
||||
setup_fixture(&instance).await;
|
||||
execute_sql(&instance, &format!("CREATE TABLE {HYBRID_MATCH_LEFT} (ts TIMESTAMP(3) TIME INDEX, job STRING, instance STRING, v DOUBLE, PRIMARY KEY (job, instance))")).await;
|
||||
execute_sql(&instance, &format!("CREATE TABLE {HYBRID_MATCH_RIGHT} (ts TIMESTAMP(3) TIME INDEX, job STRING, instance STRING, v DOUBLE, PRIMARY KEY (job, instance)) PARTITION ON COLUMNS (job) ()")).await;
|
||||
execute_sql(&instance, &format!("CREATE TABLE {HYBRID_MATCH_RIGHT_UNPARTITIONED} (ts TIMESTAMP(3) TIME INDEX, job STRING, instance STRING, v DOUBLE, PRIMARY KEY (job, instance))")).await;
|
||||
execute_sql(
|
||||
&instance,
|
||||
&format!("INSERT INTO {HYBRID_MATCH_LEFT} VALUES ({EVAL_MILLIS}, 'api', 'a1', 10)"),
|
||||
)
|
||||
.await;
|
||||
for table in [HYBRID_MATCH_RIGHT, HYBRID_MATCH_RIGHT_UNPARTITIONED] {
|
||||
execute_sql(&instance, &format!("INSERT INTO {table} VALUES ({EVAL_MILLIS}, 'api', 'b1', 2), ({EVAL_MILLIS}, 'other', 'b2', 3), ({EVAL_MILLIS}, 'other', 'b3', 4)" )).await;
|
||||
}
|
||||
let propagated = format!("{HYBRID_MATCH_LEFT}{{job=\"api\"}} / on(job) {HYBRID_MATCH_RIGHT}");
|
||||
let (legacy, _) = run_query(&instance, &propagated, false, &observer).await;
|
||||
assert_eq!(
|
||||
vec![5.0],
|
||||
legacy
|
||||
.rows
|
||||
.iter()
|
||||
.map(|row| row.value.unwrap())
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
let mark = observer.mark();
|
||||
let (hybrid, capture) = run_query(&instance, &propagated, true, &observer).await;
|
||||
assert_eq!(
|
||||
vec![5.0],
|
||||
hybrid
|
||||
.rows
|
||||
.iter()
|
||||
.map(|row| row.value.unwrap())
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
assert_eq!(
|
||||
2,
|
||||
capture.fused.len(),
|
||||
"both binary operands use native execution"
|
||||
);
|
||||
let plans = observer.since(mark);
|
||||
let frontend = plans
|
||||
.iter()
|
||||
.find(|plan| find_binary_join(plan).is_some())
|
||||
.expect("frontend Join plan");
|
||||
let join = find_binary_join(frontend).unwrap();
|
||||
let uniqueness = join
|
||||
.children()
|
||||
.iter()
|
||||
.map(|branch| uniqueness_operators_above_merge(branch))
|
||||
.fold((false, false), |(window, filter), found| {
|
||||
(window || found.0, filter || found.1)
|
||||
});
|
||||
assert!(
|
||||
uniqueness.0 && uniqueness.1,
|
||||
"on(job) FE uniqueness operators: {}",
|
||||
displayable(frontend.as_ref()).indent(false)
|
||||
);
|
||||
|
||||
// On the two-scan route the right side's two `job="other"` series are a duplicate match group,
|
||||
// which Prometheus refuses whether or not the left side has a series to match them with
|
||||
// (`VectorBinop` in engine.go). The 5 above comes from the matcher `{job="api"}` the existing
|
||||
// plan copies to the right side before matching.
|
||||
let unpartitioned =
|
||||
format!("{HYBRID_MATCH_LEFT}{{job=\"api\"}} / on(job) {HYBRID_MATCH_RIGHT_UNPARTITIONED}");
|
||||
let mut query_ctx = QueryContext::with_db_name(None);
|
||||
query_ctx.set_extension(NATIVE_PROMQL_EXTENSION_KEY, "true".to_string());
|
||||
let output = instance
|
||||
.do_query(&tql_eval(&unpartitioned), Arc::new(query_ctx))
|
||||
.await
|
||||
.remove(0)
|
||||
.expect("the query plans");
|
||||
let error = match output.data {
|
||||
OutputData::Stream(stream) => collect_batches(stream)
|
||||
.await
|
||||
.expect_err("a duplicate match group fails"),
|
||||
other => panic!("`{unpartitioned}` was expected to stream: {other:?}"),
|
||||
};
|
||||
assert!(
|
||||
error.to_string().contains(
|
||||
"found duplicate series for the match group {job=\"other\"} on the right hand-side"
|
||||
),
|
||||
"{unpartitioned}: {error}"
|
||||
);
|
||||
|
||||
check_hybrid_binary_cases(&instance, &observer).await;
|
||||
let subquery = format!(
|
||||
"max_over_time((rate({COUNTER_TABLE}[5m]) / on(job, instance) rate({COUNTER_RHS_TABLE}[5m]))[2m:1m])"
|
||||
);
|
||||
let (legacy, legacy_capture) = run_query(&instance, &subquery, false, &observer).await;
|
||||
let (native, native_capture) = run_query(&instance, &subquery, true, &observer).await;
|
||||
assert!(
|
||||
legacy_capture.fused.is_empty() && native_capture.fused.is_empty(),
|
||||
"hybrid must be disabled inside subquery: {subquery}"
|
||||
);
|
||||
assert_eq!(
|
||||
legacy.rows.len(),
|
||||
native.rows.len(),
|
||||
"subquery legacy/native row count"
|
||||
);
|
||||
assert_rows(
|
||||
&native.rows,
|
||||
&legacy
|
||||
.rows
|
||||
.iter()
|
||||
.map(|row| ResultRow {
|
||||
ts_millis: row.ts_millis,
|
||||
labels: row.labels.clone(),
|
||||
value: row.value,
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
&format!("subquery fallback `{subquery}`"),
|
||||
);
|
||||
check_reduced_label_matching(&instance, &observer).await;
|
||||
}
|
||||
|
||||
async fn check_reduced_label_matching(instance: &Arc<Instance>, observer: &NativePlanObserver) {
|
||||
let unique_query = format!(
|
||||
"{GAUGE_TABLE}{{instance=~\"h1|h4\"}} / on(job) {COUNTER_RHS_TABLE}{{instance=~\"h1|h4\"}}"
|
||||
);
|
||||
let (legacy, legacy_capture) = run_query(instance, &unique_query, false, observer).await;
|
||||
assert!(legacy_capture.fused.is_empty());
|
||||
let mark = observer.mark();
|
||||
let (native, capture) = run_query(instance, &unique_query, true, observer).await;
|
||||
assert_eq!(
|
||||
legacy.rows.len(),
|
||||
native.rows.len(),
|
||||
"{unique_query}: row count"
|
||||
);
|
||||
assert_rows(
|
||||
&native.rows,
|
||||
&legacy
|
||||
.rows
|
||||
.iter()
|
||||
.map(|row| ResultRow {
|
||||
ts_millis: row.ts_millis,
|
||||
labels: row.labels.clone(),
|
||||
value: row.value,
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
&format!("on(job) native == legacy `{unique_query}`"),
|
||||
);
|
||||
assert_eq!(
|
||||
1,
|
||||
native.rows.len(),
|
||||
"the matching instance filters leave one matching job"
|
||||
);
|
||||
assert_eq!(2, capture.fused.len(), "{unique_query}");
|
||||
let plans = observer.since(mark);
|
||||
let frontend = plans
|
||||
.iter()
|
||||
.find(|plan| find_binary_join(plan).is_some())
|
||||
.expect("frontend Join plan");
|
||||
let join = find_binary_join(frontend).unwrap();
|
||||
let uniqueness = join
|
||||
.children()
|
||||
.iter()
|
||||
.map(|branch| uniqueness_operators_above_merge(branch))
|
||||
.fold((false, false), |(window, assertion_filter), found| {
|
||||
(window || found.0, assertion_filter || found.1)
|
||||
});
|
||||
assert!(
|
||||
uniqueness.0,
|
||||
"on(job) requires a frontend uniqueness WindowAggExec above MergeScan: {}",
|
||||
displayable(frontend.as_ref()).indent(false)
|
||||
);
|
||||
assert!(
|
||||
uniqueness.1,
|
||||
"on(job) requires a frontend prom_assert_unique_match_group FilterExec above MergeScan: {}",
|
||||
displayable(frontend.as_ref()).indent(false)
|
||||
);
|
||||
for branch in join.children() {
|
||||
assert!(
|
||||
contains_execution_node(branch, "MergeScanExec"),
|
||||
"on(job) FE branch must retain remote boundary: {}",
|
||||
displayable(frontend.as_ref()).indent(false)
|
||||
);
|
||||
assert!(
|
||||
!contains_execution_node(branch, FUSED_EXEC_NAME),
|
||||
"on(job) fused node must stay in region"
|
||||
);
|
||||
}
|
||||
for fused in &capture.fused {
|
||||
assert_fused_reads_scan_directly(fused, &unique_query);
|
||||
}
|
||||
|
||||
let duplicate_query = format!("{GAUGE_TABLE} / on(job) {COUNTER_RHS_TABLE}");
|
||||
for enabled in [false, true] {
|
||||
let sql = tql_eval(&duplicate_query);
|
||||
let mut query_ctx = QueryContext::with_db_name(None);
|
||||
query_ctx.set_extension(QUERY_PARALLELISM_HINT, SCAN_PARTITIONS.to_string());
|
||||
query_ctx.set_extension(NATIVE_PROMQL_EXTENSION_KEY, enabled.to_string());
|
||||
let output = instance
|
||||
.do_query(&sql, Arc::new(query_ctx))
|
||||
.await
|
||||
.remove(0)
|
||||
.expect("duplicate query should produce an execution stream");
|
||||
let error = match output.data {
|
||||
OutputData::Stream(stream) => collect_batches(stream)
|
||||
.await
|
||||
.expect_err("duplicate on(job) matches must fail"),
|
||||
OutputData::RecordBatches(_) => panic!("duplicate on(job) matches must fail"),
|
||||
other => panic!("unexpected duplicate-query output: {other:?}"),
|
||||
};
|
||||
let message = error.to_string().to_ascii_lowercase();
|
||||
assert!(
|
||||
message.contains("many-to-many")
|
||||
|| message.contains("multiple matches")
|
||||
|| message.contains("duplicate"),
|
||||
"`{sql}` should fail for duplicate matching groups, got: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async fn check_hybrid_binary_cases(instance: &Arc<Instance>, observer: &NativePlanObserver) {
|
||||
let cases = [
|
||||
(
|
||||
format!("{GAUGE_TABLE} / on(job, instance) {COUNTER_RHS_TABLE}{{instance!=\"h5\"}}"),
|
||||
4usize,
|
||||
),
|
||||
(
|
||||
format!(
|
||||
"100 * (1 - ({GAUGE_TABLE} / on(job, instance) {COUNTER_RHS_TABLE}{{instance!=\"h5\"}}))"
|
||||
),
|
||||
4usize,
|
||||
),
|
||||
(
|
||||
format!("rate({COUNTER_TABLE}[5m]) / on(job, instance) rate({COUNTER_RHS_TABLE}[5m])"),
|
||||
5usize,
|
||||
),
|
||||
];
|
||||
for (index, (promql, expected_rows)) in cases.into_iter().enumerate() {
|
||||
let (legacy, legacy_capture) = run_query(instance, &promql, false, observer).await;
|
||||
assert!(legacy_capture.fused.is_empty());
|
||||
let mark = observer.mark();
|
||||
let (native, capture) = run_query(instance, &promql, true, observer).await;
|
||||
assert_eq!(
|
||||
legacy.rows.len(),
|
||||
native.rows.len(),
|
||||
"{promql}: full row count"
|
||||
);
|
||||
assert_rows(
|
||||
&native.rows,
|
||||
&legacy
|
||||
.rows
|
||||
.iter()
|
||||
.map(|row| ResultRow {
|
||||
ts_millis: row.ts_millis,
|
||||
labels: row.labels.clone(),
|
||||
value: row.value,
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
&format!("native == legacy `{promql}`"),
|
||||
);
|
||||
assert_eq!(expected_rows, native.rows.len(), "{promql}");
|
||||
assert_eq!(
|
||||
legacy
|
||||
.columns
|
||||
.iter()
|
||||
.filter(|column| column.ends_with("ts"))
|
||||
.count(),
|
||||
1,
|
||||
"{promql}: legacy timestamp column"
|
||||
);
|
||||
assert_eq!(
|
||||
native
|
||||
.columns
|
||||
.iter()
|
||||
.filter(|column| column.ends_with("ts"))
|
||||
.count(),
|
||||
1,
|
||||
"{promql}: native timestamp column"
|
||||
);
|
||||
if index == 2 {
|
||||
let expected = [0.5; 5];
|
||||
assert!(
|
||||
native.rows.iter().zip(expected).all(|(row, expected)| row
|
||||
.value
|
||||
.is_some_and(|value| close_enough(value, expected))),
|
||||
"{promql}: independently scaled RHS rates must have expected ratios: {:#?}",
|
||||
native.rows
|
||||
);
|
||||
}
|
||||
if index < 2 {
|
||||
assert!(
|
||||
native
|
||||
.rows
|
||||
.iter()
|
||||
.all(|row| row.labels.get("instance").is_none_or(|v| v != "h5")),
|
||||
"one-to-one match must drop unmatched series"
|
||||
);
|
||||
}
|
||||
assert_eq!(2, capture.fused.len(), "{promql}: plans since {mark}");
|
||||
let region_plans = observer
|
||||
.since(mark)
|
||||
.into_iter()
|
||||
.filter(|plan| contains_execution_node(plan, "SeriesScanExec"))
|
||||
.collect::<Vec<_>>();
|
||||
let scan_tables = region_plans
|
||||
.iter()
|
||||
.map(|plan| {
|
||||
let text = displayable(plan.as_ref()).indent(false).to_string();
|
||||
if text.contains("native_agg_gauge") {
|
||||
GAUGE_TABLE
|
||||
} else if text.contains(COUNTER_RHS_TABLE) {
|
||||
COUNTER_RHS_TABLE
|
||||
} else if text.contains(COUNTER_TABLE) {
|
||||
COUNTER_TABLE
|
||||
} else {
|
||||
panic!("{promql}: unable to identify scan table: {text}")
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert!(
|
||||
scan_tables.contains(&GAUGE_TABLE) || scan_tables.contains(&COUNTER_TABLE),
|
||||
"{promql}: gauge/counter source missing: {scan_tables:?}"
|
||||
);
|
||||
assert!(
|
||||
scan_tables.contains(&COUNTER_RHS_TABLE),
|
||||
"{promql}: independent RHS scan missing: {scan_tables:?}"
|
||||
);
|
||||
for fused in &capture.fused {
|
||||
assert_fused_reads_scan_directly(fused, &format!("hybrid `{promql}`"));
|
||||
}
|
||||
for fused in &capture.fused {
|
||||
let per_fused = NativeCapture {
|
||||
fused: vec![Arc::clone(fused)],
|
||||
finalize: Vec::new(),
|
||||
};
|
||||
let counts =
|
||||
fused_output_values_per_partition(&per_fused, &format!("hybrid `{promql}`"));
|
||||
assert!(
|
||||
counts.iter().all(|count| count.is_some_and(|n| n > 0)),
|
||||
"{promql}: each scan partition must emit values, got {counts:?}"
|
||||
);
|
||||
}
|
||||
let plans = observer.since(mark);
|
||||
let frontend = plans
|
||||
.iter()
|
||||
.find(|plan| find_binary_join(plan).is_some())
|
||||
.unwrap_or_else(|| panic!("{promql}: FE JoinExec missing; plans: {plans:#?}"));
|
||||
let join = find_binary_join(frontend).unwrap();
|
||||
assert_eq!(
|
||||
2,
|
||||
join.children().len(),
|
||||
"{promql}: FE join branches: {}",
|
||||
displayable(frontend.as_ref()).indent(false)
|
||||
);
|
||||
for branch in join.children() {
|
||||
assert!(
|
||||
contains_execution_node(branch, "MergeScanExec"),
|
||||
"{promql}: FE Join branch lacks MergeScanExec: {}",
|
||||
displayable(frontend.as_ref()).indent(false)
|
||||
);
|
||||
assert!(
|
||||
!contains_execution_node(branch, FUSED_EXEC_NAME),
|
||||
"{promql}: FusedSeriesExec leaked onto frontend: {}",
|
||||
displayable(frontend.as_ref()).indent(false)
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
!is_nested_under_merge(frontend, "HashJoinExec"),
|
||||
"{promql}: FE Join is under MergeScan: {}",
|
||||
displayable(frontend.as_ref()).indent(false)
|
||||
);
|
||||
if index == 1 {
|
||||
assert!(
|
||||
has_projection_parent_of_join(frontend),
|
||||
"06 outer scalar arithmetic must project above JoinExec: {}",
|
||||
displayable(frontend.as_ref()).indent(false)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn find_binary_join(plan: &Arc<dyn ExecutionPlan>) -> Option<&Arc<dyn ExecutionPlan>> {
|
||||
if matches!(
|
||||
plan.name(),
|
||||
"HashJoinExec" | "NestedLoopJoinExec" | "CrossJoinExec"
|
||||
) {
|
||||
return Some(plan);
|
||||
}
|
||||
plan.children()
|
||||
.iter()
|
||||
.find_map(|child| find_binary_join(child))
|
||||
}
|
||||
|
||||
fn find_execution_node<'a>(
|
||||
plan: &'a Arc<dyn ExecutionPlan>,
|
||||
name: &str,
|
||||
) -> Option<&'a Arc<dyn ExecutionPlan>> {
|
||||
if plan.name() == name {
|
||||
return Some(plan);
|
||||
}
|
||||
plan.children()
|
||||
.iter()
|
||||
.find_map(|child| find_execution_node(child, name))
|
||||
}
|
||||
|
||||
fn contains_execution_node(plan: &Arc<dyn ExecutionPlan>, name: &str) -> bool {
|
||||
find_execution_node(plan, name).is_some()
|
||||
}
|
||||
|
||||
/// Finds the PromQL uniqueness validation operators on the frontend side of a remote scan.
|
||||
/// Traversal stops at each MergeScan so it cannot accidentally match region-side execution.
|
||||
fn uniqueness_operators_above_merge(plan: &Arc<dyn ExecutionPlan>) -> (bool, bool) {
|
||||
if plan.name() == "MergeScanExec" {
|
||||
return (false, false);
|
||||
}
|
||||
|
||||
let mut has_window = matches!(plan.name(), "WindowAggExec" | "BoundedWindowAggExec");
|
||||
let mut has_assertion_filter = plan.name() == "FilterExec"
|
||||
&& format!("{plan:?}").contains("prom_assert_unique_match_group");
|
||||
for child in plan.children() {
|
||||
let (child_window, child_filter) = uniqueness_operators_above_merge(child);
|
||||
has_window |= child_window;
|
||||
has_assertion_filter |= child_filter;
|
||||
}
|
||||
(has_window, has_assertion_filter)
|
||||
}
|
||||
|
||||
fn is_nested_under_merge(plan: &Arc<dyn ExecutionPlan>, name: &str) -> bool {
|
||||
fn walk(plan: &Arc<dyn ExecutionPlan>, under_merge: bool, name: &str) -> bool {
|
||||
if under_merge && plan.name() == name {
|
||||
return true;
|
||||
}
|
||||
plan.children()
|
||||
.iter()
|
||||
.any(|child| walk(child, under_merge || plan.name() == "MergeScanExec", name))
|
||||
}
|
||||
walk(plan, false, name)
|
||||
}
|
||||
|
||||
fn has_projection_parent_of_join(plan: &Arc<dyn ExecutionPlan>) -> bool {
|
||||
plan.name() == "ProjectionExec"
|
||||
&& plan
|
||||
.children()
|
||||
.iter()
|
||||
.any(|child| find_binary_join(child).is_some())
|
||||
|| plan
|
||||
.children()
|
||||
.iter()
|
||||
.any(|child| has_projection_parent_of_join(child))
|
||||
}
|
||||
|
||||
/// Executes one statement and drops its result.
|
||||
async fn execute_sql(instance: &Arc<Instance>, sql: &str) {
|
||||
for result in instance.do_query(sql, QueryContext::arc()).await {
|
||||
@@ -499,6 +1056,7 @@ async fn setup_fixture(instance: &Arc<Instance>) {
|
||||
}
|
||||
|
||||
execute_sql(instance, &counter_table_sql()).await;
|
||||
execute_sql(instance, &format!("CREATE TABLE {COUNTER_RHS_TABLE} (ts TIMESTAMP(3) TIME INDEX, job STRING, instance STRING, v DOUBLE, PRIMARY KEY (job, instance)) PARTITION ON COLUMNS (job) () WITH ('compaction.type' = 'twcs', 'compaction.twcs.trigger_file_num' = '100')")).await;
|
||||
let (counter_first, counter_second) = (
|
||||
COUNTER_FIRST_SECS * 1000,
|
||||
(COUNTER_FIRST_SECS + COUNTER_STEP_SECS) * 1000,
|
||||
@@ -521,8 +1079,15 @@ async fn setup_fixture(instance: &Arc<Instance>) {
|
||||
),
|
||||
)
|
||||
.await;
|
||||
let rhs_second_value = COUNTER_FIRST_VALUE + 2.0 * increase;
|
||||
execute_sql(instance, &format!("INSERT INTO {COUNTER_RHS_TABLE} (ts, job, instance, v) VALUES ({counter_first}, '{job}', '{host}', {COUNTER_FIRST_VALUE:?}), ({counter_second}, '{job}', '{host}', {rhs_second_value:?})")).await;
|
||||
if index + 1 < COUNTER_SERIES.len() {
|
||||
execute_sql(instance, &format!("ADMIN FLUSH_TABLE('{COUNTER_TABLE}')")).await;
|
||||
execute_sql(
|
||||
instance,
|
||||
&format!("ADMIN FLUSH_TABLE('{COUNTER_RHS_TABLE}')"),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -950,6 +1515,197 @@ async fn check_case(instance: &Arc<Instance>, observer: &NativePlanObserver, cas
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn native_aggregate_topk_over_global_partitions() {
|
||||
common_telemetry::init_default_ut_logging();
|
||||
const TABLE: &str = "native_topk_counters";
|
||||
const T1: i64 = BASE_SECS + 180;
|
||||
const T2: i64 = BASE_SECS + 600;
|
||||
let observer = Arc::new(NativePlanObserver {
|
||||
record_original_streams: true,
|
||||
..Default::default()
|
||||
});
|
||||
let plugins = Plugins::new();
|
||||
plugins.insert(Arc::clone(&observer) as PhysicalPlanWrapperRef);
|
||||
let standalone = GreptimeDbStandaloneBuilder::new("native_aggregate_topk")
|
||||
.with_query_options(QueryOptions {
|
||||
experimental_native_promql: true,
|
||||
..Default::default()
|
||||
})
|
||||
.with_plugin(plugins)
|
||||
.build()
|
||||
.await;
|
||||
let instance = Arc::clone(standalone.fe_instance());
|
||||
execute_sql(&instance, &format!(
|
||||
"CREATE TABLE {TABLE} (ts TIMESTAMP(3) TIME INDEX, hostname STRING, device STRING, \"region\" STRING, v DOUBLE, PRIMARY KEY (hostname, device, \"region\")) WITH ('compaction.type' = 'twcs', 'compaction.twcs.trigger_file_num' = '100')"
|
||||
)).await;
|
||||
// Flush host00/dev0 first and host00/dev1 last; flush each of the 28 series independently.
|
||||
let order = std::iter::once((0usize, 0usize))
|
||||
.chain((1..14).flat_map(|host| (0..2).map(move |device| (host, device))))
|
||||
.chain(std::iter::once((0usize, 1usize)));
|
||||
for (host, device) in order {
|
||||
let slope_before = 140 - 10 * host as i64;
|
||||
let slope_after = 10 + 10 * host as i64;
|
||||
let mut values = Vec::new();
|
||||
for elapsed in (0i64..=600).step_by(30) {
|
||||
let value =
|
||||
100_000 + slope_before * elapsed.min(300) + slope_after * (elapsed - 300).max(0);
|
||||
values.push(format!(
|
||||
"({}, 'host{host:02}', 'dev{device}', 'us-west-2', {value}.0)",
|
||||
(BASE_SECS + elapsed) * 1000
|
||||
));
|
||||
}
|
||||
execute_sql(
|
||||
&instance,
|
||||
&format!(
|
||||
"INSERT INTO {TABLE} (ts, hostname, device, \"region\", v) VALUES {}",
|
||||
values.join(",")
|
||||
),
|
||||
)
|
||||
.await;
|
||||
execute_sql(&instance, &format!("ADMIN FLUSH_TABLE('{TABLE}')")).await;
|
||||
}
|
||||
let promql = format!(
|
||||
"topk(10, sum by(hostname)(rate({TABLE}{{device!=\"lo\",region=\"us-west-2\"}}[5m])))"
|
||||
);
|
||||
let sql = format!("TQL EVAL ({T1}, {T2}, '7m') {promql}");
|
||||
let run = async |native: bool| {
|
||||
let mark = observer.mark();
|
||||
let mut ctx = QueryContext::with_db_name(None);
|
||||
ctx.set_extension(QUERY_PARALLELISM_HINT, SCAN_PARTITIONS.to_string());
|
||||
ctx.set_extension(NATIVE_PROMQL_EXTENSION_KEY, native.to_string());
|
||||
let output = instance
|
||||
.do_query(&sql, Arc::new(ctx))
|
||||
.await
|
||||
.remove(0)
|
||||
.unwrap();
|
||||
let batches = match output.data {
|
||||
OutputData::Stream(stream) => collect_batches(stream).await.unwrap(),
|
||||
OutputData::RecordBatches(batches) => batches,
|
||||
other => panic!("unexpected output: {other:?}"),
|
||||
};
|
||||
(
|
||||
QueryResult::from_batches(&batches, &sql),
|
||||
NativeCapture::from_plans(&observer.since(mark)),
|
||||
)
|
||||
};
|
||||
let (legacy, legacy_capture) = run(false).await;
|
||||
assert!(legacy_capture.fused.is_empty() && legacy_capture.finalize.is_empty());
|
||||
let (native, capture) = run(true).await;
|
||||
let partial_batches = observer.partial_batches();
|
||||
assert_same_columns(&native, &legacy, &sql);
|
||||
assert_rows(
|
||||
&native.rows,
|
||||
&legacy.rows,
|
||||
&format!("native vs legacy `{sql}`"),
|
||||
);
|
||||
assert_eq!(20, native.rows.len(), "both timestamps return ten rows");
|
||||
let distinct_timestamps = native
|
||||
.rows
|
||||
.iter()
|
||||
.map(|row| row.ts_millis)
|
||||
.collect::<std::collections::BTreeSet<_>>();
|
||||
assert_eq!(
|
||||
[T1 * 1000, T2 * 1000]
|
||||
.into_iter()
|
||||
.collect::<std::collections::BTreeSet<_>>(),
|
||||
distinct_timestamps
|
||||
);
|
||||
let mut expected = Vec::new();
|
||||
for (ts, hosts, post) in [(T1 * 1000, 0..10, false), (T2 * 1000, 4..14, true)] {
|
||||
for host in hosts {
|
||||
let slope = if post {
|
||||
10 + 10 * host
|
||||
} else {
|
||||
140 - 10 * host
|
||||
};
|
||||
let scale = if post { 2.0 } else { 1.3 };
|
||||
expected.push(ResultRow {
|
||||
ts_millis: ts,
|
||||
labels: [("hostname".to_string(), format!("host{host:02}"))].into(),
|
||||
value: Some(slope as f64 * scale),
|
||||
});
|
||||
}
|
||||
}
|
||||
expected.sort_by(|a, b| (a.ts_millis, &a.labels).cmp(&(b.ts_millis, &b.labels)));
|
||||
assert_rows(&native.rows, &expected, &format!("expected `{sql}`"));
|
||||
assert_eq!(1, capture.fused.len());
|
||||
assert_eq!(1, capture.finalize.len());
|
||||
let fused_plan = displayable(capture.fused[0].as_ref())
|
||||
.indent(false)
|
||||
.to_string();
|
||||
assert!(
|
||||
fused_plan.contains("partial state: sum by (hostname) Complete"),
|
||||
"{fused_plan}"
|
||||
);
|
||||
let producer_tree = fused_plan
|
||||
.split_once("NativeTree")
|
||||
.map(|(_, tree)| tree.split("partial state:").next().unwrap_or(tree))
|
||||
.unwrap_or(&fused_plan);
|
||||
assert!(
|
||||
!producer_tree.to_ascii_lowercase().contains("topk"),
|
||||
"{fused_plan}"
|
||||
);
|
||||
let finalize_plan = displayable(capture.finalize[0].as_ref())
|
||||
.indent(false)
|
||||
.to_string();
|
||||
assert!(
|
||||
finalize_plan.to_ascii_lowercase().contains("topk"),
|
||||
"{finalize_plan}"
|
||||
);
|
||||
let per_partition = fused_output_values_per_partition(&capture, &sql);
|
||||
assert!(
|
||||
per_partition
|
||||
.iter()
|
||||
.all(|count| count.is_some_and(|count| count > 0)),
|
||||
"{per_partition:?}"
|
||||
);
|
||||
// Prove the partition-local states overlap without decoding or summing their values: at each
|
||||
// evaluation point, at least one selected hostname/point/field was emitted by distinct original
|
||||
// producer partitions.
|
||||
let mut owners = BTreeMap::<(String, u64, u32), BTreeSet<usize>>::new();
|
||||
for (partition, batch) in &partial_batches {
|
||||
let schema = batch.schema();
|
||||
// `state_schema` names its fixed columns and labels `metric_name`, `tag_N`, `point` and
|
||||
// `field`; use those names rather than baking their positions into this assertion.
|
||||
let hostname = batch
|
||||
.column(schema.index_of("tag_0").unwrap())
|
||||
.as_any()
|
||||
.downcast_ref::<StringArray>()
|
||||
.unwrap();
|
||||
let batch_point = batch
|
||||
.column(schema.index_of("point").unwrap())
|
||||
.as_any()
|
||||
.downcast_ref::<datafusion::arrow::array::UInt64Array>()
|
||||
.unwrap();
|
||||
let field = batch
|
||||
.column(schema.index_of("field").unwrap())
|
||||
.as_any()
|
||||
.downcast_ref::<datafusion::arrow::array::UInt32Array>()
|
||||
.unwrap();
|
||||
for row in 0..batch.num_rows() {
|
||||
owners
|
||||
.entry((
|
||||
hostname.value(row).to_string(),
|
||||
batch_point.value(row),
|
||||
field.value(row),
|
||||
))
|
||||
.or_default()
|
||||
.insert(*partition);
|
||||
}
|
||||
}
|
||||
for (point, expected_hosts) in [(0u64, 0..10), (1u64, 4..14)] {
|
||||
assert!(
|
||||
expected_hosts.into_iter().any(|host| {
|
||||
owners
|
||||
.get(&(format!("host{host:02}"), point, 0))
|
||||
.is_some_and(|partitions| partitions.len() >= 2)
|
||||
}),
|
||||
"no selected hostname has rows from distinct original producer partitions at point {point}: {owners:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn native_aggregate_complete_over_four_nonempty_mito_partitions() {
|
||||
common_telemetry::init_default_ut_logging();
|
||||
|
||||
@@ -3467,8 +3467,9 @@ async fn native_promql_cross_table_comparisons() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A two-selector query over a table split into regions keeps the existing plan: the two-scan
|
||||
/// native node has no placement there, and the query used to fail in the dist analyzer.
|
||||
/// A two-selector query over a table split into regions is not planned as one two-scan native node:
|
||||
/// that node has no placement there, and the query used to fail in the dist analyzer. The hybrid
|
||||
/// route may still run each operand natively under the existing join.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn native_promql_two_selectors_on_a_partitioned_table_keep_the_legacy_plan() {
|
||||
let (standalone, observer) = native_test_instance(GreptimeDbStandaloneBuilder::new(
|
||||
@@ -3506,7 +3507,12 @@ async fn native_promql_two_selectors_on_a_partitioned_table_keep_the_legacy_plan
|
||||
let sql = format!("TQL EVAL (5, 5, '1s') {query}");
|
||||
observer.clear();
|
||||
let native = native_result_rows(native_tql_output(&instance, &sql, true).await).await;
|
||||
assert!(observer.fused().is_none(), "`{query}` was planned natively");
|
||||
assert!(
|
||||
observer
|
||||
.fused()
|
||||
.is_none_or(|plan| plan.children().len() < 2),
|
||||
"`{query}` was planned as one two-scan native node"
|
||||
);
|
||||
assert_eq!(native, expected, "{query}");
|
||||
let legacy = native_result_rows(native_tql_output(&instance, &sql, false).await).await;
|
||||
assert_eq!(native, legacy, "{query}");
|
||||
|
||||
@@ -1960,13 +1960,22 @@ async fn assert_raw_scan_fused_formats(observer: &NativeFusedObserver, frontend:
|
||||
promql::extension_plan::FusedOutputFormat::PlainRows,
|
||||
promql::extension_plan::FusedOutputFormat::SeriesFrame,
|
||||
] {
|
||||
scan_plan.clone().reset_state().unwrap();
|
||||
let scan_plan = scan_plan.clone().reset_state().unwrap();
|
||||
let exec = promql::extension_plan::FusedSeries::try_new_with_format(
|
||||
Arc::from("native_demo"),
|
||||
tree.clone(),
|
||||
binding.clone(),
|
||||
vec![logical_child.clone()],
|
||||
row_schema.clone().to_dfschema_ref().unwrap(),
|
||||
if format == promql::extension_plan::FusedOutputFormat::SeriesFrame {
|
||||
let packed = common_query::series_frame::pack_evaluated_series(
|
||||
Box::pin(futures::stream::empty()),
|
||||
Arc::clone(&output_schema),
|
||||
)
|
||||
.unwrap();
|
||||
Arc::new(DFSchema::try_from(packed.schema()).unwrap())
|
||||
} else {
|
||||
row_schema.clone().to_dfschema_ref().unwrap()
|
||||
},
|
||||
format,
|
||||
)
|
||||
.unwrap()
|
||||
|
||||
Reference in New Issue
Block a user