Files
greptimedb/tests-integration/tests/native_prefix.rs
T
discord9 1d4aae7371 fix(promql): make the native prefix codec agree with itself
A payload the encoder accepts has to be one this version can decode. Three
places disagreed.

An `Input`'s field count was bounded only on the way in, so a 129-field
`Input → Instant` encoded to 983 bytes and then failed to decode. The
decoder's limit was `MAX_COUNT`, a constant that also bounds nesting depth
and `Map` operation count, which is why the missing half was easy to miss;
each limit now has its own name and both sides check it.

`FusedSeries::serialize` only checked that the tag count fit in a `u32`
while `deserialize` bounded it at 1024. Both sides now share `MAX_TAG_COUNT`.

`FusedSeries::deserialize` passed an empty schema to `try_new_with_format`,
which checks a frame node's declared schema against the layout derived from
its tree rather than overwriting it. Every serialized frame node was
therefore impossible to rebuild. The schema is derived from the decoded tree
now, which is where it comes from in the first place, so nothing is added to
the wire.

Encoding refuses an over-limit count instead of writing a payload it cannot
read back, and a field-count overflow no longer reports "operation count
exceeds maximum".

`rejects_excess_operation_count` hand-built a payload whose operation count
was read from the wrong offset: it placed an `Instant` where the decoder
expects the count, so it exercised the limit with 258 while claiming 129.
It now starts from a tree the codec accepts, asserts the bytes it edits are
the count it means, and checks the error names both the operation limit and
the value. The same is added for field count, tag count and nesting.

The integration test moves to its own file: it was written against
`tests-integration/tests/raw_series.rs`, which the raw-fan-in line creates
and this one does not carry.

Signed-off-by: discord9 <discord9@163.com>
2026-10-29 18:32:44 +08:00

266 lines
10 KiB
Rust

