mirror of
https://github.com/GreptimeTeam/greptimedb.git
synced 2026-11-14 06:46:56 +00:00
feat(promql): add a series frame output to the fused native node (#9943)
I hereby agree to the terms of the [GreptimeDB CLA](https://github.com/GreptimeTeam/.github/blob/main/CLA.md). ## Refer to a related PR or issue link (optional) - **Stacked on #9942** (PR A, the unified SeriesFrame codec). - GitHub cannot target a fork branch, so this PR's base reads `narthex`. **Review #9942 first.** - The commits unique to this PR are `b32573f9cbf`, `16b3e428951`, `d5547c55bdf` and `f2041d8d9de` (test-only lint cleanup). The chain is `narthex (771347df5d) → #9942 (8bc53c4871) → this PR (CLEANUP_SHA)`. - Merge order remains **#9942 → #9943**. Once #9942 lands, rebase this PR onto the resulting `narthex`; do not merge the codec changes twice. - Spec: Narthex Unified SeriesFrame refactor, PR B of two. ## What's changed and what's your intention? The fused native node could only answer one way: a row table with the grid timestamps, one column per series field and one per declared tag. That is what a query returns, but it is not what a consumer of the native path needs when it has to tell series and positions apart — the labels of a series, and which grid positions it actually holds, are both flattened away by the row shape. This adds a second output format, **fixed when the plan is built** rather than chosen at run time: ```rust pub enum FusedOutputFormat { PlainRows, SeriesFrame } ``` | | `PlainRows` (default) | `SeriesFrame` | | --- | --- | --- | | output schema | the existing row table (time / fields / tags) | the codec's declared evaluated frame | | encoding | `series_to_record_batch` | straight from `EvaluatedChunk` via `SeriesFrameCursor` | | reachable from a query | yes | no | | `EXPLAIN` | byte-identical | appends ` (output: series_frame)` | The format is part of the plan because it decides the node's output schema, so it is part of the node's identity (`PartialEq`/`Hash`/`PartialOrd`), its `EXPLAIN` text, and its rebuild over a new child. A format error is refused while the plan is built, not when the first poll happens. The `SeriesFrame` schema is derived from the **`NativeTree.root().output` evaluated `SeriesSchema`**, never from the original scan schema or the input tags: after a label rewrite or an aggregate, the result's labels and fields are what count. ### Independent chains remain independent `is_last` closes the current chain, not the entire label set. Separate chains may therefore carry identical complete labels and each close independently. This PR adds no plan-owned global label identity management; combining contributions across partitions remains the consumer's responsibility. Tests check exact positions, payload bits and separate finality for both consecutive same-label chains in one encoder and chains in separate partitions. ### Parallelism is unchanged For the supported single-source Frame path, output partition count equals the input scan's partition count. No `CoalescePartitionsExec`, `RepartitionExec` or round-robin is introduced in either format. `series_frame_preserves_four_input_partitions` checks the in-memory boundary. The standalone integration test additionally captures a real single-region `SeriesScanExec` at `parallelism=4` and executes every partition in each output format. ### Implementation notes worth knowing 1. **Stream and batch schemas agree.** The codec-derived output schema includes the explicit `contiguous` ordering declaration. Published batches are checked against that declared schema. 2. **The end of the execution must flush.** The frame builder buffers up to a batch across chunks, so missing the final flush loses the tail rows. 3. **`Poll::Pending` also flushes.** Otherwise a closing row stays in the builder behind an input that may never become ready — the same convention PR A's `PackStream` already follows. ### Key code locations - `src/promql/src/extension_plan.rs`: exports `FusedOutputFormat`. - `src/promql/src/extension_plan/fused_series.rs`: default/explicit format constructors, root-output schema derivation, plan identity/rebuild/EXPLAIN, direct chunk encoding and stream flushing. - `src/promql/src/extension_plan/fused_series/tests.rs` and `tests/frame_output.rs`: internal logical/physical execution and lifecycle/semantic regressions. - `tests-integration/tests/http.rs`: real standalone scan replay in both output formats. ### Review fixes and acceptance - **Batch-level backpressure:** the stream retains the existing `SeriesFrameCursor` and advances only until one batch is ready, then returns control to the consumer. It no longer encodes an entire evaluated chunk into a `ready` queue before returning the first batch. Pending native output is drained before more upstream input; a pre-EOF stall does not close the executor. PlainRows and partial-state conversion keep their original timing. - **Real scan acceptance:** the standalone File integration fixture has one physical region and `parallelism=4`. The test captures its actual `SeriesScanExec`, replays the internal fused plan in both formats, executes all four nonempty partitions, and checks the exact 24 expected rows, eight Frame chain closings and per-partition native output metrics. Frame is not exposed as a user query's final result to perform this test. - Replaced the vacuous sparse fixture with exact nonempty positions `0,2,5`, two fields with partial NULLs and preserved ordinary-NaN bits, and a PlainRows comparison. Added fixed-value `abs(rate(...))` and label-rewrite comparisons, including a new label outside the input tag projection and two independent same-label chains in one encoder. Cross-partition chains also check exact positions and value bits. - Added retained-cursor/one-batch checks, buffered upstream-error termination, and early-drop ownership checks. Reintroducing eager encoding compiles but makes the backpressure regression fail at its queue assertion; restoring the reviewed source makes it pass. - Removed the redundant physical scalar/Frame guard; the shared root-output validator remains. - Independent correctness and anti-over-design review: **APPROVE** for `8bc53c48714..d5547c55bdf`. Review approval is separate from new-head CI. ### Limitations - `SeriesFrame` is **not reachable from a user query** yet (production defaults to `PlainRows`). Per spec §8.7 the acceptance executes the internal physical plan and decodes its output, instead of exposing the frame as a query's final result format. - No sqlness result changes: the production planner still constructs `PlainRows`. The existing standalone integration case is extended to execute and decode the internal Frame plan. - Rebased onto `narthex` at `771347df5d9`, including #9936, #9882 and #9886. The native zero-source scalar, `timestamp()`/instant-`@`, multi-input and partial-state paths are preserved. A Frame over a scalar root is rejected because this format requires evaluated samples. - The multi-source case is refused for frames rather than implemented: this node still answers only partition 0 when it has more than one raw source, so a frame over several sources would have to change that first. - Consumer contract: a sparse chunk decodes into **several** `Series` (one frame row per grid run), so a consumer must concatenate by position and cannot assume "one chunk → one decoded result". Nothing consumes frames in this PR, but Complete Aggregate's SeriesFrame input must respect it. - Not verified: Windows; the results below are Linux. ### Does this break API or data compatibility? `PlainRows` remains the default and the only format a user query reaches. This increment leaves its conversion and plan display paths in place. The added tests compare exact Frame semantics without changing the user-facing result format. No persisted format is touched. ### Tests Coverage includes codec-derived schema, logical rebuild and EXPLAIN, dense and genuinely sparse output, multi-field NULL/NaN values, composed math and label rewrites, four in-memory and four real scan partitions, independent same-label chains, partial-state schema, constructor refusals, zero-sample closing, Pending/EOF flush, buffered error, cancellation and incremental encoding. <!-- CURRENT_VERIFICATION_START --> Main verification on `d5547c55bdf` (Linux; default features): - `make fmt-check`: passed. - `cargo nextest run -p datatypes -p common-query -p table -p promql -p query --retries 0`: **2698 passed, 2 skipped**. - `cargo nextest run -p tests-integration --test main -E 'test(test_native_promql_route_plans_the_query) & test(file)' --retries 0 --success-output immediate`: **1 passed, 771 skipped**. Both formats produced per-partition decoded row counts **[15, 3, 3, 3]**; exact 24-row results and eight Frame chain closings were asserted. - `cargo check --workspace --all-targets`: passed. - The final strict Clippy pass found two test-only hygiene issues in the new tests: an unnecessary `f64` cast and two `println!` calls that the workspace's `print_stdout` lint rejects. The follow-up types the existing float literal explicitly, removes the cast, and drops the diagnostics, whose values were already asserted by the surrounding exact checks. It changes no production code or assertions. - Final test-only cleanup `f2041d8d9de` on top of the reviewed `d5547c55bdf`: `make fmt-check` passed; `cargo clippy -p promql -p query -p tests-integration --all-targets -- -D warnings` passed; the affected `abs_rate_frame_matches_plain_rows_with_exact_values_and_labels` (1 passed) and real File scan replay (1 passed, 771 skipped) passed again. The full five-crate suite above was run at `d5547c55bdf`; this last commit touches tests only and was not followed by another full-suite run. Other focused evidence: the eager-encoding mutation compiled and then failed the queue-boundary assertion; restored production source passed. License/TOML/diff checks passed. These are local Linux results, not a claim of Windows or new-head CI success; full all-feature workspace Clippy and the full workspace test suite were not run. <!-- CURRENT_VERIFICATION_END --> ## PR Checklist Please convert it to a draft if some of the following conditions are not met. - [x] I have written the necessary rustdoc comments. - [x] I have added the necessary unit tests and integration tests. - [ ] This PR requires documentation updates. - [x] API changes are backward compatible. - [x] Schema or data changes are backward compatible. - [ ] This PR needs to be backported to release branches, and I have added the `backport-<target>` labels (e.g. `backport-v1.3` targets `release/v1.3`).
This commit is contained in:
Generated
+1
@@ -15119,6 +15119,7 @@ dependencies = [
|
||||
"paste",
|
||||
"pipeline",
|
||||
"plugins",
|
||||
"promql",
|
||||
"prost 0.14.1",
|
||||
"query",
|
||||
"rand 0.9.4",
|
||||
|
||||
@@ -41,7 +41,9 @@ use datafusion::error::{DataFusionError, Result as DataFusionResult};
|
||||
use datafusion::logical_expr::{Expr, Extension, LogicalPlan};
|
||||
use datatypes::data_type::DataType as _;
|
||||
pub use empty_metric::{EmptyMetric, EmptyMetricExec, EmptyMetricStream, build_special_time_expr};
|
||||
pub use fused_series::{FusedSeries, FusedSeriesBinding, FusedSeriesExec, FusedSeriesStream};
|
||||
pub use fused_series::{
|
||||
FusedOutputFormat, FusedSeries, FusedSeriesBinding, FusedSeriesExec, FusedSeriesStream,
|
||||
};
|
||||
pub use histogram_fold::{
|
||||
HistogramFold, HistogramFoldExec, HistogramFoldOperation, HistogramFoldStream,
|
||||
};
|
||||
|
||||
@@ -44,7 +44,9 @@ use std::pin::Pin;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
use common_query::series_frame::{ensure_frame_matches_label_source, unpack_raw_series};
|
||||
use common_query::series_frame::{
|
||||
ensure_frame_matches_label_source, pack_evaluated_series, unpack_raw_series,
|
||||
};
|
||||
use datafusion::arrow::array::{ArrayRef, Float64Array, StringArray, TimestampMillisecondArray};
|
||||
use datafusion::arrow::buffer::ScalarBuffer;
|
||||
use datafusion::arrow::datatypes::{DataType, SchemaRef, TimeUnit};
|
||||
@@ -64,6 +66,7 @@ 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;
|
||||
@@ -164,6 +167,39 @@ impl PartialOrd for FusedSeriesBinding {
|
||||
}
|
||||
}
|
||||
|
||||
/// The output representation fixed when a fused plan is built.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub enum FusedOutputFormat {
|
||||
/// The existing tabular query result.
|
||||
PlainRows,
|
||||
/// A self-describing evaluated SeriesFrame.
|
||||
SeriesFrame,
|
||||
}
|
||||
|
||||
fn evaluated_output(tree: &NativeTree) -> DataFusionResult<Arc<SeriesSchema>> {
|
||||
match &tree.root().output {
|
||||
NativeType::Samples(schema) if schema.eval_grid.is_some() => Ok(Arc::clone(schema)),
|
||||
NativeType::Samples(_) => Err(DataFusionError::Plan(
|
||||
"FusedSeries frame output requires evaluated samples with a grid".into(),
|
||||
)),
|
||||
NativeType::AggregateState(_) => Err(DataFusionError::Plan(
|
||||
"FusedSeries frame output does not support an aggregate-state root".into(),
|
||||
)),
|
||||
NativeType::Scalar(_) => Err(DataFusionError::Plan(
|
||||
"FusedSeries frame output does not support a scalar root".into(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn series_frame_output_schema(schema: &SeriesSchema) -> DataFusionResult<SchemaRef> {
|
||||
let stream =
|
||||
pack_evaluated_series(Box::pin(futures::stream::empty()), Arc::new(schema.clone()))
|
||||
.map_err(|error| {
|
||||
DataFusionError::Plan(format!("FusedSeries cannot declare frame schema: {error}"))
|
||||
})?;
|
||||
Ok(stream.schema())
|
||||
}
|
||||
|
||||
/// The logical plan of one node that runs a compiled native tree.
|
||||
///
|
||||
/// The node adds nothing to the tree: it records the query the tree was compiled from, so
|
||||
@@ -186,6 +222,8 @@ pub struct FusedSeries {
|
||||
/// This row-state bridge is temporary: native final aggregation will consume series frames
|
||||
/// directly, removing state-row encoding and reconstruction.
|
||||
partial_fold: Option<BoundAggregate>,
|
||||
/// Fixed output representation; partial state export always remains PlainRows.
|
||||
format: FusedOutputFormat,
|
||||
}
|
||||
|
||||
impl FusedSeries {
|
||||
@@ -201,8 +239,39 @@ impl FusedSeries {
|
||||
binding: FusedSeriesBinding,
|
||||
children: Vec<LogicalPlan>,
|
||||
output_schema: DFSchemaRef,
|
||||
) -> DataFusionResult<Self> {
|
||||
Self::try_new_with_format(
|
||||
promql,
|
||||
tree,
|
||||
binding,
|
||||
children,
|
||||
output_schema,
|
||||
FusedOutputFormat::PlainRows,
|
||||
)
|
||||
}
|
||||
|
||||
/// Creates a node with an explicitly selected output format.
|
||||
pub fn try_new_with_format(
|
||||
promql: Arc<str>,
|
||||
tree: NativeTree,
|
||||
binding: FusedSeriesBinding,
|
||||
children: Vec<LogicalPlan>,
|
||||
output_schema: DFSchemaRef,
|
||||
format: FusedOutputFormat,
|
||||
) -> DataFusionResult<Self> {
|
||||
validate_binding(&tree, &binding, children.len())?;
|
||||
if format == FusedOutputFormat::SeriesFrame && children.len() > 1 {
|
||||
return Err(DataFusionError::Plan(
|
||||
"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
|
||||
};
|
||||
Ok(Self {
|
||||
promql,
|
||||
tree,
|
||||
@@ -210,6 +279,7 @@ impl FusedSeries {
|
||||
children,
|
||||
output_schema,
|
||||
partial_fold: None,
|
||||
format,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -243,6 +313,7 @@ impl FusedSeries {
|
||||
children,
|
||||
output_schema,
|
||||
partial_fold: Some(op),
|
||||
format: FusedOutputFormat::PlainRows,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -296,6 +367,7 @@ impl FusedSeries {
|
||||
self.binding.time_index.clone(),
|
||||
self.binding.tags.clone(),
|
||||
self.partial_fold.clone(),
|
||||
self.format,
|
||||
)?))
|
||||
}
|
||||
}
|
||||
@@ -327,6 +399,9 @@ impl UserDefinedLogicalNodeCore for FusedSeries {
|
||||
self.promql,
|
||||
render_partial_fold(op)
|
||||
),
|
||||
None if self.format == FusedOutputFormat::SeriesFrame => {
|
||||
write!(f, "FusedSeries: {} (output: series_frame)", self.promql)
|
||||
}
|
||||
None => write!(f, "FusedSeries: {}", self.promql),
|
||||
}
|
||||
}
|
||||
@@ -344,6 +419,7 @@ impl UserDefinedLogicalNodeCore for FusedSeries {
|
||||
children: inputs,
|
||||
output_schema: Arc::clone(&self.output_schema),
|
||||
partial_fold: self.partial_fold.clone(),
|
||||
format: self.format,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -361,6 +437,7 @@ impl PartialEq for FusedSeries {
|
||||
&& self.children == other.children
|
||||
&& self.output_schema == other.output_schema
|
||||
&& partial_key(self.partial_fold.as_ref()) == partial_key(other.partial_fold.as_ref())
|
||||
&& self.format == other.format
|
||||
}
|
||||
}
|
||||
|
||||
@@ -373,6 +450,7 @@ impl Hash for FusedSeries {
|
||||
self.children.hash(state);
|
||||
self.output_schema.hash(state);
|
||||
partial_key(self.partial_fold.as_ref()).hash(state);
|
||||
self.format.hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,6 +478,10 @@ impl PartialOrd for FusedSeries {
|
||||
Some(Ordering::Equal) => {}
|
||||
ord => return ord,
|
||||
}
|
||||
match self.format.cmp(&other.format) {
|
||||
Ordering::Equal => {}
|
||||
ord => return Some(ord),
|
||||
}
|
||||
match tree_input_fields(&self.tree).cmp(&tree_input_fields(&other.tree)) {
|
||||
Ordering::Equal => {}
|
||||
ord => return Some(ord),
|
||||
@@ -783,6 +865,7 @@ pub struct FusedSeriesExec {
|
||||
metric: ExecutionPlanMetricsSet,
|
||||
/// Aggregate state exported by each partition, when this is the partial mode.
|
||||
partial_fold: Option<BoundAggregate>,
|
||||
format: FusedOutputFormat,
|
||||
/// This node's partitioning, emission and boundedness.
|
||||
properties: Arc<PlanProperties>,
|
||||
}
|
||||
@@ -807,9 +890,19 @@ impl FusedSeriesExec {
|
||||
time_column: String,
|
||||
tags: Vec<String>,
|
||||
) -> DataFusionResult<Self> {
|
||||
Self::build(promql, inputs, tree, output_schema, time_column, tags, None)
|
||||
Self::build(
|
||||
promql,
|
||||
inputs,
|
||||
tree,
|
||||
output_schema,
|
||||
time_column,
|
||||
tags,
|
||||
None,
|
||||
FusedOutputFormat::PlainRows,
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn build(
|
||||
promql: Arc<str>,
|
||||
inputs: Vec<(Arc<dyn ExecutionPlan>, RawLabelSource, SeriesOrdering)>,
|
||||
@@ -818,7 +911,13 @@ impl FusedSeriesExec {
|
||||
time_column: String,
|
||||
tags: Vec<String>,
|
||||
partial_fold: Option<BoundAggregate>,
|
||||
format: FusedOutputFormat,
|
||||
) -> DataFusionResult<Self> {
|
||||
if partial_fold.is_none() && format == FusedOutputFormat::SeriesFrame && inputs.len() > 1 {
|
||||
return Err(DataFusionError::Plan(
|
||||
"FusedSeries SeriesFrame output does not yet support multiple sources".into(),
|
||||
));
|
||||
}
|
||||
let raw_schemas = validate_physical_tree(&tree)?;
|
||||
if inputs.len() != raw_schemas.len() {
|
||||
return Err(DataFusionError::Plan(format!(
|
||||
@@ -853,7 +952,19 @@ impl FusedSeriesExec {
|
||||
}
|
||||
}
|
||||
}
|
||||
let classification = if partial_fold.is_some() {
|
||||
let frame_schema = if partial_fold.is_none() && format == FusedOutputFormat::SeriesFrame {
|
||||
let declared = series_frame_output_schema(evaluated_output(&tree)?.as_ref())?;
|
||||
if declared != output_schema {
|
||||
return Err(DataFusionError::Plan(
|
||||
"FusedSeriesExec output schema does not match the evaluated frame layout"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let classification = if partial_fold.is_some() || frame_schema {
|
||||
Vec::new()
|
||||
} else if scalar_root {
|
||||
let scalar_fields = scalar_root_fields(&output_schema, &output_tags, &time_column)?;
|
||||
@@ -931,6 +1042,7 @@ impl FusedSeriesExec {
|
||||
metric: ExecutionPlanMetricsSet::new(),
|
||||
properties,
|
||||
partial_fold,
|
||||
format,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -953,6 +1065,8 @@ impl DisplayAs for FusedSeriesExec {
|
||||
write!(f, "FusedSeriesExec: {}\n{}", self.promql, self.tree)?;
|
||||
if let Some(op) = &self.partial_fold {
|
||||
write!(f, "\npartial state: {}", render_partial_fold(op))?;
|
||||
} else if self.format == FusedOutputFormat::SeriesFrame {
|
||||
write!(f, "\noutput: series_frame")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1028,6 +1142,7 @@ impl ExecutionPlan for FusedSeriesExec {
|
||||
self.time_column.clone(),
|
||||
self.tags.to_vec(),
|
||||
self.partial_fold.clone(),
|
||||
self.format,
|
||||
)?))
|
||||
}
|
||||
|
||||
@@ -1098,6 +1213,30 @@ impl ExecutionPlan for FusedSeriesExec {
|
||||
if streams.is_empty() {
|
||||
executor.end_of_input()?;
|
||||
}
|
||||
// A frame is encoded from the tree's own evaluated output, and only when the node answers
|
||||
// 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 {
|
||||
inputs: streams,
|
||||
executor: Some(executor),
|
||||
@@ -1106,6 +1245,9 @@ impl ExecutionPlan for FusedSeriesExec {
|
||||
tags: Arc::clone(&self.tags),
|
||||
output_schema: self.output_schema.clone(),
|
||||
partial_fold,
|
||||
frame_builder,
|
||||
eval_schema,
|
||||
frame_cursor: None,
|
||||
_export_budget: Arc::clone(&export_budget),
|
||||
ready: VecDeque::new(),
|
||||
done: false,
|
||||
@@ -1226,6 +1368,10 @@ 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>,
|
||||
/// 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.
|
||||
@@ -1249,11 +1395,18 @@ impl FusedSeriesStream {
|
||||
})? {
|
||||
executor.push_raw(*input_id, chunk.clone())?;
|
||||
}
|
||||
let mut settled = Vec::new();
|
||||
while let Some(chunk) = executor.next_chunk()? {
|
||||
settled.push(chunk);
|
||||
if self.frame_builder.is_some() {
|
||||
Ok(())
|
||||
} else {
|
||||
let mut settled = Vec::new();
|
||||
while let Some(chunk) = executor.next_chunk()? {
|
||||
settled.push(chunk);
|
||||
}
|
||||
for chunk in settled {
|
||||
self.convert(chunk)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
self.convert(settled)
|
||||
}
|
||||
|
||||
/// Ends the input, so the tree settles what it was holding.
|
||||
@@ -1300,6 +1453,8 @@ impl FusedSeriesStream {
|
||||
self.done = true;
|
||||
self.executor = None;
|
||||
self.partial_fold = None;
|
||||
self.frame_builder = None;
|
||||
self.frame_cursor = None;
|
||||
self.inputs.clear();
|
||||
// This reservation belongs only to this execution. Release it even if the consumer keeps
|
||||
// the failed stream and the plan alive.
|
||||
@@ -1311,26 +1466,65 @@ impl FusedSeriesStream {
|
||||
self.ready.clear();
|
||||
}
|
||||
|
||||
/// Converts settled chunks into query rows or folds them into partial aggregate state.
|
||||
fn convert(&mut self, chunks: Vec<EvaluatedChunk>) -> DataFusionResult<()> {
|
||||
/// 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<()> {
|
||||
if let Some(fold) = self.partial_fold.as_mut() {
|
||||
for chunk in chunks {
|
||||
fold.update(chunk)?;
|
||||
}
|
||||
fold.update(chunk)?;
|
||||
return Ok(());
|
||||
}
|
||||
for chunk in &chunks {
|
||||
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,
|
||||
&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())?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Publishes the partition's fold only after the input and native execution have drained.
|
||||
fn finish_fold(&mut self) -> DataFusionResult<()> {
|
||||
if let Some(mut fold) = self.partial_fold.take() {
|
||||
@@ -1357,22 +1551,56 @@ impl Stream for FusedSeriesStream {
|
||||
// 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_empty() {
|
||||
this.executor = None;
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
this.fail();
|
||||
return Poll::Ready(Some(Err(error)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if this.done {
|
||||
return Poll::Ready(None);
|
||||
}
|
||||
let Some((source, input)) = this.inputs.front_mut() else {
|
||||
match this.pull_one() {
|
||||
Ok(Some(chunk)) => {
|
||||
if let Err(error) = this.convert(vec![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() {
|
||||
if let Err(error) = this.finish_fold().and_then(|()| this.flush_frames()) {
|
||||
this.fail();
|
||||
return Poll::Ready(Some(Err(error)));
|
||||
}
|
||||
@@ -1388,7 +1616,16 @@ impl Stream for FusedSeriesStream {
|
||||
};
|
||||
let (source, polled) = (*source, input.as_mut().poll_next(cx));
|
||||
match polled {
|
||||
Poll::Pending => return Poll::Pending,
|
||||
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.pop_front();
|
||||
if this.inputs.is_empty()
|
||||
|
||||
@@ -32,10 +32,11 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::fmt::{self, Display, Formatter};
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
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 datafusion::arrow::array::{Array, Float64Array, StringArray, TimestampMillisecondArray};
|
||||
use datafusion::arrow::buffer::{Buffer, ScalarBuffer};
|
||||
use datafusion::arrow::datatypes::{DataType, Field, Schema, SchemaRef, TimeUnit};
|
||||
@@ -65,17 +66,23 @@ use datafusion::physical_plan::{
|
||||
use datafusion::prelude::SessionContext;
|
||||
use datatypes::prelude::ConcreteDataType;
|
||||
use datatypes::series::frame::{
|
||||
FrameLabelMode, LABEL_DECODE_VERSION, LabelDecodeColumn, LabelDecodeEncoding, LabelDecodeSpec,
|
||||
LabelDecodeTag, pack_raw_series_chunk, raw_series_frame_schema, series_frame_schema,
|
||||
FrameContent, FrameLabelMode, LABEL_DECODE_VERSION, LabelDecodeColumn, LabelDecodeEncoding,
|
||||
LabelDecodeSpec, LabelDecodeTag, SeriesFrameBuilder, 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;
|
||||
use store_api::region_engine::{RawLabelSource, SendableRawSeriesStream};
|
||||
|
||||
use super::{FusedSeries, FusedSeriesBinding, OutputColumn, POLL_BUDGET, series_to_record_batch};
|
||||
mod frame_output;
|
||||
|
||||
use super::{
|
||||
FusedOutputFormat, FusedSeries, FusedSeriesBinding, FusedSeriesStream, OutputColumn,
|
||||
POLL_BUDGET, series_to_record_batch,
|
||||
};
|
||||
use crate::evaluated::EvaluatedChunk;
|
||||
use crate::extension_plan::aggregate_exchange::state_schema;
|
||||
use crate::native::execute::{InputId, NativeExecutionState, NativeExecutor};
|
||||
@@ -85,6 +92,7 @@ use crate::native::plan::{
|
||||
};
|
||||
use crate::range_eval::{RangeFunction, RangeWindow};
|
||||
use crate::vector_match::{BinaryMatching, BinaryOp, Cardinality, FillValues, MatchingLabels};
|
||||
use common_query::series_frame::series_frame_ordering;
|
||||
|
||||
/// The query every tree here is compiled from.
|
||||
const PROMQL: &str = "rate(x[5m])";
|
||||
@@ -2496,6 +2504,215 @@ 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.into_iter().map(|input| (0, input)).collect(),
|
||||
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_eq!(1, stream.inputs.len());
|
||||
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
|
||||
@@ -3731,3 +3948,619 @@ async fn a_failed_input_exports_no_partial_state() {
|
||||
"refused for another reason: {error}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Frame output is planned from the evaluated root and is emitted without flattening to rows.
|
||||
#[tokio::test]
|
||||
async fn series_frame_output_uses_evaluated_codec_schema() {
|
||||
let tree = rate_tree();
|
||||
let frame_node = 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("evaluated samples have a frame schema");
|
||||
assert!(
|
||||
frame_node
|
||||
.schema()
|
||||
.fields()
|
||||
.iter()
|
||||
.all(|field| field.name() != "ts"),
|
||||
"frame schema comes from the codec rather than the row output"
|
||||
);
|
||||
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());
|
||||
let batches = collect(exec.clone(), SessionContext::default().task_ctx())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!batches.is_empty());
|
||||
assert!(batches.iter().all(|batch| batch.schema() == exec.schema()));
|
||||
assert!(batches.iter().any(|batch| batch.num_rows() > 0));
|
||||
}
|
||||
|
||||
/// Frame output refuses the currently unsupported multi-source physical shape at planning time.
|
||||
#[test]
|
||||
fn series_frame_rejects_multiple_sources_during_planning() {
|
||||
let tree = binary_tree_over_sources(BinaryOp::Add, raw_schema(), raw_schema(), 0, 1);
|
||||
let err = FusedSeries::try_new_with_format(
|
||||
Arc::from(PROMQL),
|
||||
tree.clone(),
|
||||
binding(&tree),
|
||||
vec![input_plan(frame_schema()), input_plan(frame_schema())],
|
||||
binary_output_schema().to_dfschema_ref().unwrap(),
|
||||
FusedOutputFormat::SeriesFrame,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("multiple sources"));
|
||||
}
|
||||
|
||||
/// A real four-partition input remains four independent frame-output executions.
|
||||
#[tokio::test]
|
||||
async fn series_frame_preserves_four_input_partitions() {
|
||||
let partitions = (0..4)
|
||||
.map(|index| {
|
||||
frames(&[series_chunk(
|
||||
&format!("part-{index}"),
|
||||
&series_samples((index + 1) as f64),
|
||||
true,
|
||||
)])
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let tree = rate_tree();
|
||||
let node = 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,
|
||||
)
|
||||
.unwrap();
|
||||
let exec = plan_of(node, memory_exec_partitioned(partitions)).unwrap();
|
||||
assert_eq!(4, exec.properties().partitioning.partition_count());
|
||||
let schema = exec.schema();
|
||||
let mut closes = 0;
|
||||
for partition in 0..4 {
|
||||
let stream = exec
|
||||
.execute(partition, SessionContext::default().task_ctx())
|
||||
.unwrap();
|
||||
let decoded = unpack_evaluated_series(stream, None)
|
||||
.unwrap()
|
||||
.try_collect::<Vec<_>>()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
1,
|
||||
decoded.len(),
|
||||
"one chain closes in partition {partition}"
|
||||
);
|
||||
assert!(decoded[0].1);
|
||||
closes += decoded.iter().filter(|(_, last)| *last).count();
|
||||
let values = decoded[0].0.fields.get(0).unwrap();
|
||||
let value = values.iter().next().unwrap();
|
||||
let datatypes::series::samples::SampleRef::Float(value) = value else {
|
||||
panic!("float sample expected")
|
||||
};
|
||||
assert!((value - (partition + 1) as f64).abs() < 1e-9);
|
||||
assert_eq!(schema, exec.schema());
|
||||
}
|
||||
assert_eq!(4, closes);
|
||||
}
|
||||
|
||||
/// PlainRows and SeriesFrame encode precisely the same rate values, timestamps and labels.
|
||||
#[tokio::test]
|
||||
async fn series_frame_rate_matches_plain_rows_bitwise() {
|
||||
let input = memory_exec(two_series_frames());
|
||||
let plain = plan(rate_tree(), Arc::clone(&input)).unwrap();
|
||||
let plain_rows = rows(
|
||||
&collect(plain, SessionContext::default().task_ctx())
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
let tree = rate_tree();
|
||||
let node = 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,
|
||||
)
|
||||
.unwrap();
|
||||
let exec = plan_of(node, input).unwrap();
|
||||
let batches = collect(exec.clone(), SessionContext::default().task_ctx())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(batches.iter().all(|batch| batch.schema() == exec.schema()));
|
||||
let stream = Box::pin(RecordBatchStreamAdapter::new(
|
||||
exec.schema(),
|
||||
futures::stream::iter(batches.into_iter().map(Ok)),
|
||||
));
|
||||
let decoded = unpack_evaluated_series(stream, None)
|
||||
.unwrap()
|
||||
.try_collect::<Vec<_>>()
|
||||
.await
|
||||
.unwrap();
|
||||
let mut frame_rows = Vec::new();
|
||||
for (series, _) in decoded {
|
||||
let timestamps = series.time.timestamps().unwrap();
|
||||
let samples = series.fields.get(0).unwrap();
|
||||
for (row, timestamp) in timestamps.iter().enumerate() {
|
||||
let value = samples.iter().nth(row).unwrap();
|
||||
let datatypes::series::samples::SampleRef::Float(value) = value else {
|
||||
panic!("float sample expected")
|
||||
};
|
||||
frame_rows.push((
|
||||
*timestamp,
|
||||
value.to_bits(),
|
||||
series.metric.get(HOST).map(str::to_owned),
|
||||
series.metric.name().map(str::to_owned),
|
||||
));
|
||||
}
|
||||
}
|
||||
let mut expected = plain_rows
|
||||
.into_iter()
|
||||
.map(|(time, value, host, name)| (time, value.map(f64::to_bits), host, name))
|
||||
.collect::<Vec<_>>();
|
||||
frame_rows.sort();
|
||||
expected.sort();
|
||||
assert_eq!(
|
||||
expected,
|
||||
frame_rows
|
||||
.into_iter()
|
||||
.map(|(time, bits, host, name)| (time, Some(bits), host, name))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
/// The partial-state node keeps its state schema and explain annotation, independently of output format.
|
||||
#[tokio::test]
|
||||
async fn partial_fold_keeps_plain_rows_state_export() {
|
||||
let node = partial_node(AggregateKind::Sum);
|
||||
assert_eq!(FusedOutputFormat::PlainRows, node.format);
|
||||
let exec = plan_of(node, memory_exec(one_series_frames())).unwrap();
|
||||
let state = state_schema(&[HOST.to_string()]);
|
||||
assert_eq!(state.as_ref(), exec.schema().as_ref());
|
||||
assert!(
|
||||
displayable(exec.as_ref())
|
||||
.indent(false)
|
||||
.to_string()
|
||||
.contains("partial state:")
|
||||
);
|
||||
let batch = state_of(&exec, 0, &state).await;
|
||||
assert_eq!(state.as_ref(), batch.schema().as_ref());
|
||||
}
|
||||
|
||||
/// `EXPLAIN` says nothing extra about the row table and names the frame output.
|
||||
///
|
||||
/// The row table is what a query returns, so its node line has to stay exactly what it was; a frame
|
||||
/// output answers something else, and the line is the only place a reader can see that.
|
||||
#[test]
|
||||
fn the_explain_text_names_the_frame_format_only() {
|
||||
let rows = fused(rate_tree());
|
||||
assert_eq!(
|
||||
"FusedSeries: rate(x[5m])",
|
||||
Explain(&rows).to_string(),
|
||||
"the row-table line is what it always was"
|
||||
);
|
||||
let framed = FusedSeries::try_new_with_format(
|
||||
Arc::from(PROMQL),
|
||||
rows.tree().clone(),
|
||||
rows.binding().clone(),
|
||||
rows.inputs().into_iter().cloned().collect(),
|
||||
Arc::clone(rows.schema()),
|
||||
FusedOutputFormat::SeriesFrame,
|
||||
)
|
||||
.expect("a rate tree is evaluated on a grid");
|
||||
assert!(
|
||||
Explain(&framed)
|
||||
.to_string()
|
||||
.contains("(output: series_frame)"),
|
||||
"a frame output names its format: {}",
|
||||
Explain(&framed)
|
||||
);
|
||||
|
||||
let plain = plan(rate_tree(), memory_exec(Vec::new())).expect("the runtime executes the tree");
|
||||
let text = displayable(plain.as_ref()).indent(false).to_string();
|
||||
assert!(
|
||||
text.starts_with("FusedSeriesExec: rate(x[5m])\n"),
|
||||
"the row-table node line is unchanged: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The logical schema follows the format, and a frame schema is the codec's own declaration.
|
||||
///
|
||||
/// The frame layout is not assembled here: it is read back through the declaration parser, so what
|
||||
/// the plan claims is what a consumer of the frame will decode.
|
||||
#[test]
|
||||
fn the_logical_schema_follows_the_output_format() {
|
||||
let rows = fused(rate_tree());
|
||||
assert_eq!(FusedOutputFormat::PlainRows, rows.format);
|
||||
assert_eq!(&output_schema(), rows.schema().inner());
|
||||
|
||||
let framed = FusedSeries::try_new_with_format(
|
||||
Arc::from(PROMQL),
|
||||
rows.tree().clone(),
|
||||
rows.binding().clone(),
|
||||
rows.inputs().into_iter().cloned().collect(),
|
||||
Arc::clone(rows.schema()),
|
||||
FusedOutputFormat::SeriesFrame,
|
||||
)
|
||||
.expect("a rate tree is evaluated on a grid");
|
||||
assert_eq!(FusedOutputFormat::SeriesFrame, framed.format);
|
||||
let declaration =
|
||||
SeriesFrameDeclaration::parse(framed.schema().inner().as_ref()).expect("a frame schema");
|
||||
assert_eq!(FrameContent::Evaluated, declaration.content());
|
||||
assert_eq!(FrameLabelMode::Explicit, declaration.labels());
|
||||
assert_eq!(
|
||||
SeriesOrdering::Contiguous,
|
||||
series_frame_ordering(framed.schema().inner().as_ref())
|
||||
.expect("the frame declares its ordering"),
|
||||
"computed values promise per-chain contiguity and nothing stronger"
|
||||
);
|
||||
}
|
||||
|
||||
/// Rebuilding a frame node keeps the format it was built with.
|
||||
///
|
||||
/// The format decides the node's schema, so a rebuild that dropped it would answer rows where the
|
||||
/// plan said frame. `with_exprs_and_inputs` is what the optimizer uses.
|
||||
#[test]
|
||||
fn rebuilding_a_frame_node_keeps_its_output_format() {
|
||||
let rows = fused(rate_tree());
|
||||
let framed = FusedSeries::try_new_with_format(
|
||||
Arc::from(PROMQL),
|
||||
rows.tree().clone(),
|
||||
rows.binding().clone(),
|
||||
rows.inputs().into_iter().cloned().collect(),
|
||||
Arc::clone(rows.schema()),
|
||||
FusedOutputFormat::SeriesFrame,
|
||||
)
|
||||
.expect("a rate tree is evaluated on a grid");
|
||||
let rebuilt = framed
|
||||
.with_exprs_and_inputs(
|
||||
Vec::new(),
|
||||
framed.inputs().into_iter().cloned().collect::<Vec<_>>(),
|
||||
)
|
||||
.expect("the node rebuilds over its own input");
|
||||
assert_eq!(FusedOutputFormat::SeriesFrame, rebuilt.format);
|
||||
assert_eq!(framed.schema(), rebuilt.schema());
|
||||
assert_eq!(Explain(&framed).to_string(), Explain(&rebuilt).to_string());
|
||||
}
|
||||
|
||||
/// A tree whose root is not evaluated samples cannot be framed, and says so when the plan is built.
|
||||
///
|
||||
/// A scalar has no series to name, so it is not a series of float samples; an aggregate-state root is
|
||||
/// refused by the same check for the same reason, and `partial_node` cannot even be built with a
|
||||
/// frame format. The refusal is a planning error, so a caller learns about it before the query runs
|
||||
/// rather than from a partition that fails later.
|
||||
#[test]
|
||||
fn a_series_frame_refuses_a_root_it_cannot_carry() {
|
||||
let scalar = scalar_tree(grid(), 1.0);
|
||||
// A scalar tree binds no raw source, so it is built over no child at all: the refusal below is
|
||||
// then about the root, not about a source count that was wrong first.
|
||||
let binding = FusedSeriesBinding {
|
||||
grid: grid(),
|
||||
metric_name: Some(Arc::from(METRIC)),
|
||||
time_index: TIME.to_string(),
|
||||
tags: vec![HOST.to_string(), NAME.to_string()],
|
||||
};
|
||||
let error = FusedSeries::try_new_with_format(
|
||||
Arc::from(PROMQL),
|
||||
scalar,
|
||||
binding,
|
||||
Vec::new(),
|
||||
output_schema().to_dfschema_ref().unwrap(),
|
||||
FusedOutputFormat::SeriesFrame,
|
||||
)
|
||||
.expect_err("a scalar is not a series to frame");
|
||||
assert!(
|
||||
matches!(error, DataFusionError::Plan(_)),
|
||||
"refused with {error}"
|
||||
);
|
||||
assert!(
|
||||
error.to_string().contains("scalar"),
|
||||
"the refusal says what the root is: {error}"
|
||||
);
|
||||
|
||||
// The partial mode is a different output entirely, so a frame format cannot even be asked for.
|
||||
assert_eq!(
|
||||
FusedOutputFormat::PlainRows,
|
||||
partial_node(AggregateKind::Sum).format
|
||||
);
|
||||
}
|
||||
|
||||
/// A pending input hands the rows a frame already encoded over before it reports pending.
|
||||
///
|
||||
/// The builder buffers up to a batch, and a closing row only exists once its chunk is encoded. If a
|
||||
/// pending input could hold those rows, a consumer waiting for a series to close would wait for input
|
||||
/// that may never arrive.
|
||||
///
|
||||
/// This polls by hand rather than awaiting the stream: `StagedStream` reports pending once and then
|
||||
/// wakes itself, so an `await` would run straight through the boundary and satisfy every later
|
||||
/// assertion whether or not the rows were handed over at the right moment. What is checked instead is
|
||||
/// the poll that *meets* the pending input - it has to return rows, and the input has to have been
|
||||
/// read only once by then.
|
||||
#[tokio::test]
|
||||
async fn a_pending_input_does_not_hold_the_rows_a_frame_buffered() {
|
||||
let schema = frame_schema();
|
||||
let batches = one_series_frames();
|
||||
let handed = Arc::new(AtomicUsize::new(0));
|
||||
let staged_schema = Arc::clone(&schema);
|
||||
let staged_handed = Arc::clone(&handed);
|
||||
let source = staged(Arc::clone(&schema), move || {
|
||||
Box::pin(
|
||||
StagedStream::new(Arc::clone(&staged_schema), batches.clone())
|
||||
// The third read is the one that reports pending. By then the first two chunks have
|
||||
// filled an evaluation window, so the encoder is holding rows: the boundary is met
|
||||
// with something buffered, which is the case worth checking.
|
||||
.pending_after(2)
|
||||
.counted(&staged_handed),
|
||||
)
|
||||
});
|
||||
let tree = rate_tree();
|
||||
let node = FusedSeries::try_new_with_format(
|
||||
Arc::from(PROMQL),
|
||||
tree.clone(),
|
||||
binding(&tree),
|
||||
vec![input_plan(schema)],
|
||||
output_schema().to_dfschema_ref().unwrap(),
|
||||
FusedOutputFormat::SeriesFrame,
|
||||
)
|
||||
.expect("a rate tree is evaluated on a grid");
|
||||
let exec = plan_of(node, source).expect("the runtime executes the tree");
|
||||
let declared = exec.schema();
|
||||
let mut stream = exec
|
||||
.execute(0, SessionContext::default().task_ctx())
|
||||
.expect("the node executes");
|
||||
|
||||
// Poll by hand so the boundary is observable: `await` runs through the pending poll, and every
|
||||
// later assertion would then hold whether or not the rows came out at the right moment.
|
||||
//
|
||||
// The pending poll is the one under test. `StagedStream` wakes itself straight after reporting
|
||||
// pending, so a stream that answered nothing there would still be collected correctly; the only
|
||||
// way to see the difference is to look at what that single poll returned. Two chunks have been
|
||||
// read by then and an evaluation window is full, so the encoder is holding rows: it must answer
|
||||
// them, not pending.
|
||||
let next = stream.next();
|
||||
futures::pin_mut!(next);
|
||||
let mut answered = match futures::poll!(next) {
|
||||
std::task::Poll::Ready(Some(Ok(batch))) => vec![batch],
|
||||
std::task::Poll::Ready(Some(Err(error))) => panic!("the frame answered {error}"),
|
||||
std::task::Poll::Ready(None) => panic!("the frame ended before answering a row"),
|
||||
std::task::Poll::Pending => panic!(
|
||||
"the pending input held the rows the frame already encoded: the input handed over {} \
|
||||
batches",
|
||||
handed.load(Ordering::SeqCst)
|
||||
),
|
||||
};
|
||||
assert_eq!(
|
||||
declared.as_ref(),
|
||||
answered[0].schema().as_ref(),
|
||||
"a batch is declared as the plan declared it"
|
||||
);
|
||||
answered.extend(
|
||||
stream
|
||||
.try_collect::<Vec<_>>()
|
||||
.await
|
||||
.expect("the stream resumes where the pending poll left it"),
|
||||
);
|
||||
let stream = Box::pin(RecordBatchStreamAdapter::new(
|
||||
declared,
|
||||
futures::stream::iter(answered.into_iter().map(Ok)),
|
||||
));
|
||||
let decoded = unpack_evaluated_series(stream, None)
|
||||
.expect("the node declares the frame it yields")
|
||||
.try_collect::<Vec<_>>()
|
||||
.await
|
||||
.expect("every batch of the frame decodes");
|
||||
assert_eq!(
|
||||
1,
|
||||
decoded.iter().filter(|(_, last)| *last).count(),
|
||||
"one series closes exactly once, across the pending boundary"
|
||||
);
|
||||
// What the row table answers for the same input is the reference: `rate` decides which grid
|
||||
// points carry a value, so computing the expected timestamps here would guess at it.
|
||||
let plain =
|
||||
plan(rate_tree(), memory_exec(one_series_frames())).expect("the runtime executes a tree");
|
||||
let mut expected = rows(
|
||||
&collect(plain, SessionContext::default().task_ctx())
|
||||
.await
|
||||
.expect("the row table runs"),
|
||||
)
|
||||
.into_iter()
|
||||
.map(|(time, _, _, _)| time)
|
||||
.collect::<Vec<_>>();
|
||||
expected.sort_unstable();
|
||||
let mut times = Vec::new();
|
||||
for (series, _) in &decoded {
|
||||
for timestamp in series.time.timestamps().expect("a grid axis").iter() {
|
||||
times.push(*timestamp);
|
||||
}
|
||||
}
|
||||
times.sort_unstable();
|
||||
assert_eq!(
|
||||
expected, times,
|
||||
"every row the row table answers is in the frame, none twice"
|
||||
);
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// window closes the series with a chunk that carries no position. Dropping it would lose the only
|
||||
/// signal that says this series is done - the row table has no row for it at all, which is exactly
|
||||
/// why the frame has to keep it.
|
||||
#[tokio::test]
|
||||
async fn a_frame_carries_the_closing_chunk_a_row_table_drops() {
|
||||
let schema = raw_schema();
|
||||
// Every sample is newer than the last grid point, so `rate` decides nothing at any of them.
|
||||
let late = [(GRID_START + RANGE_MS + SAMPLE_STEP, 1.0)];
|
||||
let chunk = chunk_of(
|
||||
&schema,
|
||||
"late",
|
||||
metric(&[(HOST, "late")], Some(METRIC)),
|
||||
&late.iter().map(|(time, _)| *time).collect::<Vec<_>>(),
|
||||
vec![late.iter().map(|(_, value)| Some(*value)).collect()],
|
||||
true,
|
||||
);
|
||||
let tree = rate_tree();
|
||||
let node = 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("a rate tree is evaluated on a grid");
|
||||
let exec = plan_of(node, memory_exec(vec![frame_of(&schema, &chunk)]))
|
||||
.expect("the runtime executes the tree");
|
||||
let declared = exec.schema();
|
||||
let batches = collect(exec, SessionContext::default().task_ctx())
|
||||
.await
|
||||
.expect("the frame runs");
|
||||
assert!(
|
||||
batches
|
||||
.iter()
|
||||
.all(|batch| batch.schema().as_ref() == declared.as_ref()),
|
||||
"every batch carries the declared schema"
|
||||
);
|
||||
let stream = Box::pin(RecordBatchStreamAdapter::new(
|
||||
declared,
|
||||
futures::stream::iter(batches.into_iter().map(Ok)),
|
||||
));
|
||||
let decoded = unpack_evaluated_series(stream, None)
|
||||
.expect("the node declares the frame it yields")
|
||||
.try_collect::<Vec<_>>()
|
||||
.await
|
||||
.expect("the frame decodes");
|
||||
|
||||
assert_eq!(
|
||||
1,
|
||||
decoded.len(),
|
||||
"the closing chunk is one frame row, not a dropped chunk: {decoded:?}"
|
||||
);
|
||||
let (series, is_last) = &decoded[0];
|
||||
assert!(is_last, "that row is the one that closes the chain");
|
||||
assert!(
|
||||
series.time.is_empty(),
|
||||
"it carries no grid position: {:?}",
|
||||
series.time.positions().collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
// The row table answers nothing for the same input, which is what makes this chunk worth keeping.
|
||||
let plain = plan(rate_tree(), memory_exec(vec![frame_of(&schema, &chunk)]))
|
||||
.expect("the runtime executes a tree");
|
||||
let rows = rows(
|
||||
&collect(plain, SessionContext::default().task_ctx())
|
||||
.await
|
||||
.expect("the row table runs"),
|
||||
);
|
||||
assert!(
|
||||
rows.is_empty(),
|
||||
"the row table drops the closing chunk entirely: {rows:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Two chains that close on one label set are both emitted, each closing itself.
|
||||
///
|
||||
/// A range function drops the metric name, so two different raw series can reach the output under the
|
||||
/// same complete labels. A frame carries one chain per chunk and the later one is a further
|
||||
/// contribution, not a repeat of the first: refusing it as a duplicate identity would drop values the
|
||||
/// query answers with.
|
||||
#[tokio::test]
|
||||
async fn two_chains_that_close_on_one_label_set_each_keep_their_frame() {
|
||||
// `rate` drops `__name__`, and neither series declares any other tag, so both collapse to the
|
||||
// same empty label set.
|
||||
let schema = raw_schema();
|
||||
let chunk_of_host = |host: &str, scale: f64| {
|
||||
let samples = series_samples(scale);
|
||||
chunk_of(
|
||||
&schema,
|
||||
host,
|
||||
metric(&[], None),
|
||||
&samples.iter().map(|(time, _)| *time).collect::<Vec<_>>(),
|
||||
vec![samples.iter().map(|(_, value)| Some(*value)).collect()],
|
||||
true,
|
||||
)
|
||||
};
|
||||
let partitions = vec![
|
||||
vec![frame_of(&schema, &chunk_of_host("web-1", 1.0))],
|
||||
vec![frame_of(&schema, &chunk_of_host("web-2", 2.0))],
|
||||
];
|
||||
let tree = rate_tree();
|
||||
let node = 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("a rate tree is evaluated on a grid");
|
||||
let exec =
|
||||
plan_of(node, memory_exec_partitioned(partitions)).expect("the runtime executes the tree");
|
||||
assert_eq!(
|
||||
2,
|
||||
exec.properties().partitioning.partition_count(),
|
||||
"the frame keeps the input's partition count"
|
||||
);
|
||||
let declared = exec.schema();
|
||||
let batches = collect(exec, SessionContext::default().task_ctx())
|
||||
.await
|
||||
.expect("both chains are legal");
|
||||
let decoded = unpack_evaluated_series(
|
||||
Box::pin(RecordBatchStreamAdapter::new(
|
||||
declared,
|
||||
futures::stream::iter(batches.into_iter().map(Ok)),
|
||||
)),
|
||||
None,
|
||||
)
|
||||
.expect("the node declares the frame it yields")
|
||||
.try_collect::<Vec<_>>()
|
||||
.await
|
||||
.expect("the frame decodes");
|
||||
|
||||
assert_eq!(
|
||||
2,
|
||||
decoded.iter().filter(|(_, last)| *last).count(),
|
||||
"each chain closes once, in its own partition"
|
||||
);
|
||||
assert_eq!(
|
||||
decoded[0].0.metric, decoded[1].0.metric,
|
||||
"the two chains carry the one label set"
|
||||
);
|
||||
let mut per_chain = Vec::new();
|
||||
for (series, last) in &decoded {
|
||||
assert!(*last, "each partition's single chain closes exactly once");
|
||||
assert_eq!(None, series.metric.name(), "rate drops the metric name");
|
||||
assert!(
|
||||
series.metric.tags().is_empty(),
|
||||
"both chains have empty labels"
|
||||
);
|
||||
let mut rows = Vec::new();
|
||||
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!("each rate grid position has a present value"),
|
||||
};
|
||||
rows.push((position, value.to_bits()));
|
||||
}
|
||||
rows.sort_unstable_by_key(|(position, _)| *position);
|
||||
per_chain.push(rows);
|
||||
}
|
||||
assert_eq!(2, per_chain.len(), "one closing chain from each partition");
|
||||
per_chain.sort();
|
||||
for (rows, value) in per_chain.iter().zip([1.0f64, 2.0]) {
|
||||
assert_eq!(
|
||||
(0..GRID_LEN as usize)
|
||||
.map(|i| (i, value.to_bits()))
|
||||
.collect::<Vec<_>>(),
|
||||
*rows,
|
||||
"each chain preserves every exact grid position and value",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
// Copyright 2023 Greptime Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::*;
|
||||
use crate::map::MathOp;
|
||||
use crate::map_label::LabelRewrite;
|
||||
use crate::native::plan::{BoundMap, MapOp};
|
||||
|
||||
fn instant_tree(schema: Arc<SeriesSchema>, grid: Arc<EvalGrid>) -> NativeTree {
|
||||
let output = NativeType::Samples(Arc::new(SeriesSchema {
|
||||
fields: schema.fields.clone(),
|
||||
eval_grid: Some(Arc::clone(&grid)),
|
||||
}));
|
||||
NativeTree::try_new(NativeNode {
|
||||
expr: NativeExpr::Instant {
|
||||
input: Box::new(NativeNode {
|
||||
expr: NativeExpr::Input(BoundInput {
|
||||
source_index: 0,
|
||||
output: NativeType::Samples(Arc::clone(&schema)),
|
||||
ordering: SeriesOrdering::Contiguous,
|
||||
}),
|
||||
output: NativeType::Samples(Arc::clone(&schema)),
|
||||
}),
|
||||
op: BoundInstant {
|
||||
lookback_ms: 0,
|
||||
offset_ms: 0,
|
||||
at_ms: None,
|
||||
timestamp: false,
|
||||
output_grid: grid,
|
||||
},
|
||||
},
|
||||
output,
|
||||
})
|
||||
.expect("instant selector tree is valid")
|
||||
}
|
||||
|
||||
async fn frame_decode(
|
||||
exec: Arc<dyn ExecutionPlan>,
|
||||
) -> Vec<(datatypes::series::samples::Series, bool)> {
|
||||
let batches = collect(Arc::clone(&exec), SessionContext::default().task_ctx())
|
||||
.await
|
||||
.expect("frame output executes");
|
||||
let stream = Box::pin(RecordBatchStreamAdapter::new(
|
||||
exec.schema(),
|
||||
futures::stream::iter(batches.into_iter().map(Ok)),
|
||||
));
|
||||
unpack_evaluated_series(stream, None)
|
||||
.expect("evaluated frame schema decodes")
|
||||
.try_collect::<Vec<_>>()
|
||||
.await
|
||||
.expect("evaluated frame decodes")
|
||||
}
|
||||
|
||||
fn compact_float_rows(
|
||||
decoded: &[(datatypes::series::samples::Series, bool)],
|
||||
) -> Vec<(i64, Vec<Option<u64>>)> {
|
||||
let mut rows = Vec::new();
|
||||
for (series, _) in decoded {
|
||||
let timestamps = series.time.timestamps().expect("grid timestamps");
|
||||
for (row, timestamp) in timestamps.iter().enumerate() {
|
||||
let values = series
|
||||
.fields
|
||||
.as_slice()
|
||||
.iter()
|
||||
.map(
|
||||
|field| match field.iter().nth(row).expect("sample at timestamp") {
|
||||
datatypes::series::samples::SampleRef::Float(value) => {
|
||||
Some(value.to_bits())
|
||||
}
|
||||
datatypes::series::samples::SampleRef::Missing => None,
|
||||
_ => panic!("float or null sample expected"),
|
||||
},
|
||||
)
|
||||
.collect();
|
||||
rows.push((*timestamp, values));
|
||||
}
|
||||
}
|
||||
rows.sort_by_key(|(timestamp, _)| *timestamp);
|
||||
rows
|
||||
}
|
||||
|
||||
fn two_field_output_schema() -> SchemaRef {
|
||||
Arc::new(Schema::new(vec![
|
||||
Field::new(VALUE, DataType::Float64, true),
|
||||
Field::new(OTHER, DataType::Float64, true),
|
||||
Field::new(HOST, DataType::Utf8, true),
|
||||
Field::new(NAME, DataType::Utf8, true),
|
||||
Field::new(
|
||||
TIME,
|
||||
DataType::Timestamp(TimeUnit::Millisecond, None),
|
||||
false,
|
||||
),
|
||||
]))
|
||||
}
|
||||
|
||||
fn plain_float_rows(batches: &[RecordBatch], fields: &[&str]) -> Vec<(i64, Vec<Option<u64>>)> {
|
||||
let mut rows = Vec::new();
|
||||
for batch in batches {
|
||||
let time = batch
|
||||
.column_by_name(TIME)
|
||||
.unwrap()
|
||||
.as_any()
|
||||
.downcast_ref::<TimestampMillisecondArray>()
|
||||
.unwrap();
|
||||
let columns = fields
|
||||
.iter()
|
||||
.map(|field| {
|
||||
batch
|
||||
.column_by_name(field)
|
||||
.unwrap()
|
||||
.as_any()
|
||||
.downcast_ref::<Float64Array>()
|
||||
.unwrap()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
for row in 0..batch.num_rows() {
|
||||
rows.push((
|
||||
time.value(row),
|
||||
columns
|
||||
.iter()
|
||||
.map(|column| column.is_valid(row).then(|| column.value(row).to_bits()))
|
||||
.collect(),
|
||||
));
|
||||
}
|
||||
}
|
||||
rows.sort_by_key(|(timestamp, _)| *timestamp);
|
||||
rows
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sparse_instant_frame_preserves_positions_nulls_and_nan_across_formats() {
|
||||
let schema = two_field_schema();
|
||||
let grid = Arc::new(EvalGrid::try_new(GridSpec::try_new(0, 1_000, 6).unwrap()).unwrap());
|
||||
let tree = instant_tree(Arc::clone(&schema), grid);
|
||||
let chunk = chunk_of(
|
||||
&schema,
|
||||
"sparse",
|
||||
metric(&[(HOST, "sparse")], Some(METRIC)),
|
||||
&[0, 2_000, 5_000],
|
||||
vec![
|
||||
vec![Some(1.0), None, Some(f64::from_bits(0x7ff8_0000_0000_0042))],
|
||||
vec![None, Some(2.0), None],
|
||||
],
|
||||
true,
|
||||
);
|
||||
let batches = vec![frame_of(&schema, &chunk)];
|
||||
let binding = binding_of(&tree, Arc::clone(&schema), vec![HOST.into(), NAME.into()]);
|
||||
let make_node = |format| {
|
||||
FusedSeries::try_new_with_format(
|
||||
Arc::from("instant(x)"),
|
||||
tree.clone(),
|
||||
binding.clone(),
|
||||
vec![input_plan(frame_schema_of(&schema))],
|
||||
two_field_output_schema().to_dfschema_ref().unwrap(),
|
||||
format,
|
||||
)
|
||||
.expect("instant tree has the declared frame output")
|
||||
};
|
||||
|
||||
let frame_exec = plan_of(
|
||||
make_node(FusedOutputFormat::SeriesFrame),
|
||||
memory_exec_with(frame_schema_of(&schema), batches.clone()),
|
||||
)
|
||||
.unwrap();
|
||||
let decoded = frame_decode(frame_exec).await;
|
||||
assert_eq!(1, decoded.iter().filter(|(_, last)| *last).count());
|
||||
let expected = vec![
|
||||
(0, vec![Some(1.0f64.to_bits()), None]),
|
||||
(2_000, vec![None, Some(2.0f64.to_bits())]),
|
||||
(5_000, vec![Some(0x7ff8_0000_0000_0042), None]),
|
||||
];
|
||||
assert_eq!(expected, compact_float_rows(&decoded));
|
||||
|
||||
let plain_exec = plan_of(
|
||||
make_node(FusedOutputFormat::PlainRows),
|
||||
memory_exec_with(frame_schema_of(&schema), batches),
|
||||
)
|
||||
.unwrap();
|
||||
let plain_batches = collect(plain_exec, SessionContext::default().task_ctx())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(expected, plain_float_rows(&plain_batches, &[VALUE, OTHER]));
|
||||
assert!(decoded.iter().all(|(series, _)| {
|
||||
series.metric.get(HOST) == Some("sparse") && series.metric.name() == Some(METRIC)
|
||||
}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn abs_rate_frame_matches_plain_rows_with_exact_values_and_labels() {
|
||||
let rate = rate_tree();
|
||||
let tree = NativeTree::try_new(NativeNode {
|
||||
expr: NativeExpr::Map {
|
||||
input: Box::new(rate.root().clone()),
|
||||
op: BoundMap {
|
||||
ops: Arc::from([MapOp::Math(MathOp::Abs)]),
|
||||
},
|
||||
},
|
||||
output: rate.root().output.clone(),
|
||||
})
|
||||
.unwrap();
|
||||
let batches = two_series_frames();
|
||||
let binding = binding(&tree);
|
||||
let make_node = |format| {
|
||||
FusedSeries::try_new_with_format(
|
||||
Arc::from("abs(rate(x[5m]))"),
|
||||
tree.clone(),
|
||||
binding.clone(),
|
||||
vec![input_plan(frame_schema())],
|
||||
output_schema().to_dfschema_ref().unwrap(),
|
||||
format,
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
let plain_exec = plan_of(
|
||||
make_node(FusedOutputFormat::PlainRows),
|
||||
memory_exec(batches.clone()),
|
||||
)
|
||||
.unwrap();
|
||||
let plain_batches = collect(plain_exec, SessionContext::default().task_ctx())
|
||||
.await
|
||||
.unwrap();
|
||||
let mut expected = rows(&plain_batches)
|
||||
.into_iter()
|
||||
.map(|(time, value, host, name)| (time, value.map(f64::to_bits), host, name))
|
||||
.collect::<Vec<_>>();
|
||||
expected.sort();
|
||||
let mut exact = Vec::new();
|
||||
for (host, scale) in [("web-1", 1.0_f64), ("web-2", 2.0)] {
|
||||
for position in 0..GRID_LEN {
|
||||
exact.push((
|
||||
GRID_START + position as i64 * GRID_STEP,
|
||||
Some(scale.to_bits()),
|
||||
Some(host.to_string()),
|
||||
None,
|
||||
));
|
||||
}
|
||||
}
|
||||
exact.sort();
|
||||
assert_eq!(exact, expected);
|
||||
|
||||
let frame_exec = plan_of(
|
||||
make_node(FusedOutputFormat::SeriesFrame),
|
||||
memory_exec(batches),
|
||||
)
|
||||
.unwrap();
|
||||
let decoded = frame_decode(frame_exec).await;
|
||||
assert_eq!(2, decoded.iter().filter(|(_, last)| *last).count());
|
||||
let mut actual = Vec::new();
|
||||
for (series, _) in &decoded {
|
||||
assert_eq!(None, series.metric.name());
|
||||
let host = series.metric.get(HOST).unwrap().to_string();
|
||||
for (time, values) in compact_float_rows(&[(series.clone(), true)]) {
|
||||
actual.push((time, values[0], Some(host.clone()), None));
|
||||
}
|
||||
}
|
||||
actual.sort();
|
||||
assert_eq!(exact, actual);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rewritten_identical_labels_keep_disjoint_chains_in_one_encoder() {
|
||||
let schema = raw_schema();
|
||||
let semantic_grid =
|
||||
Arc::new(EvalGrid::try_new(GridSpec::try_new(0, 1_000, 6).unwrap()).unwrap());
|
||||
let base = instant_tree(Arc::clone(&schema), semantic_grid);
|
||||
let tree = NativeTree::try_new(NativeNode {
|
||||
expr: NativeExpr::Map {
|
||||
input: Box::new(base.root().clone()),
|
||||
op: BoundMap {
|
||||
ops: Arc::from([
|
||||
MapOp::Label(Arc::new(
|
||||
LabelRewrite::replace("host", "shared", "host", ".*").unwrap(),
|
||||
)),
|
||||
MapOp::Label(Arc::new(
|
||||
LabelRewrite::replace("zone", "edge", "host", ".*").unwrap(),
|
||||
)),
|
||||
]),
|
||||
},
|
||||
},
|
||||
output: base.root().output.clone(),
|
||||
})
|
||||
.unwrap();
|
||||
let chunks = vec![
|
||||
chunk_of(
|
||||
&schema,
|
||||
"a",
|
||||
metric(&[(HOST, "a")], Some(METRIC)),
|
||||
&[0, 2_000],
|
||||
vec![vec![Some(10.0), Some(12.0)]],
|
||||
true,
|
||||
),
|
||||
chunk_of(
|
||||
&schema,
|
||||
"b",
|
||||
metric(&[(HOST, "b")], Some(METRIC)),
|
||||
&[1_000, 5_000],
|
||||
vec![vec![Some(21.0), Some(25.0)]],
|
||||
true,
|
||||
),
|
||||
];
|
||||
let batches = frames_of(&schema, &chunks);
|
||||
let binding = binding_of(&tree, Arc::clone(&schema), vec![HOST.into(), NAME.into()]);
|
||||
let make_node = |format| {
|
||||
FusedSeries::try_new_with_format(
|
||||
Arc::from("rewritten instant"),
|
||||
tree.clone(),
|
||||
binding.clone(),
|
||||
vec![input_plan(frame_schema())],
|
||||
output_schema().to_dfschema_ref().unwrap(),
|
||||
format,
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
let exec = plan_of(
|
||||
make_node(FusedOutputFormat::SeriesFrame),
|
||||
memory_exec(batches.clone()),
|
||||
)
|
||||
.unwrap();
|
||||
let decoded = frame_decode(exec).await;
|
||||
assert_eq!(
|
||||
2,
|
||||
decoded.iter().filter(|(_, last)| *last).count(),
|
||||
"each chain closes once"
|
||||
);
|
||||
let mut groups = Vec::new();
|
||||
let mut group_rows = Vec::new();
|
||||
let mut closes = 0;
|
||||
for (series, last) in &decoded {
|
||||
assert_eq!(Some("shared"), series.metric.get(HOST));
|
||||
assert_eq!(Some("edge"), series.metric.get("zone"));
|
||||
assert_eq!(Some(METRIC), series.metric.name());
|
||||
let timestamps = series.time.timestamps().unwrap();
|
||||
let values = series.fields.get(0).unwrap();
|
||||
for (i, time) in timestamps.iter().enumerate() {
|
||||
let value = match values.iter().nth(i).unwrap() {
|
||||
datatypes::series::samples::SampleRef::Float(value) => value.to_bits(),
|
||||
_ => panic!("float sample expected"),
|
||||
};
|
||||
group_rows.push((*time, value));
|
||||
}
|
||||
if *last {
|
||||
closes += 1;
|
||||
group_rows.sort_by_key(|(time, _)| *time);
|
||||
groups.push(std::mem::take(&mut group_rows));
|
||||
}
|
||||
}
|
||||
assert_eq!(2, closes, "each chain closes exactly once");
|
||||
assert!(group_rows.is_empty(), "all output chains close");
|
||||
groups.sort();
|
||||
assert_eq!(
|
||||
vec![
|
||||
vec![(0, 10.0f64.to_bits()), (2_000, 12.0f64.to_bits())],
|
||||
vec![(1_000, 21.0f64.to_bits()), (5_000, 25.0f64.to_bits())],
|
||||
],
|
||||
groups
|
||||
);
|
||||
|
||||
let plain_exec = plan_of(
|
||||
make_node(FusedOutputFormat::PlainRows),
|
||||
memory_exec_with(frame_schema(), batches),
|
||||
)
|
||||
.unwrap();
|
||||
let plain = collect(plain_exec, SessionContext::default().task_ctx())
|
||||
.await
|
||||
.unwrap();
|
||||
let mut plain = rows(&plain)
|
||||
.into_iter()
|
||||
.map(|(time, value, host, name)| (time, value.map(f64::to_bits), host, name))
|
||||
.collect::<Vec<_>>();
|
||||
plain.sort();
|
||||
assert_eq!(
|
||||
vec![
|
||||
(
|
||||
0,
|
||||
Some(10.0f64.to_bits()),
|
||||
Some("shared".into()),
|
||||
Some(METRIC.into())
|
||||
),
|
||||
(
|
||||
1_000,
|
||||
Some(21.0f64.to_bits()),
|
||||
Some("shared".into()),
|
||||
Some(METRIC.into())
|
||||
),
|
||||
(
|
||||
2_000,
|
||||
Some(12.0f64.to_bits()),
|
||||
Some("shared".into()),
|
||||
Some(METRIC.into())
|
||||
),
|
||||
(
|
||||
5_000,
|
||||
Some(25.0f64.to_bits()),
|
||||
Some("shared".into()),
|
||||
Some(METRIC.into())
|
||||
),
|
||||
],
|
||||
plain
|
||||
);
|
||||
}
|
||||
@@ -109,6 +109,7 @@ otel-arrow-rust.workspace = true
|
||||
paste.workspace = true
|
||||
pipeline.workspace = true
|
||||
plugins.workspace = true
|
||||
promql.workspace = true
|
||||
prost.workspace = true
|
||||
rand.workspace = true
|
||||
rskafka.workspace = true
|
||||
|
||||
@@ -46,15 +46,20 @@ use common_frontend::slow_query_event::{
|
||||
};
|
||||
use common_memory_manager::OnExhaustedPolicy;
|
||||
use common_options::plugin_options::StandaloneFlag;
|
||||
use datafusion::common::{DFSchema, ToDFSchema};
|
||||
use datafusion::logical_expr::{EmptyRelation, LogicalPlan};
|
||||
use datafusion::physical_plan::metrics::MetricValue;
|
||||
use datafusion::physical_plan::{ExecutionPlan, ExecutionPlanProperties};
|
||||
use datatypes::arrow::array::{
|
||||
Array as _, Float64Array, StringArray, StringViewArray, TimestampMillisecondArray,
|
||||
};
|
||||
use datatypes::arrow::datatypes::{DataType, SchemaRef, TimeUnit};
|
||||
use datatypes::series::samples::{SampleRef, SeriesSchema};
|
||||
use datatypes::series::time::{EvalGrid, GridSpec};
|
||||
use flate2::Compression;
|
||||
use flate2::write::GzEncoder;
|
||||
use frontend::instance::Instance;
|
||||
use futures::TryStreamExt;
|
||||
use log_query::{AggFunc, Context, Limit, LogExpr, LogQuery, TimeFilter};
|
||||
use loki_proto::logproto::{EntryAdapter, LabelPairAdapter, PushRequest, StreamAdapter};
|
||||
use loki_proto::prost_types::Timestamp;
|
||||
@@ -1855,6 +1860,211 @@ fn fixture_rows(value: impl Fn(f64) -> f64) -> Vec<PromqlRow> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Replays the captured raw scan through both public FusedSeries output formats.
|
||||
async fn assert_raw_scan_fused_formats(observer: &NativeFusedObserver, frontend: &Arc<Instance>) {
|
||||
let table_id = frontend
|
||||
.catalog_manager()
|
||||
.table("greptime", "public", "native_demo", None)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("fixture table exists")
|
||||
.table_info()
|
||||
.table_id();
|
||||
let (_, route) = frontend
|
||||
.table_metadata_manager()
|
||||
.table_route_manager()
|
||||
.get_physical_table_route(table_id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
route.region_routes.len(),
|
||||
1,
|
||||
"fixture has exactly one physical region"
|
||||
);
|
||||
|
||||
observer.clear();
|
||||
let (_, row_schema, _) = promql_output(frontend, "native_demo", true, "0", "2").await;
|
||||
let origin = observer.take("native_demo");
|
||||
let children = origin.children();
|
||||
assert_eq!(children.len(), 1, "the fused origin has exactly one child");
|
||||
let mut scan_plan = children[0].clone();
|
||||
if scan_plan
|
||||
.downcast_ref::<table::table::scan::SeriesScanExec>()
|
||||
.is_none()
|
||||
{
|
||||
let wrapper_children = scan_plan.children();
|
||||
assert_eq!(
|
||||
wrapper_children.len(),
|
||||
1,
|
||||
"the optional scan wrapper has one child"
|
||||
);
|
||||
scan_plan = wrapper_children[0].clone();
|
||||
}
|
||||
let scan = scan_plan
|
||||
.downcast_ref::<table::table::scan::SeriesScanExec>()
|
||||
.expect("the fused input is a real SeriesScanExec");
|
||||
assert_eq!(scan.properties().output_partitioning().partition_count(), 4);
|
||||
let raw_schema = Arc::clone(scan.series_schema());
|
||||
let label_source = scan.label_source().clone();
|
||||
let ordering = scan.raw_series_ordering();
|
||||
let grid = Arc::new(EvalGrid::try_new(GridSpec::try_new(0, 1_000, 3).unwrap()).unwrap());
|
||||
let output_schema = Arc::new(SeriesSchema {
|
||||
fields: Arc::clone(&raw_schema.fields),
|
||||
eval_grid: Some(Arc::clone(&grid)),
|
||||
});
|
||||
let raw_type = promql::native::plan::NativeType::Samples(Arc::clone(&raw_schema));
|
||||
let root = promql::native::plan::NativeNode {
|
||||
expr: promql::native::plan::NativeExpr::Instant {
|
||||
input: Box::new(promql::native::plan::NativeNode {
|
||||
expr: promql::native::plan::NativeExpr::Input(promql::native::plan::BoundInput {
|
||||
source_index: 0,
|
||||
output: raw_type.clone(),
|
||||
ordering,
|
||||
}),
|
||||
output: raw_type,
|
||||
}),
|
||||
op: promql::native::plan::BoundInstant {
|
||||
lookback_ms: 300_000,
|
||||
offset_ms: 0,
|
||||
at_ms: None,
|
||||
timestamp: false,
|
||||
output_grid: Arc::clone(&grid),
|
||||
},
|
||||
},
|
||||
output: promql::native::plan::NativeType::Samples(Arc::clone(&output_schema)),
|
||||
};
|
||||
let tree = promql::native::plan::NativeTree::try_new(root).unwrap();
|
||||
let binding = promql::extension_plan::FusedSeriesBinding {
|
||||
grid,
|
||||
metric_name: Some(Arc::from("native_demo")),
|
||||
time_index: row_schema
|
||||
.fields()
|
||||
.iter()
|
||||
.find(|field| {
|
||||
matches!(
|
||||
field.data_type(),
|
||||
DataType::Timestamp(TimeUnit::Millisecond, _)
|
||||
)
|
||||
})
|
||||
.expect("PromQL result has a millisecond timestamp field")
|
||||
.name()
|
||||
.clone(),
|
||||
tags: vec!["host".to_string()],
|
||||
};
|
||||
let logical_child = LogicalPlan::EmptyRelation(EmptyRelation {
|
||||
produce_one_row: false,
|
||||
schema: DFSchema::try_from(scan.schema()).unwrap().into(),
|
||||
});
|
||||
let expected = fixture_rows(|value| value);
|
||||
for format in [
|
||||
promql::extension_plan::FusedOutputFormat::PlainRows,
|
||||
promql::extension_plan::FusedOutputFormat::SeriesFrame,
|
||||
] {
|
||||
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(),
|
||||
format,
|
||||
)
|
||||
.unwrap()
|
||||
.to_execution_plan(vec![(scan_plan.clone(), label_source.clone(), ordering)])
|
||||
.unwrap();
|
||||
let context = "standalone raw SeriesScanExec replay";
|
||||
let mut actual = Vec::new();
|
||||
let mut closed = 0;
|
||||
let mut partition_counts = Vec::new();
|
||||
for partition in 0..4 {
|
||||
let stream = exec
|
||||
.execute(
|
||||
partition,
|
||||
datafusion::prelude::SessionContext::new().task_ctx(),
|
||||
)
|
||||
.unwrap();
|
||||
if format == promql::extension_plan::FusedOutputFormat::SeriesFrame {
|
||||
let series = common_query::series_frame::unpack_evaluated_series(stream, None)
|
||||
.unwrap()
|
||||
.try_collect::<Vec<_>>()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
!series.is_empty(),
|
||||
"partition {partition} emitted no series frames"
|
||||
);
|
||||
partition_counts.push(
|
||||
series
|
||||
.iter()
|
||||
.map(|(s, _)| s.time.timestamps().unwrap().len())
|
||||
.sum::<usize>(),
|
||||
);
|
||||
for (series, last) in series {
|
||||
assert_eq!(series.metric.name(), Some("native_demo"));
|
||||
assert_eq!(series.metric.tags().len(), 1);
|
||||
assert_eq!(series.fields.len(), 1);
|
||||
let host = series.metric.get("host").unwrap().to_string();
|
||||
let timestamps = series.time.timestamps().unwrap();
|
||||
let samples = series.fields.get(0).unwrap();
|
||||
for (i, timestamp) in timestamps.iter().enumerate() {
|
||||
let SampleRef::Float(value) = samples.iter().nth(i).unwrap() else {
|
||||
panic!("expected float sample")
|
||||
};
|
||||
actual.push((vec![("host".to_string(), host.clone())], *timestamp, value));
|
||||
}
|
||||
closed += usize::from(last);
|
||||
}
|
||||
} else {
|
||||
let batches = stream.try_collect::<Vec<_>>().await.unwrap();
|
||||
let mut count = 0;
|
||||
for batch in batches {
|
||||
let ts = batch
|
||||
.column_by_name(&binding.time_index)
|
||||
.unwrap()
|
||||
.as_any()
|
||||
.downcast_ref::<TimestampMillisecondArray>()
|
||||
.unwrap();
|
||||
let host = batch
|
||||
.column_by_name("host")
|
||||
.unwrap()
|
||||
.as_any()
|
||||
.downcast_ref::<StringArray>()
|
||||
.unwrap();
|
||||
let values = batch
|
||||
.column_by_name("val")
|
||||
.unwrap()
|
||||
.as_any()
|
||||
.downcast_ref::<Float64Array>()
|
||||
.unwrap();
|
||||
for i in 0..batch.num_rows() {
|
||||
actual.push((
|
||||
vec![("host".to_string(), host.value(i).to_string())],
|
||||
ts.value(i),
|
||||
values.value(i),
|
||||
));
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(row_schema.fields().len(), 3);
|
||||
assert!(count > 0, "partition {partition} emitted no rows");
|
||||
partition_counts.push(count);
|
||||
}
|
||||
}
|
||||
assert_eq!(partition_counts.len(), 4);
|
||||
assert!(
|
||||
partition_counts.iter().all(|count| *count > 0),
|
||||
"all four partitions emitted output: {partition_counts:?}"
|
||||
);
|
||||
assert_eq!(partition_counts.iter().sum::<usize>(), 24);
|
||||
if format == promql::extension_plan::FusedOutputFormat::SeriesFrame {
|
||||
assert_eq!(closed, 8, "one closing frame per host series");
|
||||
}
|
||||
actual.sort_unstable_by(|left, right| left.0.cmp(&right.0).then(left.1.cmp(&right.1)));
|
||||
assert_rows_eq(&actual, &expected, context);
|
||||
assert_native_partition_output(&exec, Some(4), 24, context);
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs one PromQL query through the public frontend handler and returns the plan it executed, the
|
||||
/// schema it answered with and every row it reported.
|
||||
async fn promql_output(
|
||||
@@ -2206,6 +2416,8 @@ pub async fn test_native_promql_route_plans_the_query(store_type: StorageType) {
|
||||
.await;
|
||||
}
|
||||
|
||||
assert_raw_scan_fused_formats(&observer, frontend).await;
|
||||
|
||||
for (query, start, end, expected, scan_partitions) in [
|
||||
(
|
||||
"1 + 2",
|
||||
|
||||
Reference in New Issue
Block a user