// Copyright 2023 Greptime Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! A serialized native PromQL prefix executed on a datanode over its own region scan.
use std::sync::Arc;
use api::v1::region::{QueryRequest, RegionRequestHeader};
use common_recordbatch::RecordBatches;
use common_recordbatch::adapter::DfRecordBatchStreamAdapter;
use datafusion::logical_expr::{Extension, LogicalPlan};
use datatypes::series::SeriesOrdering;
use datatypes::series::samples::SampleRef;
use datatypes::series::samples::{FieldSpec, SeriesSchema};
use datatypes::series::time::{EvalGrid, GridSpec};
use futures::{StreamExt, TryStreamExt};
use promql::extension_plan::{FusedOutputFormat, FusedSeries, FusedSeriesBinding};
use promql::native::plan::{
BoundInput, BoundWindow, NativeExpr, NativeNode, NativeTree, NativeType,
};
use promql::range_eval::{RangeFunction, RangeWindow};
use query::parser::QueryLanguageParser;
use query::query_engine::DefaultSerializer;
use session::context::QueryContext;
use substrait::{DFLogicalSubstraitConvertor, SubstraitPlan};
use tests_integration::standalone::GreptimeDbStandaloneBuilder;
use tests_integration::test_util::execute_sql;
/// The logical frame schema a `FusedOutputFormat::SeriesFrame` node declares: the shared evaluated
/// packer's layout for the tree's evaluated root, which `try_new_with_format` compares against.
fn evaluated_frame_schema(tree: &NativeTree) -> datafusion::common::DFSchemaRef {
let NativeType::Samples(samples) = &tree.root().output else {
panic!("the prefix root evaluates to samples");
};
let stream = common_query::series_frame::pack_evaluated_series(
Box::pin(futures::stream::empty()),
Arc::clone(samples),
)
.expect("an evaluated root declares a frame");
Arc::new(
datafusion::common::DFSchema::try_from(stream.schema())
.expect("the frame schema converts to a logical schema"),
)
}
#[tokio::test(flavor = "multi_thread")]
async fn native_prefix_executes_over_real_mito_region() {
let instance = GreptimeDbStandaloneBuilder::new("native_prefix_region_scan")
.with_auto_create_table(false)
.build()
.await;
let fe = instance.fe_instance();
execute_sql(fe, "CREATE TABLE native_prefix (ts TIMESTAMP TIME INDEX, val DOUBLE, host STRING, PRIMARY KEY(host))").await;
execute_sql(fe, "INSERT INTO native_prefix(host, val, ts) VALUES ('a', 0.0, 1000), ('a', 10.0, 2000), ('a', 20.0, 3000), ('a', 30.0, 4000)").await;
execute_sql(fe, "ADMIN FLUSH_TABLE('native_prefix')").await;
let ctx = QueryContext::arc();
let statement = QueryLanguageParser::parse_sql("SELECT * FROM native_prefix", &ctx).unwrap();
let planned = fe
.query_engine()
.planner()
.plan(&statement, ctx.clone())
.await
.unwrap();
let datafusion::logical_expr::LogicalPlan::Projection(projection) = planned else {
panic!("expected projection: {planned:?}");
};
let datafusion::logical_expr::LogicalPlan::TableScan(scan) = projection.input.as_ref() else {
panic!("expected table scan: {projection:?}");
};
let scan = scan.clone();
let child = LogicalPlan::TableScan(datafusion::logical_expr::logical_plan::TableScan {
projection: Some((0..scan.projected_schema.fields().len()).collect()),
..scan
});
let grid = Arc::new(EvalGrid::try_new(GridSpec::try_new(3000, 1000, 2).unwrap()).unwrap());
let raw = Arc::new(SeriesSchema {
fields: Arc::from([FieldSpec {
name: Arc::from("val"),
}]),
eval_grid: None,
});
let evaluated = Arc::new(SeriesSchema {
fields: Arc::clone(&raw.fields),
eval_grid: Some(Arc::clone(&grid)),
});
// The input node's output must be the same binding as its `BoundInput.output`: the tree
// compares those two by allocation identity, so an equal-but-separate schema is refused.
let raw_type = NativeType::Samples(raw);
let tree = NativeTree::try_new(NativeNode {
expr: NativeExpr::Window {
input: Box::new(NativeNode {
expr: NativeExpr::Input(BoundInput {
source_index: 0,
output: raw_type.clone(),
ordering: SeriesOrdering::KeyThenTime,
}),
output: raw_type,
}),
op: BoundWindow {
function: RangeFunction::Rate,
window: RangeWindow {
range_ms: 3000,
offset_ms: 0,
anchor_ms: None,
modifier: None,
},
input_grid: None,
output_grid: Arc::clone(&grid),
},
},
output: NativeType::Samples(evaluated),
})
.unwrap();
let binding = FusedSeriesBinding {
grid,
metric_name: Some(Arc::from("native_prefix")),
time_index: "ts".into(),
tags: vec!["host".into()],
};
// A frame-output node declares the frame layout of its evaluated root, not the projection of
// the scan below it: `try_new_with_format` compares the two and refuses a mismatch.
let frame_schema = evaluated_frame_schema(&tree);
let fused = FusedSeries::try_new_with_format(
Arc::from("rate(native_prefix[3s])"),
tree,
binding,
vec![child],
frame_schema,
FusedOutputFormat::SeriesFrame,
)
.unwrap();
let fused_payload = fused.serialize().unwrap();
let plan = LogicalPlan::Extension(Extension {
node: Arc::new(fused),
});
let bytes = DFLogicalSubstraitConvertor
.encode(&plan, DefaultSerializer)
.unwrap()
.to_vec();
let region = instance
.mito_engine
.regions()
.into_iter()
.find(|r| {
r.metadata()
.column_metadatas
.iter()
.any(|c| c.column_schema.name == "host")
})
.unwrap();
let metadata = region.metadata();
let server = instance.datanode.region_server();
let mut stream = server
.handle_remote_read(
QueryRequest {
header: Some(RegionRequestHeader {
query_context: Some((&*ctx).into()),
..Default::default()
}),
region_id: metadata.region_id.as_u64(),
plan: bytes.clone(),
},
ctx.clone(),
)
.await
.unwrap();
let mut batches = Vec::new();
while let Some(batch) = stream.next().await {
batches.push(batch.unwrap());
}
let schema = batches.first().expect("evaluated frames").schema.clone();
let batches = RecordBatches::try_new(schema, batches).unwrap();
let series = common_query::series_frame::unpack_evaluated_series(
Box::pin(DfRecordBatchStreamAdapter::new(batches.as_stream())),
None,
)
.unwrap()
.try_collect::<Vec<_>>()
.await
.unwrap();
assert_eq!(series.len(), 1);
let (series, _) = &series[0];
// `rate` drops `__name__`, as Prometheus does for every range function except
// `last_over_time` and `first_over_time`; the series' own tags survive.
assert_eq!(series.metric.name(), None);
assert_eq!(
series
.metric
.get("host")
.map(ToString::to_string)
.as_deref(),
Some("a")
);
assert_eq!(series.time.timestamps().unwrap().as_ref(), &[3000, 4000]);
// At 3000 ms the (0, 3000] window has samples 0, 10, 20: increase 20 over
// 2 observed seconds. Prometheus extrapolates the 1s left-boundary gap and
// 0s right-boundary gap to the full 3s range: 20 * (3 / 2) / 3 = 10/s.
// At 4000 ms, (1000, 4000] has 10, 20, 30: increase 20 over 2 seconds,
// likewise extrapolated to 10/s.
let values = series.fields.get(0).unwrap();
// At 3000 ms the window holds 0, 10, 20: an increase of 20 over 2 observed
// seconds. Prometheus would keep the 1 s leading gap (below the 1.1x average
// threshold), but a counter whose first sample is 0 cannot be extrapolated
// backwards at all: `duration_to_zero = sampled * first/value = 0` clamps it,
// so the rate is 20 * (2 + 0) / 2 / 3 = 20/3.
//
// At 4000 ms the window holds 10, 20, 30: also 20 over 2 s, but now
// `duration_to_zero = 2 * (10/20) = 1 s` does not shorten the 1 s leading gap,
// so the rate is 20 * (2 + 1 + 0) / 2 / 3 = 10.
let expected = [20.0 / 3.0, 10.0];
for (index, expected) in expected.into_iter().enumerate() {
let SampleRef::Float(value) = values.sample(index) else {
panic!("the rate field is float at position {index}");
};
assert!(
(value - expected).abs() < 1e-12,
"value at position {index} is {value}, not {expected}"
);
}
let mut tampered = bytes;
// The version is the first four bytes of the embedded FusedSeries payload; flipping the
// Substrait envelope is intentionally avoided, so locate the native protocol version.
let native_prefix_version = 1u32.to_le_bytes();
let embedded = tampered
.windows(fused_payload.len())
.position(|window| window == fused_payload.as_slice())
.expect("FusedSeries payload in Substrait plan");
let prefix_version = embedded + 8;
assert_eq!(
&tampered[prefix_version..prefix_version + 4],
&native_prefix_version
);
tampered[prefix_version] = 2;
let error = server
.handle_remote_read(
QueryRequest {
header: Some(RegionRequestHeader {
query_context: Some((&*ctx).into()),
..Default::default()
}),
region_id: metadata.region_id.as_u64(),
plan: tampered,
},
ctx,
)
.await
.err()
.expect("invalid prefix rejected");
assert!(
format!("{error:?}").contains("unknown version"),
"{error:?}"
);
}